From 7729917611c3c7c3c4e42ac95a4aa14652b9e888 Mon Sep 17 00:00:00 2001 From: Mo Tarbin Date: Sat, 15 Aug 2026 00:45:51 -0400 Subject: [PATCH 01/12] enhance label search functionality with Fuse.js integration and improved UI --- public/locales/en/labels.json | 6 + .../Chores/LocalNotificationScheduler.js | 126 +++++++----------- src/views/Labels/LabelView.jsx | 90 ++++++++++++- 3 files changed, 138 insertions(+), 84 deletions(-) diff --git a/public/locales/en/labels.json b/public/locales/en/labels.json index 93f96cb..990bb5d 100644 --- a/public/locales/en/labels.json +++ b/public/locales/en/labels.json @@ -5,5 +5,11 @@ "message": "Are you sure you want to delete this label? This will remove the label from all tasks." }, "loadError": "Failed to load labels. Please try again.", + "search": { + "placeholder": "Search labels", + "noResultsTitle": "No labels match", + "noResultsDescription": "No label matches \"{{searchTerm}}\".", + "clear": "Clear search" + }, "blurb": "Manage your labels and organize your tasks effectively. Labels will be automatically shared with your circle if they are used on a shared task." } diff --git a/src/views/Chores/LocalNotificationScheduler.js b/src/views/Chores/LocalNotificationScheduler.js index 6642ac1..2a3dfc3 100644 --- a/src/views/Chores/LocalNotificationScheduler.js +++ b/src/views/Chores/LocalNotificationScheduler.js @@ -78,11 +78,16 @@ const scheduleNotificationFromTemplate = ( const now = new Date() const time = getTimeFromTemplate(template, dueDate) const notificationId = getIdFromTemplate(chore.id, template) - const { title, body } = getNotificationText(chore.name, template) + const { title, body } = getNotificationText( + chore.name, + template, + dueDate, + time, + ) if (time > now) { notifications.push({ title, - body: `${body} at ${time.toLocaleTimeString()}`, + body, id: notificationId, allowWhileIdle: true, schedule: { @@ -96,91 +101,50 @@ const scheduleNotificationFromTemplate = ( } } -const getNotificationText = (choreName, template = {}) => { - // Determine notification type based on template value - const getNotificationType = () => { - if (!template || template.value === undefined) { - return 'due' - } +const getNotificationText = ( + choreName, + template = {}, + dueDate, + notificationTime, +) => { + const startOfDay = date => + new Date(date.getFullYear(), date.getMonth(), date.getDate()) + const dayDifference = Math.round( + (startOfDay(dueDate) - startOfDay(notificationTime)) / + (24 * 60 * 60 * 1000), + ) + const time = dueDate.toLocaleTimeString([], { + hour: 'numeric', + minute: '2-digit', + }) - if (template.value < 0) { - return 'reminder' - } else if (template.value === 0) { - return 'due' - } else { - return 'overdue' - } + let dueTime + if (dayDifference === 0) { + dueTime = `today at ${time}` + } else if (dayDifference === 1) { + dueTime = `tomorrow at ${time}` + } else if (dayDifference === -1) { + dueTime = `yesterday at ${time}` + } else { + const date = dueDate.toLocaleDateString([], { + month: 'short', + day: 'numeric', + }) + dueTime = `${date} at ${time}` } - const notificationType = getNotificationType() - - // Truncate chore name if too long for better readability - const maxChoreNameLength = 25 - const truncatedName = - choreName.length > maxChoreNameLength - ? `${choreName.substring(0, maxChoreNameLength)}...` - : choreName - - // Generate time-based descriptive text - const getTimeDescription = () => { - if (!template || !template.value || !template.unit) { - return 'soon' - } - - const { value, unit } = template - const absValue = Math.abs(value) - - switch (unit) { - case 'm': - if (absValue === 1) return value < 0 ? 'in 1 minute' : '1 minute ago' - if (absValue < 60) - return value < 0 - ? `in ${absValue} minutes` - : `${absValue} minutes ago` - break - case 'h': - if (absValue === 1) return value < 0 ? 'in 1 hour' : '1 hour ago' - if (absValue < 24) - return value < 0 ? `in ${absValue} hours` : `${absValue} hours ago` - break - case 'd': - if (absValue === 1) return value < 0 ? 'tomorrow' : 'yesterday' - if (absValue === 7) return value < 0 ? 'next week' : 'last week' - if (absValue < 7) - return value < 0 ? `in ${absValue} days` : `${absValue} days ago` - if (absValue < 30) { - const weeks = Math.round(absValue / 7) - return value < 0 ? `in ${weeks} weeks` : `${weeks} weeks ago` - } - break - default: - return value < 0 ? `in ${absValue} ${unit}` : `${absValue} ${unit} ago` - } - - return value < 0 ? `in ${absValue} ${unit}` : `${absValue} ${unit} ago` + let body + if (template.value < 0) { + body = `Due ${dueTime}` + } else if (template.value > 0) { + body = `Overdue Β· Was due ${dueTime}` + } else { + body = 'Due now' } - const messages = { - reminder: { - title: `πŸ“‹ ${truncatedName}`, - body: `Reminder: Due ${getTimeDescription()}`, - }, - due: { - title: `πŸ”” ${truncatedName}`, - body: 'Due now - Time to get started!', - }, - overdue: { - title: `❗ ${truncatedName}`, - body: `Overdue ${getTimeDescription()} - Complete when you can`, - }, - } - - // Fallback to due if type not found - const messageTemplate = messages[notificationType] || messages.due - return { - title: messageTemplate.title, - body: messageTemplate.body, + title: choreName, + body, } } const cancelPendingNotifications = async () => { diff --git a/src/views/Labels/LabelView.jsx b/src/views/Labels/LabelView.jsx index 841215e..10628b6 100644 --- a/src/views/Labels/LabelView.jsx +++ b/src/views/Labels/LabelView.jsx @@ -7,10 +7,12 @@ import { CircularProgress, Container, IconButton, + Input, Stack, Typography, } from '@mui/joy' -import { useEffect, useState } from 'react' +import Fuse from 'fuse.js' +import { useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import LabelModal from '../Modals/Inputs/LabelModal' @@ -22,7 +24,14 @@ import { TrailingActions, } from '@meauxt/react-swipeable-list' import '@meauxt/react-swipeable-list/dist/styles.css' -import { Add, MoreVert, Style } from '@mui/icons-material' +import { + Add, + Close, + MoreVert, + Search, + SearchOff, + Style, +} from '@mui/icons-material' import EmptyState from '../../components/common/EmptyState' import { useQueryClient } from '@tanstack/react-query' import { useUserProfile } from '../../queries/UserQueries' @@ -162,6 +171,36 @@ const LabelView = () => { const queryClient = useQueryClient() const [confirmationModel, setConfirmationModel] = useState({}) const [showMoreInfoId, setShowMoreInfoId] = useState(null) + const [searchTerm, setSearchTerm] = useState('') + const searchInputRef = useRef(null) + + const fuse = useMemo( + () => + new Fuse(userLabels, { + keys: ['name'], + includeScore: true, + isCaseSensitive: false, + findAllMatches: true, + }), + [userLabels], + ) + + const filteredLabels = useMemo(() => { + if (!searchTerm) { + return userLabels + } + return fuse.search(searchTerm).map(result => result.item) + }, [fuse, searchTerm, userLabels]) + + const handleSearchChange = e => { + setSearchTerm(e.target.value.toLowerCase()) + setShowMoreInfoId(null) + } + + const handleSearchClose = () => { + setSearchTerm('') + searchInputRef.current?.blur() + } const handleAddLabel = () => { setCurrentLabel(null) @@ -251,6 +290,36 @@ const LabelView = () => { + {userLabels.length > 0 && ( + + } + endDecorator={ + searchTerm && ( + + + + ) + } + /> + + )} { }} /> )} + {userLabels.length > 0 && filteredLabels.length === 0 && ( + } + title={t('search.noResultsTitle')} + description={t('search.noResultsDescription', { + searchTerm, + })} + primaryAction={{ + label: t('search.clear'), + onClick: handleSearchClose, + }} + /> + )} - {userLabels.map(label => ( + {filteredLabels.map(label => ( Date: Sat, 15 Aug 2026 12:33:39 -0400 Subject: [PATCH 02/12] feat: enhance chore actions with bulk operations and new label detail view - Refactored bulk operations in useChoreActions to streamline completion, archiving, deletion, and other actions. - Introduced new hooks for managing local chore state during bulk operations. - Added handleBulkDueDate, handleBulkAssignee, handleBulkPriority, and handleBulkLabels functions for better task management. - Implemented a new LabelDetailView component to display and manage tasks associated with a specific label. - Updated LabelView to navigate to LabelDetailView on label click. - Improved multi-select functionality to support range selection and summary of selected chores. - Minor UI adjustments and text updates in AdvancedOptionsSection for clarity. --- public/locales/en/labels.json | 22 + src/contexts/RouterContext.jsx | 5 + src/search/searchProviders.js | 2 +- src/views/Chores/MyChores.jsx | 19 + .../Chores/components/MultiSelectToolbar.jsx | 508 +++++++++++-- src/views/Chores/hooks/useChoreActions.js | 694 ++++++++++-------- src/views/Chores/hooks/useMultiSelect.js | 147 +++- src/views/Labels/LabelDetailView.jsx | 405 ++++++++++ src/views/Labels/LabelView.jsx | 3 + .../components/AdvancedOptionsSection.jsx | 2 +- 10 files changed, 1430 insertions(+), 377 deletions(-) create mode 100644 src/views/Labels/LabelDetailView.jsx diff --git a/public/locales/en/labels.json b/public/locales/en/labels.json index 990bb5d..f02b90f 100644 --- a/public/locales/en/labels.json +++ b/public/locales/en/labels.json @@ -11,5 +11,27 @@ "noResultsDescription": "No label matches \"{{searchTerm}}\".", "clear": "Clear search" }, + "detail": { + "taskCount_one": "{{count}} task", + "taskCount_other": "{{count}} tasks", + "labelActions": "Label actions", + "filters": { + "all": "All", + "overdue": "Overdue", + "today": "Today", + "undated": "No date" + }, + "noMatchingStatus": "No task in this label is in that state right now.", + "clearFilters": "Show all tasks", + "searchPlaceholder": "Search tasks in this label", + "emptyTitle": "No tasks with this label", + "emptyDescription": "Nothing is tagged \"{{label}}\" yet. Add the label to a task and it will show up here.", + "browseTasks": "Browse tasks", + "noResultsTitle": "No tasks match", + "noResultsDescription": "No task in this label matches \"{{searchTerm}}\".", + "notFoundTitle": "Label not found", + "notFoundDescription": "This label may have been deleted or is no longer shared with you.", + "backToLabels": "Back to labels" + }, "blurb": "Manage your labels and organize your tasks effectively. Labels will be automatically shared with your circle if they are used on a shared task." } diff --git a/src/contexts/RouterContext.jsx b/src/contexts/RouterContext.jsx index 6c5ef53..613e5bd 100644 --- a/src/contexts/RouterContext.jsx +++ b/src/contexts/RouterContext.jsx @@ -27,6 +27,7 @@ import JoinCircleView from '../views/Circles/JoinCircle' import NotFound from '../views/components/NotFound' import FilterView from '../views/Filters/FilterView' import ChoreHistory from '../views/History/ChoreHistory' +import LabelDetailView from '../views/Labels/LabelDetailView' import LabelView from '../views/Labels/LabelView' import Landing from '../views/Landing/Landing' import CircleSetupView from '../views/Onboarding/CircleSetupView' @@ -262,6 +263,10 @@ const Router = createBrowserRouter([ path: 'labels/', element: , }, + { + path: 'labels/:labelId', + element: , + }, { path: 'projects/', element: , diff --git a/src/search/searchProviders.js b/src/search/searchProviders.js index 13f5288..6b98a4f 100644 --- a/src/search/searchProviders.js +++ b/src/search/searchProviders.js @@ -125,7 +125,7 @@ registerSearchProvider({ title: label.name || 'Untitled label', subtitle: 'Label', keywords: 'tag label', - route: '/labels', + route: `/labels/${label.id}`, color: label.color, }), ), diff --git a/src/views/Chores/MyChores.jsx b/src/views/Chores/MyChores.jsx index bcd5d19..867de23 100644 --- a/src/views/Chores/MyChores.jsx +++ b/src/views/Chores/MyChores.jsx @@ -149,6 +149,7 @@ const MyChores = () => { clearSelection, enterMultiSelectWithChore, getSelectedChoresData, + getSelectionSummary, isMultiSelectMode, selectAllVisibleChores, selectedChores, @@ -575,9 +576,13 @@ const MyChores = () => { const { handleAssigneeChange, handleBulkArchive, + handleBulkAssignee, handleBulkComplete, handleBulkDelete, + handleBulkDueDate, + handleBulkLabels, handleBulkMoveToProject, + handleBulkPriority, handleBulkSkip, handleChangeDueDate, handleChoreAction, @@ -629,6 +634,13 @@ const MyChores = () => { searchTerm, ]) + // Drives the bulk-edit sheet's controls (current value per field, which + // labels are on all vs some). Only worth computing while selecting. + const selectionSummary = useMemo( + () => (isMultiSelectMode ? getSelectionSummary(chores) : null), + [isMultiSelectMode, getSelectionSummary, chores], + ) + const { showKeyboardShortcuts } = useKeyboardShortcuts({ isMultiSelectMode, selectedChores, @@ -1075,6 +1087,13 @@ const MyChores = () => { onArchive={handleBulkArchive} onDelete={handleBulkDelete} onMoveToProject={handleBulkMoveToProject} + onSetDueDate={handleBulkDueDate} + onSetAssignee={handleBulkAssignee} + onSetPriority={handleBulkPriority} + onToggleLabel={handleBulkLabels} + selectionSummary={selectionSummary} + members={membersData?.res || []} + labels={userLabels || []} projects={projects} showKeyboardShortcuts={showKeyboardShortcuts} selectAllDisabled={ diff --git a/src/views/Chores/components/MultiSelectToolbar.jsx b/src/views/Chores/components/MultiSelectToolbar.jsx index cc8334b..e11e819 100644 --- a/src/views/Chores/components/MultiSelectToolbar.jsx +++ b/src/views/Chores/components/MultiSelectToolbar.jsx @@ -1,11 +1,19 @@ import { Archive, + CalendarMonth, + Check, CheckBox, CheckBoxOutlineBlank, Close, Delete, Done, DriveFileMove, + EditCalendar, + Flag, + Label as LabelIcon, + MoreHoriz, + Person, + Remove, SelectAll, SkipNext, } from '@mui/icons-material' @@ -13,6 +21,7 @@ import { Avatar, Box, Button, + Chip, Divider, ListItemContent, ListItemDecorator, @@ -20,12 +29,18 @@ import { MenuItem, Typography, } from '@mui/joy' +import moment from 'moment' import { useRef, useState } from 'react' +import AppModal from '../../../components/common/AppModal' import KeyboardShortcutHint from '../../../components/common/KeyboardShortcutHint' import LABEL_COLORS, { getTextColorFromBackgroundColor, } from '../../../utils/Colors' +import Priorities from '../../../utils/Priorities' import { getIconComponent } from '../../../utils/ProjectIcons' +import DueDatePickerModal, { + splitDueDate, +} from '../../components/DueDatePickerModal' const renderProjectAvatar = (color, icon) => { const bg = color || LABEL_COLORS[0].value @@ -39,6 +54,83 @@ const renderProjectAvatar = (color, icon) => { ) } +// `onSetDueDate` takes the same { dueDateOnly, dueTime, useCustomTime } shape +// the picker emits, or null to unplan. The quick options move the date only and +// leave the time unset, so each task keeps whatever hour it was already due at +// (and stays "anytime" if it had none). +// 23:59 is the app's "no specific time" stamp, so the picker should open on +// Anytime for it rather than showing it as a time the user chose. +const prefillDueDate = value => { + const parts = splitDueDate(value) + return parts.dueTime === '23:59' + ? { dueDateOnly: parts.dueDateOnly, dueTime: null, useCustomTime: false } + : parts +} + +const dateOnly = date => ({ + dueDateOnly: date.format('YYYY-MM-DD'), + dueTime: null, + useCustomTime: false, +}) + +const DUE_DATE_PRESETS = [ + { + key: 'today', + label: 'Today', + resolve: () => dateOnly(moment()), + hint: () => moment().format('ddd, MMM D'), + }, + { + key: 'tomorrow', + label: 'Tomorrow', + resolve: () => dateOnly(moment().add(1, 'day')), + hint: () => moment().add(1, 'day').format('ddd, MMM D'), + }, + { + key: 'next-week', + label: 'Next week', + resolve: () => dateOnly(moment().add(1, 'week').startOf('isoWeek')), + hint: () => moment().add(1, 'week').startOf('isoWeek').format('ddd, MMM D'), + }, +] + +const SectionHeader = ({ icon, label, value }) => ( + + {icon && ( + + {icon} + + )} + + {label} + + {value && ( + + {value} + + )} + +) + +const ChipRow = ({ children }) => ( + {children} +) + +const selectableChipSx = { + py: 0.64, + cursor: 'pointer', + transition: 'all 0.15s ease', + userSelect: 'none', + '&:hover': { opacity: 0.85 }, +} + const MultiSelectToolbar = ({ isVisible, selectedCount, @@ -49,19 +141,65 @@ const MultiSelectToolbar = ({ onArchive, onDelete, onMoveToProject, + onSetDueDate, + onSetAssignee, + onSetPriority, + onToggleLabel, + // Shape produced by useMultiSelect.getSelectionSummary β€” drives which value + // each control shows as current, and which labels can be added vs removed. + selectionSummary, + members = [], + labels = [], projects = [], showKeyboardShortcuts, selectAllDisabled, }) => { + const [moreOpen, setMoreOpen] = useState(false) + const [dueDatePickerOpen, setDueDatePickerOpen] = useState(false) + const [dueMenuAnchor, setDueMenuAnchor] = useState(null) const [projectMenuAnchor, setProjectMenuAnchor] = useState(null) + const dueMenuRef = useRef(null) const projectMenuRef = useRef(null) + const closeDueMenu = () => setDueMenuAnchor(null) const closeProjectMenu = () => setProjectMenuAnchor(null) - const handleMoveToProject = project => { - closeProjectMenu() - onMoveToProject?.(project) - } + const summary = selectionSummary || {} + const labelState = summary.labels || { common: [], partial: [] } + const commonLabelIds = new Set(labelState.common || []) + const partialLabelIds = new Set(labelState.partial || []) + + // null from the summary means "no restriction" β€” every circle member is a + // valid assignee for the whole selection. + const assignableMembers = + summary.assignableUserIds == null + ? members + : members.filter(m => summary.assignableUserIds.includes(m.userId)) + + // Every bulk edit clears the selection, so the sheet has nothing left to act + // on afterwards. + const runAndClose = + action => + (...args) => { + setMoreOpen(false) + action?.(...args) + } + + const dueDateValue = summary.dueDate?.isMixed + ? 'Mixed' + : summary.dueDate?.value + ? moment(summary.dueDate.value).format('MMM D') + : null + + const priorityValue = summary.priority?.isMixed + ? 'Mixed' + : Priorities.find(p => p.value === summary.priority?.value)?.name.trim() || + null + + const assigneeValue = summary.assignee?.isMixed + ? 'Mixed' + : members.find(m => m.userId === summary.assignee?.value)?.displayName || + null return ( @@ -127,7 +258,7 @@ const MultiSelectToolbar = ({ @@ -189,19 +320,22 @@ const MultiSelectToolbar = ({ + {/* The verbs a task can be done to β€” complete, reschedule, move, + archive, delete β€” all keep permanent buttons and wrap to a second + row when they need to. The sheet holds only the field editors + (priority, assignee, labels), so nothing appears in both places. */} + + {onSetDueDate && ( + <> + + + {DUE_DATE_PRESETS.map(preset => ( + { + closeDueMenu() + onSetDueDate(preset.resolve()) + }} + > + + + + + {preset.label} + + {preset.hint()} + + + + ))} + + { + closeDueMenu() + setDueDatePickerOpen(true) + }} + > + + + + + Pick date… + + + { + closeDueMenu() + onSetDueDate(null) + }} + > + + + + + No due date + + + + + )} + + {onMoveToProject && ( <> + + + + {/* ── More sheet: the field editors that have no button in the bar ────── */} + setMoreOpen(false)} + maxHeight='92vh' + title={ + + + {selectedCount} task{selectedCount !== 1 ? 's' : ''} selected + + } + footer={ + + } + > + + {/* Due date, project and the destructive actions are deliberately + absent β€” each has its own button in the bar, and two entry points + would just make them ambiguous. */} + {onSetPriority && ( + <> + } + label='Priority' + value={priorityValue} + /> + + {Priorities.map(priority => { + const isCurrent = + !summary.priority?.isMixed && + summary.priority?.value === priority.value + return ( + : undefined + } + onClick={runAndClose(() => onSetPriority(priority.value))} + sx={selectableChipSx} + > + {priority.name.trim()} + + ) + })} + onSetPriority(0))} + sx={selectableChipSx} + > + None + + + + )} + + {onSetAssignee && assignableMembers.length > 0 && ( + <> + + } + label='Assignee' + value={assigneeValue} + /> + + {assignableMembers.map(member => { + const isCurrent = + !summary.assignee?.isMixed && + summary.assignee?.value === member.userId + return ( + + ) : ( + + {(member.displayName || '?') + .charAt(0) + .toUpperCase()} + + ) + } + onClick={runAndClose(() => onSetAssignee(member.userId))} + sx={selectableChipSx} + > + {member.displayName || member.username} + + ) + })} + + + )} + + {onToggleLabel && labels.length > 0 && ( + <> + + {/* Tapping adds the label to every task; tapping one that is + already on all of them removes it. A half-filled chip means + only some of the selection has it β€” tapping completes the set. */} + } + label='Labels' + value='Tap to add Β· tap again to remove' + /> + + {labels.map(label => { + const onAll = commonLabelIds.has(label.id) + const onSome = partialLabelIds.has(label.id) + return ( + + ) : onSome ? ( + + ) : undefined + } + onClick={runAndClose(() => + onToggleLabel(label, onAll ? 'remove' : 'add'), + )} + sx={{ + ...selectableChipSx, + ...(label.color && !onAll + ? { borderColor: label.color } + : {}), + ...(label.color && onAll + ? { + backgroundColor: label.color, + color: getTextColorFromBackgroundColor( + label.color, + ), + } + : {}), + }} + > + {label.name} + + ) + })} + + + )} + + + + {dueDatePickerOpen && ( + setDueDatePickerOpen(false)} + onApply={parts => { + setDueDatePickerOpen(false) + setMoreOpen(false) + // Passed through as parts, not a timestamp: leaving the time on + // "Anytime" means "keep each task's own hour", which only the + // per-chore handler can resolve. + onSetDueDate?.(parts.dueDateOnly ? parts : null) + }} + onRemove={() => { + setDueDatePickerOpen(false) + setMoreOpen(false) + onSetDueDate?.(null) + }} + /> + )} ) } diff --git a/src/views/Chores/hooks/useChoreActions.js b/src/views/Chores/hooks/useChoreActions.js index 55d9a2c..784cc6f 100644 --- a/src/views/Chores/hooks/useChoreActions.js +++ b/src/views/Chores/hooks/useChoreActions.js @@ -1,4 +1,5 @@ import { useQueryClient } from '@tanstack/react-query' +import moment from 'moment' import { useCallback } from 'react' import { useArchiveChore, @@ -16,6 +17,7 @@ import { SkipChore, UndoChoreAction, UpdateChoreAssignee, + UpdateChorePriority, UpdateDueDate, } from '../../../utils/Fetcher' import { offlineDB } from '../../../utils/OfflineDB' @@ -28,6 +30,28 @@ const isNetworkError = err => err instanceof TypeError && err.message === 'Failed to fetch' +const plural = count => (count === 1 ? '' : 's') +const taskCount = count => `${count} task${plural(count)}` + +// "No specific time" is stored as end of day, and it has to be exactly +// 23:59:59 β€” that stamp is what ChoreEdit writes and what the task card checks +// to render "Today" rather than "Today 11:59 PM". Rebuilding from HH:mm alone +// would land on :00 seconds and lose that meaning. +const END_OF_DAY = '23:59' +const atTimeOfDay = (date, time) => + (!time || time === END_OF_DAY + ? moment(date, 'YYYY-MM-DD').endOf('day') + : moment(`${date} ${time}`, 'YYYY-MM-DD HH:mm') + ).toISOString() + +// Fetcher calls resolve with a Response even on 4xx/5xx, so a bulk run has to +// check explicitly or it will report failures as successes. +const expectOk = async request => { + const response = await request + if (!response?.ok) throw new Error('Request failed') + return response +} + export const useChoreActions = ({ chores, filteredChores, @@ -867,346 +891,386 @@ export const useChoreActions = ({ [showSuccess, showError, closeModal], ) - const handleBulkComplete = useCallback(async () => { - const selectedData = getSelectedChoresData(chores) - if (selectedData.length === 0) return + // ── bulk operations ──────────────────────────────────────────────────────── + // + // Every bulk action is the same shape: optionally confirm, apply per chore, + // tally what worked, tell the user, refetch, drop the selection. `runBulk` + // owns that shape so each action only describes what it does to one chore. + // + // Failures are best-effort and partial: a chore that fails leaves the others + // applied, and the toast says how many of each. - setConfirmModelConfig({ - isOpen: true, - title: 'Complete Tasks', - confirmText: 'Complete', - cancelText: 'Cancel', - message: `Mark ${selectedData.length} task${selectedData.length > 1 ? 's' : ''} as completed?`, - onClose: async isConfirmed => { - if (isConfirmed === true) { - try { - const completedTasks = [] - const failedTasks = [] - - for (const chore of selectedData) { - try { - await MarkChoreComplete( - chore.id, - impersonatedUser - ? { completedBy: impersonatedUser.userId } - : null, - null, - null, - ) - completedTasks.push(chore) - } catch (error) { - failedTasks.push(chore) + const patchLocalChores = useCallback( + (ids, patch) => { + const idSet = new Set(ids) + const apply = list => + list.map(chore => + idSet.has(chore.id) + ? { + ...chore, + ...(typeof patch === 'function' ? patch(chore) : patch), } - } + : chore, + ) + setChores(apply) + setFilteredChores(apply) + }, + [setChores, setFilteredChores], + ) - if (completedTasks.length > 0) { - showSuccess({ - title: 'βœ… Tasks Completed', - message: `Successfully completed ${completedTasks.length} task${completedTasks.length > 1 ? 's' : ''}.`, - }) - } + const removeLocalChores = useCallback( + ids => { + const idSet = new Set(ids) + const drop = list => list.filter(chore => !idSet.has(chore.id)) + setChores(drop) + setFilteredChores(drop) + }, + [setChores, setFilteredChores], + ) - if (failedTasks.length > 0) { - showError({ - title: 'Some Tasks Failed', - message: `${failedTasks.length} task${failedTasks.length > 1 ? 's' : ''} could not be completed.`, - }) - } + const runBulk = useCallback( + async ({ + buildUndo, + // { title, confirmText, message } β€” omitted when the picker the user + // just used is itself the confirmation. + confirm, + // "completed", "rescheduled", … β€” reads as `2 tasks could not be ${verb}.` + failureVerb, + onSucceeded, + perChore, + successTitle, + // "Completed", "Rescheduled", … β€” reads as `${verb} 3 tasks.` + successVerb, + targets, + }) => { + if (!targets || targets.length === 0) return - refetchChores() - clearSelection() - } catch (error) { - showError({ - title: 'Bulk Complete Failed', - message: 'An unexpected error occurred. Please try again.', - }) - } - } - setConfirmModelConfig({}) - }, - }) - }, [ - getSelectedChoresData, - impersonatedUser, - showSuccess, - showError, - refetchChores, - clearSelection, - setConfirmModelConfig, - ]) + const execute = async () => { + const succeeded = [] + const failed = [] - const handleBulkArchive = useCallback(async () => { - const selectedData = getSelectedChoresData(chores) - if (selectedData.length === 0) return - - setConfirmModelConfig({ - isOpen: true, - title: 'Archive Tasks', - confirmText: 'Archive', - cancelText: 'Cancel', - message: `Archive ${selectedData.length} task${selectedData.length > 1 ? 's' : ''}?`, - onClose: async isConfirmed => { - if (isConfirmed === true) { + for (const chore of targets) { try { - const archivedTasks = [] - const failedTasks = [] - for (const chore of selectedData) { - try { - await new Promise((resolve, reject) => { - archiveChore.mutate(chore.id, { - onSuccess: data => { - archivedTasks.push(data) - setChores(prev => prev.filter(c => c.id !== chore.id)) - setFilteredChores(prev => - prev.filter(c => c.id !== chore.id), - ) - resolve(data) - }, - onError: error => { - failedTasks.push(chore) - reject(error) - }, - }) - }) - } catch (error) {} - } - if (archivedTasks.length > 0) { - showSuccess({ - title: 'πŸ“¦ Tasks Archived', - message: `Successfully archived ${archivedTasks.length} task${archivedTasks.length > 1 ? 's' : ''}.`, - }) - } - if (failedTasks.length > 0) { - showError({ - title: 'Some Tasks Failed', - message: `${failedTasks.length} task${failedTasks.length > 1 ? 's' : ''} could not be archived.`, - }) - } - refetchChores() - clearSelection() + await perChore(chore) + succeeded.push(chore) } catch (error) { - showError({ - title: 'Bulk Archive Failed', - message: 'An unexpected error occurred. Please try again.', - }) + failed.push(chore) } } - setConfirmModelConfig({}) - }, - }) - }, [ - getSelectedChoresData, - archiveChore, - setChores, - setFilteredChores, - showSuccess, - showError, - refetchChores, - clearSelection, - setConfirmModelConfig, - ]) - const handleBulkDelete = useCallback(async () => { - const selectedData = getSelectedChoresData(chores) - if (selectedData.length === 0) return - - setConfirmModelConfig({ - isOpen: true, - title: 'Delete Tasks', - confirmText: 'Delete', - cancelText: 'Cancel', - message: `Delete ${selectedData.length} task${selectedData.length > 1 ? 's' : ''}?\n\nThis action cannot be undone.`, - onClose: async isConfirmed => { - if (isConfirmed === true) { - try { - const deletedTasks = [] - const failedTasks = [] - - for (const chore of selectedData) { - try { - await DeleteChore(chore.id) - deletedTasks.push(chore) - } catch (error) { - failedTasks.push(chore) - } - } - - if (deletedTasks.length > 0) { - showSuccess({ - title: 'πŸ—‘οΈ Tasks Deleted', - message: `Successfully deleted ${deletedTasks.length} task${deletedTasks.length > 1 ? 's' : ''}.`, - }) - - const deletedIds = new Set(deletedTasks.map(c => c.id)) - const newChores = chores.filter(c => !deletedIds.has(c.id)) - const newFilteredChores = filteredChores.filter( - c => !deletedIds.has(c.id), - ) - setChores(newChores) - setFilteredChores(newFilteredChores) - } - - if (failedTasks.length > 0) { - showError({ - title: 'Some Tasks Failed', - message: `${failedTasks.length} task${failedTasks.length > 1 ? 's' : ''} could not be deleted.`, - }) - } - refetchChores() - clearSelection() - } catch (error) { - showError({ - title: 'Bulk Delete Failed', - message: 'An unexpected error occurred. Please try again.', - }) - } + if (succeeded.length > 0) { + onSucceeded?.(succeeded) + showSuccess({ + title: successTitle, + message: `${successVerb} ${taskCount(succeeded.length)}.`, + ...(buildUndo ? { undoAction: buildUndo(succeeded) } : {}), + }) } - setConfirmModelConfig({}) - }, - }) - }, [ - getSelectedChoresData, - chores, - filteredChores, - setChores, - setFilteredChores, - showSuccess, - showError, - refetchChores, - clearSelection, - setConfirmModelConfig, - ]) - const handleBulkSkip = useCallback(async () => { - const selectedData = getSelectedChoresData(chores) - if (selectedData.length === 0) return - - setConfirmModelConfig({ - isOpen: true, - title: 'Skip Tasks', - confirmText: 'Skip', - cancelText: 'Cancel', - message: `Skip ${selectedData.length} task${selectedData.length > 1 ? 's' : ''} to next due date?`, - onClose: async isConfirmed => { - if (isConfirmed === true) { - try { - const skippedTasks = [] - const failedTasks = [] - - for (const chore of selectedData) { - try { - await SkipChore(chore.id) - skippedTasks.push(chore) - } catch (error) { - failedTasks.push(chore) - } - } - - if (skippedTasks.length > 0) { - showSuccess({ - title: '⏭️ Tasks Skipped', - message: `Successfully skipped ${skippedTasks.length} task${skippedTasks.length > 1 ? 's' : ''}.`, - undoAction: async () => { - try { - for (const chore of skippedTasks) { - await UndoChoreAction(chore.id) - } - queryClient.invalidateQueries(['chores']) - showUndo({ - title: 'Undo Successful', - message: `Undo skip for ${skippedTasks.length} task${skippedTasks.length > 1 ? 's' : ''}.`, - }) - } catch (error) { - showError({ - title: 'Undo Failed', - message: 'Unable to undo the action. Please try again.', - }) - } - }, - }) - } - - if (failedTasks.length > 0) { - showError({ - title: 'Some Tasks Failed', - message: `${failedTasks.length > 1 ? 's' : ''} could not be skipped.`, - }) - } - - refetchChores() - clearSelection() - } catch (error) { - showError({ - title: 'Bulk Skip Failed', - message: 'An unexpected error occurred. Please try again.', - }) - } + if (failed.length > 0) { + showError({ + title: 'Some Tasks Failed', + message: `${taskCount(failed.length)} could not be ${failureVerb}.`, + }) } - setConfirmModelConfig({}) - }, - }) - }, [ - getSelectedChoresData, - showSuccess, - showError, - showUndo, - refetchChores, - clearSelection, - setConfirmModelConfig, - ]) - const handleBulkMoveToProject = useCallback( - async project => { - const selectedData = getSelectedChoresData(chores) - if (selectedData.length === 0) return + refetchChores() + clearSelection() + } - const projectId = project?.id ?? null - const movedTasks = [] - const failedTasks = [] - - for (const chore of selectedData) { + if (!confirm) { try { - const response = await SaveChore({ ...chore, projectId }) - if (response.ok) { - movedTasks.push(chore) - } else { - failedTasks.push(chore) - } + await execute() } catch (error) { - failedTasks.push(chore) + showError({ + title: `Bulk ${failureVerb} failed`, + message: 'An unexpected error occurred. Please try again.', + }) } + return } - if (movedTasks.length > 0) { - const movedIds = new Set(movedTasks.map(c => c.id)) - const applyMove = list => - list.map(c => (movedIds.has(c.id) ? { ...c, projectId } : c)) - setChores(applyMove) - setFilteredChores(applyMove) - showSuccess({ - title: 'Tasks Moved', - message: `Moved ${movedTasks.length} task${movedTasks.length > 1 ? 's' : ''} to ${project?.name || 'Default Project'}.`, - }) - } - if (failedTasks.length > 0) { - showError({ - title: 'Some Tasks Failed', - message: `${failedTasks.length} task${failedTasks.length > 1 ? 's' : ''} could not be moved.`, - }) - } - - refetchChores() - clearSelection() + setConfirmModelConfig({ + isOpen: true, + cancelText: 'Cancel', + ...confirm, + onClose: async isConfirmed => { + setConfirmModelConfig({}) + if (isConfirmed !== true) return + try { + await execute() + } catch (error) { + showError({ + title: `Bulk ${failureVerb} failed`, + message: 'An unexpected error occurred. Please try again.', + }) + } + }, + }) }, [ - chores, - getSelectedChoresData, - setChores, - setFilteredChores, showSuccess, showError, refetchChores, clearSelection, + setConfirmModelConfig, + ], + ) + + const handleBulkComplete = useCallback(async () => { + const targets = getSelectedChoresData(chores) + runBulk({ + targets, + confirm: { + title: 'Complete Tasks', + confirmText: 'Complete', + message: `Mark ${taskCount(targets.length)} as completed?`, + }, + perChore: chore => + expectOk( + MarkChoreComplete( + chore.id, + impersonatedUser ? { completedBy: impersonatedUser.userId } : null, + null, + null, + ), + ), + successTitle: 'βœ… Tasks Completed', + successVerb: 'Completed', + failureVerb: 'completed', + }) + }, [getSelectedChoresData, chores, impersonatedUser, runBulk]) + + const handleBulkSkip = useCallback(async () => { + const targets = getSelectedChoresData(chores) + runBulk({ + targets, + confirm: { + title: 'Skip Tasks', + confirmText: 'Skip', + message: `Skip ${taskCount(targets.length)} to next due date?`, + }, + perChore: chore => expectOk(SkipChore(chore.id)), + successTitle: '⏭️ Tasks Skipped', + successVerb: 'Skipped', + failureVerb: 'skipped', + buildUndo: succeeded => async () => { + try { + for (const chore of succeeded) { + await UndoChoreAction(chore.id) + } + queryClient.invalidateQueries(['chores']) + showUndo({ + title: 'Undo Successful', + message: `Undo skip for ${taskCount(succeeded.length)}.`, + }) + } catch (error) { + showError({ + title: 'Undo Failed', + message: 'Unable to undo the action. Please try again.', + }) + } + }, + }) + }, [getSelectedChoresData, chores, runBulk, queryClient, showUndo, showError]) + + const handleBulkArchive = useCallback(async () => { + const targets = getSelectedChoresData(chores) + runBulk({ + targets, + confirm: { + title: 'Archive Tasks', + confirmText: 'Archive', + message: `Archive ${taskCount(targets.length)}?`, + }, + perChore: chore => + new Promise((resolve, reject) => { + archiveChore.mutate(chore.id, { + onSuccess: resolve, + onError: reject, + }) + }), + successTitle: 'πŸ“¦ Tasks Archived', + successVerb: 'Archived', + failureVerb: 'archived', + onSucceeded: succeeded => removeLocalChores(succeeded.map(c => c.id)), + }) + }, [getSelectedChoresData, chores, runBulk, archiveChore, removeLocalChores]) + + const handleBulkDelete = useCallback(async () => { + const targets = getSelectedChoresData(chores) + runBulk({ + targets, + confirm: { + title: 'Delete Tasks', + confirmText: 'Delete', + message: `Delete ${taskCount(targets.length)}?\n\nThis action cannot be undone.`, + }, + perChore: chore => expectOk(DeleteChore(chore.id)), + successTitle: 'πŸ—‘οΈ Tasks Deleted', + successVerb: 'Deleted', + failureVerb: 'deleted', + onSucceeded: succeeded => removeLocalChores(succeeded.map(c => c.id)), + }) + }, [getSelectedChoresData, chores, runBulk, removeLocalChores]) + + const handleBulkMoveToProject = useCallback( + async project => { + const projectId = project?.id ?? null + runBulk({ + targets: getSelectedChoresData(chores), + perChore: chore => expectOk(SaveChore({ ...chore, projectId })), + successTitle: 'Tasks Moved', + successVerb: `Moved to ${project?.name || 'Default Project'} β€”`, + failureVerb: 'moved', + onSucceeded: succeeded => + patchLocalChores( + succeeded.map(c => c.id), + { projectId }, + ), + }) + }, + [getSelectedChoresData, chores, runBulk, patchLocalChores], + ) + + // Takes the picker's { dueDateOnly, dueTime, useCustomTime } parts, or null to + // unplan. Moving a batch is a date operation: each task keeps the time of day + // it was already due at, so "next week 5am" moved to tomorrow becomes + // "tomorrow 5am", and a task with no specific time stays at anytime. Only a + // time the user explicitly picked overrides that, for the whole selection. + const handleBulkDueDate = useCallback( + async parts => { + const clearing = !parts?.dueDateOnly + + const dueDateFor = chore => { + if (clearing) return null + if (parts.useCustomTime && parts.dueTime) { + return atTimeOfDay(parts.dueDateOnly, parts.dueTime) + } + // A task with no due date yet has no hour to carry over, so it lands on + // end of day like anything else without a specific time. + const current = moment(chore.nextDueDate) + return atTimeOfDay( + parts.dueDateOnly, + chore.nextDueDate && current.isValid() + ? current.format('HH:mm') + : null, + ) + } + + runBulk({ + targets: getSelectedChoresData(chores), + perChore: chore => expectOk(UpdateDueDate(chore.id, dueDateFor(chore))), + successTitle: clearing ? 'Due Date Removed' : 'Tasks Scheduled', + successVerb: clearing ? 'Unplanned' : 'Rescheduled', + failureVerb: clearing ? 'unplanned' : 'rescheduled', + onSucceeded: succeeded => + patchLocalChores( + succeeded.map(c => c.id), + chore => ({ + nextDueDate: dueDateFor(chore), + }), + ), + }) + }, + [getSelectedChoresData, chores, runBulk, patchLocalChores], + ) + + const handleBulkAssignee = useCallback( + async assigneeId => { + runBulk({ + targets: getSelectedChoresData(chores), + perChore: chore => expectOk(UpdateChoreAssignee(chore.id, assigneeId)), + successTitle: 'Tasks Reassigned', + successVerb: 'Reassigned', + failureVerb: 'reassigned', + onSucceeded: succeeded => + patchLocalChores( + succeeded.map(c => c.id), + { assignedTo: assigneeId }, + ), + }) + }, + [getSelectedChoresData, chores, runBulk, patchLocalChores], + ) + + const handleBulkPriority = useCallback( + async priority => { + runBulk({ + targets: getSelectedChoresData(chores), + perChore: chore => expectOk(UpdateChorePriority(chore.id, priority)), + successTitle: 'Priority Updated', + successVerb: 'Updated priority on', + failureVerb: 'updated', + onSucceeded: succeeded => + patchLocalChores( + succeeded.map(c => c.id), + { priority }, + ), + }) + }, + [getSelectedChoresData, chores, runBulk, patchLocalChores], + ) + + // Add/remove rather than replace: a mixed selection has no single "current" + // label set, and replacing would silently drop labels the user never saw. + // There is no per-label endpoint, so this goes through a full chore save. + const handleBulkLabels = useCallback( + async (label, mode) => { + if (!label) return + const selected = getSelectedChoresData(chores) + const nextLabelsFor = chore => { + const current = chore.labelsV2 || [] + return mode === 'add' + ? [...current, label] + : current.filter(l => l.id !== label.id) + } + + // Chores already in the desired state aren't worth a round trip, and + // counting them would inflate the toast. + const targets = selected.filter(chore => { + const hasLabel = (chore.labelsV2 || []).some(l => l.id === label.id) + return mode === 'add' ? !hasLabel : hasLabel + }) + + if (targets.length === 0) { + showSuccess({ + title: 'No Changes', + message: + mode === 'add' + ? `Every selected task already has "${label.name}".` + : `No selected task has "${label.name}".`, + }) + clearSelection() + return + } + + runBulk({ + targets, + perChore: chore => + expectOk(SaveChore({ ...chore, labelsV2: nextLabelsFor(chore) })), + successTitle: mode === 'add' ? 'Label Added' : 'Label Removed', + successVerb: + mode === 'add' + ? `Added "${label.name}" to` + : `Removed "${label.name}" from`, + failureVerb: 'updated', + onSucceeded: succeeded => + patchLocalChores( + succeeded.map(c => c.id), + chore => ({ + labelsV2: nextLabelsFor(chore), + }), + ), + }) + }, + [ + getSelectedChoresData, + chores, + runBulk, + patchLocalChores, + showSuccess, + clearSelection, ], ) @@ -1222,5 +1286,9 @@ export const useChoreActions = ({ handleBulkDelete, handleBulkSkip, handleBulkMoveToProject, + handleBulkDueDate, + handleBulkAssignee, + handleBulkPriority, + handleBulkLabels, } } diff --git a/src/views/Chores/hooks/useMultiSelect.js b/src/views/Chores/hooks/useMultiSelect.js index cadb30f..1e4cbd7 100644 --- a/src/views/Chores/hooks/useMultiSelect.js +++ b/src/views/Chores/hooks/useMultiSelect.js @@ -1,30 +1,44 @@ -import { useState, useCallback } from 'react' +import { useCallback, useRef, useState } from 'react' + +// A field is "shared" only when every selected chore agrees on it. Anything +// else is mixed, which the bulk editor shows as an unset control rather than +// pretending one of the values is the current one. +const sharedValue = (items, pick) => { + if (items.length === 0) return { value: null, isMixed: false } + const first = pick(items[0]) + const isMixed = items.some(item => pick(item) !== first) + return { value: isMixed ? null : first, isMixed } +} + +const labelIdsOf = chore => (chore.labelsV2 || []).map(label => label.id) export const useMultiSelect = () => { const [isMultiSelectMode, setIsMultiSelectMode] = useState(false) const [selectedChores, setSelectedChores] = useState(new Set()) + // Anchor for shift-click range selection. A ref because it only matters at + // the moment of the next click and should never trigger a render. + const lastSelectedId = useRef(null) const toggleMultiSelectMode = useCallback(() => { - const newMode = !isMultiSelectMode - setIsMultiSelectMode(newMode) + setIsMultiSelectMode(prev => { + if (!prev) setSelectedChores(new Set()) + return !prev + }) + lastSelectedId.current = null + }, []) - if (newMode) { - setSelectedChores(new Set()) - } - }, [isMultiSelectMode]) - - const toggleChoreSelection = useCallback( - choreId => { - const newSelection = new Set(selectedChores) - if (newSelection.has(choreId)) { - newSelection.delete(choreId) + const toggleChoreSelection = useCallback(choreId => { + setSelectedChores(prev => { + const next = new Set(prev) + if (next.has(choreId)) { + next.delete(choreId) } else { - newSelection.add(choreId) + next.add(choreId) } - setSelectedChores(newSelection) - }, - [selectedChores], - ) + return next + }) + lastSelectedId.current = choreId + }, []) // Entry point for press-and-hold on a task card: turn multi-select on (if it // isn't already) with that task selected. @@ -33,6 +47,7 @@ export const useMultiSelect = () => { if (!isMultiSelectMode) { setIsMultiSelectMode(true) setSelectedChores(new Set([choreId])) + lastSelectedId.current = choreId return } toggleChoreSelection(choreId) @@ -40,6 +55,39 @@ export const useMultiSelect = () => { [isMultiSelectMode, toggleChoreSelection], ) + // Shift-click: add everything between the previous click and this one, in the + // order the user actually sees them. Falls back to a plain toggle when there + // is no anchor yet or the anchor has scrolled out of the current list. + const selectChoreRange = useCallback( + (choreId, orderedChores = []) => { + const anchorId = lastSelectedId.current + if (anchorId === null || anchorId === choreId) { + toggleChoreSelection(choreId) + return + } + + const anchorIndex = orderedChores.findIndex(c => c.id === anchorId) + const targetIndex = orderedChores.findIndex(c => c.id === choreId) + if (anchorIndex === -1 || targetIndex === -1) { + toggleChoreSelection(choreId) + return + } + + const [from, to] = + anchorIndex < targetIndex + ? [anchorIndex, targetIndex] + : [targetIndex, anchorIndex] + + setSelectedChores(prev => { + const next = new Set(prev) + for (let i = from; i <= to; i++) next.add(orderedChores[i].id) + return next + }) + lastSelectedId.current = choreId + }, + [toggleChoreSelection], + ) + const selectAllVisibleChores = useCallback( (visibleChores, choreSections = [], openChoreSections = {}) => { let choresToSelect = [] @@ -65,8 +113,7 @@ export const useMultiSelect = () => { } if (choresToSelect.length > 0) { - const allIds = new Set(choresToSelect.map(chore => chore.id)) - setSelectedChores(allIds) + setSelectedChores(new Set(choresToSelect.map(chore => chore.id))) } return choresToSelect.length @@ -75,6 +122,7 @@ export const useMultiSelect = () => { ) const clearSelection = useCallback(() => { + lastSelectedId.current = null if (selectedChores.size === 0) { setIsMultiSelectMode(false) return @@ -84,22 +132,79 @@ export const useMultiSelect = () => { const getSelectedChoresData = useCallback( allChores => { + if (selectedChores.size === 0) return [] + const byId = new Map(allChores.map(chore => [chore.id, chore])) return Array.from(selectedChores) - .map(id => allChores.find(chore => chore.id === id)) + .map(id => byId.get(id)) .filter(Boolean) }, [selectedChores], ) + // What the bulk editor needs to render its controls: the current value where + // the selection agrees, and which labels are on all / only some of them so + // "add" and "remove" can be offered accurately. + const getSelectionSummary = useCallback( + allChores => { + const selected = getSelectedChoresData(allChores) + + const labelCounts = new Map() + const labelsById = new Map() + selected.forEach(chore => { + ;(chore.labelsV2 || []).forEach(label => { + labelsById.set(label.id, label) + labelCounts.set(label.id, (labelCounts.get(label.id) || 0) + 1) + }) + }) + + const commonLabelIds = [] + const partialLabelIds = [] + labelCounts.forEach((count, id) => { + if (count === selected.length) commonLabelIds.push(id) + else partialLabelIds.push(id) + }) + + return { + count: selected.length, + assignee: sharedValue(selected, c => c.assignedTo), + priority: sharedValue(selected, c => c.priority), + dueDate: sharedValue(selected, c => c.nextDueDate), + project: sharedValue(selected, c => c.projectId ?? null), + labels: { + byId: labelsById, + common: commonLabelIds, + partial: partialLabelIds, + // Anything present on at least one chore can be removed. + removable: [...commonLabelIds, ...partialLabelIds], + }, + // Candidate assignees common to the whole selection. An empty (or + // absent) `assignees` list on a chore means "anyone", so those chores + // place no restriction. null here means no restriction at all, and the + // caller should offer every circle member. + assignableUserIds: selected.reduce((acc, chore) => { + const ids = (chore.assignees || []).map(a => a.userId) + if (ids.length === 0) return acc + if (acc === null) return ids + return acc.filter(id => ids.includes(id)) + }, null), + hasArchived: selected.some(chore => chore.isActive === false), + labelIdsOf, + } + }, + [getSelectedChoresData], + ) + return { isMultiSelectMode, selectedChores, toggleMultiSelectMode, toggleChoreSelection, + selectChoreRange, enterMultiSelectWithChore, selectAllVisibleChores, clearSelection, getSelectedChoresData, + getSelectionSummary, setIsMultiSelectMode, setSelectedChores, } diff --git a/src/views/Labels/LabelDetailView.jsx b/src/views/Labels/LabelDetailView.jsx new file mode 100644 index 0000000..715922c --- /dev/null +++ b/src/views/Labels/LabelDetailView.jsx @@ -0,0 +1,405 @@ +import { + Close, + Delete as DeleteIcon, + Edit as EditIcon, + MoreVert, + Search, + SearchOff, + Style, + ViewAgenda, + ViewModule, +} from '@mui/icons-material' +import { + Box, + Chip, + Container, + Divider, + Dropdown, + IconButton, + Input, + List, + Menu, + MenuButton, + MenuItem, + Stack, + Typography, +} from '@mui/joy' +import { useQueryClient } from '@tanstack/react-query' +import Fuse from 'fuse.js' +import moment from 'moment' +import { useMemo, useRef, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { useNavigate, useParams } from 'react-router-dom' + +import EmptyState from '../../components/common/EmptyState' +import { useChores } from '../../queries/ChoreQueries' +import { useCircleMembers, useUserProfile } from '../../queries/UserQueries' +import { DeleteLabel } from '../../utils/Fetcher' +import ChoreListView from '../Chores/ChoreListView' +import LoadingComponent from '../components/Loading' +import ConfirmationModal from '../Modals/Inputs/ConfirmationModal' +import LabelModal from '../Modals/Inputs/LabelModal' +import { useLabels } from './LabelQueries' + +const EMPTY_SELECTION = new Set() + +const LabelDetailView = () => { + const { t } = useTranslation('labels') + const { labelId } = useParams() + const navigate = useNavigate() + const queryClient = useQueryClient() + + const { data: labels, isLoading: isLabelsLoading } = useLabels() + const { data: choresData, isLoading: isChoresLoading } = useChores(false) + const { data: membersData, isLoading: isMembersLoading } = useCircleMembers() + const { data: userProfile } = useUserProfile() + + const [searchTerm, setSearchTerm] = useState('') + const [statusFilter, setStatusFilter] = useState('all') + const [modalOpen, setModalOpen] = useState(false) + const [confirmationModel, setConfirmationModel] = useState({}) + const [viewMode, setViewMode] = useState( + localStorage.getItem('labelDetailViewMode') || 'default', + ) + const searchInputRef = useRef(null) + + const label = useMemo( + () => (labels || []).find(item => String(item.id) === String(labelId)), + [labels, labelId], + ) + + // Tasks carrying this label, soonest due first β€” undated tasks sink to the + // bottom rather than sorting as epoch 0. + const labelChores = useMemo(() => { + const chores = choresData?.res || [] + return chores + .filter(chore => + chore.labelsV2?.some(item => String(item.id) === String(labelId)), + ) + .sort((a, b) => { + if (!a.nextDueDate) return 1 + if (!b.nextDueDate) return -1 + return new Date(a.nextDueDate) - new Date(b.nextDueDate) + }) + }, [choresData, labelId]) + + // Buckets are exclusive: a task due at 9am today is overdue by 3pm, and + // counting it under both "overdue" and "today" would make the chips add up + // to more than the task count. + const bucketOf = chore => { + if (!chore.nextDueDate) return 'undated' + if (moment(chore.nextDueDate).isBefore()) return 'overdue' + if (moment(chore.nextDueDate).isSame(moment(), 'day')) return 'today' + return 'upcoming' + } + + const counts = useMemo(() => { + const tally = { overdue: 0, today: 0, undated: 0 } + labelChores.forEach(chore => { + const bucket = bucketOf(chore) + if (bucket in tally) tally[bucket] += 1 + }) + return { ...tally, all: labelChores.length } + }, [labelChores]) + + const fuse = useMemo( + () => + new Fuse(labelChores, { + keys: ['name', 'description'], + includeScore: true, + isCaseSensitive: false, + findAllMatches: true, + }), + [labelChores], + ) + + const visibleChores = useMemo(() => { + const searched = searchTerm + ? fuse.search(searchTerm).map(result => result.item) + : labelChores + if (statusFilter === 'all') return searched + return searched.filter(chore => bucketOf(chore) === statusFilter) + }, [fuse, searchTerm, labelChores, statusFilter]) + + const handleSearchClose = () => { + setSearchTerm('') + searchInputRef.current?.blur() + } + + const resetFilters = () => { + setSearchTerm('') + setStatusFilter('all') + searchInputRef.current?.blur() + } + + const toggleViewMode = () => { + const newMode = viewMode === 'default' ? 'compact' : 'default' + setViewMode(newMode) + localStorage.setItem('labelDetailViewMode', newMode) + } + + const handleSaveLabel = () => { + queryClient.invalidateQueries({ queryKey: ['labels'] }) + setModalOpen(false) + } + + const handleDeleteClicked = () => { + setConfirmationModel({ + isOpen: true, + title: t('delete.title'), + message: t('delete.message'), + confirmText: t('common:delete'), + color: 'danger', + cancelText: t('common:cancel'), + onClose: confirmed => { + if (confirmed === true) { + DeleteLabel(label.id).then(() => { + queryClient.invalidateQueries({ queryKey: ['labels'] }) + navigate('/labels') + }) + } + setConfirmationModel({}) + }, + }) + } + + if (isLabelsLoading || isChoresLoading || isMembersLoading) { + return + } + + if (!label) { + return ( + + } + title={t('detail.notFoundTitle')} + description={t('detail.notFoundDescription')} + primaryAction={{ label: t('detail.backToLabels'), to: '/labels' }} + /> + + ) + } + + const isOwnedByCurrentUser = label.created_by === userProfile?.id + + return ( + + {/* Identity: the label's own color is what names this page, so it leads + the title rather than sitting in a decorative avatar. */} + + + + + + {label.name} + + {!isOwnedByCurrentUser && ( + + {t('shared')} + + )} + + + {t('detail.taskCount', { count: counts.all })} + + + + {/* One trailing target. Delete is destructive, so it lives behind the + overflow instead of being a naked icon next to the title. */} + + + + + + setModalOpen(true)}> + + {t('common:edit')} + + + + + {t('common:delete')} + + + + + + {/* Status chips double as the filter control: the counts users want to + read are the cuts they want to make. */} + {labelChores.length > 0 && ( + + {[ + { id: 'all', label: t('detail.filters.all'), color: 'neutral' }, + { + id: 'overdue', + label: t('detail.filters.overdue'), + color: 'danger', + }, + { id: 'today', label: t('detail.filters.today'), color: 'primary' }, + { + id: 'undated', + label: t('detail.filters.undated'), + color: 'neutral', + }, + ] + .filter(chip => chip.id === 'all' || counts[chip.id] > 0) + .map(chip => { + const isSelected = statusFilter === chip.id + return ( + setStatusFilter(chip.id)} + aria-pressed={isSelected} + sx={{ flexShrink: 0 }} + endDecorator={ + + {counts[chip.id]} + + } + > + {chip.label} + + ) + })} + + )} + + {/* Search + view mode */} + {labelChores.length > 0 && ( + + setSearchTerm(e.target.value.toLowerCase())} + startDecorator={} + endDecorator={ + searchTerm && ( + + + + ) + } + /> + + {viewMode === 'default' ? : } + + + )} + + {/* Tasks */} + {labelChores.length === 0 ? ( + } + title={t('detail.emptyTitle')} + description={t('detail.emptyDescription', { label: label.name })} + primaryAction={{ label: t('detail.browseTasks'), to: '/chores' }} + /> + ) : visibleChores.length === 0 ? ( + } + title={t('detail.noResultsTitle')} + description={ + searchTerm + ? t('detail.noResultsDescription', { searchTerm }) + : t('detail.noMatchingStatus') + } + primaryAction={{ + label: t('detail.clearFilters'), + onClick: resetFilters, + }} + /> + ) : ( + + + + )} + + {modalOpen && ( + setModalOpen(false)} + onSave={handleSaveLabel} + label={label} + /> + )} + + + ) +} + +export default LabelDetailView diff --git a/src/views/Labels/LabelView.jsx b/src/views/Labels/LabelView.jsx index 10628b6..ce09edd 100644 --- a/src/views/Labels/LabelView.jsx +++ b/src/views/Labels/LabelView.jsx @@ -14,6 +14,7 @@ import { import Fuse from 'fuse.js' import { useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' +import { useNavigate } from 'react-router-dom' import LabelModal from '../Modals/Inputs/LabelModal' import { @@ -163,6 +164,7 @@ const LabelView = () => { const { t } = useTranslation('labels') const { data: labels, isLabelsLoading, isError } = useLabels() const { data: userProfile } = useUserProfile() + const navigate = useNavigate() const [userLabels, setUserLabels] = useState([]) const [modalOpen, setModalOpen] = useState(false) @@ -357,6 +359,7 @@ const LabelView = () => { {filteredLabels.map(label => ( navigate(`/labels/${label.id}`)} swipeActionOpen={showMoreInfoId === label.id ? 'trailing' : null} trailingActions={ diff --git a/src/views/components/AdvancedOptionsSection.jsx b/src/views/components/AdvancedOptionsSection.jsx index 4bf4734..68a662c 100644 --- a/src/views/components/AdvancedOptionsSection.jsx +++ b/src/views/components/AdvancedOptionsSection.jsx @@ -319,7 +319,7 @@ const AdvancedOptionsSection = ({ textColor='text.tertiary' sx={{ my: 0.5, fontStyle: 'italic' }} > - Set a due date to configure completion window and deadline. + Set a due date to configure completion window )} From 2d5dd81371152aa6e31cad065403e0eef3b1d95b Mon Sep 17 00:00:00 2001 From: Mo Tarbin Date: Sat, 15 Aug 2026 21:36:56 -0400 Subject: [PATCH 03/12] Fix : https://github.com/donetick/donetick/issues/794 --- src/views/components/ChoreActionMenu.jsx | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/src/views/components/ChoreActionMenu.jsx b/src/views/components/ChoreActionMenu.jsx index a8dac8d..24f86fe 100644 --- a/src/views/components/ChoreActionMenu.jsx +++ b/src/views/components/ChoreActionMenu.jsx @@ -24,6 +24,7 @@ import { } from '@mui/icons-material' import { Avatar, + Button, Divider, IconButton, List, @@ -467,7 +468,7 @@ const ChoreActionMenu = ({ }) const renderModalProjectPicker = () => ( - + <> @@ -490,7 +491,7 @@ const ChoreActionMenu = ({ ))} - + ) return ( @@ -523,15 +524,18 @@ const ChoreActionMenu = ({ contentSx={{ px: 0, pb: 1 }} > {showProjectPicker && ( - } onClick={() => setShowProjectPicker(false)} - sx={{ gap: 1, mx: 2, mb: 1 }} + sx={{ mx: 2, mb: 1 }} > - Back - + )} {showProjectPicker From 76fb3500ec3e7f835aa83fcd1390561f92b1e0be Mon Sep 17 00:00:00 2001 From: Mo Tarbin Date: Sun, 16 Aug 2026 00:31:47 -0400 Subject: [PATCH 04/12] fix: update PolicyUpdateModal to open documents in system browser --- src/views/Modals/PolicyUpdateModal.jsx | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/src/views/Modals/PolicyUpdateModal.jsx b/src/views/Modals/PolicyUpdateModal.jsx index 6afdd9b..d16717d 100644 --- a/src/views/Modals/PolicyUpdateModal.jsx +++ b/src/views/Modals/PolicyUpdateModal.jsx @@ -1,11 +1,23 @@ +import { Browser } from '@capacitor/browser' +import { Capacitor } from '@capacitor/core' import { ChevronRight, Gavel, PrivacyTip } from '@mui/icons-material' import { Button, Stack } from '@mui/joy' import { useTranslation } from 'react-i18next' -import { useNavigate } from 'react-router-dom' import ModalActions from '../../components/common/ModalActions.jsx' import { useResponsiveModal } from '../../hooks/useResponsiveModal.js' +const POLICY_BASE_URL = 'https://app.donetick.com' + +// Native webviews swallow target="_blank"; route through the system browser. +const openUrl = async url => { + if (Capacitor.isNativePlatform()) { + await Browser.open({ url }) + } else { + window.open(url, '_blank', 'noopener,noreferrer') + } +} + /** * One-time notice that the Privacy Policy and Terms changed. The frame is * generic and always points at the documents, so a future revision only needs @@ -14,7 +26,6 @@ import { useResponsiveModal } from '../../hooks/useResponsiveModal.js' const PolicyUpdateModal = ({ onAcknowledge, onClose, open }) => { const { t } = useTranslation() const { ResponsiveModal } = useResponsiveModal() - const navigate = useNavigate() const handleClose = () => { onAcknowledge?.() @@ -23,7 +34,7 @@ const PolicyUpdateModal = ({ onAcknowledge, onClose, open }) => { const openDocument = path => { handleClose() - navigate(path) + openUrl(`${POLICY_BASE_URL}${path}`) } const documentButtonSx = { From 77834aa3c08b93ee77e24a082651dc93c3eb7de4 Mon Sep 17 00:00:00 2001 From: Mo Tarbin Date: Sun, 16 Aug 2026 00:32:14 -0400 Subject: [PATCH 05/12] fix: add z-index to AppModal handle as ReportIssue modal X was not pressable --- src/components/common/AppModal.jsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/components/common/AppModal.jsx b/src/components/common/AppModal.jsx index 72c84a7..84b1677 100644 --- a/src/components/common/AppModal.jsx +++ b/src/components/common/AppModal.jsx @@ -183,6 +183,7 @@ const AppModal = forwardRef( onClick={handleClose} sx={{ position: 'absolute', + zIndex: 1, top: isSheet && showHandle ? 6 : 12, right: { xs: 10, sm: 16 }, borderRadius: '50%', From 5c71e4ce58b9d53bdbceb3d980d58d4a8430ab0c Mon Sep 17 00:00:00 2001 From: Mo Tarbin Date: Sun, 16 Aug 2026 01:07:55 -0400 Subject: [PATCH 06/12] fix: handle manual bug reports by modifying error structure in submitErrorReport --- src/service/ErrorReportService.js | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/src/service/ErrorReportService.js b/src/service/ErrorReportService.js index 1d9689c..07c44c9 100644 --- a/src/service/ErrorReportService.js +++ b/src/service/ErrorReportService.js @@ -249,13 +249,28 @@ export const submitErrorReport = async ({ description, report, }) => { + // The relay rejects reports without an error. Manual bug reports have no + // thrown Error, so mark only the submitted copy while preserving the local + // diagnostics as a manual report. + const submittedReport = + report.kind === 'bug' + ? { + ...report, + error: { + ...report.error, + name: 'ManualBugReport', + message: 'Submitted manually from the app', + }, + } + : report + const payload = { source: 'donetick-app', - kind: report.kind === 'bug' ? 'bug-report' : 'error-report', + kind: 'error-report', reportId: report.reportId, description: description?.trim() || null, contactEmail: contactEmail?.trim() || null, - report, + report: submittedReport, } // Enforced here, not only in the UI, so no future caller can relay a From 2480049fd1f67c44acf2bc1c2f9268bab26a3050 Mon Sep 17 00:00:00 2001 From: Mo Tarbin Date: Sun, 16 Aug 2026 01:26:28 -0400 Subject: [PATCH 07/12] Improve user feedback and modal behavior in ErrorReportModal and PolicyUpdateModal --- src/views/Modals/ErrorReportModal.jsx | 2 +- src/views/Modals/PolicyUpdateModal.jsx | 14 ++++++++++---- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/src/views/Modals/ErrorReportModal.jsx b/src/views/Modals/ErrorReportModal.jsx index be4d41b..5c8333e 100644 --- a/src/views/Modals/ErrorReportModal.jsx +++ b/src/views/Modals/ErrorReportModal.jsx @@ -329,7 +329,7 @@ const ErrorReportModal = ({ error, errorInfo, onClose, open }) => { level='body-sm' sx={{ color: 'text.secondary', mt: 0.5 }} > - Thanks β€” this goes straight to the people who can fix it. + Thanks! this goes straight to the people who can fix it. diff --git a/src/views/Modals/PolicyUpdateModal.jsx b/src/views/Modals/PolicyUpdateModal.jsx index d16717d..de22db7 100644 --- a/src/views/Modals/PolicyUpdateModal.jsx +++ b/src/views/Modals/PolicyUpdateModal.jsx @@ -27,13 +27,16 @@ const PolicyUpdateModal = ({ onAcknowledge, onClose, open }) => { const { t } = useTranslation() const { ResponsiveModal } = useResponsiveModal() - const handleClose = () => { + // Acknowledgement is the only way out: the backdrop, escape key, and close + // button are all disabled so the user must press "Got it". + const handleAcknowledge = () => { onAcknowledge?.() onClose() } + // Reading a document must not dismiss the notice; the modal is still waiting + // on an acknowledgement when the user returns from the browser. const openDocument = path => { - handleClose() openUrl(`${POLICY_BASE_URL}${path}`) } @@ -47,7 +50,10 @@ const PolicyUpdateModal = ({ onAcknowledge, onClose, open }) => { return ( { From 79d92ca8c988d1ab42f949ffbf40d3bc29a7b0e7 Mon Sep 17 00:00:00 2001 From: Mo Tarbin Date: Sun, 16 Aug 2026 01:36:20 -0400 Subject: [PATCH 08/12] add quick actions for creating labels, projects, and filters in GlobalSearchPalette refactor: move stripHtml function to Helpers utility fix: update chore filters to include raw description for better search indexing enhance: implement search parameter handling for modal openings in ProjectView and LabelView --- public/locales/en/common.json | 3 + src/search/GlobalSearchPalette.jsx | 99 +++++++++++++++++++---- src/search/searchProviders.js | 10 +-- src/utils/Helpers.jsx | 13 +++ src/views/Chores/hooks/useChoreFilters.js | 11 ++- src/views/Filters/FilterView.jsx | 44 ++++++---- src/views/Labels/LabelView.jsx | 59 +++++++++----- src/views/Projects/ProjectView.jsx | 47 +++++++---- 8 files changed, 210 insertions(+), 76 deletions(-) diff --git a/public/locales/en/common.json b/public/locales/en/common.json index af1f291..4317f1a 100644 --- a/public/locales/en/common.json +++ b/public/locales/en/common.json @@ -56,6 +56,9 @@ "quickAction": "Quick action", "navigation": "Navigation", "createTask": "Create a task", + "createLabel": "Create a label", + "createProject": "Create a project", + "createFilter": "Create a filter", "viewAllTasks": "View all tasks", "viewArchivedTasks": "View archived tasks", "openSettings": "Open settings", diff --git a/src/search/GlobalSearchPalette.jsx b/src/search/GlobalSearchPalette.jsx index dd7125b..a7963f2 100644 --- a/src/search/GlobalSearchPalette.jsx +++ b/src/search/GlobalSearchPalette.jsx @@ -56,13 +56,41 @@ const buildQuickActions = t => [ provider: 'actions', title: t('search.actions.createTask'), subtitle: t('search.actions.quickAction'), - route: '/chores/create', + keywords: 'new task chore add create', + // Reuses the widget deep-link param so this lands on the task list with the + // quick-add modal open, instead of the full create page. + route: '/chores?add_task=1', + }, + { + id: 'action:create-label', + provider: 'actions', + title: t('search.actions.createLabel'), + subtitle: t('search.actions.quickAction'), + keywords: 'new label tag add create', + route: '/labels?create=1', + }, + { + id: 'action:create-project', + provider: 'actions', + title: t('search.actions.createProject'), + subtitle: t('search.actions.quickAction'), + keywords: 'new project folder add create', + route: '/projects?create=1', + }, + { + id: 'action:create-filter', + provider: 'actions', + title: t('search.actions.createFilter'), + subtitle: t('search.actions.quickAction'), + keywords: 'new filter view saved search add create', + route: '/filters?create=1', }, { id: 'action:tasks', provider: 'actions', title: t('search.actions.viewAllTasks'), subtitle: t('search.actions.navigation'), + keywords: 'tasks chores list open', route: '/chores', }, { @@ -70,6 +98,7 @@ const buildQuickActions = t => [ provider: 'actions', title: t('search.actions.viewArchivedTasks'), subtitle: t('search.actions.navigation'), + keywords: 'archive archived tasks open', route: '/archived', }, { @@ -77,6 +106,7 @@ const buildQuickActions = t => [ provider: 'actions', title: t('search.actions.openSettings'), subtitle: t('search.actions.navigation'), + keywords: 'settings preferences configuration open', route: '/settings', }, ] @@ -192,6 +222,22 @@ const GlobalSearchPalette = ({ const [recents] = useState(readRecents) const selectedResultRef = useRef(null) + const quickActions = useMemo(() => buildQuickActions(t), [t]) + const quickActionIndex = useMemo( + () => + new Fuse(quickActions, { + threshold: 0.38, + distance: 120, + ignoreLocation: true, + includeScore: true, + keys: [ + { name: 'title', weight: 0.7 }, + { name: 'keywords', weight: 0.3 }, + ], + }), + [quickActions], + ) + const searchIndexes = useMemo( () => new Map( @@ -227,7 +273,7 @@ const GlobalSearchPalette = ({ const recentResults = recents .map(item => currentById.get(item.id) || item) .filter(item => item.provider !== 'history' || currentById.has(item.id)) - return [...recentResults, ...buildQuickActions(t)] + return [...recentResults, ...quickActions] } const grouped = GROUPS.filter(group => group !== 'actions').flatMap(group => @@ -250,15 +296,40 @@ const GlobalSearchPalette = ({ }) .sort((a, b) => a.score - b.score), ) - grouped.push({ - id: 'action:filter-tasks', - provider: 'actions', - title: t('search.actions.filterTasks', { query: query.trim() }), - subtitle: t('search.actions.filterTasksSubtitle'), - route: `/chores?search=${encodeURIComponent(query.trim())}`, - }) - return grouped - }, [documents, query, recents, searchIndexes, t]) + const actionMatches = ( + quickActionIndex.search(normalized, { limit: 4 }) || [] + ) + .map(match => ({ ...match.item, score: match.score ?? 1 })) + .sort((a, b) => a.score - b.score) + + // An action whose title the query starts spelling out ("create la…") is + // what the person is after, so it leads. Anything matched only through its + // keywords stays below the real content it shares words with. + const leadingActions = actionMatches.filter(action => + action.title.toLocaleLowerCase().startsWith(normalized), + ) + const trailingActions = actionMatches.filter( + action => !leadingActions.includes(action), + ) + + return [ + ...leadingActions, + ...grouped, + ...trailingActions, + { + id: 'action:filter-tasks', + provider: 'actions', + title: t('search.actions.filterTasks', { query: query.trim() }), + subtitle: t('search.actions.filterTasksSubtitle'), + route: `/chores?search=${encodeURIComponent(query.trim())}`, + }, + ] + }, [documents, query, quickActionIndex, recents, searchIndexes, t]) + + // Everything except the always-present "filter the task list" fallback. + const matchCount = results.filter( + result => result.id !== 'action:filter-tasks', + ).length useEffect(() => { selectedResultRef.current?.scrollIntoView({ @@ -339,7 +410,7 @@ const GlobalSearchPalette = ({ pb: 'var(--safe-area-inset-bottom, 0px)', }} > - {!isLoading && query.trim() && results.length === 1 && ( + {!isLoading && query.trim() && matchCount === 0 && ( ↡ {t('search.footer.open')} {query.trim() - ? t('search.footer.results', { - count: Math.max(0, results.length - 1), - }) + ? t('search.footer.results', { count: matchCount }) : t('search.footer.typeToSearch')} diff --git a/src/search/searchProviders.js b/src/search/searchProviders.js index 6b98a4f..27b5552 100644 --- a/src/search/searchProviders.js +++ b/src/search/searchProviders.js @@ -1,13 +1,5 @@ import { SETTINGS_SECTIONS } from '../constants/settingsSections' - -const stripHtml = value => { - if (!value) return '' - if (typeof globalThis.document === 'undefined') - return String(value).replace(/<[^>]*>/g, ' ') - const element = globalThis.document.createElement('div') - element.innerHTML = String(value) - return element.textContent || element.innerText || '' -} +import { stripHtml } from '../utils/Helpers' const HISTORY_STATUS = { 0: 'in progress', diff --git a/src/utils/Helpers.jsx b/src/utils/Helpers.jsx index abf17de..5589ddc 100644 --- a/src/utils/Helpers.jsx +++ b/src/utils/Helpers.jsx @@ -1,10 +1,22 @@ import moment from 'moment' + import { apiClient } from './ApiClient' const isPlusAccount = userProfile => { return userProfile?.expiration && moment(userProfile?.expiration).isAfter() } +// Turns rich-text/HTML content (task descriptions, notes) into plain text so it +// can be indexed or matched by search. +const stripHtml = value => { + if (!value) return '' + if (typeof globalThis.document === 'undefined') + return String(value).replace(/<[^>]*>/g, ' ') + const element = globalThis.document.createElement('div') + element.innerHTML = String(value) + return element.textContent || element.innerText || '' +} + const resolvePhotoURL = url => { if (!url) return '' if (url.startsWith('http') || url.startsWith('https')) { @@ -83,4 +95,5 @@ export { isPlusAccount, isSignedUrlExpired, resolvePhotoURL, + stripHtml, } diff --git a/src/views/Chores/hooks/useChoreFilters.js b/src/views/Chores/hooks/useChoreFilters.js index a8903a6..4361aa8 100644 --- a/src/views/Chores/hooks/useChoreFilters.js +++ b/src/views/Chores/hooks/useChoreFilters.js @@ -1,11 +1,13 @@ import Fuse from 'fuse.js' import { useCallback, useMemo, useState } from 'react' + import { ChoreFilters, filterByProject } from '../../../utils/Chores' +import { stripHtml } from '../../../utils/Helpers' export const useChoreFilters = ({ chores, - selectedProject, impersonatedUser, + selectedProject, userProfile, }) => { const [searchTerm, setSearchTerm] = useState('') @@ -30,9 +32,14 @@ export const useChoreFilters = ({ const searchableChores = chores.map(chore => ({ ...chore, raw_label: chore.labelsV2?.map(label => label.name).join(' '), + raw_description: stripHtml(chore.description), })) return new Fuse(searchableChores, { - keys: ['name', 'raw_label'], + keys: [ + { name: 'name', weight: 0.6 }, + { name: 'raw_label', weight: 0.25 }, + { name: 'raw_description', weight: 0.15 }, + ], includeScore: true, isCaseSensitive: false, findAllMatches: true, diff --git a/src/views/Filters/FilterView.jsx b/src/views/Filters/FilterView.jsx index be37bd8..a340ea7 100644 --- a/src/views/Filters/FilterView.jsx +++ b/src/views/Filters/FilterView.jsx @@ -1,11 +1,20 @@ +import '@meauxt/react-swipeable-list/dist/styles.css' + import { - Type as ListType, SwipeableList, SwipeableListItem, SwipeAction, TrailingActions, + Type as ListType, } from '@meauxt/react-swipeable-list' -import '@meauxt/react-swipeable-list/dist/styles.css' +import { + Add, + FilterAlt, + MoreVert, + Star, + StarBorder, + Task, +} from '@mui/icons-material' import DeleteIcon from '@mui/icons-material/Delete' import EditIcon from '@mui/icons-material/Edit' import { @@ -19,22 +28,13 @@ import { Typography, } from '@mui/joy' import { useEffect, useMemo, useState } from 'react' -import { useNavigate } from 'react-router-dom' +import { useNavigate, useSearchParams } from 'react-router-dom' -import { - Add, - FilterAlt, - MoreVert, - Star, - StarBorder, - Task, -} from '@mui/icons-material' import EmptyState from '../../components/common/EmptyState' import { useChores } from '../../queries/ChoreQueries' import { useCircleMembers, useUserProfile } from '../../queries/UserQueries' import { getFilterCount, getFilterOverdueCount } from '../../utils/FilterEngine' import { getSafeBottomStyles } from '../../utils/SafeAreaUtils' - import { useLabels } from '../Labels/LabelQueries' import AdvancedFilterBuilder from '../Modals/Inputs/AdvancedFilterBuilder' import ConfirmationModal from '../Modals/Inputs/ConfirmationModal' @@ -49,9 +49,9 @@ import { const FilterCardContent = ({ filter, - taskCount = 0, - overdueCount = 0, onToggleActions, + overdueCount = 0, + taskCount = 0, }) => { // Get condition labels for display const getConditionSummary = () => { @@ -246,6 +246,7 @@ const FilterCardContent = ({ const FilterView = () => { const navigate = useNavigate() + const [searchParams, setSearchParams] = useSearchParams() const { data: userProfile } = useUserProfile() const { data: chores = { res: [] } } = useChores(false) const { data: labels = [] } = useLabels() @@ -321,6 +322,21 @@ const FilterView = () => { setShowAdvancedFilterBuilder(true) } + // ?create=1 lets other surfaces (global search quick actions) land here with + // the filter builder already open. + useEffect(() => { + if (searchParams.get('create') !== '1') return + setEditingFilter(null) + setShowAdvancedFilterBuilder(true) + setSearchParams( + params => { + params.delete('create') + return params + }, + { replace: true }, + ) + }, [searchParams, setSearchParams]) + const handleEditFilter = filter => { setEditingFilter(filter) setShowAdvancedFilterBuilder(true) diff --git a/src/views/Labels/LabelView.jsx b/src/views/Labels/LabelView.jsx index ce09edd..e3144a0 100644 --- a/src/views/Labels/LabelView.jsx +++ b/src/views/Labels/LabelView.jsx @@ -1,3 +1,20 @@ +import '@meauxt/react-swipeable-list/dist/styles.css' + +import { + SwipeableList, + SwipeableListItem, + SwipeAction, + TrailingActions, + Type as ListType, +} from '@meauxt/react-swipeable-list' +import { + Add, + Close, + MoreVert, + Search, + SearchOff, + Style, +} from '@mui/icons-material' import DeleteIcon from '@mui/icons-material/Delete' import EditIcon from '@mui/icons-material/Edit' import { @@ -11,38 +28,22 @@ import { Stack, Typography, } from '@mui/joy' +import { useQueryClient } from '@tanstack/react-query' import Fuse from 'fuse.js' import { useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' -import { useNavigate } from 'react-router-dom' -import LabelModal from '../Modals/Inputs/LabelModal' +import { useNavigate, useSearchParams } from 'react-router-dom' -import { - Type as ListType, - SwipeableList, - SwipeableListItem, - SwipeAction, - TrailingActions, -} from '@meauxt/react-swipeable-list' -import '@meauxt/react-swipeable-list/dist/styles.css' -import { - Add, - Close, - MoreVert, - Search, - SearchOff, - Style, -} from '@mui/icons-material' import EmptyState from '../../components/common/EmptyState' -import { useQueryClient } from '@tanstack/react-query' import { useUserProfile } from '../../queries/UserQueries' import { getTextColorFromBackgroundColor } from '../../utils/Colors' import { DeleteLabel } from '../../utils/Fetcher' import { getSafeBottomStyles } from '../../utils/SafeAreaUtils' import ConfirmationModal from '../Modals/Inputs/ConfirmationModal' +import LabelModal from '../Modals/Inputs/LabelModal' import { useLabels } from './LabelQueries' -const LabelCardContent = ({ label, currentUserId, onToggleActions }) => { +const LabelCardContent = ({ currentUserId, label, onToggleActions }) => { const { t } = useTranslation('labels') // Check if current user owns this label const isOwnedByCurrentUser = label.created_by === currentUserId @@ -162,9 +163,10 @@ const LabelCardContent = ({ label, currentUserId, onToggleActions }) => { const LabelView = () => { const { t } = useTranslation('labels') - const { data: labels, isLabelsLoading, isError } = useLabels() + const { data: labels, isError, isLabelsLoading } = useLabels() const { data: userProfile } = useUserProfile() const navigate = useNavigate() + const [searchParams, setSearchParams] = useSearchParams() const [userLabels, setUserLabels] = useState([]) const [modalOpen, setModalOpen] = useState(false) @@ -255,6 +257,21 @@ const LabelView = () => { } }, [labels]) + // ?create=1 lets other surfaces (global search quick actions) land here with + // the create modal already open. + useEffect(() => { + if (searchParams.get('create') !== '1') return + setCurrentLabel(null) + setModalOpen(true) + setSearchParams( + params => { + params.delete('create') + return params + }, + { replace: true }, + ) + }, [searchParams, setSearchParams]) + if (isLabelsLoading) { return ( { const { t } = useTranslation('projects') // Check if current user owns this project @@ -221,7 +222,7 @@ const ProjectCardContent = ({ const ProjectView = () => { const { t } = useTranslation('projects') - const { data: projects, isProjectsLoading, isError } = useProjects() + const { data: projects, isError, isProjectsLoading } = useProjects() const { data: userProfile } = useUserProfile() const { data: chores = { res: [] } } = useChores(false) // false to exclude archived const { data: projectsData = [], isLoading: projectsLoading } = useProjects() @@ -230,6 +231,7 @@ const ProjectView = () => { !projectsLoading, ) const navigate = useNavigate() + const [searchParams, setSearchParams] = useSearchParams() const [userProjects, setUserProjects] = useState([]) const [modalOpen, setModalOpen] = useState(false) @@ -302,6 +304,21 @@ const ProjectView = () => { } }, [projects]) + // ?create=1 lets other surfaces (global search quick actions) land here with + // the create modal already open. + useEffect(() => { + if (searchParams.get('create') !== '1') return + setCurrentProject(null) + setModalOpen(true) + setSearchParams( + params => { + params.delete('create') + return params + }, + { replace: true }, + ) + }, [searchParams, setSearchParams]) + // Calculate real task counts from chores data useEffect(() => { if (chores && chores.res) { From 325d5e6df747faa2babdaac1a6cd8385d94575ec Mon Sep 17 00:00:00 2001 From: Mo Tarbin Date: Sun, 16 Aug 2026 02:07:44 -0400 Subject: [PATCH 09/12] fix: update assignee filter logic and improve display options in MyChores and ChoreToolbarPrototype --- src/views/Chores/MyChores.jsx | 71 ++++++++++++++++--- .../components/ChoreToolbarPrototype.jsx | 20 +++--- 2 files changed, 70 insertions(+), 21 deletions(-) diff --git a/src/views/Chores/MyChores.jsx b/src/views/Chores/MyChores.jsx index 867de23..d04a303 100644 --- a/src/views/Chores/MyChores.jsx +++ b/src/views/Chores/MyChores.jsx @@ -74,6 +74,16 @@ import NotificationAccessSnackbar from './NotificationAccessSnackbar' import Sidepanel from './Sidepanel' import { INSIGHT_FILTER_DEFS } from './SmartInsightsCard' +// Mirrors the assignee options in the toolbar, phrased to drop into a +// sentence ("none of them are assigned to you"). +const ASSIGNEE_FILTER_LABELS = { + assigned_to_me: 'assigned to you', + available_for_me: 'available for you to pick up', + assigned_to_others: 'assigned to someone else', + assigned_to_me_tasks: 'assigned to you', + created_by_me: 'created by you', +} + const MyChores = () => { const { data: userProfile, isLoading: isUserProfileLoading } = useUserProfile() @@ -884,20 +894,51 @@ const MyChores = () => { }) }, [getFilteredChores, selectedCalendarDate]) + // The assignee filter ("Mine", "Available to me", ...) is applied inside + // ChoresGrouper, not in projectFilteredChores, so it can hide every task + // while the unfiltered list still looks full. It narrows like any other. + const assigneeFilterLabel = ASSIGNEE_FILTER_LABELS[selectedChoreFilter] + const hasAssigneeFilter = Boolean( + selectedChoreFilter && selectedChoreFilter !== 'anyone', + ) + // "Narrowed" means the user actively cut the list down (search, quick - // filters, a saved filter). Picking a project is not narrowing: an empty - // project is an empty place, not a filtered-away result. + // filters, a saved filter, the assignee filter). Picking a project is not + // narrowing: an empty project is an empty place, not a filtered-away result. const isNarrowed = Boolean( - searchTerm?.length > 0 || hasQuickFilters || activeFilterId, + searchTerm?.length > 0 || + hasQuickFilters || + activeFilterId || + hasAssigneeFilter, ) const isCustomProjectSelected = Boolean( selectedProject && selectedProject.id !== 'default', ) + // Worth its own wording: the assignee filter is the one narrowing that is + // easy to forget you left on, so name it rather than saying "filters". + const isAssigneeOnlyNarrowing = Boolean( + assigneeFilterLabel && + !searchTerm?.length && + !hasQuickFilters && + !activeFilterId, + ) + + // What the list actually renders. Sections are the source of truth outside + // of search, since they are the only place the assignee filter is applied. + const visibleChoreCount = useMemo( + () => + choreSections.reduce( + (total, section) => total + (section.content?.length || 0), + 0, + ), + [choreSections], + ) const clearNarrowing = () => { clearQuickFilters() setSearchTerm('') clearActiveFilter() + setSelectedChoreFilterWithCache('anyone') updateFilterUrl(null, null) } @@ -1106,10 +1147,13 @@ const MyChores = () => { {/* Empty state. Three different situations, three different messages: nothing created yet, nothing left after narrowing, or an empty - project. Only the middle one is about filters. */} - {(isNarrowed + project. Only the middle one is about filters. + The trigger is what the list actually renders, not the pre-filter + count, so a view emptied purely by the assignee filter still + explains itself instead of showing a blank page. */} + {(searchTerm?.length > 0 ? getFilteredChores.length === 0 - : projectFilteredChores.length === 0) && + : visibleChoreCount === 0) && // only if not in calendar view: viewMode !== 'calendar' && (chores.length === 0 ? ( @@ -1129,7 +1173,10 @@ const MyChores = () => { onClick: () => Navigate('/chores/create'), }} /> - ) : isNarrowed ? ( + ) : isNarrowed && + (searchTerm?.length > 0 || + activeFilterId || + projectFilteredChores.length > 0) ? ( { description={ searchTerm?.length > 0 ? `Nothing matches "${searchTerm}". Try a different search, or clear what is narrowing the list.` - : 'You have tasks, but none of them fit the filters that are currently on.' + : isAssigneeOnlyNarrowing + ? `There are tasks here, but none of them are ${assigneeFilterLabel}. Switch back to everyone to see the rest.` + : 'You have tasks, but none of them fit the filters that are currently on.' } primaryAction={{ label: - searchTerm?.length > 0 ? 'Clear search' : 'Clear filters', + searchTerm?.length > 0 + ? 'Clear search' + : isAssigneeOnlyNarrowing + ? "Show everyone's tasks" + : 'Clear filters', onClick: clearNarrowing, }} /> diff --git a/src/views/Chores/components/ChoreToolbarPrototype.jsx b/src/views/Chores/components/ChoreToolbarPrototype.jsx index cd4e28d..9203009 100644 --- a/src/views/Chores/components/ChoreToolbarPrototype.jsx +++ b/src/views/Chores/components/ChoreToolbarPrototype.jsx @@ -23,13 +23,13 @@ import { Check, CheckBox, CheckBoxOutlineBlank, + DisplaySettings, FilterList, Save, Sort, Tune, ViewAgenda, ViewComfy, - ViewModule, } from '@mui/icons-material' import { Badge, @@ -594,23 +594,19 @@ const ChoreToolbar = ({ /> )} - {/* Display button β€” View + Group combined */} + {/* Display button β€” View + Group combined. + Icon stays fixed: mirroring viewMode made this read as a toggle + showing the current view rather than a button that opens a sheet. */} setDisplaySheetOpen(true)} - aria-label='View and group options' - title='View & Group' + aria-label='Display options' + title='Display' > - {viewMode === 'calendar' ? ( - - ) : viewMode === 'compact' ? ( - - ) : ( - - )} + {/* Multiselect */} @@ -884,7 +880,7 @@ const ChoreToolbar = ({ onClose={() => setDisplaySheetOpen(false)} title={ - + Display } From 5359ae193b7997b7c377509392f58e7b371a9f1e Mon Sep 17 00:00:00 2001 From: Mo Tarbin Date: Sun, 16 Aug 2026 02:16:33 -0400 Subject: [PATCH 10/12] add SortAndFilterMenu component and integrate it into various views for enhanced sorting and filtering capabilities --- public/locales/en/labels.json | 4 +- public/locales/en/projects.json | 8 + src/components/common/SortAndFilterMenu.jsx | 256 ++++++++++++++++++++ src/views/Chores/ArchivedTasks.jsx | 53 +++- src/views/Filters/FilterView.jsx | 171 ++++++++++++- src/views/Labels/LabelView.jsx | 95 +++++++- src/views/Projects/ProjectView.jsx | 204 ++++++++++++++-- src/views/Things/ThingsView.jsx | 161 +++++++++++- 8 files changed, 914 insertions(+), 38 deletions(-) create mode 100644 src/components/common/SortAndFilterMenu.jsx diff --git a/public/locales/en/labels.json b/public/locales/en/labels.json index f02b90f..47a296c 100644 --- a/public/locales/en/labels.json +++ b/public/locales/en/labels.json @@ -9,7 +9,9 @@ "placeholder": "Search labels", "noResultsTitle": "No labels match", "noResultsDescription": "No label matches \"{{searchTerm}}\".", - "clear": "Clear search" + "noFilterResultsDescription": "No label matches the current filter.", + "clear": "Clear search", + "showAll": "Show all labels" }, "detail": { "taskCount_one": "{{count}} task", diff --git a/public/locales/en/projects.json b/public/locales/en/projects.json index 0e7eed4..19c9c9e 100644 --- a/public/locales/en/projects.json +++ b/public/locales/en/projects.json @@ -12,6 +12,14 @@ "message": "Are you sure you want to delete \"{{name}}\"? This will remove the project but keep all tasks (they'll move to the Default Project)." }, "loadError": "Failed to load projects. Please try again.", + "search": { + "placeholder": "Search projects", + "noResultsTitle": "No projects match", + "noResultsDescription": "No project matches \"{{searchTerm}}\".", + "noFilterResultsDescription": "No project matches the current filter.", + "clear": "Clear search", + "showAll": "Show all projects" + }, "blurb": "Organize your tasks into projects. Create custom workspaces to keep your tasks organized and easily accessible.", "defaultDescription": "All tasks without a specific project", "selector": { diff --git a/src/components/common/SortAndFilterMenu.jsx b/src/components/common/SortAndFilterMenu.jsx new file mode 100644 index 0000000..b6235c8 --- /dev/null +++ b/src/components/common/SortAndFilterMenu.jsx @@ -0,0 +1,256 @@ +import { ArrowDownward, ArrowUpward, Check, Sort } from '@mui/icons-material' +import { + Box, + Divider, + IconButton, + ListItemContent, + ListItemDecorator, + Menu, + MenuItem, + Radio, + Typography, +} from '@mui/joy' +import { useEffect, useRef, useState } from 'react' + +/** + * Compact sort + filter menu, meant to sit next to a search input. + * + * Props: + * sortOptions - [{ name, value }] shown under the sort header + * selectedSort - currently selected sort value + * onSortChange - (value) => void + * sortDirection - 'asc' | 'desc' + * onSortDirectionChange - (direction) => void + * filterTitle - optional header for the filter section + * filterOptions - optional [{ name, value }] rendered as radios + * selectedFilter - currently selected filter value + * onFilterChange - (value) => void + * isActive - highlights the trigger button when a non-default choice is on + */ +const SortAndFilterMenu = ({ + filterOptions, + filterTitle, + icon = , + isActive, + onFilterChange, + onSortChange, + onSortDirectionChange, + selectedFilter, + selectedSort, + sortDirection = 'asc', + sortOptions = [], + title = 'Sort by', +}) => { + const [anchorEl, setAnchorEl] = useState(null) + const menuRef = useRef(null) + const buttonRef = useRef(null) + + const handleMenuClose = () => setAnchorEl(null) + + useEffect(() => { + const handleMenuOutsideClick = event => { + if ( + menuRef.current && + !menuRef.current.contains(event.target) && + !buttonRef.current?.contains(event.target) + ) { + handleMenuClose() + } + } + + document.addEventListener('mousedown', handleMenuOutsideClick) + return () => { + document.removeEventListener('mousedown', handleMenuOutsideClick) + } + }, []) + + const SectionHeader = ({ children }) => ( + + + + {children} + + + + ) + + return ( + <> + setAnchorEl(anchorEl ? null : event.currentTarget)} + variant='outlined' + color={isActive ? 'primary' : 'neutral'} + size='sm' + sx={{ height: 32, width: 32, borderRadius: '50%', flexShrink: 0 }} + aria-label='Sort and filter options' + title='Sort & Filter' + > + {icon} + + + + {title} + + + {sortOptions.map(option => ( + { + onSortChange(option.value) + handleMenuClose() + }} + sx={{ + borderRadius: 'var(--joy-radius-sm)', + backgroundColor: + selectedSort === option.value + ? 'var(--joy-palette-primary-softBg)' + : 'transparent', + '&:hover': { + backgroundColor: + selectedSort === option.value + ? 'var(--joy-palette-primary-softBg)' + : 'var(--joy-palette-neutral-softHoverBg)', + }, + }} + > + + + + {option.name} + + {selectedSort === option.value && ( + + )} + + + + ))} + + {onSortDirectionChange && ( + <> + + + onSortDirectionChange(sortDirection === 'asc' ? 'desc' : 'asc') + } + sx={{ + borderRadius: 'var(--joy-radius-sm)', + '&:hover': { + backgroundColor: 'var(--joy-palette-neutral-softHoverBg)', + }, + }} + > + + {sortDirection === 'asc' ? ( + + ) : ( + + )} + + + + {sortDirection === 'asc' ? 'Ascending' : 'Descending'} + + + + + )} + + {filterOptions?.length > 0 && ( + <> + + {filterTitle || 'Filter'} + {filterOptions.map(option => ( + { + onFilterChange(option.value) + handleMenuClose() + }} + sx={{ + borderRadius: 'var(--joy-radius-sm)', + backgroundColor: + selectedFilter === option.value + ? 'var(--joy-palette-primary-softBg)' + : 'transparent', + '&:hover': { + backgroundColor: + selectedFilter === option.value + ? 'var(--joy-palette-primary-softBg)' + : 'var(--joy-palette-neutral-softHoverBg)', + }, + }} + > + + + + + + {option.name} + + + + ))} + + )} + + + ) +} + +export default SortAndFilterMenu diff --git a/src/views/Chores/ArchivedTasks.jsx b/src/views/Chores/ArchivedTasks.jsx index 7e5a9f4..547f1e7 100644 --- a/src/views/Chores/ArchivedTasks.jsx +++ b/src/views/Chores/ArchivedTasks.jsx @@ -32,6 +32,7 @@ import { useNavigate } from 'react-router-dom' import EmptyState from '../../components/common/EmptyState' import FilterBar from '../../components/common/FilterBar' import KeyboardShortcutHint from '../../components/common/KeyboardShortcutHint' +import SortAndFilterMenu from '../../components/common/SortAndFilterMenu' import { useImpersonateUser } from '../../contexts/ImpersonateUserContext.jsx' import { useFilter } from '../../hooks/useFilter' import { useUnArchiveChore } from '../../queries/ChoreQueries' @@ -203,11 +204,46 @@ const ArchivedTasks = () => { const { activeFilters, clearAll, - filteredData: finalChores, + filteredData: filteredByBar, hasActiveFilters, setFilter, } = useFilter(filteredChores, filterDefs) + const [sortBy, setSortBy] = useState( + () => localStorage.getItem('archivedChoresSortBy') || 'archivedAt', + ) + const [sortDirection, setSortDirection] = useState( + () => localStorage.getItem('archivedChoresSortDirection') || 'desc', + ) + + useEffect(() => { + localStorage.setItem('archivedChoresSortBy', sortBy) + localStorage.setItem('archivedChoresSortDirection', sortDirection) + }, [sortBy, sortDirection]) + + const finalChores = useMemo(() => { + const direction = sortDirection === 'desc' ? -1 : 1 + return [...filteredByBar].sort((a, b) => { + switch (sortBy) { + case 'name': + return direction * (a.name || '').localeCompare(b.name || '') + case 'priority': + return direction * ((a.priority ?? 0) - (b.priority ?? 0)) + case 'dueDate': { + const aDue = new Date(a.nextDueDate || 0).getTime() + const bDue = new Date(b.nextDueDate || 0).getTime() + return direction * (aDue - bDue) + } + case 'archivedAt': + default: { + const aDate = new Date(a.updatedAt || 0).getTime() + const bDate = new Date(b.updatedAt || 0).getTime() + return direction * (aDate - bDate) + } + } + }) + }, [filteredByBar, sortBy, sortDirection]) + useEffect(() => { const loadArchivedChores = async () => { if (!membersLoading && userProfile) { @@ -734,6 +770,21 @@ const ArchivedTasks = () => { } /> + {/* Sort Menu */} + + {/* View Mode Toggle Button */} { const [editingFilter, setEditingFilter] = useState(null) const [confirmationModel, setConfirmationModel] = useState({}) const [showMoreInfoId, setShowMoreInfoId] = useState(null) + const [searchTerm, setSearchTerm] = useState('') + const [sortBy, setSortBy] = useState( + () => localStorage.getItem('filtersSortBy') || 'smart', + ) + const [sortDirection, setSortDirection] = useState( + () => localStorage.getItem('filtersSortDirection') || 'asc', + ) + const [pinnedFilter, setPinnedFilter] = useState('all') + const searchInputRef = useRef(null) + + useEffect(() => { + localStorage.setItem('filtersSortBy', sortBy) + localStorage.setItem('filtersSortDirection', sortDirection) + }, [sortBy, sortDirection]) + // Sort filters: pinned first, then by usage count, then by last used const savedFilters = useMemo(() => { return [...filtersData].sort((a, b) => { @@ -282,6 +303,71 @@ const FilterView = () => { }) }, [filtersData]) + const visibleFilters = useMemo(() => { + if (pinnedFilter === 'pinned') { + return savedFilters.filter(filter => filter.isPinned) + } + if (pinnedFilter === 'unpinned') { + return savedFilters.filter(filter => !filter.isPinned) + } + return savedFilters + }, [pinnedFilter, savedFilters]) + + const fuse = useMemo( + () => + new Fuse(visibleFilters, { + keys: ['name', 'description'], + includeScore: true, + isCaseSensitive: false, + findAllMatches: true, + }), + [visibleFilters], + ) + + const filteredFilters = useMemo(() => { + const matched = searchTerm + ? fuse.search(searchTerm).map(result => result.item) + : visibleFilters + + const direction = sortDirection === 'desc' ? -1 : 1 + + // "Smart" keeps the pinned-then-usage order the list already arrives in. + if (sortBy === 'smart') { + return direction === -1 ? [...matched].reverse() : matched + } + + return [...matched].sort((a, b) => { + switch (sortBy) { + case 'name': + return direction * (a.name || '').localeCompare(b.name || '') + case 'usage': + return direction * ((a.usageCount || 0) - (b.usageCount || 0)) + case 'lastUsed': { + const aUsed = new Date(a.lastUsedAt || 0).getTime() + const bUsed = new Date(b.lastUsedAt || 0).getTime() + return direction * (aUsed - bUsed) + } + case 'created': { + const aCreated = new Date(a.createdAt || 0).getTime() + const bCreated = new Date(b.createdAt || 0).getTime() + return direction * (aCreated - bCreated) + } + default: + return 0 + } + }) + }, [fuse, searchTerm, visibleFilters, sortBy, sortDirection]) + + const handleSearchChange = e => { + setSearchTerm(e.target.value) + setShowMoreInfoId(null) + } + + const handleSearchClose = () => { + setSearchTerm('') + searchInputRef.current?.blur() + } + // Calculate task counts for each filter useEffect(() => { if (chores && chores.res && savedFilters.length > 0) { @@ -428,6 +514,68 @@ const FilterView = () => { + {savedFilters.length > 0 && ( + + } + endDecorator={ + searchTerm && ( + + + + ) + } + /> + { + setPinnedFilter(value) + setShowMoreInfoId(null) + }} + isActive={ + pinnedFilter !== 'all' || + sortBy !== 'smart' || + sortDirection !== 'asc' + } + /> + + )} + { onClick: handleAddFilter, }} /> + ) : filteredFilters.length === 0 ? ( + } + title='No filters match' + description={ + searchTerm + ? `No saved filter matches "${searchTerm}".` + : 'No saved filter matches the current filter.' + } + primaryAction={{ + label: searchTerm ? 'Clear search' : 'Show all filters', + onClick: () => { + handleSearchClose() + setPinnedFilter('all') + }, + }} + /> ) : ( - {savedFilters.map(filter => { + {filteredFilters.map(filter => { return ( { const [confirmationModel, setConfirmationModel] = useState({}) const [showMoreInfoId, setShowMoreInfoId] = useState(null) const [searchTerm, setSearchTerm] = useState('') + const [sortBy, setSortBy] = useState( + () => localStorage.getItem('labelsSortBy') || 'name', + ) + const [sortDirection, setSortDirection] = useState( + () => localStorage.getItem('labelsSortDirection') || 'asc', + ) + const [ownershipFilter, setOwnershipFilter] = useState('all') const searchInputRef = useRef(null) + useEffect(() => { + localStorage.setItem('labelsSortBy', sortBy) + localStorage.setItem('labelsSortDirection', sortDirection) + }, [sortBy, sortDirection]) + + const visibleLabels = useMemo(() => { + if (ownershipFilter === 'mine') { + return userLabels.filter(label => label.created_by === userProfile?.id) + } + if (ownershipFilter === 'shared') { + return userLabels.filter(label => label.created_by !== userProfile?.id) + } + return userLabels + }, [ownershipFilter, userLabels, userProfile?.id]) + const fuse = useMemo( () => - new Fuse(userLabels, { + new Fuse(visibleLabels, { keys: ['name'], includeScore: true, isCaseSensitive: false, findAllMatches: true, }), - [userLabels], + [visibleLabels], ) const filteredLabels = useMemo(() => { - if (!searchTerm) { - return userLabels - } - return fuse.search(searchTerm).map(result => result.item) - }, [fuse, searchTerm, userLabels]) + const matched = searchTerm + ? fuse.search(searchTerm).map(result => result.item) + : visibleLabels + + const direction = sortDirection === 'desc' ? -1 : 1 + return [...matched].sort((a, b) => { + switch (sortBy) { + case 'color': + return direction * (a.color || '').localeCompare(b.color || '') + case 'created': + return direction * ((a.id || 0) - (b.id || 0)) + case 'name': + default: + return direction * (a.name || '').localeCompare(b.name || '') + } + }) + }, [fuse, searchTerm, visibleLabels, sortBy, sortDirection]) const handleSearchChange = e => { setSearchTerm(e.target.value.toLowerCase()) @@ -310,7 +345,9 @@ const LabelView = () => { {userLabels.length > 0 && ( - + { ) } /> + { + setOwnershipFilter(value) + setShowMoreInfoId(null) + }} + isActive={ + ownershipFilter !== 'all' || + sortBy !== 'name' || + sortDirection !== 'asc' + } + /> )} { fullHeight icon={} title={t('search.noResultsTitle')} - description={t('search.noResultsDescription', { - searchTerm, - })} + description={ + searchTerm + ? t('search.noResultsDescription', { searchTerm }) + : t('search.noFilterResultsDescription') + } primaryAction={{ - label: t('search.clear'), - onClick: handleSearchClose, + label: searchTerm ? t('search.clear') : t('search.showAll'), + onClick: () => { + handleSearchClose() + setOwnershipFilter('all') + }, }} /> )} diff --git a/src/views/Projects/ProjectView.jsx b/src/views/Projects/ProjectView.jsx index 644e3d4..1baffde 100644 --- a/src/views/Projects/ProjectView.jsx +++ b/src/views/Projects/ProjectView.jsx @@ -7,7 +7,14 @@ import { TrailingActions, Type as ListType, } from '@meauxt/react-swipeable-list' -import { Add, MoreVert, Task } from '@mui/icons-material' +import { + Add, + Close, + MoreVert, + Search, + SearchOff, + Task, +} from '@mui/icons-material' import DeleteIcon from '@mui/icons-material/Delete' import EditIcon from '@mui/icons-material/Edit' import { @@ -17,14 +24,18 @@ import { CircularProgress, Container, IconButton, + Input, Stack, Typography, } from '@mui/joy' import { useQueryClient } from '@tanstack/react-query' -import { useEffect, useState } from 'react' +import Fuse from 'fuse.js' +import { useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import { useNavigate, useSearchParams } from 'react-router-dom' +import EmptyState from '../../components/common/EmptyState' +import SortAndFilterMenu from '../../components/common/SortAndFilterMenu' import { useChores } from '../../queries/ChoreQueries' import { useUserProfile } from '../../queries/UserQueries' import { getTextColorFromBackgroundColor } from '../../utils/Colors' @@ -240,6 +251,83 @@ const ProjectView = () => { const queryClient = useQueryClient() const [confirmationModel, setConfirmationModel] = useState({}) const [showMoreInfoId, setShowMoreInfoId] = useState(null) + const [searchTerm, setSearchTerm] = useState('') + const [sortBy, setSortBy] = useState( + () => localStorage.getItem('projectsSortBy') || 'name', + ) + const [sortDirection, setSortDirection] = useState( + () => localStorage.getItem('projectsSortDirection') || 'asc', + ) + const [ownershipFilter, setOwnershipFilter] = useState('all') + const searchInputRef = useRef(null) + + useEffect(() => { + localStorage.setItem('projectsSortBy', sortBy) + localStorage.setItem('projectsSortDirection', sortDirection) + }, [sortBy, sortDirection]) + + const visibleProjects = useMemo(() => { + if (ownershipFilter === 'mine') { + return userProjects.filter( + project => project.created_by === userProfile?.id, + ) + } + if (ownershipFilter === 'shared') { + return userProjects.filter( + project => project.created_by !== userProfile?.id, + ) + } + return userProjects + }, [ownershipFilter, userProjects, userProfile?.id]) + + const fuse = useMemo( + () => + new Fuse(visibleProjects, { + keys: ['name', 'description'], + includeScore: true, + isCaseSensitive: false, + findAllMatches: true, + }), + [visibleProjects], + ) + + const filteredProjects = useMemo(() => { + const matched = searchTerm + ? fuse.search(searchTerm).map(result => result.item) + : visibleProjects + + const direction = sortDirection === 'desc' ? -1 : 1 + return [...matched].sort((a, b) => { + switch (sortBy) { + case 'tasks': + return direction * ((taskCounts[a.id] || 0) - (taskCounts[b.id] || 0)) + case 'created': + return direction * ((a.id || 0) - (b.id || 0)) + case 'name': + default: + return direction * (a.name || '').localeCompare(b.name || '') + } + }) + }, [fuse, searchTerm, visibleProjects, sortBy, sortDirection, taskCounts]) + + // The default project is pinned above the list, so it is matched separately. + const showDefaultProject = useMemo(() => { + if (ownershipFilter === 'shared') return false + if (!searchTerm) return true + return t('chores:toolbar.defaultProject') + .toLowerCase() + .includes(searchTerm.toLowerCase()) + }, [ownershipFilter, searchTerm, t]) + + const handleSearchChange = e => { + setSearchTerm(e.target.value) + setShowMoreInfoId(null) + } + + const handleSearchClose = () => { + setSearchTerm('') + searchInputRef.current?.blur() + } const handleAddProject = () => { setCurrentProject(null) @@ -388,36 +476,114 @@ const ProjectView = () => { + + } + endDecorator={ + searchTerm && ( + + + + ) + } + /> + { + setOwnershipFilter(value) + setShowMoreInfoId(null) + }} + isActive={ + ownershipFilter !== 'all' || + sortBy !== 'name' || + sortDirection !== 'asc' + } + /> + + + {!showDefaultProject && filteredProjects.length === 0 && ( + } + title={t('search.noResultsTitle')} + description={ + searchTerm + ? t('search.noResultsDescription', { searchTerm }) + : t('search.noFilterResultsDescription') + } + primaryAction={{ + label: searchTerm ? t('search.clear') : t('search.showAll'), + onClick: () => { + handleSearchClose() + setOwnershipFilter('all') + }, + }} + /> + )} {/* Default project - not swipeable */} - - handleCardClick({ + {showDefaultProject && ( + + created_by: userProfile?.id, + }} + currentUserId={userProfile?.id} + taskCounts={{ default: taskCounts.default || 0 }} + onCardClick={() => + handleCardClick({ + id: 'default', + name: t('chores:toolbar.defaultProject'), + icon: 'FolderOpen', + color: '#1976d2', + }) + } + /> + )} {/* User projects - swipeable */} - {userProjects.map(project => ( + {filteredProjects.map(project => ( handleCardClick(project)} key={project.id} diff --git a/src/views/Things/ThingsView.jsx b/src/views/Things/ThingsView.jsx index 2136bbc..68d13aa 100644 --- a/src/views/Things/ThingsView.jsx +++ b/src/views/Things/ThingsView.jsx @@ -9,11 +9,14 @@ import { } from '@meauxt/react-swipeable-list' import { Add, + Close, Delete, Edit, Flip, MoreVert, PlusOne, + Search, + SearchOff, ToggleOff, ToggleOn, Widgets, @@ -24,14 +27,17 @@ import { Chip, Container, IconButton, + Input, Stack, Typography, } from '@mui/joy' -import { useEffect, useState } from 'react' +import Fuse from 'fuse.js' +import { useEffect, useMemo, useRef, useState } from 'react' import { useNavigate } from 'react-router-dom' import { track } from '../../analytics' import EmptyState from '../../components/common/EmptyState' +import SortAndFilterMenu from '../../components/common/SortAndFilterMenu' import { useNotification } from '../../service/NotificationProvider' import { CreateThing, @@ -215,8 +221,78 @@ const ThingsView = () => { const [createModalThing, setCreateModalThing] = useState(null) const [confirmModelConfig, setConfirmModelConfig] = useState({}) const [showMoreInfoId, setShowMoreInfoId] = useState(null) + const [searchTerm, setSearchTerm] = useState('') + const [sortBy, setSortBy] = useState( + () => localStorage.getItem('thingsSortBy') || 'name', + ) + const [sortDirection, setSortDirection] = useState( + () => localStorage.getItem('thingsSortDirection') || 'asc', + ) + const [typeFilter, setTypeFilter] = useState('all') + const searchInputRef = useRef(null) const { showError, showNotification } = useNotification() + useEffect(() => { + localStorage.setItem('thingsSortBy', sortBy) + localStorage.setItem('thingsSortDirection', sortDirection) + }, [sortBy, sortDirection]) + + const visibleThings = useMemo( + () => + typeFilter === 'all' + ? things + : things.filter(thing => thing?.type === typeFilter), + [things, typeFilter], + ) + + const fuse = useMemo( + () => + new Fuse(visibleThings, { + keys: ['name', 'state'], + includeScore: true, + isCaseSensitive: false, + findAllMatches: true, + }), + [visibleThings], + ) + + const filteredThings = useMemo(() => { + const matched = searchTerm + ? fuse.search(searchTerm).map(result => result.item) + : visibleThings + + const direction = sortDirection === 'desc' ? -1 : 1 + return [...matched].sort((a, b) => { + switch (sortBy) { + case 'type': + return direction * (a.type || '').localeCompare(b.type || '') + case 'state': + return ( + direction * + String(a.state ?? '').localeCompare(String(b.state ?? '')) + ) + case 'updated': { + const aDate = new Date(a.updatedAt || a.updated_at || 0).getTime() + const bDate = new Date(b.updatedAt || b.updated_at || 0).getTime() + return direction * (aDate - bDate) + } + case 'name': + default: + return direction * (a.name || '').localeCompare(b.name || '') + } + }) + }, [fuse, searchTerm, visibleThings, sortBy, sortDirection]) + + const handleSearchChange = e => { + setSearchTerm(e.target.value) + setShowMoreInfoId(null) + } + + const handleSearchClose = () => { + setSearchTerm('') + searchInputRef.current?.blur() + } + useEffect(() => { // fetch things GetThings().then(result => { @@ -404,6 +480,67 @@ const ThingsView = () => { + {things.length > 0 && ( + + } + endDecorator={ + searchTerm && ( + + + + ) + } + /> + { + setTypeFilter(value) + setShowMoreInfoId(null) + }} + isActive={ + typeFilter !== 'all' || + sortBy !== 'name' || + sortDirection !== 'asc' + } + /> + + )} { }} /> )} + {things.length > 0 && filteredThings.length === 0 && ( + } + title='No things match' + description={ + searchTerm + ? `No thing matches "${searchTerm}".` + : 'No thing matches the current filter.' + } + primaryAction={{ + label: searchTerm ? 'Clear search' : 'Show all things', + onClick: () => { + handleSearchClose() + setTypeFilter('all') + }, + }} + /> + )} - {things.map(thing => ( + {filteredThings.map(thing => ( navigate(`/things/${thing?.id}`)} key={thing.id} From 1c99bd188609cd4d26e822a1b32a3e3f6718d08f Mon Sep 17 00:00:00 2001 From: Mo Tarbin Date: Sun, 16 Aug 2026 02:46:04 -0400 Subject: [PATCH 11/12] 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 = () => {