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 && (
+ <>
+
+
+ >
+ )}
+
)}
+
{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. */}
+
+
+
+
+
+
+
+
+ {/* 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
)}