From f2c36cae37ca705107087b189a6a4d91f7bd5811 Mon Sep 17 00:00:00 2001 From: Mo Tarbin Date: Wed, 4 Jun 2025 00:33:05 -0400 Subject: [PATCH 1/7] fix: Add overflowX hidden to Box in Sidepanel for improved layout --- src/views/Chores/Sidepanel.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/views/Chores/Sidepanel.jsx b/src/views/Chores/Sidepanel.jsx index 1bf0a82..f879c70 100644 --- a/src/views/Chores/Sidepanel.jsx +++ b/src/views/Chores/Sidepanel.jsx @@ -55,7 +55,7 @@ const Sidepanel = ({ chores }) => { width: '315px', }} > - + From 187b76e04cf943e00cc4ab295f0ca2d8a130367c Mon Sep 17 00:00:00 2001 From: Mo Tarbin Date: Sun, 8 Jun 2025 02:05:59 -0400 Subject: [PATCH 2/7] feat: Integrate NotificationTemplate component into ChoreEdit and AddTaskModal for enhanced notification management --- src/components/NotificationTemplate.jsx | 632 ++++++++++++++++++++++++ src/views/ChoreEdit/ChoreEdit.jsx | 79 +-- src/views/components/AddTaskModal.jsx | 115 +++-- 3 files changed, 731 insertions(+), 95 deletions(-) create mode 100644 src/components/NotificationTemplate.jsx diff --git a/src/components/NotificationTemplate.jsx b/src/components/NotificationTemplate.jsx new file mode 100644 index 0000000..9970ea9 --- /dev/null +++ b/src/components/NotificationTemplate.jsx @@ -0,0 +1,632 @@ +import AddIcon from '@mui/icons-material/Add' +import DeleteIcon from '@mui/icons-material/Delete' +import InfoIcon from '@mui/icons-material/Info' +import NotificationsIcon from '@mui/icons-material/Notifications' +import Alert from '@mui/joy/Alert' +import Badge from '@mui/joy/Badge' +import Box from '@mui/joy/Box' +import Button from '@mui/joy/Button' +import IconButton from '@mui/joy/IconButton' +import Input from '@mui/joy/Input' +import Option from '@mui/joy/Option' +import Select from '@mui/joy/Select' +import Typography from '@mui/joy/Typography' +import { useCallback, useEffect, useState } from 'react' + +const timeUnits = [ + { label: 'Minutes', value: 'minutes' }, + { label: 'Hours', value: 'hours' }, + { label: 'Days', value: 'days' }, +] + +const beforeAfterOptions = [ + { label: 'Before Due', value: 'before' }, + { label: 'On Due', value: 'ondue' }, + { label: 'After Due', value: 'after' }, +] + +function getRelativeLabel(notification) { + const { amount, unit, when } = notification + + // For "On Due" notification + if (when === 'ondue') { + return 'On due date' + } + + // For before/after notifications + return `${amount} ${unit} ${when === 'before' ? 'before' : 'after'} due` +} + +const NotificationTemplate = ({ maxNotifications = 5, onChange, value }) => { + const [templateName, setTemplateName] = useState( + value?.name || 'New Notification Template', + ) + const [notifications, setNotifications] = useState([ + value?.notifications || { + amount: 0, + when: 'ondue', + }, + ]) + const [error, setError] = useState(null) + + // Create a map of notification indices for timeline display + const [notificationIndexMap, setNotificationIndexMap] = useState({}) + + // Use useCallback to memoize the function + const updateNotificationIndices = useCallback(() => { + // Sort notifications for consistent ordering + const sorted = [...notifications].sort((a, b) => { + // Always ensure correct ordering: Before Due -> On Due -> After Due + if (a.when !== b.when) { + // Before Due always comes first + if (a.when === 'before') return -1 + if (b.when === 'before') return 1 + + // On Due comes before After Due + if (a.when === 'ondue') return -1 + if (b.when === 'ondue') return 1 + + // Default case (should not be reached with our options) + return 0 + } + + // Convert everything to minutes for consistent comparison + const getMinutes = notif => { + const { amount, unit } = notif + let minutes = amount + if (unit === 'hours') minutes *= 60 + if (unit === 'days') minutes *= 24 * 60 + return minutes + } + + // For Before Due: sort in descending order (furthest from due first) + // For After Due: sort in ascending order (closest to due first) + const aMinutes = getMinutes(a) + const bMinutes = getMinutes(b) + return a.when === 'before' ? bMinutes - aMinutes : aMinutes - bMinutes + }) + + // Create index mapping + const indexMap = {} + sorted.forEach((item, index) => { + const originalIdx = notifications.findIndex( + n => + n.amount === item.amount && + n.unit === item.unit && + n.when === item.when, + ) + indexMap[originalIdx] = index + 1 + }) + + setNotificationIndexMap(indexMap) + }, [notifications]) + + // Sort notifications and update the index mapping + useEffect(() => { + updateNotificationIndices() + // Clear any errors when notifications change + setError(null) + }, [updateNotificationIndices]) + + // Notify parent component of changes including the template name + useEffect(() => { + if (onChange) { + onChange({ name: templateName, notifications }) + } + }, [templateName, notifications, onChange]) + + // Validates if a notification configuration already exists + const isDuplicate = (notification, currentIdx = -1) => { + return notifications.some((n, idx) => { + // Skip comparing with itself when editing + if (idx === currentIdx) return false + + return ( + n.amount === notification.amount && + n.unit === notification.unit && + n.when === notification.when + ) + }) + } + + const handleChange = (idx, field, value) => { + let updatedNotification = { + ...notifications[idx], + [field]: value, + } + + // Special handling for "On Due" option + if (field === 'when' && value === 'ondue') { + // Set default values for On Due (not applicable) + updatedNotification = { + ...updatedNotification, + amount: 0, + unit: 'minutes', // default unit, not displayed to user + } + + // Check if another notification is already "On Due" + const existingOnDue = notifications.findIndex( + (n, i) => i !== idx && n.when === 'ondue', + ) + + if (existingOnDue !== -1) { + setError( + 'Only one notification can be set to "On Due". Please choose a different timing.', + ) + return + } + } + + // Check for duplicates when changing a notification + if (isDuplicate(updatedNotification, idx)) { + setError( + 'This notification setting already exists. Please use a different timing.', + ) + return + } + + const updated = notifications.map((n, i) => + i === idx ? updatedNotification : n, + ) + setNotifications(updated) + setError(null) + } + + const handleNameChange = e => { + setTemplateName(e.target.value) + } + const addSmartNotification = type => { + if (notifications.length >= maxNotifications) return + + let newNotification + let suggestions = [] + + switch (type) { + case 'reminder': + // Suggest common reminder times that don't exist + suggestions = [ + { amount: 1, unit: 'hours', when: 'before' }, + { amount: 1, unit: 'days', when: 'before' }, + { amount: 30, unit: 'minutes', when: 'before' }, + { amount: 2, unit: 'hours', when: 'before' }, + { amount: 3, unit: 'days', when: 'before' }, + ] + break + + case 'due': + if (notifications.some(n => n.when === 'ondue')) { + setError('Only one "Due Alert" notification is allowed.') + return + } + newNotification = { amount: 0, unit: 'minutes', when: 'ondue' } + break + + case 'followup': + suggestions = [ + { amount: 1, unit: 'hours', when: 'after' }, + { amount: 1, unit: 'days', when: 'after' }, + { amount: 3, unit: 'days', when: 'after' }, + { amount: 1, unit: 'weeks', when: 'after' }, + ] + break + } + + // For reminder/followup, find first non-duplicate suggestion + if (suggestions.length > 0) { + newNotification = suggestions.find(suggestion => !isDuplicate(suggestion)) + + if (!newNotification) { + setError(`All common ${type} times are already configured.`) + return + } + } + + // Insert the new notification in the correct chronological position + const updatedNotifications = [...notifications, newNotification].sort( + (a, b) => { + // Convert everything to minutes for consistent comparison + const getMinutes = notif => { + const { amount, unit, when } = notif + // On Due is exactly at due date (0 minutes) + if (when === 'ondue') return 0 + + let minutes = amount + if (unit === 'hours') minutes *= 60 + if (unit === 'days') minutes *= 24 * 60 + return when === 'before' ? -minutes : minutes + } + + return getMinutes(a) - getMinutes(b) + }, + ) + + setNotifications(updatedNotifications) + setError(null) + } + + const removeNotification = idx => { + const updated = notifications.filter((_, i) => i !== idx) + setNotifications(updated) + onChange && onChange(updated) + } + // Visualization: improved timeline with better scaling + const renderTimeline = () => { + // Sort notifications chronologically + const sorted = [...notifications].sort((a, b) => { + // Convert everything to minutes for consistent comparison + const getMinutes = notif => { + const { amount, unit, when } = notif + // On Due is exactly at due date (0 minutes) + if (when === 'ondue') return 0 + + let minutes = amount + if (unit === 'hours') minutes *= 60 + if (unit === 'days') minutes *= 24 * 60 + return when === 'before' ? -minutes : minutes + } + + return getMinutes(a) - getMinutes(b) + }) + + // Create a map to track sorted indices for original notifications + const notificationIndexMap = {} + sorted.forEach((item, index) => { + // Find the original index of this item in notifications array + const originalIdx = notifications.findIndex( + n => + n.amount === item.amount && + n.unit === item.unit && + n.when === item.when, + ) + notificationIndexMap[originalIdx] = index + 1 + }) // Get min and max notification times for dynamic scaling + const minutesValues = sorted.map(n => { + // On Due is exactly at due date (0 minutes) + if (n.when === 'ondue') return 0 + + let minutes = n.amount + if (n.unit === 'hours') minutes *= 60 + if (n.unit === 'days') minutes *= 24 * 60 + return n.when === 'before' ? -minutes : minutes + }) + + // Find min (before) and max (after) notification times + const minBefore = Math.min(0, ...minutesValues) // Default to 0 if no "before" notifications + const maxAfter = Math.max(0, ...minutesValues) // Default to 0 if no "after" notifications + + // Dynamic scaling based on notification range + const getPositionPercent = minutes => { + // Due date is always at center (50%) + if (minutes === 0) return 50 + + // For notifications before due date + if (minutes < 0) { + if (minBefore === 0) return 30 // Default position if no before notifications + // Scale between 10% (furthest left) and 45% (closest to due) + return 45 - (Math.abs(minutes) / Math.abs(minBefore)) * 35 + } + + // For notifications after due date + if (maxAfter === 0) return 70 // Default position if no after notifications + // Scale between 55% (closest to due) and 90% (furthest right) + return 55 + (minutes / maxAfter) * 35 + } + + return ( + + + Notification Timeline + + + {/* Timeline line */} + + {/* Due date marker */} + + + Due Date + + + + {/* Notification markers */} + {sorted.map((n, i) => { + // Convert to minutes for consistent scale + let minutes = 0 + if (n.when !== 'ondue') { + minutes = n.amount + if (n.unit === 'hours') minutes *= 60 + if (n.unit === 'days') minutes *= 24 * 60 + if (n.when === 'before') minutes = -minutes + } + // On Due notifications are always at the due date (0 minutes) + + // Calculate position based on dynamic scaling + const percent = getPositionPercent(minutes) + + return ( + + + + + + ) + })} + + + + ) + } + + return ( + + + Schedule Name + + + {/* Template Name Field */} + + + Template Name + + + + + {error && ( + } + > + {error} + + )} + + {notifications.map((n, idx) => { + // Get ordered badge number from timeline sorting + const badgeNumber = notificationIndexMap[idx] + + return ( + + + + + {/* Empty box to attach badge to */} + + + + {/* Show disabled fields for "On Due" option for visual consistency */} + + handleChange(idx, 'amount', Math.max(1, Number(e.target.value))) + } + sx={{ + width: 70, + mr: 1, + opacity: n.when === 'ondue' ? 0.6 : 1, + ...(n.when === 'ondue' && { + '& input': { + textAlign: 'center', + }, + }), + }} + size={'sm'} + placeholder={n.when === 'ondue' ? '—' : ''} + /> + + removeNotification(idx)} + disabled={notifications.length === 1} + color={'danger'} + size={'sm'} + sx={{ mr: 1 }} + variant={'soft'} + > + + + + ) + })} + + + + + + {renderTimeline()} + + ) +} + +export default NotificationTemplate diff --git a/src/views/ChoreEdit/ChoreEdit.jsx b/src/views/ChoreEdit/ChoreEdit.jsx index 27dd9a7..e2c8bc0 100644 --- a/src/views/ChoreEdit/ChoreEdit.jsx +++ b/src/views/ChoreEdit/ChoreEdit.jsx @@ -24,14 +24,15 @@ import { Typography, } from '@mui/joy' import moment from 'moment' -import { useContext, useEffect, useState } from 'react' +import { useEffect, useState } from 'react' import { useNavigate, useParams, useSearchParams } from 'react-router-dom' -import { UserContext } from '../../contexts/UserContext' +import NotificationTemplate from '../../components/NotificationTemplate.jsx' import { useChore, useCreateChore, useUpdateChore, } from '../../queries/ChoreQueries.jsx' +import { useUserProfile } from '../../queries/UserQueries.jsx' import { getTextColorFromBackgroundColor } from '../../utils/Colors.jsx' import { DeleteChore, @@ -61,7 +62,8 @@ const REPEAT_ON_TYPE = ['interval', 'days_of_the_week', 'day_of_the_month'] const NO_DUE_DATE_REQUIRED_TYPE = ['no_repeat', 'once'] const NO_DUE_DATE_ALLOWED_TYPE = ['trigger'] const ChoreEdit = () => { - const { userProfile, setUserProfile } = useContext(UserContext) + const { data: userProfile } = useUserProfile() + const [chore, setChore] = useState([]) const [choresHistory, setChoresHistory] = useState([]) const [userHistory, setUserHistory] = useState({}) @@ -708,6 +710,10 @@ const ChoreEdit = () => { { setIsNotificable(e.target.checked) + // if unchecking, reset notification metadata: + if (!e.target.checked) { + setNotificationMetadata({}) + } }} defaultChecked={isNotificable} checked={isNotificable} @@ -726,7 +732,6 @@ const ChoreEdit = () => { {isNotificable && ( { }} > - - What things should trigger the notification? - - {[ - { - title: 'Due Date/Time', - description: 'A simple reminder that a task is due', - id: 'dueDate', - }, - // { - // title: 'Upon Completion', - // description: 'A notification when a task is completed', - // id: 'completion', - // }, - { - title: 'Predued', - description: 'before a task is due in few hours', - id: 'predue', - }, - // { - // title: 'Overdue', - // description: 'A notification when a task is overdue', - // id: 'overdue', - // }, - { - title: 'Nagging', - description: 'Daily reminders until the task is completed', - id: 'nagging', - }, - ].map(item => ( - - { - setNotificationMetadata({ - ...notificationMetadata, - [item.id]: !notificationMetadata[item.id], - }) - }} - checked={ - notificationMetadata ? notificationMetadata[item.id] : false + Notification Schedule: + + { + const newNotificaitonMetadata = { + ...notificationMetadata, + templates: metadata.notifications, } - label={item.title} - key={item.title} - /> - {item.description} - - ))} - - - What things should trigger the notification? - + setNotificationMetadata(newNotificaitonMetadata) + }} + value={notificationMetadata} + /> + + Choose Who to Notify: { onChange={(event, newValue) => { setLabelsV2(userLabels.filter(l => newValue.indexOf(l.name) > -1)) }} - value={labelsV2.map(l => l.name)} + value={labelsV2?.map(l => l.name)} renderValue={selected => ( {labelsV2.map(selectedOption => { diff --git a/src/views/components/AddTaskModal.jsx b/src/views/components/AddTaskModal.jsx index 55d6f38..93a82fc 100644 --- a/src/views/components/AddTaskModal.jsx +++ b/src/views/components/AddTaskModal.jsx @@ -1,4 +1,4 @@ -import { Add } from '@mui/icons-material' +import { Add, EditNotifications } from '@mui/icons-material' import { Box, Button, @@ -14,15 +14,20 @@ import { import { FormControl } from '@mui/material' import * as chrono from 'chrono-node' import moment from 'moment' -import { useCallback, useContext, useEffect, useRef, useState } from 'react' -import { UserContext } from '../../contexts/UserContext' +import { useCallback, useEffect, useRef, useState } from 'react' import { useCreateChore } from '../../queries/ChoreQueries' -import { useCircleMembers } from '../../queries/UserQueries' +import { useCircleMembers, useUserProfile } from '../../queries/UserQueries' import { isPlusAccount } from '../../utils/Helpers' import { useLabels } from '../Labels/LabelQueries' -import { parseLabels, parsePriority, parseRepeatV2 } from './CustomParsers' +import { + parseDueDate, + parseLabels, + parsePriority, + parseRepeatV2, +} from './CustomParsers' import SmartTaskTitleInput from './SmartTaskTitleInput' +import NotificationTemplate from '../../components/NotificationTemplate' import LearnMoreButton from './LearnMore' import RichTextEditor from './RichTextEditor' import SubTasks from './SubTask' @@ -33,7 +38,8 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => { useCircleMembers() const createChoreMutation = useCreateChore() - const { userProfile } = useContext(UserContext) + const { data: userProfile } = useUserProfile() + const [taskText, setTaskText] = useState('') const [taskTitle, setTaskTitle] = useState('') const [renderedParts, setRenderedParts] = useState([]) @@ -46,11 +52,14 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => { const [assignees, setAssignees] = useState([]) const [labelsV2, setLabelsV2] = useState([]) const [frequency, setFrequency] = useState(null) + const [notificationMetadata, setNotificationMetadata] = useState({ + templates: [], + }) const [frequencyHumanReadable, setFrequencyHumanReadable] = useState(null) const [subTasks, setSubTasks] = useState(null) const [hasDescription, setHasDescription] = useState(false) const [hasSubTasks, setHasSubTasks] = useState(false) - + const [hasNotifications, setHasNotifications] = useState(false) useEffect(() => { if (isModalOpen && textareaRef.current) { textareaRef.current.focus() @@ -233,25 +242,22 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => { // }, // ]) // } - const parsedDueDate = chrono.parse(sentence, new Date(), { - forwardDate: true, - }) - if (parsedDueDate[0]?.index > -1) { - setDueDate( - moment(parsedDueDate[0].start.date()).format('YYYY-MM-DDTHH:mm:ss'), - ) - cleanedSentence = cleanedSentence.replace(parsedDueDate[0].text, '') + // Parse due date + const dueDateParsed = parseDueDate(sentence, chrono) + let dueDateHighlight = null + if (dueDateParsed.result) { + setDueDate(moment(dueDateParsed.result).format('YYYY-MM-DDTHH:mm:ss')) + cleanedSentence = dueDateParsed.cleanedSentence + dueDateHighlight = dueDateParsed.highlight[0] } if (repeat.result) { // if repeat has result the cleaned sentence will remove the date related info which mean // we need to reparse the date again to get the correct due date: - const parsedDueDate = chrono.parse(sentence, new Date(), { - forwardDate: true, - }) - if (parsedDueDate[0]?.index > -1) { + const dueDateParsedAgain = parseDueDate(sentence, chrono) + if (dueDateParsedAgain.result) { setDueDate( - moment(parsedDueDate[0].start.date()).format('YYYY-MM-DDTHH:mm:ss'), + moment(dueDateParsedAgain.result).format('YYYY-MM-DDTHH:mm:ss'), ) } } @@ -263,19 +269,13 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => { repeat.highlight, priority.highlight, labels.highlight, - parsedDueDate && parsedDueDate[0] - ? { - start: parsedDueDate[0].index, - end: parsedDueDate[0].index + parsedDueDate[0].text.length, - text: parsedDueDate[0].text, - } - : null, + dueDateHighlight, ) setRenderedParts(parts) setTaskTitle(plainText) }, - [circleMembers, userLabels, userProfile, renderHighlightedSentence], + [userLabels, renderHighlightedSentence], ) useEffect(() => { @@ -313,9 +313,11 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => { } const handleSubmit = () => { - createChore() - handleCloseModal() - setTaskText('') + console.log('Submitting task:', isPlusAccount(userProfile)) + + // createChore() + // handleCloseModal() + // setTaskText('') } const createChore = () => { @@ -327,8 +329,7 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => { assignedTo: assignees.length > 0 ? assignees[0].userId : userProfile.id, assignStrategy: 'random', isRolling: false, - notification: false, - description: description || null, + labelsV2: labelsV2, priority: priority ? Number(priority) : 0, status: 0, @@ -342,9 +343,9 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => { chore.frequencyType = frequency.frequencyType chore.frequencyMetadata = frequency.frequencyMetadata chore.frequency = frequency.frequency - if (isPlusAccount()) { + if (isPlusAccount(userProfile)) { chore.notification = true - chore.notificationMetadata = { dueDate: true } + chore.notificationMetadata = notificationMetadata } } if (!frequency && dueDate) { @@ -518,6 +519,23 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => { Due Date )} + {!hasNotifications && dueDate && ( + + )} {hasDescription && ( @@ -606,10 +624,29 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => { )} */} - - Frequency - - + {hasNotifications && dueDate && ( + + Notification Schedule + + { + const newNotificaitonMetadata = { + ...notificationMetadata, + templates: metadata.notifications, + } + setNotificationMetadata(newNotificaitonMetadata) + }} + value={notificationMetadata} + showTimeline={false} + /> + + + )} Date: Sun, 8 Jun 2025 02:07:33 -0400 Subject: [PATCH 3/7] refactor: Update LoginView to use useUserProfile hook and clean up user profile handling - fix bug cause navigation loop --- src/views/Authorization/LoginView.jsx | 49 +++++++++++++-------------- 1 file changed, 23 insertions(+), 26 deletions(-) diff --git a/src/views/Authorization/LoginView.jsx b/src/views/Authorization/LoginView.jsx index aba8e6d..11261ef 100644 --- a/src/views/Authorization/LoginView.jsx +++ b/src/views/Authorization/LoginView.jsx @@ -16,24 +16,26 @@ import { Typography, } from '@mui/joy' import Cookies from 'js-cookie' -import React, { useEffect } from 'react' +import { useEffect, useState } from 'react' import { useNavigate } from 'react-router-dom' import { LoginSocialGoogle } from 'reactjs-social-login' import { GOOGLE_CLIENT_ID, REDIRECT_URL } from '../../Config' -import { UserContext } from '../../contexts/UserContext' import Logo from '../../Logo' import { useResource } from '../../queries/ResourceQueries' -import { GetUserProfile, login } from '../../utils/Fetcher' +import { useUserProfile } from '../../queries/UserQueries' +import { login } from '../../utils/Fetcher' import { apiManager } from '../../utils/TokenManager' import MFAVerificationModal from './MFAVerificationModal' const LoginView = () => { - const { userProfile, setUserProfile } = React.useContext(UserContext) - const [username, setUsername] = React.useState('') - const [password, setPassword] = React.useState('') - const [error, setError] = React.useState(null) - const [mfaModalOpen, setMfaModalOpen] = React.useState(false) - const [mfaSessionToken, setMfaSessionToken] = React.useState('') + // Only fetch user profile if token is valid to prevent unnecessary queries + const { data: userProfileData } = useUserProfile() + const [userProfile, setUserProfile] = useState(null) + const [username, setUsername] = useState('') + const [password, setPassword] = useState('') + const [error, setError] = useState(null) + const [mfaModalOpen, setMfaModalOpen] = useState(false) + const [mfaSessionToken, setMfaSessionToken] = useState('') const { data: resource } = useResource() const Navigate = useNavigate() useEffect(() => { @@ -136,19 +138,17 @@ const LoginView = () => { }) } const getUserProfileAndNavigateToHome = () => { - GetUserProfile().then(data => { - data.json().then(data => { - setUserProfile(data.res) - // check if redirect url is set in cookie: - const redirectUrl = Cookies.get('ca_redirect') - if (redirectUrl) { - Cookies.remove('ca_redirect') - Navigate(redirectUrl) - } else { - Navigate('/my/chores') - } - }) - }) + // Refetch user profile after login + // refetchUserProfile().then(() => { + // // check if redirect url is set in cookie: + const redirectUrl = Cookies.get('ca_redirect') + if (redirectUrl) { + Cookies.remove('ca_redirect') + Navigate(redirectUrl) + } else { + Navigate('/my/chores') + } + // }) } const handleMFASuccess = data => { @@ -274,7 +274,6 @@ const LoginView = () => { type='submit' fullWidth size='lg' - q variant='plain' sx={{ width: '100%', @@ -283,7 +282,6 @@ const LoginView = () => { borderRadius: '8px', }} onClick={() => { - setUserProfile(null) localStorage.removeItem('ca_token') localStorage.removeItem('ca_expiration') // go to login page: @@ -353,7 +351,6 @@ const LoginView = () => { type='submit' fullWidth size='lg' - q variant='plain' sx={{ width: '100%', @@ -382,7 +379,7 @@ const LoginView = () => { onResolve={({ provider, data }) => { loggedWithProvider(provider, data) }} - onReject={err => { + onReject={() => { setError("Couldn't log in with Google, please try again") }} > From e21e71b442db3539094588d64e7255f2ecb72046 Mon Sep 17 00:00:00 2001 From: Mo Tarbin Date: Sun, 8 Jun 2025 02:09:23 -0400 Subject: [PATCH 4/7] feat: Refactor user profile management and improve token validation - Introduced `useUserProfile` hook to centralize user profile fetching and management. - Updated various components to utilize the new `useUserProfile` hook instead of context. - Enhanced token validation logic in `Fetch` function to prevent unnecessary redirects. - Added `parseDueDate` function to handle due date parsing with improved logic. - Cleaned up user profile state management across multiple views and settings. - Improved loading states and error handling in user-related components. - remove usercontext and just use react query for userProfile --- src/App.jsx | 29 +-- src/components/NotificationTemplate.jsx | 257 +++++++++++---------- src/queries/UserQueries.jsx | 31 ++- src/utils/TokenManager.jsx | 6 +- src/views/Authorization/Authenticating.jsx | 25 +- src/views/ChoreEdit/RepeatSection.jsx | 16 +- src/views/Chores/ChoreCard.jsx | 5 +- src/views/Chores/CompactChoreCard.jsx | 8 +- src/views/Chores/MyChores.jsx | 41 +++- src/views/Chores/WelcomeCard.jsx | 8 +- src/views/Circles/JoinCircle.jsx | 6 +- src/views/Settings/APITokenSettings.jsx | 7 +- src/views/Settings/MFASettings.jsx | 5 - src/views/Settings/NotificationSetting.jsx | 29 +-- src/views/Settings/ProfileSettings.jsx | 8 +- src/views/Settings/Settings.jsx | 13 +- src/views/Settings/StorageSettings.jsx | 6 +- src/views/User/UserActivities.jsx | 7 +- src/views/components/CalendarView.jsx | 8 +- src/views/components/CustomParsers.js | 77 ++++++ 20 files changed, 353 insertions(+), 239 deletions(-) diff --git a/src/App.jsx b/src/App.jsx index 6aa369c..cbad980 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -7,12 +7,10 @@ import { Outlet, useNavigate } from 'react-router-dom' import { useRegisterSW } from 'virtual:pwa-register/react' import { registerCapacitorListeners } from './CapacitorListener' import { ImpersonateUserProvider } from './contexts/ImpersonateUserContext' -import { UserContext } from './contexts/UserContext' import { useResource } from './queries/ResourceQueries' import { AuthenticationProvider } from './service/AuthenticationService' import { ErrorProvider } from './service/ErrorProvider' -import { GetUserProfile } from './utils/Fetcher' -import { apiManager, isTokenValid } from './utils/TokenManager' +import { apiManager } from './utils/TokenManager' import NetworkBanner from './views/components/NetworkBanner' const add = className => { document.getElementById('root').classList.add(className) @@ -23,15 +21,14 @@ const remove = className => { } // TODO: Update the interval to at 60 minutes const intervalMS = 5 * 60 * 1000 // 5 minutes - +const queryClient = new QueryClient({}) function App() { const resource = useResource() const navigate = useNavigate() startApiManager(navigate) startOpenReplay() - const queryClient = new QueryClient() + const { mode, systemMode } = useColorScheme() - const [userProfile, setUserProfile] = useState(null) const [showUpdateSnackbar, setShowUpdateSnackbar] = useState(true) const { @@ -72,23 +69,12 @@ function App() { return remove('dark') } - const getUserProfile = () => { - GetUserProfile() - .then(res => { - res.json().then(data => { - setUserProfile(data.res) - }) - }) - .catch(error => {}) - } + useEffect(() => { setThemeClass() }, [mode, systemMode]) useEffect(() => { registerCapacitorListeners() - if (isTokenValid()) { - if (!userProfile) getUserProfile() - } }, []) return ( @@ -99,10 +85,8 @@ function App() { - - - - + + @@ -133,6 +117,7 @@ const startOpenReplay = () => { const tracker = new Tracker({ projectKey: import.meta.env.VITE_OPENREPLAY_PROJECT_KEY, }) + tracker.start() } export default App diff --git a/src/components/NotificationTemplate.jsx b/src/components/NotificationTemplate.jsx index 9970ea9..bb02182 100644 --- a/src/components/NotificationTemplate.jsx +++ b/src/components/NotificationTemplate.jsx @@ -1,3 +1,4 @@ +import { Save } from '@mui/icons-material' import AddIcon from '@mui/icons-material/Add' import DeleteIcon from '@mui/icons-material/Delete' import InfoIcon from '@mui/icons-material/Info' @@ -14,66 +15,64 @@ import Typography from '@mui/joy/Typography' import { useCallback, useEffect, useState } from 'react' const timeUnits = [ - { label: 'Minutes', value: 'minutes' }, + { label: 'Mins', value: 'minutes' }, { label: 'Hours', value: 'hours' }, { label: 'Days', value: 'days' }, ] const beforeAfterOptions = [ - { label: 'Before Due', value: 'before' }, + { label: 'Before', value: 'before' }, { label: 'On Due', value: 'ondue' }, - { label: 'After Due', value: 'after' }, + { label: 'After', value: 'after' }, ] function getRelativeLabel(notification) { - const { amount, unit, when } = notification - - // For "On Due" notification - if (when === 'ondue') { + const { value, unit, type } = notification + if (type === 'ondue') { return 'On due date' } - - // For before/after notifications - return `${amount} ${unit} ${when === 'before' ? 'before' : 'after'} due` + return `${value} ${unit} ${type === 'before' ? 'before' : 'after'} due` } -const NotificationTemplate = ({ maxNotifications = 5, onChange, value }) => { +const NotificationTemplate = ({ + maxNotifications = 5, + onChange, + value, + showTimeline = true, +}) => { const [templateName, setTemplateName] = useState( value?.name || 'New Notification Template', ) - const [notifications, setNotifications] = useState([ - value?.notifications || { - amount: 0, - when: 'ondue', - }, - ]) - const [error, setError] = useState(null) + const [notifications, setNotifications] = useState( + value?.templates || + JSON.parse(localStorage.getItem('defaultNotificationTemplate')) || + [], + ) + const [error, setError] = useState(null) + const [showSaveDefault, setShowSaveDefault] = useState(false) // Create a map of notification indices for timeline display const [notificationIndexMap, setNotificationIndexMap] = useState({}) - // Use useCallback to memoize the function const updateNotificationIndices = useCallback(() => { // Sort notifications for consistent ordering const sorted = [...notifications].sort((a, b) => { // Always ensure correct ordering: Before Due -> On Due -> After Due - if (a.when !== b.when) { - // Before Due always comes first - if (a.when === 'before') return -1 - if (b.when === 'before') return 1 + if (a.type !== b.type) { + // Before Due first + if (a.type === 'before') return -1 + if (b.type === 'before') return 1 // On Due comes before After Due - if (a.when === 'ondue') return -1 - if (b.when === 'ondue') return 1 - - // Default case (should not be reached with our options) + if (a.type === 'ondue') return -1 + if (b.type === 'ondue') return 1 + // DEFAULT CASE ( NOT SURE FOR FUTURE )? return 0 } - // Convert everything to minutes for consistent comparison const getMinutes = notif => { - const { amount, unit } = notif - let minutes = amount + const { value, unit } = notif + let minutes = value if (unit === 'hours') minutes *= 60 if (unit === 'days') minutes *= 24 * 60 return minutes @@ -83,17 +82,16 @@ const NotificationTemplate = ({ maxNotifications = 5, onChange, value }) => { // For After Due: sort in ascending order (closest to due first) const aMinutes = getMinutes(a) const bMinutes = getMinutes(b) - return a.when === 'before' ? bMinutes - aMinutes : aMinutes - bMinutes + return a.type === 'before' ? bMinutes - aMinutes : aMinutes - bMinutes }) - // Create index mapping const indexMap = {} sorted.forEach((item, index) => { const originalIdx = notifications.findIndex( n => - n.amount === item.amount && + n.value === item.value && n.unit === item.unit && - n.when === item.when, + n.type === item.type, ) indexMap[originalIdx] = index + 1 }) @@ -104,27 +102,25 @@ const NotificationTemplate = ({ maxNotifications = 5, onChange, value }) => { // Sort notifications and update the index mapping useEffect(() => { updateNotificationIndices() - // Clear any errors when notifications change setError(null) }, [updateNotificationIndices]) // Notify parent component of changes including the template name useEffect(() => { if (onChange) { - onChange({ name: templateName, notifications }) + onChange({ notifications }) } - }, [templateName, notifications, onChange]) + }, [notifications, onChange]) // Validates if a notification configuration already exists const isDuplicate = (notification, currentIdx = -1) => { return notifications.some((n, idx) => { - // Skip comparing with itself when editing if (idx === currentIdx) return false return ( - n.amount === notification.amount && + n.value === notification.value && n.unit === notification.unit && - n.when === notification.when + n.type === notification.type ) }) } @@ -136,17 +132,17 @@ const NotificationTemplate = ({ maxNotifications = 5, onChange, value }) => { } // Special handling for "On Due" option - if (field === 'when' && value === 'ondue') { + if (field === 'type' && value === 'ondue') { // Set default values for On Due (not applicable) updatedNotification = { ...updatedNotification, - amount: 0, - unit: 'minutes', // default unit, not displayed to user + value: 1, + unit: 'minutes', } // Check if another notification is already "On Due" const existingOnDue = notifications.findIndex( - (n, i) => i !== idx && n.when === 'ondue', + (n, i) => i !== idx && n.type === 'ondue', ) if (existingOnDue !== -1) { @@ -157,7 +153,6 @@ const NotificationTemplate = ({ maxNotifications = 5, onChange, value }) => { } } - // Check for duplicates when changing a notification if (isDuplicate(updatedNotification, idx)) { setError( 'This notification setting already exists. Please use a different timing.', @@ -172,12 +167,9 @@ const NotificationTemplate = ({ maxNotifications = 5, onChange, value }) => { setError(null) } - const handleNameChange = e => { - setTemplateName(e.target.value) - } const addSmartNotification = type => { if (notifications.length >= maxNotifications) return - + setShowSaveDefault(true) let newNotification let suggestions = [] @@ -185,28 +177,28 @@ const NotificationTemplate = ({ maxNotifications = 5, onChange, value }) => { case 'reminder': // Suggest common reminder times that don't exist suggestions = [ - { amount: 1, unit: 'hours', when: 'before' }, - { amount: 1, unit: 'days', when: 'before' }, - { amount: 30, unit: 'minutes', when: 'before' }, - { amount: 2, unit: 'hours', when: 'before' }, - { amount: 3, unit: 'days', when: 'before' }, + { value: 1, unit: 'hours', type: 'before' }, + { value: 1, unit: 'days', type: 'before' }, + { value: 30, unit: 'minutes', type: 'before' }, + { value: 2, unit: 'hours', type: 'before' }, + { value: 3, unit: 'days', type: 'before' }, ] break case 'due': - if (notifications.some(n => n.when === 'ondue')) { + if (notifications.some(n => n.type === 'ondue')) { setError('Only one "Due Alert" notification is allowed.') return } - newNotification = { amount: 0, unit: 'minutes', when: 'ondue' } + newNotification = { value: 0, unit: 'minutes', type: 'ondue' } break case 'followup': suggestions = [ - { amount: 1, unit: 'hours', when: 'after' }, - { amount: 1, unit: 'days', when: 'after' }, - { amount: 3, unit: 'days', when: 'after' }, - { amount: 1, unit: 'weeks', when: 'after' }, + { value: 1, unit: 'hours', type: 'after' }, + { value: 1, unit: 'days', type: 'after' }, + { value: 3, unit: 'days', type: 'after' }, + { value: 1, unit: 'weeks', type: 'after' }, ] break } @@ -226,14 +218,14 @@ const NotificationTemplate = ({ maxNotifications = 5, onChange, value }) => { (a, b) => { // Convert everything to minutes for consistent comparison const getMinutes = notif => { - const { amount, unit, when } = notif + const { value, unit, type } = notif // On Due is exactly at due date (0 minutes) - if (when === 'ondue') return 0 + if (type === 'ondue') return 0 - let minutes = amount + let minutes = value if (unit === 'hours') minutes *= 60 if (unit === 'days') minutes *= 24 * 60 - return when === 'before' ? -minutes : minutes + return type === 'before' ? -minutes : minutes } return getMinutes(a) - getMinutes(b) @@ -249,20 +241,19 @@ const NotificationTemplate = ({ maxNotifications = 5, onChange, value }) => { setNotifications(updated) onChange && onChange(updated) } - // Visualization: improved timeline with better scaling const renderTimeline = () => { // Sort notifications chronologically const sorted = [...notifications].sort((a, b) => { // Convert everything to minutes for consistent comparison const getMinutes = notif => { - const { amount, unit, when } = notif + const { value, unit, type } = notif // On Due is exactly at due date (0 minutes) - if (when === 'ondue') return 0 + if (type === 'ondue') return 0 - let minutes = amount + let minutes = value if (unit === 'hours') minutes *= 60 if (unit === 'days') minutes *= 24 * 60 - return when === 'before' ? -minutes : minutes + return type === 'before' ? -minutes : minutes } return getMinutes(a) - getMinutes(b) @@ -274,27 +265,24 @@ const NotificationTemplate = ({ maxNotifications = 5, onChange, value }) => { // Find the original index of this item in notifications array const originalIdx = notifications.findIndex( n => - n.amount === item.amount && + n.value === item.value && n.unit === item.unit && - n.when === item.when, + n.type === item.type, ) notificationIndexMap[originalIdx] = index + 1 }) // Get min and max notification times for dynamic scaling const minutesValues = sorted.map(n => { - // On Due is exactly at due date (0 minutes) - if (n.when === 'ondue') return 0 + if (n.type === 'ondue') return 0 - let minutes = n.amount + let minutes = n.value if (n.unit === 'hours') minutes *= 60 if (n.unit === 'days') minutes *= 24 * 60 - return n.when === 'before' ? -minutes : minutes + return n.type === 'before' ? -minutes : minutes }) - // Find min (before) and max (after) notification times const minBefore = Math.min(0, ...minutesValues) // Default to 0 if no "before" notifications const maxAfter = Math.max(0, ...minutesValues) // Default to 0 if no "after" notifications - // Dynamic scaling based on notification range const getPositionPercent = minutes => { // Due date is always at center (50%) if (minutes === 0) return 50 @@ -314,7 +302,7 @@ const NotificationTemplate = ({ maxNotifications = 5, onChange, value }) => { return ( - + Notification Timeline { display: 'flex', flexDirection: 'column', position: 'relative', - height: 100, + height: 90, bgcolor: 'background.level1', borderRadius: 'md', p: 2, @@ -375,11 +363,11 @@ const NotificationTemplate = ({ maxNotifications = 5, onChange, value }) => { {sorted.map((n, i) => { // Convert to minutes for consistent scale let minutes = 0 - if (n.when !== 'ondue') { - minutes = n.amount + if (n.type !== 'ondue') { + minutes = n.value if (n.unit === 'hours') minutes *= 60 if (n.unit === 'days') minutes *= 24 * 60 - if (n.when === 'before') minutes = -minutes + if (n.type === 'before') minutes = -minutes } // On Due notifications are always at the due date (0 minutes) @@ -394,9 +382,9 @@ const NotificationTemplate = ({ maxNotifications = 5, onChange, value }) => { left: `${percent}%`, transform: 'translateX(-50%)', color: - n.when === 'before' + n.type === 'before' ? 'primary.600' - : n.when === 'ondue' + : n.type === 'ondue' ? 'warning.600' : 'success.600', display: 'flex', @@ -418,11 +406,11 @@ const NotificationTemplate = ({ maxNotifications = 5, onChange, value }) => { size={'sm'} variant={'solid'} color={ - n.when === 'before' - ? 'primary' - : n.when === 'ondue' + n.type === 'before' + ? 'success' + : n.type === 'ondue' ? 'warning' - : 'success' + : 'danger' } sx={{ '--Badge-paddingX': '4px', @@ -452,22 +440,24 @@ const NotificationTemplate = ({ maxNotifications = 5, onChange, value }) => { return ( - + {/* Schedule Name - + */} {/* Template Name Field */} - + {/* Template Name @@ -477,7 +467,7 @@ const NotificationTemplate = ({ maxNotifications = 5, onChange, value }) => { placeholder='Enter template name' sx={{ width: '100%' }} /> - + */} {error && ( { sx={{ display: 'flex', alignItems: 'center', - justifyContent: 'center', - width: 30, - mr: 1, + justifyContent: 'start', + width: 18, + flexShrink: 0, }} > @@ -512,23 +502,23 @@ const NotificationTemplate = ({ maxNotifications = 5, onChange, value }) => { sx={{ '--Badge-minHeight': '20px', '--Badge-fontSize': '0.75rem', + // centering the badge: }} color={ - n.when === 'before' - ? 'primary' - : n.when === 'ondue' + n.type === 'before' + ? 'success' + : n.type === 'ondue' ? 'warning' - : 'success' + : 'danger' } > - {/* Empty box to attach badge to */} - handleChange(idx, 'amount', Math.max(1, Number(e.target.value))) + handleChange(idx, 'value', Math.max(1, Number(e.target.value))) } sx={{ width: 70, mr: 1, - opacity: n.when === 'ondue' ? 0.6 : 1, - ...(n.when === 'ondue' && { + opacity: n.type === 'ondue' ? 0.6 : 1, + ...(n.type === 'ondue' && { '& input': { textAlign: 'center', }, }), }} size={'sm'} - placeholder={n.when === 'ondue' ? '—' : ''} + placeholder={n.type === 'ondue' ? '—' : ''} /> +