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 )}