Refactor chore management modals and actions for centralized handling

- Removed individual modal states and handlers from ChoreCard and CompactChoreCard components.
- Introduced a centralized modal state and handler in MyChores component to manage modals for changing due dates, completing with past dates, changing assignees, adding notes, writing NFC, and nudging.
- Updated action handlers to utilize the new centralized approach for better maintainability and readability.
- Cleaned up unused imports and code related to modal management in ChoreCard and CompactChoreCard components.
This commit is contained in:
Mo Tarbin
2025-09-27 13:47:41 -04:00
parent 9ff2359b4c
commit c27404cc3a
7 changed files with 544 additions and 548 deletions

View File

@@ -78,6 +78,9 @@ const BottomSheetModal = forwardRef(
// Calculate current height
const currentHeight = isExpanded ? expandedHeight : height
// Filter out DOM props that shouldn't be passed to Modal
const { fullWidth: _fullWidth, unmountDelay: _unmountDelay, ...modalProps } = props
return (
<Modal
open={internalOpen}
@@ -92,7 +95,7 @@ const BottomSheetModal = forwardRef(
justifyContent: 'center',
}}
keepMounted
{...props}
{...modalProps}
>
<Sheet
ref={ref}

View File

@@ -14,6 +14,8 @@ const FadeModal = ({
backdropBlur = true,
...props
}) => {
// Filter out props that shouldn't be passed to Modal
const { unmountDelay: _unmountDelay, ...modalProps } = props
return (
<Modal
open={open}
@@ -34,7 +36,7 @@ const FadeModal = ({
exit: 'cubic-bezier(0.4, 0, 0.2, 1)', // Standard ease out
},
}}
{...props}
{...modalProps}
>
<ModalOverflow>
<ModalDialog

View File

@@ -6,6 +6,7 @@ import {
ApproveChore,
ArchiveChore,
CreateChore,
DeleteChore,
DeleteChoreHistory,
GetChoreByID,
GetChoreDetailById,
@@ -66,41 +67,106 @@ export const useChores = includeArchive => {
},
})
}
export const useDeleteChores = () => {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async choreIds => {
// If offline mode is enabled and we're offline, handle deletion locally
if (!networkManager.isOnline && isFeatureEnabled(FEATURES.OFFLINE_MODE)) {
const offlineTasks =
(await localStore.getFromCache('offlineTasks')) || []
const updatedOfflineTasks = offlineTasks.filter(
task =>
!choreIds.includes(task.id) && !choreIds.includes(task.tempId),
)
await localStore.saveToCache('offlineTasks', updatedOfflineTasks)
// Force the chores query to refetch
queryClient.invalidateQueries(['chores'])
return
}
// If online, proceed with server-side deletion
await Promise.all(
choreIds.map(async id => {
const resp = await DeleteChore(id)
if (!resp || !resp.ok) {
throw new Error(`Failed to delete chore with ID: ${id}`)
}
}),
)
},
onSuccess: () => {
queryClient.invalidateQueries(['chores'])
},
})
}
export const useCreateChore = () => {
const queryClient = useQueryClient()
return useMutation({
mutationFn: CreateChore,
onMutate: async newTask => {
if (!networkManager.isOnline && isFeatureEnabled(FEATURES.OFFLINE_MODE)) {
const tempId = crypto.randomUUID() // Generate temp ID
const offlineTasks =
(await localStore.getFromCache('offlineTasks')) || []
const updateOfflineTasks = [
...offlineTasks,
{ ...newTask, id: tempId, tempId }, // Use the tempId for offline tracking
]
await localStore.saveToCache('offlineTasks', updateOfflineTasks) // Save to local storage
// force useChores to refetch:
queryClient.invalidateQueries(['chores'])
// Force the chores query to refetch
queryClient.refetchQueries(['chores'])
// Update the chores query cache immediately
// queryClient.setQueryData(['chores'], oldData => {
// console.log('ATTEMPT TO SAVE OFFLINE TASKS:', updateOfflineTasks)
// if (!oldData)
// return {
// res: [{ ...newTask, id: tempId, tempId }],
// } // If no data, return offline tasks
// return {
// res: [...oldData.res, { ...newTask, id: tempId, tempId }],
// }
// })
return { tempId }
mutationFn: async newTask => {
const resp = await CreateChore(newTask)
if (!resp || !resp.ok) {
throw new Error('Failed to create chore')
}
return { tempId: null }
const createdChore = await resp.json()
if (!createdChore) {
throw new Error('Failed to get created chore data')
}
// Successfully created the chore on the server, return the created chore
// update the local chores cache with the new chore:
queryClient.setQueryData(['chores'], oldData => {
if (!oldData) return { res: [createdChore.res] }
return { res: [...oldData.res, createdChore.res] }
})
return { res: createdChore }
},
// onMutate: async newTask => {
// if (!networkManager.isOnline && isFeatureEnabled(FEATURES.OFFLINE_MODE)) {
// const tempId = crypto.randomUUID() // Generate temp ID
// const offlineTasks =
// (await localStore.getFromCache('offlineTasks')) || []
// const updateOfflineTasks = [
// ...offlineTasks,
// { ...newTask, id: tempId, tempId }, // Use the tempId for offline tracking
// ]
// await localStore.saveToCache('offlineTasks', updateOfflineTasks) // Save to local storage
// // force useChores to refetch:
// queryClient.invalidateQueries(['chores'])
// // Force the chores query to refetch
// queryClient.refetchQueries(['chores'])
// // Update the chores query cache immediately
// // queryClient.setQueryData(['chores'], oldData => {
// // console.log('ATTEMPT TO SAVE OFFLINE TASKS:', updateOfflineTasks)
// // if (!oldData)
// // return {
// // res: [{ ...newTask, id: tempId, tempId }],
// // } // If no data, return offline tasks
// // return {
// // res: [...oldData.res, { ...newTask, id: tempId, tempId }],
// // }
// // })
// return { tempId }
// }
// const tempId = crypto.randomUUID() // Generate temp ID
// // Update the chores query cache immediately
// queryClient.setQueryData(['chores'], oldData => {
// if (!oldData)
// return {
// res: [{ ...newTask, id: tempId, tempId }],
// } // If no data, return offline tasks
// return {
// res: [...oldData.res, { ...newTask, id: tempId, tempId }],
// }
// })
// return { tempId: null }
// },
onSuccess: () => {
// Invalidate the chores query to refresh the data
queryClient.invalidateQueries(['chores'])
},
})
}
@@ -157,6 +223,15 @@ export const useUpdateChore = () => {
throw new Error('Failed to get updated chore data')
}
// Successfully updated the chore on the server, return the updated chore
// update the local chores cache with the updated chore:
queryClient.setQueryData(['chores'], oldData => {
if (!oldData) return { res: [updatedChore] }
return {
res: oldData.res.map(chore =>
chore.id === updatedChore.id ? updatedChore : chore,
),
}
})
return updatedChoreRes?.res || updatedChoreRes
}
},

View File

@@ -26,8 +26,10 @@ import { useEffect, useState } from 'react'
import { useNavigate, useParams, useSearchParams } from 'react-router-dom'
import NotificationTemplate from '../../components/NotificationTemplate.jsx'
import {
useArchiveChore,
useChore,
useCreateChore,
useUnArchiveChore,
useUpdateChore,
} from '../../queries/ChoreQueries.jsx'
import { useCircleMembers, useUserProfile } from '../../queries/UserQueries.jsx'
@@ -56,6 +58,7 @@ const ASSIGN_STRATEGIES = [
'keep_last_assigned',
'random_except_last_assigned',
'round_robin',
'no_assignee',
]
const REPEAT_ON_TYPE = ['interval', 'days_of_the_week', 'day_of_the_month']
@@ -111,6 +114,8 @@ const ChoreEdit = () => {
const { data: userLabelsRaw, isLoading: isUserLabelsLoading } = useLabels()
const updateChoreMutation = useUpdateChore()
const createChoreMutation = useCreateChore()
const archiveChore = useArchiveChore()
const unarchiveChore = useUnArchiveChore()
const {
data: choreData,
isLoading: isChoreLoading,
@@ -136,11 +141,13 @@ const ChoreEdit = () => {
if (name.trim() === '') {
errors.name = 'Name is required'
}
if (assignees.length === 0) {
errors.assignees = 'At least 1 assignees is required'
}
if (assignedTo < 0) {
errors.assignedTo = 'Assigned to is required'
if (assignStrategy !== 'no_assignee') {
if (assignees.length === 0) {
errors.assignees = 'At least 1 assignees is required'
}
if (assignedTo === null || assignedTo < 0) {
errors.assignedTo = 'Assigned to is required'
}
}
if (frequencyType === 'interval' && !frequency > 0) {
errors.frequency = `Invalid frequency, the ${frequencyMetadata.unit} should be > 0`
@@ -228,7 +235,7 @@ const ChoreEdit = () => {
frequencyType: frequencyType,
frequency: Number(frequency),
frequencyMetadata: frequencyMetadata,
assignedTo: assignedTo,
assignedTo: assignStrategy === 'no_assignee' ? null : assignedTo,
assignStrategy: assignStrategy,
isRolling: isRolling,
isActive: isActive,
@@ -379,20 +386,26 @@ const ChoreEdit = () => {
}, [frequencyType])
useEffect(() => {
if (assignees.length === 1) {
if (assignees.length === 0) {
setAssignStrategy('no_assignee')
setAssignedTo(null)
} else if (assignees.length === 1) {
setAssignedTo(assignees[0].userId)
if (assignStrategy === 'no_assignee') {
setAssignStrategy(ASSIGN_STRATEGIES[2]) // default to least_completed
}
}
}, [assignees])
}, [assignees, assignStrategy])
useEffect(() => {
if (performers.length > 0 && assignees.length === 0 && userProfile) {
setAssignees([
{
userId: userProfile?.id,
},
])
}
}, [performers, userProfile])
// useEffect(() => {
// if (performers.length > 0 && assignees.length === 0 && userProfile) {
// setAssignees([
// {
// userId: userProfile?.id,
// },
// ])
// }
// }, [performers, userProfile])
// if user resolve the error trigger validation to remove the error message from the respective field
useEffect(() => {
@@ -1221,16 +1234,39 @@ const ChoreEdit = () => {
}}
>
{choreId > 0 && (
<Button
color='danger'
variant='solid'
onClick={() => {
// confirm before deleting:
handleDelete()
}}
>
Delete
</Button>
<>
{isActive ? (
<Button
color='danger'
variant='outlined'
onClick={() => {
archiveChore.mutate(choreId)
}}
>
Archive
</Button>
) : (
<Button
color='neutral'
variant='outlined'
onClick={() => {
unarchiveChore.mutate(choreId)
}}
>
Unarchive
</Button>
)}
<Button
color='danger'
variant='solid'
onClick={() => {
// confirm before deleting:
handleDelete()
}}
>
Delete
</Button>
</>
)}
<Button
color='neutral'

View File

@@ -28,6 +28,7 @@ import {
Snackbar,
Typography,
} from '@mui/joy'
import { config } from 'dotenv'
import moment from 'moment'
import React from 'react'
import { useNavigate } from 'react-router-dom'
@@ -42,18 +43,10 @@ import {
ApproveChore,
DeleteChore,
MarkChoreComplete,
NudgeChore,
RejectChore,
UpdateChoreAssignee,
UpdateDueDate,
} from '../../utils/Fetcher'
import Priorities from '../../utils/Priorities'
import ConfirmationModal from '../Modals/Inputs/ConfirmationModal'
import DateModal from '../Modals/Inputs/DateModal'
import NudgeModal from '../Modals/Inputs/NudgeModal'
import SelectModal from '../Modals/Inputs/SelectModal'
import TextModal from '../Modals/Inputs/TextModal'
import WriteNFCModal from '../Modals/Inputs/WriteNFCModal'
import ChoreActionMenu from '../components/ChoreActionMenu'
const ChoreCard = ({
chore,
@@ -63,22 +56,13 @@ const ChoreCard = ({
sx,
viewOnly,
onChipClick,
onAction,
// Multi-select props
isMultiSelectMode = false,
isSelected = false,
onSelectionToggle,
}) => {
const [isChangeDueDateModalOpen, setIsChangeDueDateModalOpen] =
React.useState(false)
const [isCompleteWithPastDateModalOpen, setIsCompleteWithPastDateModalOpen] =
React.useState(false)
const [isChangeAssigneeModalOpen, setIsChangeAssigneeModalOpen] =
React.useState(false)
const [isCompleteWithNoteModalOpen, setIsCompleteWithNoteModalOpen] =
React.useState(false)
const [confirmModelConfig, setConfirmModelConfig] = React.useState({})
const [isNFCModalOpen, setIsNFCModalOpen] = React.useState(false)
const [isNudgeModalOpen, setIsNudgeModalOpen] = React.useState(false)
const [isOfficialInstance, setIsOfficialInstance] = React.useState(false)
const navigate = useNavigate()
@@ -200,59 +184,6 @@ const ChoreCard = ({
setTimeoutId(id)
}
const handleChangeDueDate = newDate => {
UpdateDueDate(chore.id, newDate).then(response => {
if (response.ok) {
response.json().then(data => {
const newChore = data.res
onChoreUpdate(newChore, 'rescheduled')
})
}
})
}
const handleCompleteWithPastDate = newDate => {
MarkChoreComplete(
chore.id,
impersonatedUser ? { completedBy: impersonatedUser.userId } : null,
new Date(newDate).toISOString(),
null,
).then(response => {
if (response.ok) {
response.json().then(data => {
const newChore = data.res
onChoreUpdate(newChore, 'completed')
})
}
})
}
const handleAssigneChange = assigneeId => {
UpdateChoreAssignee(chore.id, assigneeId).then(response => {
if (response.ok) {
response.json().then(data => {
const newChore = data.res
onChoreUpdate(newChore, 'assigned')
})
}
})
}
const handleCompleteWithNote = note => {
MarkChoreComplete(
chore.id,
impersonatedUser
? { note, completedBy: impersonatedUser.userId }
: { note },
null,
null,
).then(response => {
if (response.ok) {
response.json().then(data => {
const newChore = data.res
onChoreUpdate(newChore, 'completed')
})
}
})
}
const handleApproveChore = () => {
resetSwipe()
@@ -276,32 +207,6 @@ const ChoreCard = ({
})
}
const handleNudge = async ({ choreId, message, notifyAllAssignees }) => {
try {
const response = await NudgeChore(choreId, {
message,
notifyAllAssignees,
})
if (response.ok) {
const data = await response.json()
showNotification({
type: 'success',
title: 'Nudge Sent!',
message: data.message || 'Nudge sent successfully',
})
} else {
throw new Error('Failed to send nudge')
}
} catch (error) {
showError({
title: 'Failed to Send Nudge',
message: error.message || 'Unable to send nudge at this time',
})
} finally {
setIsNudgeModalOpen(false)
resetSwipe()
}
}
// Check if the current user can approve/reject (admin, manager, or task owner)
const canApproveReject = () => {
@@ -807,7 +712,7 @@ const ChoreCard = ({
onClick={e => {
e.stopPropagation()
resetSwipe()
setIsChangeDueDateModalOpen(true)
onAction('changeDueDate', chore)
}}
sx={{
width: 40,
@@ -844,7 +749,7 @@ const ChoreCard = ({
onClick={e => {
e.stopPropagation()
resetSwipe()
setIsNudgeModalOpen(true)
onAction('nudge', chore)
}}
sx={{
width: 40,
@@ -1241,16 +1146,12 @@ const ChoreCard = ({
chore={chore}
onChoreUpdate={onChoreUpdate}
onChoreRemove={onChoreRemove}
onCompleteWithNote={() =>
setIsCompleteWithNoteModalOpen(true)
}
onCompleteWithPastDate={() =>
setIsCompleteWithPastDateModalOpen(true)
}
onChangeAssignee={() => setIsChangeAssigneeModalOpen(true)}
onChangeDueDate={() => setIsChangeDueDateModalOpen(true)}
onWriteNFC={() => setIsNFCModalOpen(true)}
onNudge={() => setIsNudgeModalOpen(true)}
onCompleteWithNote={() => onAction('completeWithNote', chore)}
onCompleteWithPastDate={() => onAction('completeWithPastDate', chore)}
onChangeAssignee={() => onAction('changeAssignee', chore)}
onChangeDueDate={() => onAction('changeDueDate', chore)}
onWriteNFC={() => onAction('writeNFC', chore)}
onNudge={() => onAction('nudge', chore)}
onDelete={handleDelete}
onMouseEnter={handleMouseEnter}
onOpen={() => {
@@ -1264,69 +1165,9 @@ const ChoreCard = ({
</Box>
</Grid>
</Grid>
<DateModal
isOpen={isChangeDueDateModalOpen}
key={'changeDueDate' + chore.id}
current={chore.nextDueDate}
title={`Change due date`}
onClose={() => {
setIsChangeDueDateModalOpen(false)
}}
onSave={handleChangeDueDate}
/>
<DateModal
isOpen={isCompleteWithPastDateModalOpen}
key={'completedInPast' + chore.id}
current={chore.nextDueDate}
title={`Save Chore that you completed in the past`}
onClose={() => {
setIsCompleteWithPastDateModalOpen(false)
}}
onSave={handleCompleteWithPastDate}
/>
<SelectModal
isOpen={isChangeAssigneeModalOpen}
options={performers}
displayKey='displayName'
title={`Delegate to someone else`}
placeholder={'Select a performer'}
onClose={() => {
setIsChangeAssigneeModalOpen(false)
}}
onSave={selected => {
handleAssigneChange(selected.id)
}}
/>
{confirmModelConfig?.isOpen && (
<ConfirmationModal config={confirmModelConfig} />
)}
<TextModal
isOpen={isCompleteWithNoteModalOpen}
title='Add note to attach to this completion:'
onClose={() => {
setIsCompleteWithNoteModalOpen(false)
}}
okText={'Complete'}
onSave={handleCompleteWithNote}
/>
<WriteNFCModal
config={{
isOpen: isNFCModalOpen,
url: `${window.location.origin}/chores/${chore.id}`,
onClose: () => {
setIsNFCModalOpen(false)
},
}}
/>
<NudgeModal
config={{
isOpen: isNudgeModalOpen,
choreId: chore.id,
onClose: () => setIsNudgeModalOpen(false),
onConfirm: handleNudge,
}}
/>
</Card>
</Box>
<Snackbar

View File

@@ -40,18 +40,10 @@ import {
ApproveChore,
DeleteChore,
MarkChoreComplete,
NudgeChore,
RejectChore,
UpdateChoreAssignee,
UpdateDueDate,
} from '../../utils/Fetcher'
import { usePauseChore, useStartChore } from '../../queries/TimeQueries'
import ConfirmationModal from '../Modals/Inputs/ConfirmationModal'
import DateModal from '../Modals/Inputs/DateModal'
import NudgeModal from '../Modals/Inputs/NudgeModal'
import SelectModal from '../Modals/Inputs/SelectModal'
import TextModal from '../Modals/Inputs/TextModal'
import WriteNFCModal from '../Modals/Inputs/WriteNFCModal'
import ChoreActionMenu from '../components/ChoreActionMenu'
const CompactChoreCard = ({
@@ -62,22 +54,13 @@ const CompactChoreCard = ({
sx,
viewOnly,
onChipClick,
onAction,
// Multi-select props
isMultiSelectMode = false,
isSelected = false,
onSelectionToggle,
}) => {
const [isChangeDueDateModalOpen, setIsChangeDueDateModalOpen] =
React.useState(false)
const [isCompleteWithPastDateModalOpen, setIsCompleteWithPastDateModalOpen] =
React.useState(false)
const [isChangeAssigneeModalOpen, setIsChangeAssigneeModalOpen] =
React.useState(false)
const [isCompleteWithNoteModalOpen, setIsCompleteWithNoteModalOpen] =
React.useState(false)
const [confirmModelConfig, setConfirmModelConfig] = React.useState({})
const [isNFCModalOpen, setIsNFCModalOpen] = React.useState(false)
const [isNudgeModalOpen, setIsNudgeModalOpen] = React.useState(false)
const [isOfficialInstance, setIsOfficialInstance] = React.useState(false)
const navigate = useNavigate()
const startChore = useStartChore()
@@ -91,7 +74,7 @@ const CompactChoreCard = ({
const { impersonatedUser } = useImpersonateUser()
const { showError, showNotification } = useNotification()
const { showError } = useNotification()
// Swipe functionality state
const [swipeTranslateX, setSwipeTranslateX] = React.useState(0)
@@ -372,61 +355,6 @@ const CompactChoreCard = ({
setTimeoutId(id)
}
const handleChangeDueDate = newDate => {
UpdateDueDate(chore.id, newDate).then(response => {
if (response.ok) {
response.json().then(data => {
const newChore = data.res
onChoreUpdate(newChore, 'rescheduled')
})
}
})
}
const handleCompleteWithPastDate = newDate => {
MarkChoreComplete(
chore.id,
impersonatedUser ? { completedBy: impersonatedUser.userId } : null,
new Date(newDate).toISOString(),
null,
).then(response => {
if (response.ok) {
response.json().then(data => {
const newChore = data.res
onChoreUpdate(newChore, 'completed')
})
}
})
}
const handleAssigneChange = assigneeId => {
UpdateChoreAssignee(chore.id, assigneeId).then(response => {
if (response.ok) {
response.json().then(data => {
const newChore = data.res
onChoreUpdate(newChore, 'assigned')
})
}
})
}
const handleCompleteWithNote = note => {
MarkChoreComplete(
chore.id,
impersonatedUser
? { note, completedBy: impersonatedUser.userId }
: { note },
null,
null,
).then(response => {
if (response.ok) {
response.json().then(data => {
const newChore = data.res
onChoreUpdate(newChore, 'completed')
})
}
})
}
const handleApproveChore = () => {
resetSwipe()
@@ -450,32 +378,6 @@ const CompactChoreCard = ({
})
}
const handleNudge = async ({ choreId, message, notifyAllAssignees }) => {
try {
const response = await NudgeChore(choreId, {
message,
notifyAllAssignees,
})
if (response.ok) {
const data = await response.json()
showNotification({
type: 'success',
title: 'Nudge Sent!',
message: data.message || 'Nudge sent successfully',
})
} else {
throw new Error('Failed to send nudge')
}
} catch (error) {
showError({
title: 'Failed to Send Nudge',
message: error.message || 'Unable to send nudge at this time',
})
} finally {
setIsNudgeModalOpen(false)
resetSwipe()
}
}
// Check if the current user can approve/reject (admin, manager, or task owner)
const canApproveReject = () => {
@@ -794,7 +696,7 @@ const CompactChoreCard = ({
onClick={e => {
e.stopPropagation()
resetSwipe()
setIsChangeDueDateModalOpen(true)
onAction('changeDueDate', chore)
}}
sx={{
width: 40,
@@ -841,7 +743,7 @@ const CompactChoreCard = ({
onClick={e => {
e.stopPropagation()
resetSwipe()
setIsNudgeModalOpen(true)
onAction('nudge', chore)
}}
sx={{
width: 40,
@@ -1271,14 +1173,12 @@ const CompactChoreCard = ({
chore={chore}
onChoreUpdate={onChoreUpdate}
onChoreRemove={onChoreRemove}
onCompleteWithNote={() => setIsCompleteWithNoteModalOpen(true)}
onCompleteWithPastDate={() =>
setIsCompleteWithPastDateModalOpen(true)
}
onChangeAssignee={() => setIsChangeAssigneeModalOpen(true)}
onChangeDueDate={() => setIsChangeDueDateModalOpen(true)}
onWriteNFC={() => setIsNFCModalOpen(true)}
onNudge={() => setIsNudgeModalOpen(true)}
onCompleteWithNote={() => onAction('completeWithNote', chore)}
onCompleteWithPastDate={() => onAction('completeWithPastDate', chore)}
onChangeAssignee={() => onAction('changeAssignee', chore)}
onChangeDueDate={() => onAction('changeDueDate', chore)}
onWriteNFC={() => onAction('writeNFC', chore)}
onNudge={() => onAction('nudge', chore)}
onDelete={handleDelete}
onMouseEnter={handleMouseEnter}
// onMouseLeave={handleMouseLeave}
@@ -1300,64 +1200,10 @@ const CompactChoreCard = ({
</Box>
</Box>
{/* All modals (same as original) */}
<DateModal
isOpen={isChangeDueDateModalOpen}
key={'changeDueDate' + chore.id}
current={chore.nextDueDate}
title={`Change due date`}
onClose={() => setIsChangeDueDateModalOpen(false)}
onSave={handleChangeDueDate}
/>
<DateModal
isOpen={isCompleteWithPastDateModalOpen}
key={'completedInPast' + chore.id}
current={chore.nextDueDate}
title={`Save Chore that you completed in the past`}
onClose={() => setIsCompleteWithPastDateModalOpen(false)}
onSave={handleCompleteWithPastDate}
/>
<SelectModal
isOpen={isChangeAssigneeModalOpen}
options={performers}
displayKey='displayName'
title={`Delegate to someone else`}
placeholder={'Select a performer'}
onClose={() => setIsChangeAssigneeModalOpen(false)}
onSave={selected => handleAssigneChange(selected.id)}
/>
{confirmModelConfig?.isOpen && (
<ConfirmationModal config={confirmModelConfig} />
)}
<TextModal
isOpen={isCompleteWithNoteModalOpen}
title='Add note to attach to this completion:'
onClose={() => setIsCompleteWithNoteModalOpen(false)}
okText={'Complete'}
onSave={handleCompleteWithNote}
/>
<WriteNFCModal
config={{
isOpen: isNFCModalOpen,
url: `${window.location.origin}/chores/${chore.id}`,
onClose: () => setIsNFCModalOpen(false),
}}
/>
<NudgeModal
config={{
isOpen: isNudgeModalOpen,
choreId: chore.id,
onClose: () => setIsNudgeModalOpen(false),
onConfirm: handleNudge,
}}
/>
{/* Snackbar for pending completion */}
<Snackbar
open={isPendingCompletion}

View File

@@ -39,24 +39,41 @@ import {
import Fuse from 'fuse.js'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useNavigate, useSearchParams } from 'react-router-dom'
import { useArchiveChore, useChores } from '../../queries/ChoreQueries'
import {
useArchiveChore,
useChores,
useDeleteChores,
useUnArchiveChore,
} from '../../queries/ChoreQueries'
import { useNotification } from '../../service/NotificationProvider'
import { TASK_COLOR } from '../../utils/Colors'
import Priorities from '../../utils/Priorities'
import LoadingComponent from '../components/Loading'
import { useLabels } from '../Labels/LabelQueries'
import ConfirmationModal from '../Modals/Inputs/ConfirmationModal'
import DateModal from '../Modals/Inputs/DateModal'
import NudgeModal from '../Modals/Inputs/NudgeModal'
import SelectModal from '../Modals/Inputs/SelectModal'
import TextModal from '../Modals/Inputs/TextModal'
import WriteNFCModal from '../Modals/Inputs/WriteNFCModal'
import ChoreCard from './ChoreCard'
import CompactChoreCard from './CompactChoreCard'
import IconButtonWithMenu from './IconButtonWithMenu'
import MultiSelectHelp from './MultiSelectHelp'
import { useMediaQuery } from '@mui/material'
import { useQueryClient } from '@tanstack/react-query'
import KeyboardShortcutHint from '../../components/common/KeyboardShortcutHint'
import { useImpersonateUser } from '../../contexts/ImpersonateUserContext.jsx'
import { useCircleMembers, useUserProfile } from '../../queries/UserQueries'
import { ChoreFilters, ChoresGrouper, ChoreSorter } from '../../utils/Chores'
import { DeleteChore, MarkChoreComplete, SkipChore } from '../../utils/Fetcher'
import {
DeleteChore,
MarkChoreComplete,
NudgeChore,
SkipChore,
UpdateChoreAssignee,
UpdateDueDate,
} from '../../utils/Fetcher'
import { getSafeBottom } from '../../utils/SafeAreaUtils.js'
import TaskInput from '../components/AddTaskModal'
import CalendarDual from '../components/CalendarDual'
@@ -74,8 +91,11 @@ const MyChores = () => {
useUserProfile()
const isLargeScreen = useMediaQuery(theme => theme.breakpoints.up('md'))
const { showSuccess, showError, showWarning } = useNotification()
const queryClient = useQueryClient()
const { impersonatedUser } = useImpersonateUser()
const archiveChore = useArchiveChore()
const unarchiveChore = useUnArchiveChore()
const deleteChores = useDeleteChores()
const [chores, setChores] = useState([])
const [filteredChores, setFilteredChores] = useState([])
const [searchFilter, setSearchFilter] = useState('All')
@@ -121,12 +141,14 @@ const MyChores = () => {
const [isMultiSelectMode, setIsMultiSelectMode] = useState(false)
const [selectedChores, setSelectedChores] = useState(new Set())
const [confirmModelConfig, setConfirmModelConfig] = useState({})
// Centralized modal state
const [activeModal, setActiveModal] = useState(null)
const [modalData, setModalData] = useState({})
const [modalChore, setModalChore] = useState(null)
const [showKeyboardShortcuts, setShowKeyboardShortcuts] = useState(false)
const processedChores = useMemo(() => {
console.time('🏁 processedChores calculation')
if (!choresData?.res) {
console.timeEnd('🏁 processedChores calculation')
return []
}
@@ -141,15 +163,11 @@ const MyChores = () => {
)
}
console.timeEnd('🏁 processedChores calculation')
return sortedChores
}, [choresData?.res, impersonatedUser])
const processedSections = useMemo(() => {
console.time('🏁 processedSections calculation')
if (!processedChores.length || !userProfile?.id) {
console.timeEnd('🏁 processedSections calculation')
return []
}
@@ -161,7 +179,6 @@ const MyChores = () => {
],
)
console.timeEnd('🏁 processedSections calculation')
return sections
}, [
processedChores,
@@ -179,8 +196,6 @@ const MyChores = () => {
membersData?.res &&
choresData?.res
) {
console.time('🏁 Main useEffect processing')
const processEffectAsync = async () => {
setPerformers(membersData.res)
setChores(processedChores)
@@ -216,8 +231,6 @@ const MyChores = () => {
membersData.res,
)
}
console.timeEnd('🏁 Main useEffect processing')
}
processEffectAsync()
@@ -236,7 +249,6 @@ const MyChores = () => {
// Auto-update sections when processedSections changes
useEffect(() => {
if (processedSections.length > 0) {
console.time('🏁 Auto-update sections')
setChoreSections(processedSections)
// Auto-open sections if needed - only check localStorage once
@@ -251,7 +263,6 @@ const MyChores = () => {
)
setOpenChoreSections(openSections)
}
console.timeEnd('🏁 Auto-update sections')
}
}, [processedSections])
@@ -484,6 +495,108 @@ const MyChores = () => {
}
}, [isMultiSelectMode, selectedChores.size, addTaskModalOpen])
// Centralized modal handlers
const handleChoreAction = (action, chore, extraData = {}) => {
setModalChore(chore)
setModalData(extraData)
setActiveModal(action)
}
const closeModal = () => {
setActiveModal(null)
setModalChore(null)
setModalData({})
}
const handleChangeDueDate = newDate => {
if (!modalChore) return
UpdateDueDate(modalChore.id, newDate).then(response => {
if (response.ok) {
response.json().then(data => {
const newChore = data.res
handleChoreUpdated(newChore, 'rescheduled')
})
}
})
closeModal()
}
const handleCompleteWithPastDate = newDate => {
if (!modalChore) return
MarkChoreComplete(
modalChore.id,
impersonatedUser ? { completedBy: impersonatedUser.userId } : null,
new Date(newDate).toISOString(),
null,
).then(response => {
if (response.ok) {
response.json().then(data => {
const newChore = data.res
handleChoreUpdated(newChore, 'completed')
})
}
})
closeModal()
}
const handleAssigneeChange = assigneeId => {
if (!modalChore) return
UpdateChoreAssignee(modalChore.id, assigneeId).then(response => {
if (response.ok) {
response.json().then(data => {
const newChore = data.res
handleChoreUpdated(newChore, 'assigned')
})
}
})
closeModal()
}
const handleCompleteWithNote = note => {
if (!modalChore) return
MarkChoreComplete(
modalChore.id,
impersonatedUser
? { note, completedBy: impersonatedUser.userId }
: { note },
null,
null,
).then(response => {
if (response.ok) {
response.json().then(data => {
const newChore = data.res
handleChoreUpdated(newChore, 'completed')
})
}
})
closeModal()
}
const handleNudge = async ({ choreId, message, notifyAllAssignees }) => {
try {
const response = await NudgeChore(choreId, {
message,
notifyAllAssignees,
})
if (response.ok) {
const data = await response.json()
showSuccess({
title: 'Nudge Sent!',
message: data.message || 'Nudge sent successfully',
})
} else {
throw new Error('Failed to send nudge')
}
} catch (error) {
showError({
title: 'Failed to Send Nudge',
message: error.message || 'Unable to send nudge at this time',
})
} finally {
closeModal()
}
}
// Auto-update selected calendar date when day changes (large screen only)
useEffect(() => {
if (!isLargeScreen || viewMode !== 'calendar' || !selectedCalendarDate) {
@@ -580,8 +693,9 @@ const MyChores = () => {
// Helper function to render the appropriate card component
const renderChoreCard = (chore, key) => {
performance.mark(`chore-render-start-${chore.id}`)
const CardComponent = viewMode === 'compact' ? CompactChoreCard : ChoreCard
return (
const result = (
<CardComponent
key={key || chore.id}
chore={chore}
@@ -590,16 +704,23 @@ const MyChores = () => {
performers={performers}
userLabels={userLabels}
onChipClick={handleLabelFiltering}
onAction={handleChoreAction}
// Multi-select props
isMultiSelectMode={isMultiSelectMode}
isSelected={selectedChores.has(chore.id)}
onSelectionToggle={() => toggleChoreSelection(chore.id)}
/>
)
performance.mark(`chore-render-end-${chore.id}`)
performance.measure(
`chore-render-${chore.id}`,
`chore-render-start-${chore.id}`,
`chore-render-end-${chore.id}`,
)
return result
}
const getFilteredChores = useMemo(() => {
console.time('🏁 getFilteredChores')
let result = []
if (searchTerm?.length > 0 || searchFilter !== 'All') {
@@ -620,7 +741,6 @@ const MyChores = () => {
)
}
console.timeEnd('🏁 getFilteredChores')
return result
}, [
searchTerm,
@@ -634,7 +754,6 @@ const MyChores = () => {
const getChoresForDate = useCallback(
date => {
console.time('🏁 getChoresForDate')
const filteredChoresData = getFilteredChores
const result = filteredChoresData.filter(chore => {
if (!chore.nextDueDate) return false
@@ -642,30 +761,35 @@ const MyChores = () => {
const selectedDate = date.toLocaleDateString()
return choreDate === selectedDate
})
console.timeEnd('🏁 getChoresForDate')
return result
},
[getFilteredChores],
)
const updateChores = useCallback(
newChore => {
console.time('🏁 updateChores')
let newChores = [...chores, newChore]
const updateChores = newChore => {
let newChores = [...chores, newChore]
if (impersonatedUser) {
newChores = newChores.filter(
chore => chore.assignedTo === impersonatedUser.userId,
)
}
// Filter chores based on impersonated user
if (impersonatedUser) {
newChores = newChores.filter(
chore => chore.assignedTo === impersonatedUser.userId,
)
}
setChores(newChores)
setFilteredChores(newChores)
setSearchFilter('All')
console.timeEnd('🏁 updateChores')
},
[chores, impersonatedUser],
)
setChores(newChores)
setFilteredChores(newChores)
setChoreSections(
ChoresGrouper(
selectedChoreSection,
newChores,
ChoreFilters(impersonatedUser?.userId || userProfile?.id)[
selectedChoreFilter
],
),
)
setSearchFilter('All')
}
const handleMenuOutsideClick = event => {
if (
anchorEl &&
@@ -706,109 +830,118 @@ const MyChores = () => {
setSelectedCalendarDate(null)
}
const handleChoreUpdated = useCallback(
(updatedChore, event) => {
console.time('🏁 handleChoreUpdated')
var newChores = chores.map(chore => {
if (chore.id === updatedChore.id) {
return updatedChore
}
return chore
})
var newFilteredChores = filteredChores.map(chore => {
if (chore.id === updatedChore.id) {
return updatedChore
}
return chore
})
if (
event === 'archive' ||
(event === 'completed' && updatedChore.frequencyType === 'once')
) {
newChores = newChores.filter(chore => chore.id !== updatedChore.id)
newFilteredChores = newFilteredChores.filter(
chore => chore.id !== updatedChore.id,
)
const handleChoreUpdated = (updatedChore, event) => {
var newChores = chores.map(chore => {
if (chore.id === updatedChore.id) {
return updatedChore
}
setChores(newChores)
setFilteredChores(newFilteredChores)
console.timeEnd('🏁 handleChoreUpdated')
return chore
})
switch (event) {
case 'completed':
showSuccess({
title: 'Task Completed',
message: 'Great job! The task has been marked as completed.',
})
break
case 'skipped':
showSuccess({
title: 'Task Skipped',
message: 'The task has been moved to the next due date.',
})
break
case 'rescheduled':
showSuccess({
title: 'Task Rescheduled',
message: 'The task due date has been updated successfully.',
})
break
case 'due-date-removed':
showSuccess({
title: 'Task Unplanned',
message: 'The task is now unplanned and has no due date.',
})
break
case 'unarchive':
showSuccess({
title: 'Task Restored',
message: 'The task has been restored and is now active.',
})
break
case 'archive':
showSuccess({
title: 'Task Archived',
message:
'The task has been archived and hidden from the active list.',
})
break
case 'started':
showSuccess({
title: 'Task Started',
message: 'The task has been marked as started.',
})
break
case 'paused':
showWarning({
title: 'Task Paused',
message: 'The task has been paused.',
})
break
case 'deleted':
default:
showSuccess({
title: 'Task Updated',
message: 'Your changes have been saved successfully.',
})
var newFilteredChores = filteredChores.map(chore => {
if (chore.id === updatedChore.id) {
return updatedChore
}
},
[chores, filteredChores, showSuccess, showWarning],
)
const handleChoreDeleted = useCallback(
deletedChore => {
console.time('🏁 handleChoreDeleted')
const newChores = chores.filter(chore => chore.id !== deletedChore.id)
const newFilteredChores = filteredChores.filter(
chore => chore.id !== deletedChore.id,
return chore
})
if (
event === 'archive' ||
(event === 'completed' && updatedChore.frequencyType === 'once') ||
updatedChore.frequencyType === 'trigger'
) {
newChores = newChores.filter(chore => chore.id !== updatedChore.id)
newFilteredChores = newFilteredChores.filter(
chore => chore.id !== updatedChore.id,
)
setChores(newChores)
setFilteredChores(newFilteredChores)
console.timeEnd('🏁 handleChoreDeleted')
},
[chores, filteredChores],
)
}
setChores(newChores)
setFilteredChores(newFilteredChores)
setChoreSections(
ChoresGrouper(
selectedChoreSection,
newChores,
ChoreFilters(impersonatedUser?.userId || userProfile?.id)[
selectedChoreFilter
],
),
)
switch (event) {
case 'completed':
showSuccess({
title: 'Task Completed',
message: 'Great job! The task has been marked as completed.',
})
break
case 'skipped':
showSuccess({
title: 'Task Skipped',
message: 'The task has been moved to the next due date.',
})
break
case 'rescheduled':
showSuccess({
title: 'Task Rescheduled',
message: 'The task due date has been updated successfully.',
})
break
case 'due-date-removed':
showSuccess({
title: 'Task Unplanned',
message: 'The task is now unplanned and has no due date.',
})
break
case 'unarchive':
showSuccess({
title: 'Task Restored',
message: 'The task has been restored and is now active.',
})
break
case 'archive':
showSuccess({
title: 'Task Archived',
message:
'The task has been archived and hidden from the active list.',
})
break
case 'started':
showSuccess({
title: 'Task Started',
message: 'The task has been marked as started.',
})
break
case 'paused':
showWarning({
title: 'Task Paused',
message: 'The task has been paused.',
})
break
case 'deleted':
default:
showSuccess({
title: 'Task Updated',
message: 'Your changes have been saved successfully.',
})
}
}
const handleChoreDeleted = deletedChore => {
const newChores = chores.filter(chore => chore.id !== deletedChore.id)
const newFilteredChores = filteredChores.filter(
chore => chore.id !== deletedChore.id,
)
setChores(newChores)
setFilteredChores(newFilteredChores)
setChoreSections(
ChoresGrouper(
selectedChoreSection,
newChores,
ChoreFilters(impersonatedUser?.userId || userProfile?.id)[
selectedChoreFilter
],
),
)
}
const searchOptions = useMemo(
() => ({
@@ -1038,6 +1171,7 @@ const MyChores = () => {
message: `${failedTasks.length} task${failedTasks.length > 1 ? 's' : ''} could not be archived.`,
})
}
refetchChores()
clearSelection()
} catch (error) {
showError({
@@ -1081,7 +1215,6 @@ const MyChores = () => {
message: `Successfully deleted ${deletedTasks.length} task${deletedTasks.length > 1 ? 's' : ''}.`,
})
console.time('🏁 Bulk delete update')
const deletedIds = new Set(deletedTasks.map(c => c.id))
const newChores = chores.filter(c => !deletedIds.has(c.id))
const newFilteredChores = filteredChores.filter(
@@ -1089,7 +1222,6 @@ const MyChores = () => {
)
setChores(newChores)
setFilteredChores(newFilteredChores)
console.timeEnd('🏁 Bulk delete update')
}
if (failedTasks.length > 0) {
@@ -1098,7 +1230,7 @@ const MyChores = () => {
message: `${failedTasks.length} task${failedTasks.length > 1 ? 's' : ''} could not be deleted.`,
})
}
refetchChores()
clearSelection()
} catch (error) {
showError({
@@ -1256,16 +1388,12 @@ const MyChores = () => {
selectedItem={selectedChoreSection}
selectedFilter={selectedChoreFilter}
setFilter={filter => {
console.time('🏁 Filter change')
setSelectedChoreFilterWithCache(filter)
console.timeEnd('🏁 Filter change')
}}
onItemSelect={selected => {
console.time('🏁 Group by change')
setSelectedChoreSectionWithCache(selected.value)
setFilteredChores(chores)
setSearchFilter('All')
console.timeEnd('🏁 Group by change')
}}
mouseClickHandler={handleMenuOutsideClick}
/>
@@ -1825,7 +1953,7 @@ const MyChores = () => {
flexWrap: 'wrap',
}}
>
{FILTERS['Overdue'](getFilteredChores()).length > 0 && (
{FILTERS['Overdue'](getFilteredChores).length > 0 && (
<Chip
variant='soft'
color='danger'
@@ -1837,8 +1965,7 @@ const MyChores = () => {
})
// Also update state directly for immediate smooth transition
const overdueChores =
FILTERS['Overdue'](getFilteredChores())
const overdueChores = FILTERS['Overdue'](getFilteredChores)
setFilteredChores(overdueChores)
setSearchFilter('Overdue')
setViewMode('default')
@@ -1852,7 +1979,7 @@ const MyChores = () => {
}}
startDecorator={
<Chip size='md' variant='solid' color='danger'>
{FILTERS['Overdue'](getFilteredChores()).length}
{FILTERS['Overdue'](getFilteredChores).length}
</Chip>
}
>
@@ -1860,7 +1987,7 @@ const MyChores = () => {
</Chip>
)}
{FILTERS['No Due Date'](getFilteredChores()).length > 0 && (
{FILTERS['No Due Date'](getFilteredChores).length > 0 && (
<Chip
variant='soft'
color='neutral'
@@ -1873,7 +2000,7 @@ const MyChores = () => {
// Also update state directly for immediate smooth transition
const unplannedChores =
FILTERS['No Due Date'](getFilteredChores())
FILTERS['No Due Date'](getFilteredChores)
setFilteredChores(unplannedChores)
setSearchFilter('No Due Date')
setViewMode('default')
@@ -1887,7 +2014,7 @@ const MyChores = () => {
}}
startDecorator={
<Chip size='md' variant='solid' color='neutral'>
{FILTERS['No Due Date'](getFilteredChores()).length}
{FILTERS['No Due Date'](getFilteredChores).length}
</Chip>
}
>
@@ -1895,7 +2022,7 @@ const MyChores = () => {
</Chip>
)}
{FILTERS['Pending Approval'](getFilteredChores()).length > 0 && (
{FILTERS['Pending Approval'](getFilteredChores).length > 0 && (
<Chip
variant='soft'
size='lg'
@@ -1907,7 +2034,7 @@ const MyChores = () => {
// Also update state directly for immediate smooth transition
const pendingApprovalChores =
FILTERS['Pending Approval'](getFilteredChores())
FILTERS['Pending Approval'](getFilteredChores)
setFilteredChores(pendingApprovalChores)
setSearchFilter('Pending Approval')
setViewMode('default')
@@ -1928,7 +2055,7 @@ const MyChores = () => {
color: 'white',
}}
>
{FILTERS['Pending Approval'](getFilteredChores()).length}
{FILTERS['Pending Approval'](getFilteredChores).length}
</Chip>
}
>
@@ -1940,7 +2067,7 @@ const MyChores = () => {
<Box sx={{ mb: 2 }}>
{isLargeScreen ? (
<CalendarDual
chores={getFilteredChores()}
chores={getFilteredChores}
onDateChange={date => {
setSelectedCalendarDate(date)
}}
@@ -1948,7 +2075,7 @@ const MyChores = () => {
) : (
<div className='calendar-dual'>
<CalendarMonthly
chores={getFilteredChores()}
chores={getFilteredChores}
onDateChange={date => {
setSelectedCalendarDate(date)
}}
@@ -2170,12 +2297,78 @@ const MyChores = () => {
<Sidepanel chores={chores} performers={performers} />
{/* Multi-select Help - only show when in multi-select mode */}
<MultiSelectHelp isVisible={isMultiSelectMode} />
{/* <MultiSelectHelp isVisible={isMultiSelectMode} /> */}
{/* Confirmation Modal for bulk operations */}
{confirmModelConfig?.isOpen && (
<ConfirmationModal config={confirmModelConfig} />
)}
{/* Centralized Modals */}
{activeModal === 'changeDueDate' && modalChore && (
<DateModal
isOpen={true}
key={'changeDueDate' + modalChore.id}
current={modalChore.nextDueDate}
title={`Change due date`}
onClose={closeModal}
onSave={handleChangeDueDate}
/>
)}
{activeModal === 'completeWithPastDate' && modalChore && (
<DateModal
isOpen={true}
key={'completedInPast' + modalChore.id}
current={modalChore.nextDueDate}
title={`Save Chore that you completed in the past`}
onClose={closeModal}
onSave={handleCompleteWithPastDate}
/>
)}
{activeModal === 'changeAssignee' && modalChore && (
<SelectModal
isOpen={true}
options={performers}
displayKey='displayName'
title={`Delegate to someone else`}
placeholder={'Select a performer'}
onClose={closeModal}
onSave={selected => handleAssigneeChange(selected.id)}
/>
)}
{activeModal === 'completeWithNote' && modalChore && (
<TextModal
isOpen={true}
title='Add note to attach to this completion:'
onClose={closeModal}
okText={'Complete'}
onSave={handleCompleteWithNote}
/>
)}
{activeModal === 'writeNFC' && modalChore && (
<WriteNFCModal
config={{
isOpen: true,
url: `${window.location.origin}/chores/${modalChore.id}`,
onClose: closeModal,
}}
/>
)}
{activeModal === 'nudge' && modalChore && (
<NudgeModal
config={{
isOpen: true,
choreId: modalChore.id,
onClose: closeModal,
onConfirm: handleNudge,
}}
/>
)}
</div>
)
}