From 1c99bd188609cd4d26e822a1b32a3e3f6718d08f Mon Sep 17 00:00:00 2001 From: Mo Tarbin Date: Sun, 16 Aug 2026 02:46:04 -0400 Subject: [PATCH] feat: enhance chore management with new modals and filter options --- public/locales/en/chores.json | 2 + src/components/common/FilterBar.jsx | 131 +++++---- src/views/ChoreEdit/ChoreView.jsx | 347 +++++++++++++++++------ src/views/Chores/ArchivedTasks.jsx | 32 +++ src/views/components/ChoreActionMenu.jsx | 171 +++++++++-- 5 files changed, 520 insertions(+), 163 deletions(-) diff --git a/public/locales/en/chores.json b/public/locales/en/chores.json index 50b151f..d4d0001 100644 --- a/public/locales/en/chores.json +++ b/public/locales/en/chores.json @@ -63,6 +63,8 @@ "skip": "Skip", "cancel": "Cancel", "noPriority": "No Priority", + "more": "More", + "changeDueDate": "Change due date", "subtasks": "Subtasks", "noDescription": "No description available", "timer": { diff --git a/src/components/common/FilterBar.jsx b/src/components/common/FilterBar.jsx index bbc3a76..25472d6 100644 --- a/src/components/common/FilterBar.jsx +++ b/src/components/common/FilterBar.jsx @@ -147,8 +147,19 @@ const FilterBar = ({ onClearAll, resultCount, totalCount, + // When the host renders its own trigger (e.g. an icon button in a toolbar + // row), it drives the sheet through `open`/`onOpenChange` and hides ours. + open, + onOpenChange, + showTrigger = true, }) => { - const [isOpen, setIsOpen] = useState(false) + const [internalOpen, setInternalOpen] = useState(false) + const isControlled = open !== undefined + const isOpen = isControlled ? open : internalOpen + const setIsOpen = next => { + if (!isControlled) setInternalOpen(next) + onOpenChange?.(next) + } // ── Active count ─────────────────────────────────────────────────────────── @@ -293,65 +304,75 @@ const FilterBar = ({ // ── Render ───────────────────────────────────────────────────────────────── + const activeChips = filterDefs + .map(def => ({ def, label: getActiveChipLabel(def) })) + .filter(({ label }) => !!label) + .map(({ def, label }) => ({ + key: def.id, + label, + onClear: () => onSetFilter(def.id, null), + })) + + // With the trigger hoisted into a toolbar, the inline row has nothing to show + // until a filter is on — rendering it anyway would leave a phantom gap. + const showInlineBar = showTrigger || activeChips.length > 0 + return ( <> {/* ── Inline bar ─────────────────────────────────────── */} - - - - + {showTrigger && ( + + + + )} - ({ def, label: getActiveChipLabel(def) })) - .filter(({ label }) => !!label) - .map(({ def, label }) => ({ - key: def.id, - label, - onClear: () => onSetFilter(def.id, null), - }))} - onOpen={() => setIsOpen(true)} - onClearAll={hasActive ? onClearAll : undefined} - resultCount={hasActive ? resultCount : undefined} - totalCount={hasActive ? totalCount : undefined} - maxVisible={2} - chipSize='md' - /> - + setIsOpen(true)} + onClearAll={hasActive ? onClearAll : undefined} + resultCount={hasActive ? resultCount : undefined} + totalCount={hasActive ? totalCount : undefined} + maxVisible={2} + chipSize='md' + /> + + )} {/* ── Bottom sheet ────────────────────────────────────── */} { const hasHtmlTags = value => /<\/?[a-z][\s\S]*>/i.test(value) +const getNFCUrl = choreId => + Capacitor.getPlatform() === 'android' || Capacitor.getPlatform() === 'ios' + ? `donetick://chores/${choreId}` + : `${window.location.origin}/chores/${choreId}` + const ChoreView = () => { const { t } = useTranslation('chores') const { fmt } = useLocalization() @@ -124,7 +138,7 @@ const ChoreView = () => { const [confirmModelConfig, setConfirmModelConfig] = useState({ isOpen: false, }) - const [chorePriority, setChorePriority] = useState(null) + const [activeModal, setActiveModal] = useState(null) const [noteViewerConfig, setNoteViewerConfig] = useState({ isOpen: false }) const [timerActionConfig, setTimerActionConfig] = useState({ isOpen: false }) const [attachmentBrowserOpen, setAttachmentBrowserOpen] = useState(false) @@ -165,7 +179,6 @@ const ChoreView = () => { return } setChore(choreData.res) - setChorePriority(Priorities.find(p => p.value === choreData.res.priority)) document.title = 'Donetick: ' + choreData.res.name setPerformers(circleMembersData.res) @@ -236,7 +249,7 @@ const ChoreView = () => { UpdateChorePriority(choreId, priority.value).then(response => { if (response.ok) { response.json().then(() => { - setChorePriority(priority) + setChore(prev => ({ ...prev, priority: priority.value })) queryClient.invalidateQueries(['chores']) }) } @@ -583,6 +596,168 @@ const ChoreView = () => { } } + const confirmSkipTask = () => { + setConfirmModelConfig({ + isOpen: true, + title: t('choreView.skipTask'), + message: t('choreView.skipTaskConfirmation'), + confirmText: t('choreView.skip'), + cancelText: t('choreView.cancel'), + onClose: confirmed => { + if (confirmed) { + handleSkippingTask() + } + setConfirmModelConfig({}) + }, + }) + } + + const handleArchiveChore = async () => { + try { + const response = await ArchiveChore(choreId) + if (response.ok) { + await offlineDB.saveChores([{ ...chore, isActive: false }]) + setChore({ ...chore, isActive: false }) + queryClient.invalidateQueries(['chores']) + } + } catch (error) { + showError({ + title: 'Failed to archive', + message: error?.message || 'Unable to archive task', + }) + } + } + + const confirmDeleteChore = () => { + setConfirmModelConfig({ + isOpen: true, + title: 'Delete task', + message: 'Are you sure you want to delete this task?', + confirmText: 'Delete', + cancelText: t('choreView.cancel'), + onClose: async confirmed => { + setConfirmModelConfig({}) + if (!confirmed) return + try { + const response = await DeleteChore(choreId) + if (response.ok) { + queryClient.invalidateQueries(['chores']) + showSuccess({ + title: 'Task Deleted', + message: 'The task has been deleted successfully.', + }) + navigate('/chores') + } + } catch (error) { + showError({ + title: 'Failed to delete', + message: error?.message || 'Unable to delete task', + }) + } + }, + }) + } + + const handleDueDateChange = async newDate => { + try { + const response = await UpdateDueDate(choreId, newDate) + if (response.ok) { + setChore(prev => ({ ...prev, nextDueDate: newDate })) + queryClient.invalidateQueries(['chores']) + } + } catch (error) { + showError({ + title: 'Failed to reschedule', + message: error?.message || 'Unable to change the due date', + }) + } + } + + const handleMoveToProject = async project => { + const projectId = project?.id ?? null + try { + const response = await SaveChore({ ...chore, projectId }) + if (response.ok) { + setChore(prev => ({ ...prev, projectId })) + queryClient.invalidateQueries(['chores']) + showSuccess({ + title: 'Task Moved', + message: `Task moved to ${project?.name || 'Default Project'}.`, + }) + } + } catch (error) { + showError({ + title: 'Failed to move task', + message: error?.message || 'Unable to move task to project', + }) + } + } + + const handleNudge = async ({ message, notifyAllAssignees }) => { + try { + const response = await NudgeChore(choreId, { + message, + notifyAllAssignees, + }) + if (!response.ok) { + throw new Error('Failed to send nudge') + } + const data = await response.json() + showSuccess({ + title: 'Nudge Sent!', + message: data.message || 'Nudge sent successfully', + }) + } catch (error) { + showError({ + title: 'Failed to Send Nudge', + message: error?.message || 'Unable to send nudge at this time', + }) + } + } + + const handleAssigneeChange = async assigneeId => { + try { + const response = await UpdateChoreAssignee(choreId, assigneeId) + if (response.ok) { + const data = await response.json() + setChore(data.res) + queryClient.invalidateQueries(['chores']) + } + } catch (error) { + showError({ + title: 'Failed to delegate', + message: error?.message || 'Unable to change the assignee', + }) + } + } + + // Actions the menu raises that ChoreView owns; the rest of its items either + // navigate on their own or come in through the dedicated callbacks. + const handleMenuAction = (type, _chore, extraData) => { + switch (type) { + case 'skip': + confirmSkipTask() + break + case 'archive': + handleArchiveChore() + break + case 'unarchive': + handleUnarchiveChore() + break + case 'delete': + confirmDeleteChore() + break + case 'changeDueDate': + handleDueDateChange(extraData?.date?.toISOString() ?? null) + break + case 'moveToProject': + handleMoveToProject(extraData?.project) + break + default: + break + } + } + // Check if the current user can approve/reject (admin, manager, or task owner) const canApproveReject = () => { if (!circleMembersData?.res || !chore) return false @@ -794,64 +969,6 @@ const ChoreView = () => { mb: 1, }} > - - - {chorePriority ? chorePriority.icon : } - {chorePriority ? chorePriority.name : t('choreView.noPriority')} - - - {Priorities.map((priority, index) => ( - { - handleUpdatePriority(priority) - }} - color={priority.color} - > - {priority.icon} - {priority.name} - - ))} - - { - handleUpdatePriority({ - name: t('choreView.noPriority'), - value: 0, - }) - setChorePriority(null) - }} - > - {t('choreView.noPriority')} - - - - + setActiveModal('nudge')} + onWriteNFC={() => setActiveModal('writeNFC')} + onCompleteWithNote={() => setNote('')} + onCompleteWithPastDate={() => + setCompletedDate(moment(new Date()).format('YYYY-MM-DDTHH:00:00')) + } + onChangeAssignee={() => setActiveModal('changeAssignee')} + onChangeDueDate={() => setActiveModal('changeDueDate')} + onChangePriority={handleUpdatePriority} + onDelete={confirmDeleteChore} + trigger={ + + } + /> {chore.description && ( @@ -1261,21 +1410,7 @@ const ChoreView = () => {