feat: enhance chore actions with bulk operations and new label detail view

- Refactored bulk operations in useChoreActions to streamline completion, archiving, deletion, and other actions.
- Introduced new hooks for managing local chore state during bulk operations.
- Added handleBulkDueDate, handleBulkAssignee, handleBulkPriority, and handleBulkLabels functions for better task management.
- Implemented a new LabelDetailView component to display and manage tasks associated with a specific label.
- Updated LabelView to navigate to LabelDetailView on label click.
- Improved multi-select functionality to support range selection and summary of selected chores.
- Minor UI adjustments and text updates in AdvancedOptionsSection for clarity.
This commit is contained in:
Mo Tarbin
2026-08-15 12:33:39 -04:00
parent 7729917611
commit c6f7ce48d8
10 changed files with 1430 additions and 377 deletions

View File

@@ -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."
}

View File

@@ -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: <LabelView />,
},
{
path: 'labels/:labelId',
element: <LabelDetailView />,
},
{
path: 'projects/',
element: <ProjectView />,

View File

@@ -125,7 +125,7 @@ registerSearchProvider({
title: label.name || 'Untitled label',
subtitle: 'Label',
keywords: 'tag label',
route: '/labels',
route: `/labels/${label.id}`,
color: label.color,
}),
),

View File

@@ -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={

View File

@@ -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 }) => (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}>
{icon && (
<Box
sx={{
color: 'text.secondary',
display: 'flex',
alignItems: 'center',
'& svg': { fontSize: 18 },
}}
>
{icon}
</Box>
)}
<Typography level='title-sm' fontWeight={600}>
{label}
</Typography>
{value && (
<Typography level='body-xs' sx={{ ml: 'auto', color: 'text.tertiary' }}>
{value}
</Typography>
)}
</Box>
)
const ChipRow = ({ children }) => (
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>{children}</Box>
)
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 (
<Box
@@ -71,7 +209,10 @@ const MultiSelectToolbar = ({
zIndex: 1000,
overflow: 'hidden',
transition: 'all 0.3s ease-in-out',
maxHeight: isVisible ? '200px' : '0',
// Generous enough that the action buttons can wrap to two or three
// rows on a narrow screen without being clipped, while still giving
// the collapse something finite to animate to.
maxHeight: isVisible ? '400px' : '0',
opacity: isVisible ? 1 : 0,
transform: isVisible ? 'translateY(0)' : 'translateY(-20px)',
marginBottom: isVisible ? 2 : 0,
@@ -86,20 +227,14 @@ const MultiSelectToolbar = ({
border: '1px solid',
borderColor: 'divider',
boxShadow: 'm',
gap: 2,
gap: 1.5,
display: 'flex',
flexDirection: {
sm: 'column',
md: 'row',
},
alignItems: {
xs: 'stretch',
sm: 'center',
},
justifyContent: {
xs: 'center',
sm: 'space-between',
},
// Narrow screens stack: the selection status gets its own row, then
// the actions get the full width to lay out in. Only above md is
// there room to put both on one line.
flexDirection: { xs: 'column', md: 'row' },
alignItems: { xs: 'stretch', md: 'center' },
justifyContent: 'space-between',
}}
>
<Box
@@ -107,14 +242,10 @@ const MultiSelectToolbar = ({
display: 'flex',
alignItems: 'center',
gap: 2,
flexWrap: {
xs: 'wrap',
sm: 'nowrap',
},
justifyContent: {
xs: 'center',
sm: 'flex-start',
},
flexWrap: 'nowrap',
// Stacked, the count sits left and All/Close anchor right, so the
// status row reads edge to edge instead of floating in the middle.
justifyContent: { xs: 'space-between', md: 'flex-start' },
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
@@ -127,7 +258,7 @@ const MultiSelectToolbar = ({
<Divider
orientation='vertical'
sx={{
display: { xs: 'none', sm: 'block' },
display: { xs: 'none', md: 'block' },
}}
/>
@@ -189,19 +320,22 @@ const MultiSelectToolbar = ({
</Box>
</Box>
{/* 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. */}
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 1,
flexWrap: {
xs: 'wrap',
sm: 'nowrap',
},
justifyContent: {
xs: 'center',
sm: 'flex-end',
},
// Stacked, the actions become an auto-fitting grid: every button
// stretches to fill its cell, so the rows come out flush instead
// of trailing ragged whitespace. Above md they go back to a
// right-aligned row.
display: { xs: 'grid', md: 'flex' },
gridTemplateColumns: 'repeat(auto-fit, minmax(112px, 1fr))',
alignItems: 'center',
flexWrap: 'wrap',
justifyContent: 'flex-end',
}}
>
<Button
@@ -230,6 +364,91 @@ const MultiSelectToolbar = ({
/>
)}
</Button>
{onSetDueDate && (
<>
<Button
size='sm'
variant='soft'
color='primary'
ref={dueMenuRef}
onClick={() =>
setDueMenuAnchor(prev => (prev ? null : dueMenuRef.current))
}
startDecorator={<CalendarMonth />}
disabled={selectedCount === 0}
sx={{
'--Button-paddingInline': { xs: '0.75rem', sm: '1rem' },
}}
title={
dueDateValue
? `Due date on selected tasks: ${dueDateValue}`
: 'Set due date on selected tasks'
}
>
Due
</Button>
<Menu
size='md'
anchorEl={dueMenuAnchor}
open={Boolean(dueMenuAnchor)}
onClose={closeDueMenu}
placement='bottom-end'
>
{DUE_DATE_PRESETS.map(preset => (
<MenuItem
key={preset.key}
onClick={() => {
closeDueMenu()
onSetDueDate(preset.resolve())
}}
>
<ListItemDecorator>
<CalendarMonth sx={{ fontSize: 18 }} />
</ListItemDecorator>
<ListItemContent>
<Typography level='body-sm'>{preset.label}</Typography>
<Typography
level='body-xs'
sx={{ color: 'text.tertiary' }}
>
{preset.hint()}
</Typography>
</ListItemContent>
</MenuItem>
))}
<Divider />
<MenuItem
onClick={() => {
closeDueMenu()
setDueDatePickerOpen(true)
}}
>
<ListItemDecorator>
<EditCalendar sx={{ fontSize: 18 }} />
</ListItemDecorator>
<ListItemContent>
<Typography level='body-sm'>Pick date</Typography>
</ListItemContent>
</MenuItem>
<MenuItem
color='danger'
onClick={() => {
closeDueMenu()
onSetDueDate(null)
}}
>
<ListItemDecorator>
<Remove sx={{ fontSize: 18 }} />
</ListItemDecorator>
<ListItemContent>
<Typography level='body-sm'>No due date</Typography>
</ListItemContent>
</MenuItem>
</Menu>
</>
)}
<Button
size='sm'
variant='soft'
@@ -256,6 +475,7 @@ const MultiSelectToolbar = ({
/>
)}
</Button>
{onMoveToProject && (
<>
<Button
@@ -285,9 +505,10 @@ const MultiSelectToolbar = ({
placement='bottom-end'
>
<MenuItem
onClick={() =>
handleMoveToProject({ id: null, name: 'Default Project' })
}
onClick={() => {
closeProjectMenu()
onMoveToProject({ id: null, name: 'Default Project' })
}}
>
<ListItemDecorator>
{renderProjectAvatar(LABEL_COLORS[0].value, 'FolderOpen')}
@@ -299,7 +520,10 @@ const MultiSelectToolbar = ({
{projects.map(project => (
<MenuItem
key={project.id}
onClick={() => handleMoveToProject(project)}
onClick={() => {
closeProjectMenu()
onMoveToProject(project)
}}
>
<ListItemDecorator>
{renderProjectAvatar(project.color, project.icon)}
@@ -366,8 +590,210 @@ const MultiSelectToolbar = ({
/>
)}
</Button>
<Button
size='sm'
variant='outlined'
color='neutral'
onClick={() => setMoreOpen(true)}
startDecorator={<MoreHoriz />}
disabled={selectedCount === 0}
sx={{
'--Button-paddingInline': { xs: '0.75rem', sm: '1rem' },
}}
title='Set priority, assignee, or labels'
>
More
</Button>
</Box>
</Box>
{/* ── More sheet: the field editors that have no button in the bar ────── */}
<AppModal
open={moreOpen}
isMobile
onClose={() => setMoreOpen(false)}
maxHeight='92vh'
title={
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<CheckBox sx={{ fontSize: 20 }} />
{selectedCount} task{selectedCount !== 1 ? 's' : ''} selected
</Box>
}
footer={
<Button
variant='plain'
color='neutral'
onClick={() => setMoreOpen(false)}
sx={{ minWidth: 140 }}
>
Done
</Button>
}
>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0 }}>
{/* 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 && (
<>
<SectionHeader
icon={<Flag />}
label='Priority'
value={priorityValue}
/>
<ChipRow>
{Priorities.map(priority => {
const isCurrent =
!summary.priority?.isMixed &&
summary.priority?.value === priority.value
return (
<Chip
key={priority.value}
variant={isCurrent ? 'solid' : 'soft'}
color={
isCurrent ? priority.color || 'primary' : 'neutral'
}
startDecorator={
isCurrent ? <Check sx={{ fontSize: 14 }} /> : undefined
}
onClick={runAndClose(() => onSetPriority(priority.value))}
sx={selectableChipSx}
>
{priority.name.trim()}
</Chip>
)
})}
<Chip
variant='soft'
color='neutral'
onClick={runAndClose(() => onSetPriority(0))}
sx={selectableChipSx}
>
None
</Chip>
</ChipRow>
</>
)}
{onSetAssignee && assignableMembers.length > 0 && (
<>
<Divider sx={{ my: 2.5 }} />
<SectionHeader
icon={<Person />}
label='Assignee'
value={assigneeValue}
/>
<ChipRow>
{assignableMembers.map(member => {
const isCurrent =
!summary.assignee?.isMixed &&
summary.assignee?.value === member.userId
return (
<Chip
key={member.userId}
variant={isCurrent ? 'solid' : 'soft'}
color={isCurrent ? 'primary' : 'neutral'}
startDecorator={
isCurrent ? (
<Check sx={{ fontSize: 14 }} />
) : (
<Avatar size='sm' sx={{ width: 20, height: 20 }}>
{(member.displayName || '?')
.charAt(0)
.toUpperCase()}
</Avatar>
)
}
onClick={runAndClose(() => onSetAssignee(member.userId))}
sx={selectableChipSx}
>
{member.displayName || member.username}
</Chip>
)
})}
</ChipRow>
</>
)}
{onToggleLabel && labels.length > 0 && (
<>
<Divider sx={{ my: 2.5 }} />
{/* 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. */}
<SectionHeader
icon={<LabelIcon />}
label='Labels'
value='Tap to add · tap again to remove'
/>
<ChipRow>
{labels.map(label => {
const onAll = commonLabelIds.has(label.id)
const onSome = partialLabelIds.has(label.id)
return (
<Chip
key={label.id}
variant={onAll ? 'solid' : onSome ? 'outlined' : 'soft'}
color='neutral'
startDecorator={
onAll ? (
<Check sx={{ fontSize: 14 }} />
) : onSome ? (
<Remove sx={{ fontSize: 14 }} />
) : 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}
</Chip>
)
})}
</ChipRow>
</>
)}
</Box>
</AppModal>
{dueDatePickerOpen && (
<DueDatePickerModal
open
title={`Due date for ${selectedCount} task${selectedCount !== 1 ? 's' : ''}`}
{...prefillDueDate(
summary.dueDate?.isMixed ? null : summary.dueDate?.value,
)}
onClose={() => 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)
}}
/>
)}
</Box>
)
}

View File

@@ -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,
}
}

View File

@@ -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,
}

View File

@@ -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 <LoadingComponent />
}
if (!label) {
return (
<Container maxWidth='md'>
<EmptyState
variant='error'
fullHeight
icon={<Style />}
title={t('detail.notFoundTitle')}
description={t('detail.notFoundDescription')}
primaryAction={{ label: t('detail.backToLabels'), to: '/labels' }}
/>
</Container>
)
}
const isOwnedByCurrentUser = label.created_by === userProfile?.id
return (
<Container maxWidth='md'>
{/* Identity: the label's own color is what names this page, so it leads
the title rather than sitting in a decorative avatar. */}
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 1.5, mb: 2 }}>
<Stack sx={{ flex: 1, minWidth: 0, gap: 0.5 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Box
sx={{
width: 12,
height: 12,
borderRadius: '50%',
bgcolor: label.color,
flexShrink: 0,
}}
/>
<Typography
level='h3'
sx={{
fontWeight: 'lg',
color: 'text.primary',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{label.name}
</Typography>
{!isOwnedByCurrentUser && (
<Chip size='sm' variant='soft' color='warning'>
{t('shared')}
</Chip>
)}
</Box>
<Typography level='body-sm' sx={{ color: 'text.secondary' }}>
{t('detail.taskCount', { count: counts.all })}
</Typography>
</Stack>
{/* One trailing target. Delete is destructive, so it lives behind the
overflow instead of being a naked icon next to the title. */}
<Dropdown>
<MenuButton
slots={{ root: IconButton }}
slotProps={{ root: { variant: 'plain', color: 'neutral' } }}
aria-label={t('detail.labelActions')}
>
<MoreVert />
</MenuButton>
<Menu placement='bottom-end'>
<MenuItem onClick={() => setModalOpen(true)}>
<EditIcon fontSize='small' />
{t('common:edit')}
</MenuItem>
<Divider />
<MenuItem color='danger' onClick={handleDeleteClicked}>
<DeleteIcon fontSize='small' />
{t('common:delete')}
</MenuItem>
</Menu>
</Dropdown>
</Box>
{/* Status chips double as the filter control: the counts users want to
read are the cuts they want to make. */}
{labelChores.length > 0 && (
<Box
sx={{
display: 'flex',
gap: 1,
mb: 2,
overflowX: 'auto',
pb: 0.5,
scrollbarWidth: 'none',
'&::-webkit-scrollbar': { display: 'none' },
}}
>
{[
{ 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 (
<Chip
key={chip.id}
variant={isSelected ? 'solid' : 'soft'}
color={chip.color}
onClick={() => setStatusFilter(chip.id)}
aria-pressed={isSelected}
sx={{ flexShrink: 0 }}
endDecorator={
<Typography
level='body-xs'
sx={{ color: 'inherit', fontWeight: 'lg' }}
>
{counts[chip.id]}
</Typography>
}
>
{chip.label}
</Chip>
)
})}
</Box>
)}
{/* Search + view mode */}
{labelChores.length > 0 && (
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 0.5,
mb: 2,
}}
>
<Input
slotProps={{ input: { ref: searchInputRef } }}
placeholder={t('detail.searchPlaceholder')}
value={searchTerm}
fullWidth
sx={{
borderRadius: 24,
height: 24,
borderColor: 'text.disabled',
padding: 1,
}}
onChange={e => setSearchTerm(e.target.value.toLowerCase())}
startDecorator={<Search />}
endDecorator={
searchTerm && (
<IconButton
variant='plain'
size='sm'
onClick={handleSearchClose}
sx={{ borderRadius: '50%' }}
>
<Close />
</IconButton>
)
}
/>
<IconButton
variant='outlined'
color='neutral'
size='sm'
sx={{ height: 32, width: 32, borderRadius: '50%' }}
onClick={toggleViewMode}
>
{viewMode === 'default' ? <ViewAgenda /> : <ViewModule />}
</IconButton>
</Box>
)}
{/* Tasks */}
{labelChores.length === 0 ? (
<EmptyState
fullHeight
icon={<Style />}
title={t('detail.emptyTitle')}
description={t('detail.emptyDescription', { label: label.name })}
primaryAction={{ label: t('detail.browseTasks'), to: '/chores' }}
/>
) : visibleChores.length === 0 ? (
<EmptyState
variant='no-results'
fullHeight
icon={<SearchOff />}
title={t('detail.noResultsTitle')}
description={
searchTerm
? t('detail.noResultsDescription', { searchTerm })
: t('detail.noMatchingStatus')
}
primaryAction={{
label: t('detail.clearFilters'),
onClick: resetFilters,
}}
/>
) : (
<List sx={{ gap: viewMode === 'compact' ? 0 : 1 }}>
<ChoreListView
chores={visibleChores}
viewMode={viewMode}
membersData={membersData}
userLabels={labels}
userProfile={userProfile}
showActions={false}
selectedChores={EMPTY_SELECTION}
/>
</List>
)}
{modalOpen && (
<LabelModal
isOpen={modalOpen}
onClose={() => setModalOpen(false)}
onSave={handleSaveLabel}
label={label}
/>
)}
<ConfirmationModal config={confirmationModel} />
</Container>
)
}
export default LabelDetailView

View File

@@ -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 => (
<SwipeableListItem
key={label.id}
onClick={() => navigate(`/labels/${label.id}`)}
swipeActionOpen={showMoreInfoId === label.id ? 'trailing' : null}
trailingActions={
<TrailingActions>

View File

@@ -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
</Typography>
)}
</Box>