Merge pull request #233 from donetick/0814-fixes

0814 fixes
This commit is contained in:
Mohamad Tarbin
2026-08-16 11:32:06 -04:00
committed by GitHub
33 changed files with 3411 additions and 786 deletions

View File

@@ -63,6 +63,8 @@
"skip": "Skip", "skip": "Skip",
"cancel": "Cancel", "cancel": "Cancel",
"noPriority": "No Priority", "noPriority": "No Priority",
"more": "More",
"changeDueDate": "Change due date",
"subtasks": "Subtasks", "subtasks": "Subtasks",
"noDescription": "No description available", "noDescription": "No description available",
"timer": { "timer": {
@@ -103,7 +105,9 @@
"showTasksFor": "Show tasks for", "showTasksFor": "Show tasks for",
"type": { "type": {
"assignee": "Assignee" "assignee": "Assignee"
} },
"display": "Display",
"displayOptions": "Display options"
}, },
"sort": { "sort": {
"assignedToMe": "Assigned to me", "assignedToMe": "Assigned to me",

View File

@@ -56,8 +56,17 @@
"quickAction": "Quick action", "quickAction": "Quick action",
"navigation": "Navigation", "navigation": "Navigation",
"createTask": "Create a task", "createTask": "Create a task",
"createLabel": "Create a label",
"createProject": "Create a project",
"createFilter": "Create a filter",
"viewAllTasks": "View all tasks", "viewAllTasks": "View all tasks",
"viewArchivedTasks": "View archived tasks", "viewArchivedTasks": "View archived tasks",
"viewThings": "View things",
"viewLabels": "View labels",
"viewProjects": "View projects",
"viewFilters": "View filters",
"viewActivities": "View activities",
"viewPoints": "View points",
"openSettings": "Open settings", "openSettings": "Open settings",
"filterTasks": "Show tasks matching “{{query}}”", "filterTasks": "Show tasks matching “{{query}}”",
"filterTasksSubtitle": "Filter the task list" "filterTasksSubtitle": "Filter the task list"

View File

@@ -5,6 +5,36 @@
"message": "Are you sure you want to delete this label? This will remove the label from all tasks." "message": "Are you sure you want to delete this label? This will remove the label from all tasks."
}, },
"loadError": "Failed to load labels. Please try again.", "loadError": "Failed to load labels. Please try again.",
"search": {
"placeholder": "Search labels",
"noResultsTitle": "No labels match",
"noResultsDescription": "No label matches \"{{searchTerm}}\".",
"noFilterResultsDescription": "No label matches the current filter.",
"clear": "Clear search",
"showAll": "Show all labels"
},
"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.", "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.",
"modal": { "modal": {
"errorEmptyName": "Name cannot be empty", "errorEmptyName": "Name cannot be empty",

View File

@@ -12,6 +12,14 @@
"message": "Are you sure you want to delete \"{{name}}\"? This will remove the project but keep all tasks (they'll move to the Default Project)." "message": "Are you sure you want to delete \"{{name}}\"? This will remove the project but keep all tasks (they'll move to the Default Project)."
}, },
"loadError": "Failed to load projects. Please try again.", "loadError": "Failed to load projects. Please try again.",
"search": {
"placeholder": "Search projects",
"noResultsTitle": "No projects match",
"noResultsDescription": "No project matches \"{{searchTerm}}\".",
"noFilterResultsDescription": "No project matches the current filter.",
"clear": "Clear search",
"showAll": "Show all projects"
},
"blurb": "Organize your tasks into projects. Create custom workspaces to keep your tasks organized and easily accessible.", "blurb": "Organize your tasks into projects. Create custom workspaces to keep your tasks organized and easily accessible.",
"defaultDescription": "All tasks without a specific project", "defaultDescription": "All tasks without a specific project",
"selector": { "selector": {

View File

@@ -183,6 +183,7 @@ const AppModal = forwardRef(
onClick={handleClose} onClick={handleClose}
sx={{ sx={{
position: 'absolute', position: 'absolute',
zIndex: 1,
top: isSheet && showHandle ? 6 : 12, top: isSheet && showHandle ? 6 : 12,
right: { xs: 10, sm: 16 }, right: { xs: 10, sm: 16 },
borderRadius: '50%', borderRadius: '50%',

View File

@@ -147,8 +147,19 @@ const FilterBar = ({
onClearAll, onClearAll,
resultCount, resultCount,
totalCount, totalCount,
// When the host renders its own trigger (e.g. an icon button in a toolbar
// row), it drives the sheet through `open`/`onOpenChange` and hides ours.
open,
onOpenChange,
showTrigger = true,
}) => { }) => {
const [isOpen, setIsOpen] = useState(false) const [internalOpen, setInternalOpen] = useState(false)
const isControlled = open !== undefined
const isOpen = isControlled ? open : internalOpen
const setIsOpen = next => {
if (!isControlled) setInternalOpen(next)
onOpenChange?.(next)
}
// ── Active count ─────────────────────────────────────────────────────────── // ── Active count ───────────────────────────────────────────────────────────
@@ -293,65 +304,75 @@ const FilterBar = ({
// ── Render ───────────────────────────────────────────────────────────────── // ── Render ─────────────────────────────────────────────────────────────────
const activeChips = filterDefs
.map(def => ({ def, label: getActiveChipLabel(def) }))
.filter(({ label }) => !!label)
.map(({ def, label }) => ({
key: def.id,
label,
onClear: () => onSetFilter(def.id, null),
}))
// With the trigger hoisted into a toolbar, the inline row has nothing to show
// until a filter is on — rendering it anyway would leave a phantom gap.
const showInlineBar = showTrigger || activeChips.length > 0
return ( return (
<> <>
{/* ── Inline bar ─────────────────────────────────────── */} {/* ── Inline bar ─────────────────────────────────────── */}
<Box {showInlineBar && (
sx={{ <Box
display: 'flex', sx={{
alignItems: 'center', display: 'flex',
gap: 1, alignItems: 'center',
flexWrap: 'wrap', gap: 1,
mb: 2, flexWrap: 'wrap',
}} mb: 2,
> }}
<Badge
badgeContent={activeFilterCount || null}
color='primary'
size='sm'
anchorOrigin={{ vertical: 'top', horizontal: 'right' }}
sx={{ display: 'flex', alignItems: 'center' }}
> >
<Button {showTrigger && (
size='md' <Badge
variant={hasActive ? 'solid' : 'outlined'} badgeContent={activeFilterCount || null}
color={hasActive ? 'primary' : 'neutral'} color='primary'
startDecorator={<FilterList sx={{ fontSize: 16 }} />} size='sm'
onClick={() => setIsOpen(true)} anchorOrigin={{ vertical: 'top', horizontal: 'right' }}
sx={{ sx={{ display: 'flex', alignItems: 'center' }}
borderRadius: 'xl', >
py: 0.5, <Button
px: 1, size='md'
gap: 0.5, variant={hasActive ? 'solid' : 'outlined'}
alignItems: 'center', color={hasActive ? 'primary' : 'neutral'}
'& .MuiButton-startDecorator': { startDecorator={<FilterList sx={{ fontSize: 16 }} />}
display: 'flex', onClick={() => setIsOpen(true)}
alignItems: 'center', sx={{
mr: 0.5, borderRadius: 'xl',
}, py: 0.5,
}} px: 1,
> gap: 0.5,
Filters alignItems: 'center',
</Button> '& .MuiButton-startDecorator': {
</Badge> display: 'flex',
alignItems: 'center',
mr: 0.5,
},
}}
>
Filters
</Button>
</Badge>
)}
<ActiveFilterChips <ActiveFilterChips
chips={filterDefs chips={activeChips}
.map(def => ({ def, label: getActiveChipLabel(def) })) onOpen={() => setIsOpen(true)}
.filter(({ label }) => !!label) onClearAll={hasActive ? onClearAll : undefined}
.map(({ def, label }) => ({ resultCount={hasActive ? resultCount : undefined}
key: def.id, totalCount={hasActive ? totalCount : undefined}
label, maxVisible={2}
onClear: () => onSetFilter(def.id, null), chipSize='md'
}))} />
onOpen={() => setIsOpen(true)} </Box>
onClearAll={hasActive ? onClearAll : undefined} )}
resultCount={hasActive ? resultCount : undefined}
totalCount={hasActive ? totalCount : undefined}
maxVisible={2}
chipSize='md'
/>
</Box>
{/* ── Bottom sheet ────────────────────────────────────── */} {/* ── Bottom sheet ────────────────────────────────────── */}
<AppModal <AppModal

View File

@@ -0,0 +1,256 @@
import { ArrowDownward, ArrowUpward, Check, Sort } from '@mui/icons-material'
import {
Box,
Divider,
IconButton,
ListItemContent,
ListItemDecorator,
Menu,
MenuItem,
Radio,
Typography,
} from '@mui/joy'
import { useEffect, useRef, useState } from 'react'
/**
* Compact sort + filter menu, meant to sit next to a search input.
*
* Props:
* sortOptions - [{ name, value }] shown under the sort header
* selectedSort - currently selected sort value
* onSortChange - (value) => void
* sortDirection - 'asc' | 'desc'
* onSortDirectionChange - (direction) => void
* filterTitle - optional header for the filter section
* filterOptions - optional [{ name, value }] rendered as radios
* selectedFilter - currently selected filter value
* onFilterChange - (value) => void
* isActive - highlights the trigger button when a non-default choice is on
*/
const SortAndFilterMenu = ({
filterOptions,
filterTitle,
icon = <Sort />,
isActive,
onFilterChange,
onSortChange,
onSortDirectionChange,
selectedFilter,
selectedSort,
sortDirection = 'asc',
sortOptions = [],
title = 'Sort by',
}) => {
const [anchorEl, setAnchorEl] = useState(null)
const menuRef = useRef(null)
const buttonRef = useRef(null)
const handleMenuClose = () => setAnchorEl(null)
useEffect(() => {
const handleMenuOutsideClick = event => {
if (
menuRef.current &&
!menuRef.current.contains(event.target) &&
!buttonRef.current?.contains(event.target)
) {
handleMenuClose()
}
}
document.addEventListener('mousedown', handleMenuOutsideClick)
return () => {
document.removeEventListener('mousedown', handleMenuOutsideClick)
}
}, [])
const SectionHeader = ({ children }) => (
<MenuItem
disabled
sx={{
borderRadius: 'var(--joy-radius-sm)',
cursor: 'default',
opacity: 1,
}}
>
<ListItemContent>
<Typography level='title-sm' sx={{ fontWeight: 600 }}>
{children}
</Typography>
</ListItemContent>
</MenuItem>
)
return (
<>
<IconButton
ref={buttonRef}
onClick={event => setAnchorEl(anchorEl ? null : event.currentTarget)}
variant='outlined'
color={isActive ? 'primary' : 'neutral'}
size='sm'
sx={{ height: 32, width: 32, borderRadius: '50%', flexShrink: 0 }}
aria-label='Sort and filter options'
title='Sort & Filter'
>
{icon}
</IconButton>
<Menu
ref={menuRef}
anchorEl={anchorEl}
open={Boolean(anchorEl)}
onClose={handleMenuClose}
placement='bottom-end'
sx={{
minWidth: 240,
p: 1,
'--List-gap': '4px',
boxShadow: 'var(--joy-shadow-lg)',
border: '1px solid var(--joy-palette-divider)',
borderRadius: 'var(--joy-radius-md)',
zIndex: 1300,
}}
>
<SectionHeader>{title}</SectionHeader>
<Divider sx={{ my: 1 }} />
{sortOptions.map(option => (
<MenuItem
key={option.value}
onClick={() => {
onSortChange(option.value)
handleMenuClose()
}}
sx={{
borderRadius: 'var(--joy-radius-sm)',
backgroundColor:
selectedSort === option.value
? 'var(--joy-palette-primary-softBg)'
: 'transparent',
'&:hover': {
backgroundColor:
selectedSort === option.value
? 'var(--joy-palette-primary-softBg)'
: 'var(--joy-palette-neutral-softHoverBg)',
},
}}
>
<ListItemContent>
<Box
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
}}
>
<Typography
level='body-sm'
sx={{
fontWeight: selectedSort === option.value ? 600 : 400,
color:
selectedSort === option.value
? 'var(--joy-palette-primary-600)'
: 'var(--joy-palette-text-primary)',
}}
>
{option.name}
</Typography>
{selectedSort === option.value && (
<Check
sx={{
fontSize: '16px',
color: 'var(--joy-palette-primary-500)',
}}
/>
)}
</Box>
</ListItemContent>
</MenuItem>
))}
{onSortDirectionChange && (
<>
<Divider sx={{ my: 1 }} />
<MenuItem
onClick={() =>
onSortDirectionChange(sortDirection === 'asc' ? 'desc' : 'asc')
}
sx={{
borderRadius: 'var(--joy-radius-sm)',
'&:hover': {
backgroundColor: 'var(--joy-palette-neutral-softHoverBg)',
},
}}
>
<ListItemDecorator>
{sortDirection === 'asc' ? (
<ArrowUpward sx={{ fontSize: '18px' }} />
) : (
<ArrowDownward sx={{ fontSize: '18px' }} />
)}
</ListItemDecorator>
<ListItemContent>
<Typography level='body-sm'>
{sortDirection === 'asc' ? 'Ascending' : 'Descending'}
</Typography>
</ListItemContent>
</MenuItem>
</>
)}
{filterOptions?.length > 0 && (
<>
<Divider sx={{ my: 1 }} />
<SectionHeader>{filterTitle || 'Filter'}</SectionHeader>
{filterOptions.map(option => (
<MenuItem
key={option.value}
onClick={() => {
onFilterChange(option.value)
handleMenuClose()
}}
sx={{
borderRadius: 'var(--joy-radius-sm)',
backgroundColor:
selectedFilter === option.value
? 'var(--joy-palette-primary-softBg)'
: 'transparent',
'&:hover': {
backgroundColor:
selectedFilter === option.value
? 'var(--joy-palette-primary-softBg)'
: 'var(--joy-palette-neutral-softHoverBg)',
},
}}
>
<ListItemDecorator>
<Radio
checked={selectedFilter === option.value}
variant='outlined'
/>
</ListItemDecorator>
<ListItemContent>
<Typography
level='body-sm'
sx={{
fontWeight: selectedFilter === option.value ? 600 : 400,
color:
selectedFilter === option.value
? 'var(--joy-palette-primary-600)'
: 'var(--joy-palette-text-primary)',
}}
>
{option.name}
</Typography>
</ListItemContent>
</MenuItem>
))}
</>
)}
</Menu>
</>
)
}
export default SortAndFilterMenu

View File

@@ -27,6 +27,7 @@ import JoinCircleView from '../views/Circles/JoinCircle'
import NotFound from '../views/components/NotFound' import NotFound from '../views/components/NotFound'
import FilterView from '../views/Filters/FilterView' import FilterView from '../views/Filters/FilterView'
import ChoreHistory from '../views/History/ChoreHistory' import ChoreHistory from '../views/History/ChoreHistory'
import LabelDetailView from '../views/Labels/LabelDetailView'
import LabelView from '../views/Labels/LabelView' import LabelView from '../views/Labels/LabelView'
import Landing from '../views/Landing/Landing' import Landing from '../views/Landing/Landing'
import CircleSetupView from '../views/Onboarding/CircleSetupView' import CircleSetupView from '../views/Onboarding/CircleSetupView'
@@ -262,6 +263,10 @@ const Router = createBrowserRouter([
path: 'labels/', path: 'labels/',
element: <LabelView />, element: <LabelView />,
}, },
{
path: 'labels/:labelId',
element: <LabelDetailView />,
},
{ {
path: 'projects/', path: 'projects/',
element: <ProjectView />, element: <ProjectView />,

View File

@@ -152,7 +152,7 @@ export const GlobalSearchProvider = ({ children }) => {
useEffect(() => { useEffect(() => {
const onKeyDown = event => { const onKeyDown = event => {
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'f') { if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'k') {
event.preventDefault() event.preventDefault()
isOpen ? closeSearch() : openSearch() isOpen ? closeSearch() : openSearch()
} }

View File

@@ -1,6 +1,8 @@
import { import {
AddRounded, AddRounded,
ArchiveOutlined,
CheckCircleOutline, CheckCircleOutline,
FilterAltOutlined,
FolderOutlined, FolderOutlined,
HistoryRounded, HistoryRounded,
InboxOutlined, InboxOutlined,
@@ -8,6 +10,8 @@ import {
PersonOutline, PersonOutline,
SearchRounded, SearchRounded,
SettingsOutlined, SettingsOutlined,
TollOutlined,
WidgetsOutlined,
} from '@mui/icons-material' } from '@mui/icons-material'
import { import {
Box, Box,
@@ -56,29 +60,106 @@ const buildQuickActions = t => [
provider: 'actions', provider: 'actions',
title: t('search.actions.createTask'), title: t('search.actions.createTask'),
subtitle: t('search.actions.quickAction'), subtitle: t('search.actions.quickAction'),
route: '/chores/create', keywords: 'new task chore add create',
// Reuses the widget deep-link param so this lands on the task list with the
// quick-add modal open, instead of the full create page.
route: '/chores?add_task=1',
}, },
{ {
id: 'action:tasks', id: 'action:create-label',
provider: 'actions', provider: 'actions',
title: t('search.actions.viewAllTasks'), title: t('search.actions.createLabel'),
subtitle: t('search.actions.navigation'), subtitle: t('search.actions.quickAction'),
route: '/chores', keywords: 'new label tag add create',
route: '/labels?create=1',
}, },
{ {
id: 'action:archived', id: 'action:create-project',
provider: 'actions', provider: 'actions',
title: t('search.actions.viewArchivedTasks'), title: t('search.actions.createProject'),
subtitle: t('search.actions.navigation'), subtitle: t('search.actions.quickAction'),
route: '/archived', keywords: 'new project folder add create',
route: '/projects?create=1',
}, },
{ {
id: 'action:settings', id: 'action:create-filter',
provider: 'actions', provider: 'actions',
title: t('search.actions.openSettings'), title: t('search.actions.createFilter'),
subtitle: t('search.actions.navigation'), subtitle: t('search.actions.quickAction'),
route: '/settings', keywords: 'new filter view saved search add create',
route: '/filters?create=1',
}, },
// Every destination in the nav drawer is reachable from here, so the palette
// is a complete way to move around the app without opening the drawer.
...[
{
id: 'action:tasks',
title: t('search.actions.viewAllTasks'),
keywords: 'tasks chores list all open',
route: '/chores',
icon: <InboxOutlined />,
},
{
id: 'action:archived',
title: t('search.actions.viewArchivedTasks'),
keywords: 'archive archived tasks completed open',
route: '/archived',
icon: <ArchiveOutlined />,
},
{
id: 'action:things',
title: t('search.actions.viewThings'),
keywords: 'things devices sensors trackers state open',
route: '/things',
icon: <WidgetsOutlined />,
},
{
id: 'action:labels',
title: t('search.actions.viewLabels'),
keywords: 'labels tags open',
route: '/labels',
icon: <LabelOutlined />,
},
{
id: 'action:projects',
title: t('search.actions.viewProjects'),
keywords: 'projects folders groups open',
route: '/projects',
icon: <FolderOutlined />,
},
{
id: 'action:filters',
title: t('search.actions.viewFilters'),
keywords: 'filters saved views open',
route: '/filters',
icon: <FilterAltOutlined />,
},
{
id: 'action:activities',
title: t('search.actions.viewActivities'),
keywords: 'activities history timeline log open',
route: '/activities',
icon: <HistoryRounded />,
},
{
id: 'action:points',
title: t('search.actions.viewPoints'),
keywords: 'points rewards score leaderboard open',
route: '/points',
icon: <TollOutlined />,
},
{
id: 'action:settings',
title: t('search.actions.openSettings'),
keywords: 'settings preferences configuration open',
route: '/settings',
icon: <SettingsOutlined />,
},
].map(action => ({
...action,
provider: 'actions',
subtitle: t('search.actions.navigation'),
})),
] ]
const readRecents = () => { const readRecents = () => {
@@ -192,6 +273,22 @@ const GlobalSearchPalette = ({
const [recents] = useState(readRecents) const [recents] = useState(readRecents)
const selectedResultRef = useRef(null) const selectedResultRef = useRef(null)
const quickActions = useMemo(() => buildQuickActions(t), [t])
const quickActionIndex = useMemo(
() =>
new Fuse(quickActions, {
threshold: 0.38,
distance: 120,
ignoreLocation: true,
includeScore: true,
keys: [
{ name: 'title', weight: 0.7 },
{ name: 'keywords', weight: 0.3 },
],
}),
[quickActions],
)
const searchIndexes = useMemo( const searchIndexes = useMemo(
() => () =>
new Map( new Map(
@@ -227,7 +324,7 @@ const GlobalSearchPalette = ({
const recentResults = recents const recentResults = recents
.map(item => currentById.get(item.id) || item) .map(item => currentById.get(item.id) || item)
.filter(item => item.provider !== 'history' || currentById.has(item.id)) .filter(item => item.provider !== 'history' || currentById.has(item.id))
return [...recentResults, ...buildQuickActions(t)] return [...recentResults, ...quickActions]
} }
const grouped = GROUPS.filter(group => group !== 'actions').flatMap(group => const grouped = GROUPS.filter(group => group !== 'actions').flatMap(group =>
@@ -250,15 +347,40 @@ const GlobalSearchPalette = ({
}) })
.sort((a, b) => a.score - b.score), .sort((a, b) => a.score - b.score),
) )
grouped.push({ const actionMatches = (
id: 'action:filter-tasks', quickActionIndex.search(normalized, { limit: 4 }) || []
provider: 'actions', )
title: t('search.actions.filterTasks', { query: query.trim() }), .map(match => ({ ...match.item, score: match.score ?? 1 }))
subtitle: t('search.actions.filterTasksSubtitle'), .sort((a, b) => a.score - b.score)
route: `/chores?search=${encodeURIComponent(query.trim())}`,
}) // An action whose title the query starts spelling out ("create la…") is
return grouped // what the person is after, so it leads. Anything matched only through its
}, [documents, query, recents, searchIndexes, t]) // keywords stays below the real content it shares words with.
const leadingActions = actionMatches.filter(action =>
action.title.toLocaleLowerCase().startsWith(normalized),
)
const trailingActions = actionMatches.filter(
action => !leadingActions.includes(action),
)
return [
...leadingActions,
...grouped,
...trailingActions,
{
id: 'action:filter-tasks',
provider: 'actions',
title: t('search.actions.filterTasks', { query: query.trim() }),
subtitle: t('search.actions.filterTasksSubtitle'),
route: `/chores?search=${encodeURIComponent(query.trim())}`,
},
]
}, [documents, query, quickActionIndex, recents, searchIndexes, t])
// Everything except the always-present "filter the task list" fallback.
const matchCount = results.filter(
result => result.id !== 'action:filter-tasks',
).length
useEffect(() => { useEffect(() => {
selectedResultRef.current?.scrollIntoView({ selectedResultRef.current?.scrollIntoView({
@@ -339,7 +461,7 @@ const GlobalSearchPalette = ({
pb: 'var(--safe-area-inset-bottom, 0px)', pb: 'var(--safe-area-inset-bottom, 0px)',
}} }}
> >
{!isLoading && query.trim() && results.length === 1 && ( {!isLoading && query.trim() && matchCount === 0 && (
<Box sx={{ px: 3, py: 6, textAlign: 'center' }}> <Box sx={{ px: 3, py: 6, textAlign: 'center' }}>
<InboxOutlined <InboxOutlined
sx={{ fontSize: 36, color: 'text.tertiary', mb: 1 }} sx={{ fontSize: 36, color: 'text.tertiary', mb: 1 }}
@@ -393,7 +515,7 @@ const GlobalSearchPalette = ({
<ListItemDecorator <ListItemDecorator
sx={{ mt: 0.25, color: result.color || 'text.secondary' }} sx={{ mt: 0.25, color: result.color || 'text.secondary' }}
> >
{ICONS[result.provider]} {result.icon || ICONS[result.provider]}
</ListItemDecorator> </ListItemDecorator>
<ListItemContent> <ListItemContent>
<Typography <Typography
@@ -434,9 +556,7 @@ const GlobalSearchPalette = ({
<Typography level='body-xs'> {t('search.footer.open')}</Typography> <Typography level='body-xs'> {t('search.footer.open')}</Typography>
<Typography level='body-xs' sx={{ ml: 'auto' }}> <Typography level='body-xs' sx={{ ml: 'auto' }}>
{query.trim() {query.trim()
? t('search.footer.results', { ? t('search.footer.results', { count: matchCount })
count: Math.max(0, results.length - 1),
})
: t('search.footer.typeToSearch')} : t('search.footer.typeToSearch')}
</Typography> </Typography>
</Box> </Box>

View File

@@ -1,13 +1,5 @@
import { SETTINGS_SECTIONS } from '../constants/settingsSections' import { SETTINGS_SECTIONS } from '../constants/settingsSections'
import { stripHtml } from '../utils/Helpers'
const stripHtml = value => {
if (!value) return ''
if (typeof globalThis.document === 'undefined')
return String(value).replace(/<[^>]*>/g, ' ')
const element = globalThis.document.createElement('div')
element.innerHTML = String(value)
return element.textContent || element.innerText || ''
}
const HISTORY_STATUS = { const HISTORY_STATUS = {
0: 'in progress', 0: 'in progress',
@@ -125,7 +117,7 @@ registerSearchProvider({
title: label.name || 'Untitled label', title: label.name || 'Untitled label',
subtitle: 'Label', subtitle: 'Label',
keywords: 'tag label', keywords: 'tag label',
route: '/labels', route: `/labels/${label.id}`,
color: label.color, color: label.color,
}), }),
), ),

View File

@@ -249,13 +249,28 @@ export const submitErrorReport = async ({
description, description,
report, report,
}) => { }) => {
// The relay rejects reports without an error. Manual bug reports have no
// thrown Error, so mark only the submitted copy while preserving the local
// diagnostics as a manual report.
const submittedReport =
report.kind === 'bug'
? {
...report,
error: {
...report.error,
name: 'ManualBugReport',
message: 'Submitted manually from the app',
},
}
: report
const payload = { const payload = {
source: 'donetick-app', source: 'donetick-app',
kind: report.kind === 'bug' ? 'bug-report' : 'error-report', kind: 'error-report',
reportId: report.reportId, reportId: report.reportId,
description: description?.trim() || null, description: description?.trim() || null,
contactEmail: contactEmail?.trim() || null, contactEmail: contactEmail?.trim() || null,
report, report: submittedReport,
} }
// Enforced here, not only in the UI, so no future caller can relay a // Enforced here, not only in the UI, so no future caller can relay a

View File

@@ -1,10 +1,22 @@
import moment from 'moment' import moment from 'moment'
import { apiClient } from './ApiClient' import { apiClient } from './ApiClient'
const isPlusAccount = userProfile => { const isPlusAccount = userProfile => {
return userProfile?.expiration && moment(userProfile?.expiration).isAfter() return userProfile?.expiration && moment(userProfile?.expiration).isAfter()
} }
// Turns rich-text/HTML content (task descriptions, notes) into plain text so it
// can be indexed or matched by search.
const stripHtml = value => {
if (!value) return ''
if (typeof globalThis.document === 'undefined')
return String(value).replace(/<[^>]*>/g, ' ')
const element = globalThis.document.createElement('div')
element.innerHTML = String(value)
return element.textContent || element.innerText || ''
}
const resolvePhotoURL = url => { const resolvePhotoURL = url => {
if (!url) return '' if (!url) return ''
if (url.startsWith('http') || url.startsWith('https')) { if (url.startsWith('http') || url.startsWith('https')) {
@@ -83,4 +95,5 @@ export {
isPlusAccount, isPlusAccount,
isSignedUrlExpired, isSignedUrlExpired,
resolvePhotoURL, resolvePhotoURL,
stripHtml,
} }

View File

@@ -1,3 +1,4 @@
import { Capacitor } from '@capacitor/core'
import { import {
Archive, Archive,
AttachFile, AttachFile,
@@ -7,7 +8,7 @@ import {
Edit, Edit,
History, History,
HourglassEmpty, HourglassEmpty,
LowPriority, MoreVert,
OpenInFull, OpenInFull,
PeopleAlt, PeopleAlt,
Person, Person,
@@ -25,18 +26,13 @@ import {
Checkbox, Checkbox,
Chip, Chip,
Container, Container,
Dropdown,
FormControl, FormControl,
Grid, Grid,
IconButton, IconButton,
Input, Input,
Menu,
MenuButton,
MenuItem,
Sheet, Sheet,
Typography, Typography,
} from '@mui/joy' } from '@mui/joy'
import { Divider } from '@mui/material'
import { useQueryClient } from '@tanstack/react-query' import { useQueryClient } from '@tanstack/react-query'
import moment from 'moment' import moment from 'moment'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
@@ -68,20 +64,33 @@ import { getTextColorFromBackgroundColor } from '../../utils/Colors.jsx'
import { commandQueue, CommandType } from '../../utils/CommandQueue' import { commandQueue, CommandType } from '../../utils/CommandQueue'
import { import {
ApproveChore, ApproveChore,
ArchiveChore,
DeleteChore,
GetChoreDetailById, GetChoreDetailById,
MarkChoreComplete, MarkChoreComplete,
NudgeChore,
RejectChore, RejectChore,
SaveChore,
SkipChore, SkipChore,
UnArchiveChore, UnArchiveChore,
UndoChoreAction, UndoChoreAction,
UpdateChoreAssignee,
UpdateChorePriority, UpdateChorePriority,
UpdateDueDate,
} from '../../utils/Fetcher' } from '../../utils/Fetcher'
import { offlineDB } from '../../utils/OfflineDB' import { offlineDB } from '../../utils/OfflineDB'
import Priorities from '../../utils/Priorities'
import { getSafeBottomPadding } from '../../utils/SafeAreaUtils.js' import { getSafeBottomPadding } from '../../utils/SafeAreaUtils.js'
import AttachmentBrowserModal from '../Modals/Inputs/AttachmentBrowserModal' import AttachmentBrowserModal from '../Modals/Inputs/AttachmentBrowserModal'
import ConfirmationModal from '../Modals/Inputs/ConfirmationModal' import ConfirmationModal from '../Modals/Inputs/ConfirmationModal'
import NoteViewerModal from '../Modals/Inputs/NoteViewerModal' import NoteViewerModal from '../Modals/Inputs/NoteViewerModal'
import NudgeModal from '../Modals/Inputs/NudgeModal'
import SelectModal from '../Modals/Inputs/SelectModal'
import WriteNFCModal from '../Modals/Inputs/WriteNFCModal'
import ChoreActionMenu from '../components/ChoreActionMenu'
import DueDatePickerModal, {
combineDueDate,
splitDueDate,
} from '../components/DueDatePickerModal'
import LoadingComponent from '../components/Loading.jsx' import LoadingComponent from '../components/Loading.jsx'
import PendingBadge from '../components/PendingBadge' import PendingBadge from '../components/PendingBadge'
import RichTextEditor from '../components/RichTextEditor.jsx' import RichTextEditor from '../components/RichTextEditor.jsx'
@@ -106,6 +115,11 @@ const decodeHtmlEntities = value => {
const hasHtmlTags = value => /<\/?[a-z][\s\S]*>/i.test(value) const hasHtmlTags = value => /<\/?[a-z][\s\S]*>/i.test(value)
const getNFCUrl = choreId =>
Capacitor.getPlatform() === 'android' || Capacitor.getPlatform() === 'ios'
? `donetick://chores/${choreId}`
: `${window.location.origin}/chores/${choreId}`
const ChoreView = () => { const ChoreView = () => {
const { t } = useTranslation('chores') const { t } = useTranslation('chores')
const { fmt } = useLocalization() const { fmt } = useLocalization()
@@ -124,7 +138,7 @@ const ChoreView = () => {
const [confirmModelConfig, setConfirmModelConfig] = useState({ const [confirmModelConfig, setConfirmModelConfig] = useState({
isOpen: false, isOpen: false,
}) })
const [chorePriority, setChorePriority] = useState(null) const [activeModal, setActiveModal] = useState(null)
const [noteViewerConfig, setNoteViewerConfig] = useState({ isOpen: false }) const [noteViewerConfig, setNoteViewerConfig] = useState({ isOpen: false })
const [timerActionConfig, setTimerActionConfig] = useState({ isOpen: false }) const [timerActionConfig, setTimerActionConfig] = useState({ isOpen: false })
const [attachmentBrowserOpen, setAttachmentBrowserOpen] = useState(false) const [attachmentBrowserOpen, setAttachmentBrowserOpen] = useState(false)
@@ -165,7 +179,6 @@ const ChoreView = () => {
return return
} }
setChore(choreData.res) setChore(choreData.res)
setChorePriority(Priorities.find(p => p.value === choreData.res.priority))
document.title = 'Donetick: ' + choreData.res.name document.title = 'Donetick: ' + choreData.res.name
setPerformers(circleMembersData.res) setPerformers(circleMembersData.res)
@@ -236,7 +249,7 @@ const ChoreView = () => {
UpdateChorePriority(choreId, priority.value).then(response => { UpdateChorePriority(choreId, priority.value).then(response => {
if (response.ok) { if (response.ok) {
response.json().then(() => { response.json().then(() => {
setChorePriority(priority) setChore(prev => ({ ...prev, priority: priority.value }))
queryClient.invalidateQueries(['chores']) queryClient.invalidateQueries(['chores'])
}) })
} }
@@ -583,6 +596,168 @@ const ChoreView = () => {
} }
} }
const confirmSkipTask = () => {
setConfirmModelConfig({
isOpen: true,
title: t('choreView.skipTask'),
message: t('choreView.skipTaskConfirmation'),
confirmText: t('choreView.skip'),
cancelText: t('choreView.cancel'),
onClose: confirmed => {
if (confirmed) {
handleSkippingTask()
}
setConfirmModelConfig({})
},
})
}
const handleArchiveChore = async () => {
try {
const response = await ArchiveChore(choreId)
if (response.ok) {
await offlineDB.saveChores([{ ...chore, isActive: false }])
setChore({ ...chore, isActive: false })
queryClient.invalidateQueries(['chores'])
}
} catch (error) {
showError({
title: 'Failed to archive',
message: error?.message || 'Unable to archive task',
})
}
}
const confirmDeleteChore = () => {
setConfirmModelConfig({
isOpen: true,
title: 'Delete task',
message: 'Are you sure you want to delete this task?',
confirmText: 'Delete',
cancelText: t('choreView.cancel'),
onClose: async confirmed => {
setConfirmModelConfig({})
if (!confirmed) return
try {
const response = await DeleteChore(choreId)
if (response.ok) {
queryClient.invalidateQueries(['chores'])
showSuccess({
title: 'Task Deleted',
message: 'The task has been deleted successfully.',
})
navigate('/chores')
}
} catch (error) {
showError({
title: 'Failed to delete',
message: error?.message || 'Unable to delete task',
})
}
},
})
}
const handleDueDateChange = async newDate => {
try {
const response = await UpdateDueDate(choreId, newDate)
if (response.ok) {
setChore(prev => ({ ...prev, nextDueDate: newDate }))
queryClient.invalidateQueries(['chores'])
}
} catch (error) {
showError({
title: 'Failed to reschedule',
message: error?.message || 'Unable to change the due date',
})
}
}
const handleMoveToProject = async project => {
const projectId = project?.id ?? null
try {
const response = await SaveChore({ ...chore, projectId })
if (response.ok) {
setChore(prev => ({ ...prev, projectId }))
queryClient.invalidateQueries(['chores'])
showSuccess({
title: 'Task Moved',
message: `Task moved to ${project?.name || 'Default Project'}.`,
})
}
} catch (error) {
showError({
title: 'Failed to move task',
message: error?.message || 'Unable to move task to project',
})
}
}
const handleNudge = async ({ message, notifyAllAssignees }) => {
try {
const response = await NudgeChore(choreId, {
message,
notifyAllAssignees,
})
if (!response.ok) {
throw new Error('Failed to send nudge')
}
const data = await response.json()
showSuccess({
title: 'Nudge Sent!',
message: data.message || 'Nudge sent successfully',
})
} catch (error) {
showError({
title: 'Failed to Send Nudge',
message: error?.message || 'Unable to send nudge at this time',
})
}
}
const handleAssigneeChange = async assigneeId => {
try {
const response = await UpdateChoreAssignee(choreId, assigneeId)
if (response.ok) {
const data = await response.json()
setChore(data.res)
queryClient.invalidateQueries(['chores'])
}
} catch (error) {
showError({
title: 'Failed to delegate',
message: error?.message || 'Unable to change the assignee',
})
}
}
// Actions the menu raises that ChoreView owns; the rest of its items either
// navigate on their own or come in through the dedicated callbacks.
const handleMenuAction = (type, _chore, extraData) => {
switch (type) {
case 'skip':
confirmSkipTask()
break
case 'archive':
handleArchiveChore()
break
case 'unarchive':
handleUnarchiveChore()
break
case 'delete':
confirmDeleteChore()
break
case 'changeDueDate':
handleDueDateChange(extraData?.date?.toISOString() ?? null)
break
case 'moveToProject':
handleMoveToProject(extraData?.project)
break
default:
break
}
}
// Check if the current user can approve/reject (admin, manager, or task owner) // Check if the current user can approve/reject (admin, manager, or task owner)
const canApproveReject = () => { const canApproveReject = () => {
if (!circleMembersData?.res || !chore) return false if (!circleMembersData?.res || !chore) return false
@@ -794,64 +969,6 @@ const ChoreView = () => {
mb: 1, mb: 1,
}} }}
> >
<Dropdown>
<MenuButton
disabled={chore.isActive === false}
color={
chorePriority?.name === 'P1'
? 'danger'
: chorePriority?.name === 'P2'
? 'warning'
: 'neutral'
}
sx={{
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
p: 1,
width: '100%',
}}
variant='plain'
>
{chorePriority ? chorePriority.icon : <LowPriority />}
{chorePriority ? chorePriority.name : t('choreView.noPriority')}
</MenuButton>
<Menu>
{Priorities.map((priority, index) => (
<MenuItem
sx={{
pr: 1,
py: 1,
}}
key={index}
onClick={() => {
handleUpdatePriority(priority)
}}
color={priority.color}
>
{priority.icon}
{priority.name}
</MenuItem>
))}
<Divider />
<MenuItem
sx={{
pr: 1,
py: 1,
}}
onClick={() => {
handleUpdatePriority({
name: t('choreView.noPriority'),
value: 0,
})
setChorePriority(null)
}}
>
{t('choreView.noPriority')}
</MenuItem>
</Menu>
</Dropdown>
<Button <Button
size='sm' size='sm'
color='neutral' color='neutral'
@@ -889,6 +1006,38 @@ const ChoreView = () => {
<Edit /> <Edit />
Edit Edit
</Button> </Button>
<ChoreActionMenu
chore={chore}
hiddenActions={['view']}
onAction={handleMenuAction}
onNudge={() => setActiveModal('nudge')}
onWriteNFC={() => setActiveModal('writeNFC')}
onCompleteWithNote={() => setNote('')}
onCompleteWithPastDate={() =>
setCompletedDate(moment(new Date()).format('YYYY-MM-DDTHH:00:00'))
}
onChangeAssignee={() => setActiveModal('changeAssignee')}
onChangeDueDate={() => setActiveModal('changeDueDate')}
onChangePriority={handleUpdatePriority}
onDelete={confirmDeleteChore}
trigger={
<Button
size='sm'
color='neutral'
variant='plain'
fullWidth
sx={{
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
p: 1,
}}
>
<MoreVert />
{t('choreView.more', 'More')}
</Button>
}
/>
</Box> </Box>
{chore.description && ( {chore.description && (
@@ -1261,21 +1410,7 @@ const ChoreView = () => {
<Button <Button
fullWidth fullWidth
size='lg' size='lg'
onClick={() => { onClick={confirmSkipTask}
setConfirmModelConfig({
isOpen: true,
title: t('choreView.skipTask'),
message: t('choreView.skipTaskConfirmation'),
confirmText: t('choreView.skip'),
cancelText: t('choreView.cancel'),
onClose: confirmed => {
if (confirmed) {
handleSkippingTask()
}
setConfirmModelConfig({})
},
})
}}
disabled={ disabled={
notInCompletionWindow(chore) || chore.isActive === false notInCompletionWindow(chore) || chore.isActive === false
} }
@@ -1348,6 +1483,52 @@ const ChoreView = () => {
<ConfirmationModal config={confirmModelConfig} /> <ConfirmationModal config={confirmModelConfig} />
<ConfirmationModal config={timerActionConfig} /> <ConfirmationModal config={timerActionConfig} />
<NoteViewerModal config={noteViewerConfig} /> <NoteViewerModal config={noteViewerConfig} />
{activeModal === 'changeDueDate' && (
<DueDatePickerModal
open={true}
title={t('choreView.changeDueDate', 'Change due date')}
{...splitDueDate(chore.nextDueDate)}
onClose={() => setActiveModal(null)}
onApply={parts => {
handleDueDateChange(combineDueDate(parts)?.toISOString() ?? null)
setActiveModal(null)
}}
onRemove={() => {
handleDueDateChange(null)
setActiveModal(null)
}}
/>
)}
{activeModal === 'changeAssignee' && (
<SelectModal
isOpen={true}
options={performers}
displayKey='displayName'
title='Delegate to someone else'
placeholder='Select a performer'
onClose={() => setActiveModal(null)}
onSave={selected => handleAssigneeChange(selected.id)}
/>
)}
{activeModal === 'nudge' && (
<NudgeModal
config={{
isOpen: true,
choreId: chore.id,
onClose: () => setActiveModal(null),
onConfirm: handleNudge,
}}
/>
)}
{activeModal === 'writeNFC' && (
<WriteNFCModal
config={{
isOpen: true,
url: getNFCUrl(choreId),
onClose: () => setActiveModal(null),
}}
/>
)}
<AttachmentBrowserModal <AttachmentBrowserModal
choreId={choreId} choreId={choreId}
isOpen={attachmentBrowserOpen} isOpen={attachmentBrowserOpen}

View File

@@ -4,6 +4,7 @@ import {
CheckBoxOutlineBlank, CheckBoxOutlineBlank,
Close, Close,
Delete, Delete,
FilterList,
Label, Label,
Person, Person,
PriorityHigh, PriorityHigh,
@@ -14,6 +15,7 @@ import {
ViewModule, ViewModule,
} from '@mui/icons-material' } from '@mui/icons-material'
import { import {
Badge,
Box, Box,
Button, Button,
Container, Container,
@@ -33,6 +35,7 @@ import { useNavigate } from 'react-router-dom'
import EmptyState from '../../components/common/EmptyState' import EmptyState from '../../components/common/EmptyState'
import FilterBar from '../../components/common/FilterBar' import FilterBar from '../../components/common/FilterBar'
import KeyboardShortcutHint from '../../components/common/KeyboardShortcutHint' import KeyboardShortcutHint from '../../components/common/KeyboardShortcutHint'
import SortAndFilterMenu from '../../components/common/SortAndFilterMenu'
import { useImpersonateUser } from '../../contexts/ImpersonateUserContext.jsx' import { useImpersonateUser } from '../../contexts/ImpersonateUserContext.jsx'
import { useFilter } from '../../hooks/useFilter' import { useFilter } from '../../hooks/useFilter'
import { useUnArchiveChore } from '../../queries/ChoreQueries' import { useUnArchiveChore } from '../../queries/ChoreQueries'
@@ -203,13 +206,51 @@ const ArchivedTasks = () => {
) )
const { const {
activeFilterCount,
activeFilters, activeFilters,
clearAll, clearAll,
filteredData: finalChores, filteredData: filteredByBar,
hasActiveFilters, hasActiveFilters,
setFilter, setFilter,
} = useFilter(filteredChores, filterDefs) } = useFilter(filteredChores, filterDefs)
const [isFilterSheetOpen, setIsFilterSheetOpen] = useState(false)
const [sortBy, setSortBy] = useState(
() => localStorage.getItem('archivedChoresSortBy') || 'archivedAt',
)
const [sortDirection, setSortDirection] = useState(
() => localStorage.getItem('archivedChoresSortDirection') || 'desc',
)
useEffect(() => {
localStorage.setItem('archivedChoresSortBy', sortBy)
localStorage.setItem('archivedChoresSortDirection', sortDirection)
}, [sortBy, sortDirection])
const finalChores = useMemo(() => {
const direction = sortDirection === 'desc' ? -1 : 1
return [...filteredByBar].sort((a, b) => {
switch (sortBy) {
case 'name':
return direction * (a.name || '').localeCompare(b.name || '')
case 'priority':
return direction * ((a.priority ?? 0) - (b.priority ?? 0))
case 'dueDate': {
const aDue = new Date(a.nextDueDate || 0).getTime()
const bDue = new Date(b.nextDueDate || 0).getTime()
return direction * (aDue - bDue)
}
case 'archivedAt':
default: {
const aDate = new Date(a.updatedAt || 0).getTime()
const bDate = new Date(b.updatedAt || 0).getTime()
return direction * (aDate - bDate)
}
}
})
}, [filteredByBar, sortBy, sortDirection])
useEffect(() => { useEffect(() => {
const loadArchivedChores = async () => { const loadArchivedChores = async () => {
if (!membersLoading && userProfile) { if (!membersLoading && userProfile) {
@@ -722,9 +763,6 @@ const ArchivedTasks = () => {
padding: 1, padding: 1,
}} }}
onChange={handleSearchChange} onChange={handleSearchChange}
startDecorator={
showKeyboardShortcuts ? <KeyboardShortcutHint shortcut='F' /> : null
}
endDecorator={ endDecorator={
searchTerm && ( searchTerm && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}> <Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
@@ -745,6 +783,45 @@ const ArchivedTasks = () => {
} }
/> />
{/* Filter Sheet Trigger */}
<Badge
badgeContent={activeFilterCount || null}
color='primary'
size='sm'
anchorOrigin={{ vertical: 'top', horizontal: 'right' }}
sx={{ display: 'flex', alignItems: 'center' }}
>
<IconButton
variant={hasActiveFilters ? 'solid' : 'outlined'}
color={hasActiveFilters ? 'primary' : 'neutral'}
size='sm'
sx={{
height: 32,
width: 32,
borderRadius: '50%',
}}
onClick={() => setIsFilterSheetOpen(true)}
title='Filters'
>
<FilterList />
</IconButton>
</Badge>
{/* Sort Menu */}
<SortAndFilterMenu
sortOptions={[
{ name: 'Archived date', value: 'archivedAt' },
{ name: 'Name', value: 'name' },
{ name: 'Priority', value: 'priority' },
{ name: 'Due date', value: 'dueDate' },
]}
selectedSort={sortBy}
onSortChange={setSortBy}
sortDirection={sortDirection}
onSortDirectionChange={setSortDirection}
isActive={sortBy !== 'archivedAt' || sortDirection !== 'desc'}
/>
{/* View Mode Toggle Button */} {/* View Mode Toggle Button */}
<IconButton <IconButton
variant='outlined' variant='outlined'
@@ -805,6 +882,9 @@ const ArchivedTasks = () => {
onClearAll={clearAll} onClearAll={clearAll}
resultCount={finalChores.length} resultCount={finalChores.length}
totalCount={filteredChores.length} totalCount={filteredChores.length}
showTrigger={false}
open={isFilterSheetOpen}
onOpenChange={setIsFilterSheetOpen}
/> />
{/* Multi-select Toolbar */} {/* Multi-select Toolbar */}

View File

@@ -78,11 +78,16 @@ const scheduleNotificationFromTemplate = (
const now = new Date() const now = new Date()
const time = getTimeFromTemplate(template, dueDate) const time = getTimeFromTemplate(template, dueDate)
const notificationId = getIdFromTemplate(chore.id, template) const notificationId = getIdFromTemplate(chore.id, template)
const { title, body } = getNotificationText(chore.name, template) const { title, body } = getNotificationText(
chore.name,
template,
dueDate,
time,
)
if (time > now) { if (time > now) {
notifications.push({ notifications.push({
title, title,
body: `${body} at ${time.toLocaleTimeString()}`, body,
id: notificationId, id: notificationId,
allowWhileIdle: true, allowWhileIdle: true,
schedule: { schedule: {
@@ -96,91 +101,50 @@ const scheduleNotificationFromTemplate = (
} }
} }
const getNotificationText = (choreName, template = {}) => { const getNotificationText = (
// Determine notification type based on template value choreName,
const getNotificationType = () => { template = {},
if (!template || template.value === undefined) { dueDate,
return 'due' notificationTime,
} ) => {
const startOfDay = date =>
new Date(date.getFullYear(), date.getMonth(), date.getDate())
const dayDifference = Math.round(
(startOfDay(dueDate) - startOfDay(notificationTime)) /
(24 * 60 * 60 * 1000),
)
const time = dueDate.toLocaleTimeString([], {
hour: 'numeric',
minute: '2-digit',
})
if (template.value < 0) { let dueTime
return 'reminder' if (dayDifference === 0) {
} else if (template.value === 0) { dueTime = `today at ${time}`
return 'due' } else if (dayDifference === 1) {
} else { dueTime = `tomorrow at ${time}`
return 'overdue' } else if (dayDifference === -1) {
} dueTime = `yesterday at ${time}`
} else {
const date = dueDate.toLocaleDateString([], {
month: 'short',
day: 'numeric',
})
dueTime = `${date} at ${time}`
} }
const notificationType = getNotificationType() let body
if (template.value < 0) {
// Truncate chore name if too long for better readability body = `Due ${dueTime}`
const maxChoreNameLength = 25 } else if (template.value > 0) {
const truncatedName = body = `Overdue · Was due ${dueTime}`
choreName.length > maxChoreNameLength } else {
? `${choreName.substring(0, maxChoreNameLength)}...` body = 'Due now'
: choreName
// Generate time-based descriptive text
const getTimeDescription = () => {
if (!template || !template.value || !template.unit) {
return 'soon'
}
const { value, unit } = template
const absValue = Math.abs(value)
switch (unit) {
case 'm':
if (absValue === 1) return value < 0 ? 'in 1 minute' : '1 minute ago'
if (absValue < 60)
return value < 0
? `in ${absValue} minutes`
: `${absValue} minutes ago`
break
case 'h':
if (absValue === 1) return value < 0 ? 'in 1 hour' : '1 hour ago'
if (absValue < 24)
return value < 0 ? `in ${absValue} hours` : `${absValue} hours ago`
break
case 'd':
if (absValue === 1) return value < 0 ? 'tomorrow' : 'yesterday'
if (absValue === 7) return value < 0 ? 'next week' : 'last week'
if (absValue < 7)
return value < 0 ? `in ${absValue} days` : `${absValue} days ago`
if (absValue < 30) {
const weeks = Math.round(absValue / 7)
return value < 0 ? `in ${weeks} weeks` : `${weeks} weeks ago`
}
break
default:
return value < 0 ? `in ${absValue} ${unit}` : `${absValue} ${unit} ago`
}
return value < 0 ? `in ${absValue} ${unit}` : `${absValue} ${unit} ago`
} }
const messages = {
reminder: {
title: `📋 ${truncatedName}`,
body: `Reminder: Due ${getTimeDescription()}`,
},
due: {
title: `🔔 ${truncatedName}`,
body: 'Due now - Time to get started!',
},
overdue: {
title: `${truncatedName}`,
body: `Overdue ${getTimeDescription()} - Complete when you can`,
},
}
// Fallback to due if type not found
const messageTemplate = messages[notificationType] || messages.due
return { return {
title: messageTemplate.title, title: choreName,
body: messageTemplate.body, body,
} }
} }
const cancelPendingNotifications = async () => { const cancelPendingNotifications = async () => {

View File

@@ -75,6 +75,16 @@ import Sidepanel from './Sidepanel'
import { INSIGHT_FILTER_DEFS } from './SmartInsightsCard' import { INSIGHT_FILTER_DEFS } from './SmartInsightsCard'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
// Mirrors the assignee options in the toolbar, phrased to drop into a
// sentence ("none of them are assigned to you").
const ASSIGNEE_FILTER_LABELS = {
assigned_to_me: 'assigned to you',
available_for_me: 'available for you to pick up',
assigned_to_others: 'assigned to someone else',
assigned_to_me_tasks: 'assigned to you',
created_by_me: 'created by you',
}
const MyChores = () => { const MyChores = () => {
const { data: userProfile, isLoading: isUserProfileLoading } = const { data: userProfile, isLoading: isUserProfileLoading } =
useUserProfile() useUserProfile()
@@ -151,6 +161,7 @@ const MyChores = () => {
clearSelection, clearSelection,
enterMultiSelectWithChore, enterMultiSelectWithChore,
getSelectedChoresData, getSelectedChoresData,
getSelectionSummary,
isMultiSelectMode, isMultiSelectMode,
selectAllVisibleChores, selectAllVisibleChores,
selectedChores, selectedChores,
@@ -577,9 +588,13 @@ const MyChores = () => {
const { const {
handleAssigneeChange, handleAssigneeChange,
handleBulkArchive, handleBulkArchive,
handleBulkAssignee,
handleBulkComplete, handleBulkComplete,
handleBulkDelete, handleBulkDelete,
handleBulkDueDate,
handleBulkLabels,
handleBulkMoveToProject, handleBulkMoveToProject,
handleBulkPriority,
handleBulkSkip, handleBulkSkip,
handleChangeDueDate, handleChangeDueDate,
handleChoreAction, handleChoreAction,
@@ -631,6 +646,13 @@ const MyChores = () => {
searchTerm, 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({ const { showKeyboardShortcuts } = useKeyboardShortcuts({
isMultiSelectMode, isMultiSelectMode,
selectedChores, selectedChores,
@@ -874,20 +896,51 @@ const MyChores = () => {
}) })
}, [getFilteredChores, selectedCalendarDate]) }, [getFilteredChores, selectedCalendarDate])
// The assignee filter ("Mine", "Available to me", ...) is applied inside
// ChoresGrouper, not in projectFilteredChores, so it can hide every task
// while the unfiltered list still looks full. It narrows like any other.
const assigneeFilterLabel = ASSIGNEE_FILTER_LABELS[selectedChoreFilter]
const hasAssigneeFilter = Boolean(
selectedChoreFilter && selectedChoreFilter !== 'anyone',
)
// "Narrowed" means the user actively cut the list down (search, quick // "Narrowed" means the user actively cut the list down (search, quick
// filters, a saved filter). Picking a project is not narrowing: an empty // filters, a saved filter, the assignee filter). Picking a project is not
// project is an empty place, not a filtered-away result. // narrowing: an empty project is an empty place, not a filtered-away result.
const isNarrowed = Boolean( const isNarrowed = Boolean(
searchTerm?.length > 0 || hasQuickFilters || activeFilterId, searchTerm?.length > 0 ||
hasQuickFilters ||
activeFilterId ||
hasAssigneeFilter,
) )
const isCustomProjectSelected = Boolean( const isCustomProjectSelected = Boolean(
selectedProject && selectedProject.id !== 'default', selectedProject && selectedProject.id !== 'default',
) )
// Worth its own wording: the assignee filter is the one narrowing that is
// easy to forget you left on, so name it rather than saying "filters".
const isAssigneeOnlyNarrowing = Boolean(
assigneeFilterLabel &&
!searchTerm?.length &&
!hasQuickFilters &&
!activeFilterId,
)
// What the list actually renders. Sections are the source of truth outside
// of search, since they are the only place the assignee filter is applied.
const visibleChoreCount = useMemo(
() =>
choreSections.reduce(
(total, section) => total + (section.content?.length || 0),
0,
),
[choreSections],
)
const clearNarrowing = () => { const clearNarrowing = () => {
clearQuickFilters() clearQuickFilters()
setSearchTerm('') setSearchTerm('')
clearActiveFilter() clearActiveFilter()
setSelectedChoreFilterWithCache('anyone')
updateFilterUrl(null, null) updateFilterUrl(null, null)
} }
@@ -1077,6 +1130,13 @@ const MyChores = () => {
onArchive={handleBulkArchive} onArchive={handleBulkArchive}
onDelete={handleBulkDelete} onDelete={handleBulkDelete}
onMoveToProject={handleBulkMoveToProject} onMoveToProject={handleBulkMoveToProject}
onSetDueDate={handleBulkDueDate}
onSetAssignee={handleBulkAssignee}
onSetPriority={handleBulkPriority}
onToggleLabel={handleBulkLabels}
selectionSummary={selectionSummary}
members={membersData?.res || []}
labels={userLabels || []}
projects={projects} projects={projects}
showKeyboardShortcuts={showKeyboardShortcuts} showKeyboardShortcuts={showKeyboardShortcuts}
selectAllDisabled={ selectAllDisabled={
@@ -1089,10 +1149,13 @@ const MyChores = () => {
{/* Empty state. Three different situations, three different messages: {/* Empty state. Three different situations, three different messages:
nothing created yet, nothing left after narrowing, or an empty nothing created yet, nothing left after narrowing, or an empty
project. Only the middle one is about filters. */} project. Only the middle one is about filters.
{(isNarrowed The trigger is what the list actually renders, not the pre-filter
count, so a view emptied purely by the assignee filter still
explains itself instead of showing a blank page. */}
{(searchTerm?.length > 0
? getFilteredChores.length === 0 ? getFilteredChores.length === 0
: projectFilteredChores.length === 0) && : visibleChoreCount === 0) &&
// only if not in calendar view: // only if not in calendar view:
viewMode !== 'calendar' && viewMode !== 'calendar' &&
(chores.length === 0 ? ( (chores.length === 0 ? (
@@ -1112,7 +1175,10 @@ const MyChores = () => {
onClick: () => Navigate('/chores/create'), onClick: () => Navigate('/chores/create'),
}} }}
/> />
) : isNarrowed ? ( ) : isNarrowed &&
(searchTerm?.length > 0 ||
activeFilterId ||
projectFilteredChores.length > 0) ? (
<EmptyState <EmptyState
variant='no-results' variant='no-results'
fullHeight fullHeight
@@ -1121,11 +1187,17 @@ const MyChores = () => {
description={ description={
searchTerm?.length > 0 searchTerm?.length > 0
? `Nothing matches "${searchTerm}". Try a different search, or clear what is narrowing the list.` ? `Nothing matches "${searchTerm}". Try a different search, or clear what is narrowing the list.`
: 'You have tasks, but none of them fit the filters that are currently on.' : isAssigneeOnlyNarrowing
? `There are tasks here, but none of them are ${assigneeFilterLabel}. Switch back to everyone to see the rest.`
: 'You have tasks, but none of them fit the filters that are currently on.'
} }
primaryAction={{ primaryAction={{
label: label:
searchTerm?.length > 0 ? 'Clear search' : 'Clear filters', searchTerm?.length > 0
? 'Clear search'
: isAssigneeOnlyNarrowing
? "Show everyone's tasks"
: 'Clear filters',
onClick: clearNarrowing, onClick: clearNarrowing,
}} }}
/> />
@@ -1479,12 +1551,16 @@ const MyChores = () => {
<KeyboardShortcutHint <KeyboardShortcutHint
sx={{ sx={{
position: 'absolute', position: 'absolute',
top: -8, top: -12,
right: -8, // Anchored left so the wider "⌘ + Shift + J" label grows to the
// right instead of off the left edge of the viewport.
left: 2,
whiteSpace: 'nowrap',
zIndex: 1000, zIndex: 1000,
}} }}
show={showKeyboardShortcuts} show={showKeyboardShortcuts}
shortcut='J' shortcut='J'
withShift
/> />
</IconButton> </IconButton>
<IconButton <IconButton
@@ -1513,7 +1589,7 @@ const MyChores = () => {
<KeyboardShortcutHint <KeyboardShortcutHint
sx={{ position: 'relative', left: -40, top: 30 }} sx={{ position: 'relative', left: -40, top: 30 }}
show={showKeyboardShortcuts} show={showKeyboardShortcuts}
shortcut='K' shortcut='J'
/> />
</Box> </Box>
<NotificationAccessSnackbar /> <NotificationAccessSnackbar />

View File

@@ -23,13 +23,13 @@ import {
Check, Check,
CheckBox, CheckBox,
CheckBoxOutlineBlank, CheckBoxOutlineBlank,
DisplaySettings,
FilterList, FilterList,
Save, Save,
Sort, Sort,
Tune, Tune,
ViewAgenda, ViewAgenda,
ViewComfy, ViewComfy,
ViewModule,
} from '@mui/icons-material' } from '@mui/icons-material'
import { import {
Badge, Badge,
@@ -596,23 +596,19 @@ const ChoreToolbar = ({
/> />
)} )}
{/* Display button — View + Group combined */} {/* Display button — View + Group combined.
Icon stays fixed: mirroring viewMode made this read as a toggle
showing the current view rather than a button that opens a sheet. */}
<IconButton <IconButton
variant='outlined' variant='outlined'
color='neutral' color='neutral'
size='sm' size='sm'
sx={{ height: 32, width: 32, borderRadius: '50%' }} sx={{ height: 32, width: 32, borderRadius: '50%' }}
onClick={() => setDisplaySheetOpen(true)} onClick={() => setDisplaySheetOpen(true)}
aria-label='View and group options' aria-label={t('toolbar.displayOptions')}
title={t('toolbar.viewGroup')} title={t('toolbar.display')}
> >
{viewMode === 'calendar' ? ( <DisplaySettings />
<CalendarMonth />
) : viewMode === 'compact' ? (
<ViewModule />
) : (
<ViewAgenda />
)}
</IconButton> </IconButton>
{/* Multiselect */} {/* Multiselect */}
@@ -886,7 +882,7 @@ const ChoreToolbar = ({
onClose={() => setDisplaySheetOpen(false)} onClose={() => setDisplaySheetOpen(false)}
title={ title={
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}> <Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<ViewAgenda sx={{ fontSize: 20 }} /> <DisplaySettings sx={{ fontSize: 20 }} />
Display Display
</Box> </Box>
} }

View File

@@ -1,11 +1,19 @@
import { import {
Archive, Archive,
CalendarMonth,
Check,
CheckBox, CheckBox,
CheckBoxOutlineBlank, CheckBoxOutlineBlank,
Close, Close,
Delete, Delete,
Done, Done,
DriveFileMove, DriveFileMove,
EditCalendar,
Flag,
Label as LabelIcon,
MoreHoriz,
Person,
Remove,
SelectAll, SelectAll,
SkipNext, SkipNext,
} from '@mui/icons-material' } from '@mui/icons-material'
@@ -13,6 +21,7 @@ import {
Avatar, Avatar,
Box, Box,
Button, Button,
Chip,
Divider, Divider,
ListItemContent, ListItemContent,
ListItemDecorator, ListItemDecorator,
@@ -20,13 +29,19 @@ import {
MenuItem, MenuItem,
Typography, Typography,
} from '@mui/joy' } from '@mui/joy'
import moment from 'moment'
import { useRef, useState } from 'react' import { useRef, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import AppModal from '../../../components/common/AppModal'
import KeyboardShortcutHint from '../../../components/common/KeyboardShortcutHint' import KeyboardShortcutHint from '../../../components/common/KeyboardShortcutHint'
import LABEL_COLORS, { import LABEL_COLORS, {
getTextColorFromBackgroundColor, getTextColorFromBackgroundColor,
} from '../../../utils/Colors' } from '../../../utils/Colors'
import Priorities from '../../../utils/Priorities'
import { getIconComponent } from '../../../utils/ProjectIcons' import { getIconComponent } from '../../../utils/ProjectIcons'
import DueDatePickerModal, {
splitDueDate,
} from '../../components/DueDatePickerModal'
const renderProjectAvatar = (color, icon) => { const renderProjectAvatar = (color, icon) => {
const bg = color || LABEL_COLORS[0].value const bg = color || LABEL_COLORS[0].value
@@ -40,6 +55,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 = ({ const MultiSelectToolbar = ({
isVisible, isVisible,
selectedCount, selectedCount,
@@ -50,20 +142,66 @@ const MultiSelectToolbar = ({
onArchive, onArchive,
onDelete, onDelete,
onMoveToProject, 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 = [], projects = [],
showKeyboardShortcuts, showKeyboardShortcuts,
selectAllDisabled, selectAllDisabled,
}) => { }) => {
const { t } = useTranslation('chores') const { t } = useTranslation('chores')
const [moreOpen, setMoreOpen] = useState(false)
const [dueDatePickerOpen, setDueDatePickerOpen] = useState(false)
const [dueMenuAnchor, setDueMenuAnchor] = useState(null)
const [projectMenuAnchor, setProjectMenuAnchor] = useState(null) const [projectMenuAnchor, setProjectMenuAnchor] = useState(null)
const dueMenuRef = useRef(null)
const projectMenuRef = useRef(null) const projectMenuRef = useRef(null)
const closeDueMenu = () => setDueMenuAnchor(null)
const closeProjectMenu = () => setProjectMenuAnchor(null) const closeProjectMenu = () => setProjectMenuAnchor(null)
const handleMoveToProject = project => { const summary = selectionSummary || {}
closeProjectMenu() const labelState = summary.labels || { common: [], partial: [] }
onMoveToProject?.(project) 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 ( return (
<Box <Box
@@ -73,7 +211,10 @@ const MultiSelectToolbar = ({
zIndex: 1000, zIndex: 1000,
overflow: 'hidden', overflow: 'hidden',
transition: 'all 0.3s ease-in-out', 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, opacity: isVisible ? 1 : 0,
transform: isVisible ? 'translateY(0)' : 'translateY(-20px)', transform: isVisible ? 'translateY(0)' : 'translateY(-20px)',
marginBottom: isVisible ? 2 : 0, marginBottom: isVisible ? 2 : 0,
@@ -88,20 +229,14 @@ const MultiSelectToolbar = ({
border: '1px solid', border: '1px solid',
borderColor: 'divider', borderColor: 'divider',
boxShadow: 'm', boxShadow: 'm',
gap: 2, gap: 1.5,
display: 'flex', display: 'flex',
flexDirection: { // Narrow screens stack: the selection status gets its own row, then
sm: 'column', // the actions get the full width to lay out in. Only above md is
md: 'row', // there room to put both on one line.
}, flexDirection: { xs: 'column', md: 'row' },
alignItems: { alignItems: { xs: 'stretch', md: 'center' },
xs: 'stretch', justifyContent: 'space-between',
sm: 'center',
},
justifyContent: {
xs: 'center',
sm: 'space-between',
},
}} }}
> >
<Box <Box
@@ -109,14 +244,10 @@ const MultiSelectToolbar = ({
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
gap: 2, gap: 2,
flexWrap: { flexWrap: 'nowrap',
xs: 'wrap', // Stacked, the count sits left and All/Close anchor right, so the
sm: 'nowrap', // status row reads edge to edge instead of floating in the middle.
}, justifyContent: { xs: 'space-between', md: 'flex-start' },
justifyContent: {
xs: 'center',
sm: 'flex-start',
},
}} }}
> >
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}> <Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
@@ -129,7 +260,7 @@ const MultiSelectToolbar = ({
<Divider <Divider
orientation='vertical' orientation='vertical'
sx={{ sx={{
display: { xs: 'none', sm: 'block' }, display: { xs: 'none', md: 'block' },
}} }}
/> />
@@ -195,19 +326,22 @@ const MultiSelectToolbar = ({
</Box> </Box>
</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 <Box
sx={{ sx={{
display: 'flex',
alignItems: 'center',
gap: 1, gap: 1,
flexWrap: { // Stacked, the actions become an auto-fitting grid: every button
xs: 'wrap', // stretches to fill its cell, so the rows come out flush instead
sm: 'nowrap', // of trailing ragged whitespace. Above md they go back to a
}, // right-aligned row.
justifyContent: { display: { xs: 'grid', md: 'flex' },
xs: 'center', gridTemplateColumns: 'repeat(auto-fit, minmax(112px, 1fr))',
sm: 'flex-end', alignItems: 'center',
}, flexWrap: 'wrap',
justifyContent: 'flex-end',
}} }}
> >
<Button <Button
@@ -236,6 +370,91 @@ const MultiSelectToolbar = ({
/> />
)} )}
</Button> </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 <Button
size='sm' size='sm'
variant='soft' variant='soft'
@@ -262,6 +481,7 @@ const MultiSelectToolbar = ({
/> />
)} )}
</Button> </Button>
{onMoveToProject && ( {onMoveToProject && (
<> <>
<Button <Button
@@ -291,9 +511,10 @@ const MultiSelectToolbar = ({
placement='bottom-end' placement='bottom-end'
> >
<MenuItem <MenuItem
onClick={() => onClick={() => {
handleMoveToProject({ id: null, name: 'Default Project' }) closeProjectMenu()
} onMoveToProject({ id: null, name: 'Default Project' })
}}
> >
<ListItemDecorator> <ListItemDecorator>
{renderProjectAvatar(LABEL_COLORS[0].value, 'FolderOpen')} {renderProjectAvatar(LABEL_COLORS[0].value, 'FolderOpen')}
@@ -305,7 +526,10 @@ const MultiSelectToolbar = ({
{projects.map(project => ( {projects.map(project => (
<MenuItem <MenuItem
key={project.id} key={project.id}
onClick={() => handleMoveToProject(project)} onClick={() => {
closeProjectMenu()
onMoveToProject(project)
}}
> >
<ListItemDecorator> <ListItemDecorator>
{renderProjectAvatar(project.color, project.icon)} {renderProjectAvatar(project.color, project.icon)}
@@ -372,8 +596,210 @@ const MultiSelectToolbar = ({
/> />
)} )}
</Button> </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>
</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> </Box>
) )
} }

View File

@@ -43,7 +43,7 @@ const SearchBar = ({
startDecorator={ startDecorator={
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75 }}> <Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75 }}>
<SearchRounded sx={{ fontSize: 18, color: 'text.secondary' }} /> <SearchRounded sx={{ fontSize: 18, color: 'text.secondary' }} />
<KeyboardShortcutHint shortcut='F' show={showKeyboardShortcuts} /> <KeyboardShortcutHint shortcut='K' show={showKeyboardShortcuts} />
</Box> </Box>
} }
endDecorator={ endDecorator={

View File

@@ -1,4 +1,5 @@
import { useQueryClient } from '@tanstack/react-query' import { useQueryClient } from '@tanstack/react-query'
import moment from 'moment'
import { useCallback } from 'react' import { useCallback } from 'react'
import { import {
useArchiveChore, useArchiveChore,
@@ -16,6 +17,7 @@ import {
SkipChore, SkipChore,
UndoChoreAction, UndoChoreAction,
UpdateChoreAssignee, UpdateChoreAssignee,
UpdateChorePriority,
UpdateDueDate, UpdateDueDate,
} from '../../../utils/Fetcher' } from '../../../utils/Fetcher'
import { offlineDB } from '../../../utils/OfflineDB' import { offlineDB } from '../../../utils/OfflineDB'
@@ -29,6 +31,28 @@ const isNetworkError = err =>
err instanceof TypeError && err instanceof TypeError &&
err.message === 'Failed to fetch' 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 = ({ export const useChoreActions = ({
chores, chores,
filteredChores, filteredChores,
@@ -869,346 +893,392 @@ export const useChoreActions = ({
[showSuccess, showError, closeModal], [showSuccess, showError, closeModal],
) )
const handleBulkComplete = useCallback(async () => { // ── bulk operations ────────────────────────────────────────────────────────
const selectedData = getSelectedChoresData(chores) //
if (selectedData.length === 0) return // 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({ const patchLocalChores = useCallback(
isOpen: true, (ids, patch) => {
title: t('actions.bulk.completeTitle'), const idSet = new Set(ids)
confirmText: t('list.complete'), const apply = list =>
cancelText: t('choreView.cancel'), list.map(chore =>
message: `Mark ${selectedData.length} task${selectedData.length > 1 ? 's' : ''} as completed?`, idSet.has(chore.id)
onClose: async isConfirmed => { ? {
if (isConfirmed === true) { ...chore,
try { ...(typeof patch === 'function' ? patch(chore) : patch),
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)
} }
} : chore,
)
setChores(apply)
setFilteredChores(apply)
},
[setChores, setFilteredChores],
)
if (completedTasks.length > 0) { const removeLocalChores = useCallback(
showSuccess({ ids => {
title: t('actions.bulk.completedTitle'), const idSet = new Set(ids)
message: `Successfully completed ${completedTasks.length} task${completedTasks.length > 1 ? 's' : ''}.`, const drop = list => list.filter(chore => !idSet.has(chore.id))
}) setChores(drop)
} setFilteredChores(drop)
},
[setChores, setFilteredChores],
)
if (failedTasks.length > 0) { const runBulk = useCallback(
showError({ async ({
title: t('archived.someFailedTitle'), buildUndo,
message: `${failedTasks.length} task${failedTasks.length > 1 ? 's' : ''} could not be completed.`, // { 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}.`
// t() key output for the "the whole batch blew up" toast title.
failedTitle,
failureVerb,
onSucceeded,
perChore,
successTitle,
// "Completed", "Rescheduled", … — reads as `${verb} 3 tasks.`
successVerb,
targets,
}) => {
if (!targets || targets.length === 0) return
refetchChores() const execute = async () => {
clearSelection() const succeeded = []
} catch (error) { const failed = []
showError({
title: t('actions.bulk.completeFailedTitle'),
message: t('archived.unexpectedError'),
})
}
}
setConfirmModelConfig({})
},
})
}, [
getSelectedChoresData,
impersonatedUser,
showSuccess,
showError,
refetchChores,
clearSelection,
setConfirmModelConfig,
])
const handleBulkArchive = useCallback(async () => { for (const chore of targets) {
const selectedData = getSelectedChoresData(chores)
if (selectedData.length === 0) return
setConfirmModelConfig({
isOpen: true,
title: t('actions.bulk.archiveTitle'),
confirmText: t('actionMenu.archive'),
cancelText: t('choreView.cancel'),
message: `Archive ${selectedData.length} task${selectedData.length > 1 ? 's' : ''}?`,
onClose: async isConfirmed => {
if (isConfirmed === true) {
try { try {
const archivedTasks = [] await perChore(chore)
const failedTasks = [] succeeded.push(chore)
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: t('actions.bulk.archivedTitle'),
message: `Successfully archived ${archivedTasks.length} task${archivedTasks.length > 1 ? 's' : ''}.`,
})
}
if (failedTasks.length > 0) {
showError({
title: t('archived.someFailedTitle'),
message: `${failedTasks.length} task${failedTasks.length > 1 ? 's' : ''} could not be archived.`,
})
}
refetchChores()
clearSelection()
} catch (error) { } catch (error) {
showError({ failed.push(chore)
title: t('actions.bulk.archiveFailedTitle'),
message: t('archived.unexpectedError'),
})
} }
} }
setConfirmModelConfig({})
},
})
}, [
getSelectedChoresData,
archiveChore,
setChores,
setFilteredChores,
showSuccess,
showError,
refetchChores,
clearSelection,
setConfirmModelConfig,
])
const handleBulkDelete = useCallback(async () => { if (succeeded.length > 0) {
const selectedData = getSelectedChoresData(chores) onSucceeded?.(succeeded)
if (selectedData.length === 0) return showSuccess({
title: successTitle,
setConfirmModelConfig({ message: `${successVerb} ${taskCount(succeeded.length)}.`,
isOpen: true, ...(buildUndo ? { undoAction: buildUndo(succeeded) } : {}),
title: t('actions.bulk.deleteTitle'), })
confirmText: t('archived.delete'),
cancelText: t('choreView.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: t('archived.deletedBulkTitle'),
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: t('archived.someFailedTitle'),
message: `${failedTasks.length} task${failedTasks.length > 1 ? 's' : ''} could not be deleted.`,
})
}
refetchChores()
clearSelection()
} catch (error) {
showError({
title: t('archived.bulkDeleteFailTitle'),
message: t('archived.unexpectedError'),
})
}
} }
setConfirmModelConfig({})
},
})
}, [
getSelectedChoresData,
chores,
filteredChores,
setChores,
setFilteredChores,
showSuccess,
showError,
refetchChores,
clearSelection,
setConfirmModelConfig,
])
const handleBulkSkip = useCallback(async () => { if (failed.length > 0) {
const selectedData = getSelectedChoresData(chores) showError({
if (selectedData.length === 0) return title: t('archived.someFailedTitle'),
message: `${taskCount(failed.length)} could not be ${failureVerb}.`,
setConfirmModelConfig({ })
isOpen: true,
title: t('actions.bulk.skipTitle'),
confirmText: t('multiToolbar.skip'),
cancelText: t('choreView.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: t('actions.bulk.skippedTitle'),
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: t('choreView.undoSuccessful'),
message: `Undo skip for ${skippedTasks.length} task${skippedTasks.length > 1 ? 's' : ''}.`,
})
} catch (error) {
showError({
title: t('choreView.undoFailed'),
message: t('choreView.undoFailedMessage'),
})
}
},
})
}
if (failedTasks.length > 0) {
showError({
title: t('archived.someFailedTitle'),
message: `${failedTasks.length > 1 ? 's' : ''} could not be skipped.`,
})
}
refetchChores()
clearSelection()
} catch (error) {
showError({
title: t('actions.bulk.skipFailedTitle'),
message: t('archived.unexpectedError'),
})
}
} }
setConfirmModelConfig({})
},
})
}, [
getSelectedChoresData,
showSuccess,
showError,
showUndo,
refetchChores,
clearSelection,
setConfirmModelConfig,
])
const handleBulkMoveToProject = useCallback( refetchChores()
async project => { clearSelection()
const selectedData = getSelectedChoresData(chores) }
if (selectedData.length === 0) return
const projectId = project?.id ?? null if (!confirm) {
const movedTasks = []
const failedTasks = []
for (const chore of selectedData) {
try { try {
const response = await SaveChore({ ...chore, projectId }) await execute()
if (response.ok) {
movedTasks.push(chore)
} else {
failedTasks.push(chore)
}
} catch (error) { } catch (error) {
failedTasks.push(chore) showError({
title: failedTitle || `Bulk ${failureVerb} failed`,
message: t('archived.unexpectedError'),
})
} }
return
} }
if (movedTasks.length > 0) { setConfirmModelConfig({
const movedIds = new Set(movedTasks.map(c => c.id)) isOpen: true,
const applyMove = list => cancelText: t('choreView.cancel'),
list.map(c => (movedIds.has(c.id) ? { ...c, projectId } : c)) ...confirm,
setChores(applyMove) onClose: async isConfirmed => {
setFilteredChores(applyMove) setConfirmModelConfig({})
showSuccess({ if (isConfirmed !== true) return
title: 'Tasks Moved', try {
message: `Moved ${movedTasks.length} task${movedTasks.length > 1 ? 's' : ''} to ${project?.name || 'Default Project'}.`, await execute()
}) } catch (error) {
} showError({
if (failedTasks.length > 0) { title: failedTitle || `Bulk ${failureVerb} failed`,
showError({ message: t('archived.unexpectedError'),
title: t('archived.someFailedTitle'), })
message: `${failedTasks.length} task${failedTasks.length > 1 ? 's' : ''} could not be moved.`, }
}) },
} })
refetchChores()
clearSelection()
}, },
[ [
chores,
getSelectedChoresData,
setChores,
setFilteredChores,
showSuccess, showSuccess,
showError, showError,
refetchChores, refetchChores,
clearSelection, clearSelection,
setConfirmModelConfig,
],
)
const handleBulkComplete = useCallback(async () => {
const targets = getSelectedChoresData(chores)
runBulk({
targets,
confirm: {
title: t('actions.bulk.completeTitle'),
confirmText: t('list.complete'),
message: `Mark ${taskCount(targets.length)} as completed?`,
},
perChore: chore =>
expectOk(
MarkChoreComplete(
chore.id,
impersonatedUser ? { completedBy: impersonatedUser.userId } : null,
null,
null,
),
),
successTitle: t('actions.bulk.completedTitle'),
successVerb: 'Completed',
failureVerb: 'completed',
failedTitle: t('actions.bulk.completeFailedTitle'),
})
}, [getSelectedChoresData, chores, impersonatedUser, runBulk])
const handleBulkSkip = useCallback(async () => {
const targets = getSelectedChoresData(chores)
runBulk({
targets,
confirm: {
title: t('actions.bulk.skipTitle'),
confirmText: t('multiToolbar.skip'),
message: `Skip ${taskCount(targets.length)} to next due date?`,
},
perChore: chore => expectOk(SkipChore(chore.id)),
successTitle: t('actions.bulk.skippedTitle'),
successVerb: 'Skipped',
failureVerb: 'skipped',
failedTitle: t('actions.bulk.skipFailedTitle'),
buildUndo: succeeded => async () => {
try {
for (const chore of succeeded) {
await UndoChoreAction(chore.id)
}
queryClient.invalidateQueries(['chores'])
showUndo({
title: t('choreView.undoSuccessful'),
message: `Undo skip for ${taskCount(succeeded.length)}.`,
})
} catch (error) {
showError({
title: t('choreView.undoFailed'),
message: t('choreView.undoFailedMessage'),
})
}
},
})
}, [getSelectedChoresData, chores, runBulk, queryClient, showUndo, showError])
const handleBulkArchive = useCallback(async () => {
const targets = getSelectedChoresData(chores)
runBulk({
targets,
confirm: {
title: t('actions.bulk.archiveTitle'),
confirmText: t('actionMenu.archive'),
message: `Archive ${taskCount(targets.length)}?`,
},
perChore: chore =>
new Promise((resolve, reject) => {
archiveChore.mutate(chore.id, {
onSuccess: resolve,
onError: reject,
})
}),
successTitle: t('actions.bulk.archivedTitle'),
successVerb: 'Archived',
failureVerb: 'archived',
failedTitle: t('actions.bulk.archiveFailedTitle'),
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: t('actions.bulk.deleteTitle'),
confirmText: t('archived.delete'),
message: `Delete ${taskCount(targets.length)}?\n\nThis action cannot be undone.`,
},
perChore: chore => expectOk(DeleteChore(chore.id)),
successTitle: t('archived.deletedBulkTitle'),
successVerb: 'Deleted',
failureVerb: 'deleted',
failedTitle: t('archived.bulkDeleteFailTitle'),
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,
], ],
) )
@@ -1224,5 +1294,9 @@ export const useChoreActions = ({
handleBulkDelete, handleBulkDelete,
handleBulkSkip, handleBulkSkip,
handleBulkMoveToProject, handleBulkMoveToProject,
handleBulkDueDate,
handleBulkAssignee,
handleBulkPriority,
handleBulkLabels,
} }
} }

View File

@@ -1,11 +1,13 @@
import Fuse from 'fuse.js' import Fuse from 'fuse.js'
import { useCallback, useMemo, useState } from 'react' import { useCallback, useMemo, useState } from 'react'
import { ChoreFilters, filterByProject } from '../../../utils/Chores' import { ChoreFilters, filterByProject } from '../../../utils/Chores'
import { stripHtml } from '../../../utils/Helpers'
export const useChoreFilters = ({ export const useChoreFilters = ({
chores, chores,
selectedProject,
impersonatedUser, impersonatedUser,
selectedProject,
userProfile, userProfile,
}) => { }) => {
const [searchTerm, setSearchTerm] = useState('') const [searchTerm, setSearchTerm] = useState('')
@@ -30,9 +32,14 @@ export const useChoreFilters = ({
const searchableChores = chores.map(chore => ({ const searchableChores = chores.map(chore => ({
...chore, ...chore,
raw_label: chore.labelsV2?.map(label => label.name).join(' '), raw_label: chore.labelsV2?.map(label => label.name).join(' '),
raw_description: stripHtml(chore.description),
})) }))
return new Fuse(searchableChores, { return new Fuse(searchableChores, {
keys: ['name', 'raw_label'], keys: [
{ name: 'name', weight: 0.6 },
{ name: 'raw_label', weight: 0.25 },
{ name: 'raw_description', weight: 0.15 },
],
includeScore: true, includeScore: true,
isCaseSensitive: false, isCaseSensitive: false,
findAllMatches: true, findAllMatches: true,

View File

@@ -25,17 +25,16 @@ export const useKeyboardShortcuts = ({
const isHoldingCmdOrCtrl = event.ctrlKey || event.metaKey const isHoldingCmdOrCtrl = event.ctrlKey || event.metaKey
if (isHoldingCmdOrCtrl && event.key === 'k') { // Cmd/Ctrl + J opens the quick-add modal, + Shift opens the full create
// page. Cmd/Ctrl + K is reserved for global search and handled by
// GlobalSearchContext.
if (isHoldingCmdOrCtrl && event.key.toLowerCase() === 'j') {
event.preventDefault() event.preventDefault()
handlers.onOpenTaskModal() if (event.shiftKey) {
return handlers.onNavigateToCreate()
} } else {
handlers.onOpenTaskModal()
if (addTaskModalOpen) return }
if (isHoldingCmdOrCtrl && event.key === 'j') {
event.preventDefault()
handlers.onNavigateToCreate()
return return
} else if (isHoldingCmdOrCtrl && event.key === 'x') { } else if (isHoldingCmdOrCtrl && event.key === 'x') {
event.preventDefault() event.preventDefault()

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 = () => { export const useMultiSelect = () => {
const [isMultiSelectMode, setIsMultiSelectMode] = useState(false) const [isMultiSelectMode, setIsMultiSelectMode] = useState(false)
const [selectedChores, setSelectedChores] = useState(new Set()) 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 toggleMultiSelectMode = useCallback(() => {
const newMode = !isMultiSelectMode setIsMultiSelectMode(prev => {
setIsMultiSelectMode(newMode) if (!prev) setSelectedChores(new Set())
return !prev
})
lastSelectedId.current = null
}, [])
if (newMode) { const toggleChoreSelection = useCallback(choreId => {
setSelectedChores(new Set()) setSelectedChores(prev => {
} const next = new Set(prev)
}, [isMultiSelectMode]) if (next.has(choreId)) {
next.delete(choreId)
const toggleChoreSelection = useCallback(
choreId => {
const newSelection = new Set(selectedChores)
if (newSelection.has(choreId)) {
newSelection.delete(choreId)
} else { } else {
newSelection.add(choreId) next.add(choreId)
} }
setSelectedChores(newSelection) return next
}, })
[selectedChores], lastSelectedId.current = choreId
) }, [])
// Entry point for press-and-hold on a task card: turn multi-select on (if it // Entry point for press-and-hold on a task card: turn multi-select on (if it
// isn't already) with that task selected. // isn't already) with that task selected.
@@ -33,6 +47,7 @@ export const useMultiSelect = () => {
if (!isMultiSelectMode) { if (!isMultiSelectMode) {
setIsMultiSelectMode(true) setIsMultiSelectMode(true)
setSelectedChores(new Set([choreId])) setSelectedChores(new Set([choreId]))
lastSelectedId.current = choreId
return return
} }
toggleChoreSelection(choreId) toggleChoreSelection(choreId)
@@ -40,6 +55,39 @@ export const useMultiSelect = () => {
[isMultiSelectMode, toggleChoreSelection], [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( const selectAllVisibleChores = useCallback(
(visibleChores, choreSections = [], openChoreSections = {}) => { (visibleChores, choreSections = [], openChoreSections = {}) => {
let choresToSelect = [] let choresToSelect = []
@@ -65,8 +113,7 @@ export const useMultiSelect = () => {
} }
if (choresToSelect.length > 0) { if (choresToSelect.length > 0) {
const allIds = new Set(choresToSelect.map(chore => chore.id)) setSelectedChores(new Set(choresToSelect.map(chore => chore.id)))
setSelectedChores(allIds)
} }
return choresToSelect.length return choresToSelect.length
@@ -75,6 +122,7 @@ export const useMultiSelect = () => {
) )
const clearSelection = useCallback(() => { const clearSelection = useCallback(() => {
lastSelectedId.current = null
if (selectedChores.size === 0) { if (selectedChores.size === 0) {
setIsMultiSelectMode(false) setIsMultiSelectMode(false)
return return
@@ -84,22 +132,79 @@ export const useMultiSelect = () => {
const getSelectedChoresData = useCallback( const getSelectedChoresData = useCallback(
allChores => { allChores => {
if (selectedChores.size === 0) return []
const byId = new Map(allChores.map(chore => [chore.id, chore]))
return Array.from(selectedChores) return Array.from(selectedChores)
.map(id => allChores.find(chore => chore.id === id)) .map(id => byId.get(id))
.filter(Boolean) .filter(Boolean)
}, },
[selectedChores], [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 { return {
isMultiSelectMode, isMultiSelectMode,
selectedChores, selectedChores,
toggleMultiSelectMode, toggleMultiSelectMode,
toggleChoreSelection, toggleChoreSelection,
selectChoreRange,
enterMultiSelectWithChore, enterMultiSelectWithChore,
selectAllVisibleChores, selectAllVisibleChores,
clearSelection, clearSelection,
getSelectedChoresData, getSelectedChoresData,
getSelectionSummary,
setIsMultiSelectMode, setIsMultiSelectMode,
setSelectedChores, setSelectedChores,
} }

View File

@@ -1,11 +1,23 @@
import '@meauxt/react-swipeable-list/dist/styles.css'
import { import {
Type as ListType,
SwipeableList, SwipeableList,
SwipeableListItem, SwipeableListItem,
SwipeAction, SwipeAction,
TrailingActions, TrailingActions,
Type as ListType,
} from '@meauxt/react-swipeable-list' } from '@meauxt/react-swipeable-list'
import '@meauxt/react-swipeable-list/dist/styles.css' import {
Add,
Close,
FilterAlt,
MoreVert,
Search,
SearchOff,
Star,
StarBorder,
Task,
} from '@mui/icons-material'
import DeleteIcon from '@mui/icons-material/Delete' import DeleteIcon from '@mui/icons-material/Delete'
import EditIcon from '@mui/icons-material/Edit' import EditIcon from '@mui/icons-material/Edit'
import { import {
@@ -15,26 +27,20 @@ import {
CircularProgress, CircularProgress,
Container, Container,
IconButton, IconButton,
Input,
Stack, Stack,
Typography, Typography,
} from '@mui/joy' } from '@mui/joy'
import { useEffect, useMemo, useState } from 'react' import Fuse from 'fuse.js'
import { useNavigate } from 'react-router-dom' import { useEffect, useMemo, useRef, useState } from 'react'
import { useNavigate, useSearchParams } from 'react-router-dom'
import {
Add,
FilterAlt,
MoreVert,
Star,
StarBorder,
Task,
} from '@mui/icons-material'
import EmptyState from '../../components/common/EmptyState' import EmptyState from '../../components/common/EmptyState'
import SortAndFilterMenu from '../../components/common/SortAndFilterMenu'
import { useChores } from '../../queries/ChoreQueries' import { useChores } from '../../queries/ChoreQueries'
import { useCircleMembers, useUserProfile } from '../../queries/UserQueries' import { useCircleMembers, useUserProfile } from '../../queries/UserQueries'
import { getFilterCount, getFilterOverdueCount } from '../../utils/FilterEngine' import { getFilterCount, getFilterOverdueCount } from '../../utils/FilterEngine'
import { getSafeBottomStyles } from '../../utils/SafeAreaUtils' import { getSafeBottomStyles } from '../../utils/SafeAreaUtils'
import { useLabels } from '../Labels/LabelQueries' import { useLabels } from '../Labels/LabelQueries'
import AdvancedFilterBuilder from '../Modals/Inputs/AdvancedFilterBuilder' import AdvancedFilterBuilder from '../Modals/Inputs/AdvancedFilterBuilder'
import ConfirmationModal from '../Modals/Inputs/ConfirmationModal' import ConfirmationModal from '../Modals/Inputs/ConfirmationModal'
@@ -50,9 +56,9 @@ import { useTranslation } from 'react-i18next'
const FilterCardContent = ({ const FilterCardContent = ({
filter, filter,
taskCount = 0,
overdueCount = 0,
onToggleActions, onToggleActions,
overdueCount = 0,
taskCount = 0,
}) => { }) => {
// Get condition labels for display // Get condition labels for display
const getConditionSummary = () => { const getConditionSummary = () => {
@@ -248,6 +254,7 @@ const FilterCardContent = ({
const FilterView = () => { const FilterView = () => {
const { t } = useTranslation('filters') const { t } = useTranslation('filters')
const navigate = useNavigate() const navigate = useNavigate()
const [searchParams, setSearchParams] = useSearchParams()
const { data: userProfile } = useUserProfile() const { data: userProfile } = useUserProfile()
const { data: chores = { res: [] } } = useChores(false) const { data: chores = { res: [] } } = useChores(false)
const { data: labels = [] } = useLabels() const { data: labels = [] } = useLabels()
@@ -267,6 +274,21 @@ const FilterView = () => {
const [editingFilter, setEditingFilter] = useState(null) const [editingFilter, setEditingFilter] = useState(null)
const [confirmationModel, setConfirmationModel] = useState({}) const [confirmationModel, setConfirmationModel] = useState({})
const [showMoreInfoId, setShowMoreInfoId] = useState(null) const [showMoreInfoId, setShowMoreInfoId] = useState(null)
const [searchTerm, setSearchTerm] = useState('')
const [sortBy, setSortBy] = useState(
() => localStorage.getItem('filtersSortBy') || 'smart',
)
const [sortDirection, setSortDirection] = useState(
() => localStorage.getItem('filtersSortDirection') || 'asc',
)
const [pinnedFilter, setPinnedFilter] = useState('all')
const searchInputRef = useRef(null)
useEffect(() => {
localStorage.setItem('filtersSortBy', sortBy)
localStorage.setItem('filtersSortDirection', sortDirection)
}, [sortBy, sortDirection])
// Sort filters: pinned first, then by usage count, then by last used // Sort filters: pinned first, then by usage count, then by last used
const savedFilters = useMemo(() => { const savedFilters = useMemo(() => {
return [...filtersData].sort((a, b) => { return [...filtersData].sort((a, b) => {
@@ -283,6 +305,71 @@ const FilterView = () => {
}) })
}, [filtersData]) }, [filtersData])
const visibleFilters = useMemo(() => {
if (pinnedFilter === 'pinned') {
return savedFilters.filter(filter => filter.isPinned)
}
if (pinnedFilter === 'unpinned') {
return savedFilters.filter(filter => !filter.isPinned)
}
return savedFilters
}, [pinnedFilter, savedFilters])
const fuse = useMemo(
() =>
new Fuse(visibleFilters, {
keys: ['name', 'description'],
includeScore: true,
isCaseSensitive: false,
findAllMatches: true,
}),
[visibleFilters],
)
const filteredFilters = useMemo(() => {
const matched = searchTerm
? fuse.search(searchTerm).map(result => result.item)
: visibleFilters
const direction = sortDirection === 'desc' ? -1 : 1
// "Smart" keeps the pinned-then-usage order the list already arrives in.
if (sortBy === 'smart') {
return direction === -1 ? [...matched].reverse() : matched
}
return [...matched].sort((a, b) => {
switch (sortBy) {
case 'name':
return direction * (a.name || '').localeCompare(b.name || '')
case 'usage':
return direction * ((a.usageCount || 0) - (b.usageCount || 0))
case 'lastUsed': {
const aUsed = new Date(a.lastUsedAt || 0).getTime()
const bUsed = new Date(b.lastUsedAt || 0).getTime()
return direction * (aUsed - bUsed)
}
case 'created': {
const aCreated = new Date(a.createdAt || 0).getTime()
const bCreated = new Date(b.createdAt || 0).getTime()
return direction * (aCreated - bCreated)
}
default:
return 0
}
})
}, [fuse, searchTerm, visibleFilters, sortBy, sortDirection])
const handleSearchChange = e => {
setSearchTerm(e.target.value)
setShowMoreInfoId(null)
}
const handleSearchClose = () => {
setSearchTerm('')
searchInputRef.current?.blur()
}
// Calculate task counts for each filter // Calculate task counts for each filter
useEffect(() => { useEffect(() => {
if (chores && chores.res && savedFilters.length > 0) { if (chores && chores.res && savedFilters.length > 0) {
@@ -323,6 +410,21 @@ const FilterView = () => {
setShowAdvancedFilterBuilder(true) setShowAdvancedFilterBuilder(true)
} }
// ?create=1 lets other surfaces (global search quick actions) land here with
// the filter builder already open.
useEffect(() => {
if (searchParams.get('create') !== '1') return
setEditingFilter(null)
setShowAdvancedFilterBuilder(true)
setSearchParams(
params => {
params.delete('create')
return params
},
{ replace: true },
)
}, [searchParams, setSearchParams])
const handleEditFilter = filter => { const handleEditFilter = filter => {
setEditingFilter(filter) setEditingFilter(filter)
setShowAdvancedFilterBuilder(true) setShowAdvancedFilterBuilder(true)
@@ -414,6 +516,68 @@ const FilterView = () => {
</Stack> </Stack>
</Box> </Box>
{savedFilters.length > 0 && (
<Box
sx={{ px: 2, mb: 2, display: 'flex', alignItems: 'center', gap: 1 }}
>
<Input
slotProps={{ input: { ref: searchInputRef } }}
placeholder='Search filters'
value={searchTerm}
fullWidth
sx={{
borderRadius: 24,
height: 24,
borderColor: 'text.disabled',
padding: 1,
}}
onChange={handleSearchChange}
startDecorator={<Search />}
endDecorator={
searchTerm && (
<IconButton
variant='plain'
size='sm'
onClick={handleSearchClose}
sx={{ borderRadius: '50%' }}
>
<Close />
</IconButton>
)
}
/>
<SortAndFilterMenu
sortOptions={[
{ name: 'Smart', value: 'smart' },
{ name: 'Name', value: 'name' },
{ name: 'Usage count', value: 'usage' },
{ name: 'Last used', value: 'lastUsed' },
{ name: 'Created date', value: 'created' },
]}
selectedSort={sortBy}
onSortChange={setSortBy}
sortDirection={sortDirection}
onSortDirectionChange={setSortDirection}
filterTitle='Show'
filterOptions={[
{ name: 'All filters', value: 'all' },
{ name: 'Pinned', value: 'pinned' },
{ name: 'Not pinned', value: 'unpinned' },
]}
selectedFilter={pinnedFilter}
onFilterChange={value => {
setPinnedFilter(value)
setShowMoreInfoId(null)
}}
isActive={
pinnedFilter !== 'all' ||
sortBy !== 'smart' ||
sortDirection !== 'asc'
}
/>
</Box>
)}
<Box <Box
sx={{ sx={{
overflow: 'hidden', overflow: 'hidden',
@@ -431,9 +595,28 @@ const FilterView = () => {
onClick: handleAddFilter, onClick: handleAddFilter,
}} }}
/> />
) : filteredFilters.length === 0 ? (
<EmptyState
variant='no-results'
fullHeight
icon={<SearchOff />}
title='No filters match'
description={
searchTerm
? `No saved filter matches "${searchTerm}".`
: 'No saved filter matches the current filter.'
}
primaryAction={{
label: searchTerm ? 'Clear search' : 'Show all filters',
onClick: () => {
handleSearchClose()
setPinnedFilter('all')
},
}}
/>
) : ( ) : (
<SwipeableList type={ListType.IOS} fullSwipe={false}> <SwipeableList type={ListType.IOS} fullSwipe={false}>
{savedFilters.map(filter => { {filteredFilters.map(filter => {
return ( return (
<SwipeableListItem <SwipeableListItem
swipeActionOpen={ swipeActionOpen={

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

@@ -1,3 +1,20 @@
import '@meauxt/react-swipeable-list/dist/styles.css'
import {
SwipeableList,
SwipeableListItem,
SwipeAction,
TrailingActions,
Type as ListType,
} from '@meauxt/react-swipeable-list'
import {
Add,
Close,
MoreVert,
Search,
SearchOff,
Style,
} from '@mui/icons-material'
import DeleteIcon from '@mui/icons-material/Delete' import DeleteIcon from '@mui/icons-material/Delete'
import EditIcon from '@mui/icons-material/Edit' import EditIcon from '@mui/icons-material/Edit'
import { import {
@@ -7,32 +24,27 @@ import {
CircularProgress, CircularProgress,
Container, Container,
IconButton, IconButton,
Input,
Stack, Stack,
Typography, Typography,
} from '@mui/joy' } from '@mui/joy'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import LabelModal from '../Modals/Inputs/LabelModal'
import {
Type as ListType,
SwipeableList,
SwipeableListItem,
SwipeAction,
TrailingActions,
} from '@meauxt/react-swipeable-list'
import '@meauxt/react-swipeable-list/dist/styles.css'
import { Add, MoreVert, Style } from '@mui/icons-material'
import EmptyState from '../../components/common/EmptyState'
import { useQueryClient } from '@tanstack/react-query' import { useQueryClient } from '@tanstack/react-query'
import Fuse from 'fuse.js'
import { useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useNavigate, useSearchParams } from 'react-router-dom'
import EmptyState from '../../components/common/EmptyState'
import SortAndFilterMenu from '../../components/common/SortAndFilterMenu'
import { useUserProfile } from '../../queries/UserQueries' import { useUserProfile } from '../../queries/UserQueries'
import { getTextColorFromBackgroundColor } from '../../utils/Colors' import { getTextColorFromBackgroundColor } from '../../utils/Colors'
import { DeleteLabel } from '../../utils/Fetcher' import { DeleteLabel } from '../../utils/Fetcher'
import { getSafeBottomStyles } from '../../utils/SafeAreaUtils' import { getSafeBottomStyles } from '../../utils/SafeAreaUtils'
import ConfirmationModal from '../Modals/Inputs/ConfirmationModal' import ConfirmationModal from '../Modals/Inputs/ConfirmationModal'
import LabelModal from '../Modals/Inputs/LabelModal'
import { useLabels } from './LabelQueries' import { useLabels } from './LabelQueries'
const LabelCardContent = ({ label, currentUserId, onToggleActions }) => { const LabelCardContent = ({ currentUserId, label, onToggleActions }) => {
const { t } = useTranslation('labels') const { t } = useTranslation('labels')
// Check if current user owns this label // Check if current user owns this label
const isOwnedByCurrentUser = label.created_by === currentUserId const isOwnedByCurrentUser = label.created_by === currentUserId
@@ -152,8 +164,10 @@ const LabelCardContent = ({ label, currentUserId, onToggleActions }) => {
const LabelView = () => { const LabelView = () => {
const { t } = useTranslation('labels') const { t } = useTranslation('labels')
const { data: labels, isLabelsLoading, isError } = useLabels() const { data: labels, isError, isLabelsLoading } = useLabels()
const { data: userProfile } = useUserProfile() const { data: userProfile } = useUserProfile()
const navigate = useNavigate()
const [searchParams, setSearchParams] = useSearchParams()
const [userLabels, setUserLabels] = useState([]) const [userLabels, setUserLabels] = useState([])
const [modalOpen, setModalOpen] = useState(false) const [modalOpen, setModalOpen] = useState(false)
@@ -162,6 +176,70 @@ const LabelView = () => {
const queryClient = useQueryClient() const queryClient = useQueryClient()
const [confirmationModel, setConfirmationModel] = useState({}) const [confirmationModel, setConfirmationModel] = useState({})
const [showMoreInfoId, setShowMoreInfoId] = useState(null) const [showMoreInfoId, setShowMoreInfoId] = useState(null)
const [searchTerm, setSearchTerm] = useState('')
const [sortBy, setSortBy] = useState(
() => localStorage.getItem('labelsSortBy') || 'name',
)
const [sortDirection, setSortDirection] = useState(
() => localStorage.getItem('labelsSortDirection') || 'asc',
)
const [ownershipFilter, setOwnershipFilter] = useState('all')
const searchInputRef = useRef(null)
useEffect(() => {
localStorage.setItem('labelsSortBy', sortBy)
localStorage.setItem('labelsSortDirection', sortDirection)
}, [sortBy, sortDirection])
const visibleLabels = useMemo(() => {
if (ownershipFilter === 'mine') {
return userLabels.filter(label => label.created_by === userProfile?.id)
}
if (ownershipFilter === 'shared') {
return userLabels.filter(label => label.created_by !== userProfile?.id)
}
return userLabels
}, [ownershipFilter, userLabels, userProfile?.id])
const fuse = useMemo(
() =>
new Fuse(visibleLabels, {
keys: ['name'],
includeScore: true,
isCaseSensitive: false,
findAllMatches: true,
}),
[visibleLabels],
)
const filteredLabels = useMemo(() => {
const matched = searchTerm
? fuse.search(searchTerm).map(result => result.item)
: visibleLabels
const direction = sortDirection === 'desc' ? -1 : 1
return [...matched].sort((a, b) => {
switch (sortBy) {
case 'color':
return direction * (a.color || '').localeCompare(b.color || '')
case 'created':
return direction * ((a.id || 0) - (b.id || 0))
case 'name':
default:
return direction * (a.name || '').localeCompare(b.name || '')
}
})
}, [fuse, searchTerm, visibleLabels, sortBy, sortDirection])
const handleSearchChange = e => {
setSearchTerm(e.target.value.toLowerCase())
setShowMoreInfoId(null)
}
const handleSearchClose = () => {
setSearchTerm('')
searchInputRef.current?.blur()
}
const handleAddLabel = () => { const handleAddLabel = () => {
setCurrentLabel(null) setCurrentLabel(null)
@@ -214,6 +292,21 @@ const LabelView = () => {
} }
}, [labels]) }, [labels])
// ?create=1 lets other surfaces (global search quick actions) land here with
// the create modal already open.
useEffect(() => {
if (searchParams.get('create') !== '1') return
setCurrentLabel(null)
setModalOpen(true)
setSearchParams(
params => {
params.delete('create')
return params
},
{ replace: true },
)
}, [searchParams, setSearchParams])
if (isLabelsLoading) { if (isLabelsLoading) {
return ( return (
<Box <Box
@@ -251,6 +344,65 @@ const LabelView = () => {
</Typography> </Typography>
</Stack> </Stack>
</Box> </Box>
{userLabels.length > 0 && (
<Box
sx={{ px: 2, mb: 2, display: 'flex', alignItems: 'center', gap: 1 }}
>
<Input
slotProps={{ input: { ref: searchInputRef } }}
placeholder={t('search.placeholder')}
value={searchTerm}
fullWidth
sx={{
borderRadius: 24,
height: 24,
borderColor: 'text.disabled',
padding: 1,
}}
onChange={handleSearchChange}
startDecorator={<Search />}
endDecorator={
searchTerm && (
<IconButton
variant='plain'
size='sm'
onClick={handleSearchClose}
sx={{ borderRadius: '50%' }}
>
<Close />
</IconButton>
)
}
/>
<SortAndFilterMenu
sortOptions={[
{ name: 'Name', value: 'name' },
{ name: 'Color', value: 'color' },
{ name: 'Recently created', value: 'created' },
]}
selectedSort={sortBy}
onSortChange={setSortBy}
sortDirection={sortDirection}
onSortDirectionChange={setSortDirection}
filterTitle='Show'
filterOptions={[
{ name: 'All labels', value: 'all' },
{ name: 'Created by me', value: 'mine' },
{ name: 'Shared with me', value: 'shared' },
]}
selectedFilter={ownershipFilter}
onFilterChange={value => {
setOwnershipFilter(value)
setShowMoreInfoId(null)
}}
isActive={
ownershipFilter !== 'all' ||
sortBy !== 'name' ||
sortDirection !== 'asc'
}
/>
</Box>
)}
<Box <Box
sx={{ sx={{
overflow: 'hidden', overflow: 'hidden',
@@ -269,10 +421,31 @@ const LabelView = () => {
}} }}
/> />
)} )}
{userLabels.length > 0 && filteredLabels.length === 0 && (
<EmptyState
variant='no-results'
fullHeight
icon={<SearchOff />}
title={t('search.noResultsTitle')}
description={
searchTerm
? t('search.noResultsDescription', { searchTerm })
: t('search.noFilterResultsDescription')
}
primaryAction={{
label: searchTerm ? t('search.clear') : t('search.showAll'),
onClick: () => {
handleSearchClose()
setOwnershipFilter('all')
},
}}
/>
)}
<SwipeableList type={ListType.IOS} fullSwipe={false}> <SwipeableList type={ListType.IOS} fullSwipe={false}>
{userLabels.map(label => ( {filteredLabels.map(label => (
<SwipeableListItem <SwipeableListItem
key={label.id} key={label.id}
onClick={() => navigate(`/labels/${label.id}`)}
swipeActionOpen={showMoreInfoId === label.id ? 'trailing' : null} swipeActionOpen={showMoreInfoId === label.id ? 'trailing' : null}
trailingActions={ trailingActions={
<TrailingActions> <TrailingActions>

View File

@@ -329,7 +329,7 @@ const ErrorReportModal = ({ error, errorInfo, onClose, open }) => {
level='body-sm' level='body-sm'
sx={{ color: 'text.secondary', mt: 0.5 }} sx={{ color: 'text.secondary', mt: 0.5 }}
> >
Thanks this goes straight to the people who can fix it. Thanks! this goes straight to the people who can fix it.
</Typography> </Typography>
</Box> </Box>

View File

@@ -1,11 +1,23 @@
import { Browser } from '@capacitor/browser'
import { Capacitor } from '@capacitor/core'
import { ChevronRight, Gavel, PrivacyTip } from '@mui/icons-material' import { ChevronRight, Gavel, PrivacyTip } from '@mui/icons-material'
import { Button, Stack } from '@mui/joy' import { Button, Stack } from '@mui/joy'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { useNavigate } from 'react-router-dom'
import ModalActions from '../../components/common/ModalActions.jsx' import ModalActions from '../../components/common/ModalActions.jsx'
import { useResponsiveModal } from '../../hooks/useResponsiveModal.js' import { useResponsiveModal } from '../../hooks/useResponsiveModal.js'
const POLICY_BASE_URL = 'https://app.donetick.com'
// Native webviews swallow target="_blank"; route through the system browser.
const openUrl = async url => {
if (Capacitor.isNativePlatform()) {
await Browser.open({ url })
} else {
window.open(url, '_blank', 'noopener,noreferrer')
}
}
/** /**
* One-time notice that the Privacy Policy and Terms changed. The frame is * One-time notice that the Privacy Policy and Terms changed. The frame is
* generic and always points at the documents, so a future revision only needs * generic and always points at the documents, so a future revision only needs
@@ -14,16 +26,18 @@ import { useResponsiveModal } from '../../hooks/useResponsiveModal.js'
const PolicyUpdateModal = ({ onAcknowledge, onClose, open }) => { const PolicyUpdateModal = ({ onAcknowledge, onClose, open }) => {
const { t } = useTranslation() const { t } = useTranslation()
const { ResponsiveModal } = useResponsiveModal() const { ResponsiveModal } = useResponsiveModal()
const navigate = useNavigate()
const handleClose = () => { // Acknowledgement is the only way out: the backdrop, escape key, and close
// button are all disabled so the user must press "Got it".
const handleAcknowledge = () => {
onAcknowledge?.() onAcknowledge?.()
onClose() onClose()
} }
// Reading a document must not dismiss the notice; the modal is still waiting
// on an acknowledgement when the user returns from the browser.
const openDocument = path => { const openDocument = path => {
handleClose() openUrl(`${POLICY_BASE_URL}${path}`)
navigate(path)
} }
const documentButtonSx = { const documentButtonSx = {
@@ -36,7 +50,10 @@ const PolicyUpdateModal = ({ onAcknowledge, onClose, open }) => {
return ( return (
<ResponsiveModal <ResponsiveModal
open={open} open={open}
onClose={handleClose} onClose={handleAcknowledge}
closeOnBackdrop={false}
closeOnEscape={false}
showCloseButton={false}
size='sm' size='sm'
title={t('policyUpdate.title')} title={t('policyUpdate.title')}
description={t('policyUpdate.subtitle')} description={t('policyUpdate.subtitle')}
@@ -44,7 +61,7 @@ const PolicyUpdateModal = ({ onAcknowledge, onClose, open }) => {
<ModalActions <ModalActions
primary={{ primary={{
label: t('policyUpdate.acknowledge'), label: t('policyUpdate.acknowledge'),
onClick: handleClose, onClick: handleAcknowledge,
sx: { width: { xs: '100%', sm: 'auto' } }, sx: { width: { xs: '100%', sm: 'auto' } },
}} }}
/> />

View File

@@ -1,3 +1,20 @@
import '@meauxt/react-swipeable-list/dist/styles.css'
import {
SwipeableList,
SwipeableListItem,
SwipeAction,
TrailingActions,
Type as ListType,
} from '@meauxt/react-swipeable-list'
import {
Add,
Close,
MoreVert,
Search,
SearchOff,
Task,
} from '@mui/icons-material'
import DeleteIcon from '@mui/icons-material/Delete' import DeleteIcon from '@mui/icons-material/Delete'
import EditIcon from '@mui/icons-material/Edit' import EditIcon from '@mui/icons-material/Edit'
import { import {
@@ -7,24 +24,18 @@ import {
CircularProgress, CircularProgress,
Container, Container,
IconButton, IconButton,
Input,
Stack, Stack,
Typography, Typography,
} from '@mui/joy' } from '@mui/joy'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useNavigate } from 'react-router-dom'
import ProjectModal from '../Modals/Inputs/ProjectModal'
import {
Type as ListType,
SwipeableList,
SwipeableListItem,
SwipeAction,
TrailingActions,
} from '@meauxt/react-swipeable-list'
import '@meauxt/react-swipeable-list/dist/styles.css'
import { Add, MoreVert, Task } from '@mui/icons-material'
import { useQueryClient } from '@tanstack/react-query' import { useQueryClient } from '@tanstack/react-query'
import Fuse from 'fuse.js'
import { useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useNavigate, useSearchParams } from 'react-router-dom'
import EmptyState from '../../components/common/EmptyState'
import SortAndFilterMenu from '../../components/common/SortAndFilterMenu'
import { useChores } from '../../queries/ChoreQueries' import { useChores } from '../../queries/ChoreQueries'
import { useUserProfile } from '../../queries/UserQueries' import { useUserProfile } from '../../queries/UserQueries'
import { getTextColorFromBackgroundColor } from '../../utils/Colors' import { getTextColorFromBackgroundColor } from '../../utils/Colors'
@@ -33,13 +44,14 @@ import { getIconComponent } from '../../utils/ProjectIcons'
import { getSafeBottomStyles } from '../../utils/SafeAreaUtils' import { getSafeBottomStyles } from '../../utils/SafeAreaUtils'
import { useProjectFilter } from '../Chores/hooks/useProjectFilter' import { useProjectFilter } from '../Chores/hooks/useProjectFilter'
import ConfirmationModal from '../Modals/Inputs/ConfirmationModal' import ConfirmationModal from '../Modals/Inputs/ConfirmationModal'
import ProjectModal from '../Modals/Inputs/ProjectModal'
import { useProjects } from './ProjectQueries' import { useProjects } from './ProjectQueries'
const ProjectCardContent = ({ const ProjectCardContent = ({
project,
currentUserId, currentUserId,
taskCounts = {},
onCardClick, onCardClick,
onToggleActions, onToggleActions,
project,
taskCounts = {},
}) => { }) => {
const { t } = useTranslation('projects') const { t } = useTranslation('projects')
// Check if current user owns this project // Check if current user owns this project
@@ -221,7 +233,7 @@ const ProjectCardContent = ({
const ProjectView = () => { const ProjectView = () => {
const { t } = useTranslation('projects') const { t } = useTranslation('projects')
const { data: projects, isProjectsLoading, isError } = useProjects() const { data: projects, isError, isProjectsLoading } = useProjects()
const { data: userProfile } = useUserProfile() const { data: userProfile } = useUserProfile()
const { data: chores = { res: [] } } = useChores(false) // false to exclude archived const { data: chores = { res: [] } } = useChores(false) // false to exclude archived
const { data: projectsData = [], isLoading: projectsLoading } = useProjects() const { data: projectsData = [], isLoading: projectsLoading } = useProjects()
@@ -230,6 +242,7 @@ const ProjectView = () => {
!projectsLoading, !projectsLoading,
) )
const navigate = useNavigate() const navigate = useNavigate()
const [searchParams, setSearchParams] = useSearchParams()
const [userProjects, setUserProjects] = useState([]) const [userProjects, setUserProjects] = useState([])
const [modalOpen, setModalOpen] = useState(false) const [modalOpen, setModalOpen] = useState(false)
@@ -238,6 +251,83 @@ const ProjectView = () => {
const queryClient = useQueryClient() const queryClient = useQueryClient()
const [confirmationModel, setConfirmationModel] = useState({}) const [confirmationModel, setConfirmationModel] = useState({})
const [showMoreInfoId, setShowMoreInfoId] = useState(null) const [showMoreInfoId, setShowMoreInfoId] = useState(null)
const [searchTerm, setSearchTerm] = useState('')
const [sortBy, setSortBy] = useState(
() => localStorage.getItem('projectsSortBy') || 'name',
)
const [sortDirection, setSortDirection] = useState(
() => localStorage.getItem('projectsSortDirection') || 'asc',
)
const [ownershipFilter, setOwnershipFilter] = useState('all')
const searchInputRef = useRef(null)
useEffect(() => {
localStorage.setItem('projectsSortBy', sortBy)
localStorage.setItem('projectsSortDirection', sortDirection)
}, [sortBy, sortDirection])
const visibleProjects = useMemo(() => {
if (ownershipFilter === 'mine') {
return userProjects.filter(
project => project.created_by === userProfile?.id,
)
}
if (ownershipFilter === 'shared') {
return userProjects.filter(
project => project.created_by !== userProfile?.id,
)
}
return userProjects
}, [ownershipFilter, userProjects, userProfile?.id])
const fuse = useMemo(
() =>
new Fuse(visibleProjects, {
keys: ['name', 'description'],
includeScore: true,
isCaseSensitive: false,
findAllMatches: true,
}),
[visibleProjects],
)
const filteredProjects = useMemo(() => {
const matched = searchTerm
? fuse.search(searchTerm).map(result => result.item)
: visibleProjects
const direction = sortDirection === 'desc' ? -1 : 1
return [...matched].sort((a, b) => {
switch (sortBy) {
case 'tasks':
return direction * ((taskCounts[a.id] || 0) - (taskCounts[b.id] || 0))
case 'created':
return direction * ((a.id || 0) - (b.id || 0))
case 'name':
default:
return direction * (a.name || '').localeCompare(b.name || '')
}
})
}, [fuse, searchTerm, visibleProjects, sortBy, sortDirection, taskCounts])
// The default project is pinned above the list, so it is matched separately.
const showDefaultProject = useMemo(() => {
if (ownershipFilter === 'shared') return false
if (!searchTerm) return true
return t('chores:toolbar.defaultProject')
.toLowerCase()
.includes(searchTerm.toLowerCase())
}, [ownershipFilter, searchTerm, t])
const handleSearchChange = e => {
setSearchTerm(e.target.value)
setShowMoreInfoId(null)
}
const handleSearchClose = () => {
setSearchTerm('')
searchInputRef.current?.blur()
}
const handleAddProject = () => { const handleAddProject = () => {
setCurrentProject(null) setCurrentProject(null)
@@ -302,6 +392,21 @@ const ProjectView = () => {
} }
}, [projects]) }, [projects])
// ?create=1 lets other surfaces (global search quick actions) land here with
// the create modal already open.
useEffect(() => {
if (searchParams.get('create') !== '1') return
setCurrentProject(null)
setModalOpen(true)
setSearchParams(
params => {
params.delete('create')
return params
},
{ replace: true },
)
}, [searchParams, setSearchParams])
// Calculate real task counts from chores data // Calculate real task counts from chores data
useEffect(() => { useEffect(() => {
if (chores && chores.res) { if (chores && chores.res) {
@@ -371,36 +476,114 @@ const ProjectView = () => {
</Stack> </Stack>
</Box> </Box>
<Box sx={{ px: 2, mb: 2, display: 'flex', alignItems: 'center', gap: 1 }}>
<Input
slotProps={{ input: { ref: searchInputRef } }}
placeholder={t('search.placeholder')}
value={searchTerm}
fullWidth
sx={{
borderRadius: 24,
height: 24,
borderColor: 'text.disabled',
padding: 1,
}}
onChange={handleSearchChange}
startDecorator={<Search />}
endDecorator={
searchTerm && (
<IconButton
variant='plain'
size='sm'
onClick={handleSearchClose}
sx={{ borderRadius: '50%' }}
>
<Close />
</IconButton>
)
}
/>
<SortAndFilterMenu
sortOptions={[
{ name: 'Name', value: 'name' },
{ name: 'Task count', value: 'tasks' },
{ name: 'Recently created', value: 'created' },
]}
selectedSort={sortBy}
onSortChange={setSortBy}
sortDirection={sortDirection}
onSortDirectionChange={setSortDirection}
filterTitle='Show'
filterOptions={[
{ name: 'All projects', value: 'all' },
{ name: 'Created by me', value: 'mine' },
{ name: 'Shared with me', value: 'shared' },
]}
selectedFilter={ownershipFilter}
onFilterChange={value => {
setOwnershipFilter(value)
setShowMoreInfoId(null)
}}
isActive={
ownershipFilter !== 'all' ||
sortBy !== 'name' ||
sortDirection !== 'asc'
}
/>
</Box>
<Box <Box
sx={{ sx={{
overflow: 'hidden', overflow: 'hidden',
}} }}
> >
{!showDefaultProject && filteredProjects.length === 0 && (
<EmptyState
variant='no-results'
fullHeight
icon={<SearchOff />}
title={t('search.noResultsTitle')}
description={
searchTerm
? t('search.noResultsDescription', { searchTerm })
: t('search.noFilterResultsDescription')
}
primaryAction={{
label: searchTerm ? t('search.clear') : t('search.showAll'),
onClick: () => {
handleSearchClose()
setOwnershipFilter('all')
},
}}
/>
)}
{/* Default project - not swipeable */} {/* Default project - not swipeable */}
<ProjectCardContent {showDefaultProject && (
project={{ <ProjectCardContent
id: 'default', project={{
name: t('chores:toolbar.defaultProject'),
description: t('defaultDescription'),
icon: 'FolderOpen',
color: '#1976d2',
created_by: userProfile?.id,
}}
currentUserId={userProfile?.id}
taskCounts={{ default: taskCounts.default || 0 }}
onCardClick={() =>
handleCardClick({
id: 'default', id: 'default',
name: t('chores:toolbar.defaultProject'), name: t('chores:toolbar.defaultProject'),
description: t('defaultDescription'),
icon: 'FolderOpen', icon: 'FolderOpen',
color: '#1976d2', color: '#1976d2',
}) created_by: userProfile?.id,
} }}
/> currentUserId={userProfile?.id}
taskCounts={{ default: taskCounts.default || 0 }}
onCardClick={() =>
handleCardClick({
id: 'default',
name: t('chores:toolbar.defaultProject'),
icon: 'FolderOpen',
color: '#1976d2',
})
}
/>
)}
{/* User projects - swipeable */} {/* User projects - swipeable */}
<SwipeableList type={ListType.IOS} fullSwipe={false}> <SwipeableList type={ListType.IOS} fullSwipe={false}>
{userProjects.map(project => ( {filteredProjects.map(project => (
<SwipeableListItem <SwipeableListItem
onClick={() => handleCardClick(project)} onClick={() => handleCardClick(project)}
key={project.id} key={project.id}

View File

@@ -9,11 +9,14 @@ import {
} from '@meauxt/react-swipeable-list' } from '@meauxt/react-swipeable-list'
import { import {
Add, Add,
Close,
Delete, Delete,
Edit, Edit,
Flip, Flip,
MoreVert, MoreVert,
PlusOne, PlusOne,
Search,
SearchOff,
ToggleOff, ToggleOff,
ToggleOn, ToggleOn,
Widgets, Widgets,
@@ -24,14 +27,17 @@ import {
Chip, Chip,
Container, Container,
IconButton, IconButton,
Input,
Stack, Stack,
Typography, Typography,
} from '@mui/joy' } from '@mui/joy'
import { useEffect, useState } from 'react' import Fuse from 'fuse.js'
import { useEffect, useMemo, useRef, useState } from 'react'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { track } from '../../analytics' import { track } from '../../analytics'
import EmptyState from '../../components/common/EmptyState' import EmptyState from '../../components/common/EmptyState'
import SortAndFilterMenu from '../../components/common/SortAndFilterMenu'
import { useNotification } from '../../service/NotificationProvider' import { useNotification } from '../../service/NotificationProvider'
import { import {
CreateThing, CreateThing,
@@ -215,8 +221,78 @@ const ThingsView = () => {
const [createModalThing, setCreateModalThing] = useState(null) const [createModalThing, setCreateModalThing] = useState(null)
const [confirmModelConfig, setConfirmModelConfig] = useState({}) const [confirmModelConfig, setConfirmModelConfig] = useState({})
const [showMoreInfoId, setShowMoreInfoId] = useState(null) const [showMoreInfoId, setShowMoreInfoId] = useState(null)
const [searchTerm, setSearchTerm] = useState('')
const [sortBy, setSortBy] = useState(
() => localStorage.getItem('thingsSortBy') || 'name',
)
const [sortDirection, setSortDirection] = useState(
() => localStorage.getItem('thingsSortDirection') || 'asc',
)
const [typeFilter, setTypeFilter] = useState('all')
const searchInputRef = useRef(null)
const { showError, showNotification } = useNotification() const { showError, showNotification } = useNotification()
useEffect(() => {
localStorage.setItem('thingsSortBy', sortBy)
localStorage.setItem('thingsSortDirection', sortDirection)
}, [sortBy, sortDirection])
const visibleThings = useMemo(
() =>
typeFilter === 'all'
? things
: things.filter(thing => thing?.type === typeFilter),
[things, typeFilter],
)
const fuse = useMemo(
() =>
new Fuse(visibleThings, {
keys: ['name', 'state'],
includeScore: true,
isCaseSensitive: false,
findAllMatches: true,
}),
[visibleThings],
)
const filteredThings = useMemo(() => {
const matched = searchTerm
? fuse.search(searchTerm).map(result => result.item)
: visibleThings
const direction = sortDirection === 'desc' ? -1 : 1
return [...matched].sort((a, b) => {
switch (sortBy) {
case 'type':
return direction * (a.type || '').localeCompare(b.type || '')
case 'state':
return (
direction *
String(a.state ?? '').localeCompare(String(b.state ?? ''))
)
case 'updated': {
const aDate = new Date(a.updatedAt || a.updated_at || 0).getTime()
const bDate = new Date(b.updatedAt || b.updated_at || 0).getTime()
return direction * (aDate - bDate)
}
case 'name':
default:
return direction * (a.name || '').localeCompare(b.name || '')
}
})
}, [fuse, searchTerm, visibleThings, sortBy, sortDirection])
const handleSearchChange = e => {
setSearchTerm(e.target.value)
setShowMoreInfoId(null)
}
const handleSearchClose = () => {
setSearchTerm('')
searchInputRef.current?.blur()
}
useEffect(() => { useEffect(() => {
// fetch things // fetch things
GetThings().then(result => { GetThings().then(result => {
@@ -404,6 +480,67 @@ const ThingsView = () => {
</Typography> </Typography>
</Stack> </Stack>
</Box> </Box>
{things.length > 0 && (
<Box
sx={{ px: 2, mb: 2, display: 'flex', alignItems: 'center', gap: 1 }}
>
<Input
slotProps={{ input: { ref: searchInputRef } }}
placeholder='Search things'
value={searchTerm}
fullWidth
sx={{
borderRadius: 24,
height: 24,
borderColor: 'text.disabled',
padding: 1,
}}
onChange={handleSearchChange}
startDecorator={<Search />}
endDecorator={
searchTerm && (
<IconButton
variant='plain'
size='sm'
onClick={handleSearchClose}
sx={{ borderRadius: '50%' }}
>
<Close />
</IconButton>
)
}
/>
<SortAndFilterMenu
sortOptions={[
{ name: 'Name', value: 'name' },
{ name: 'Type', value: 'type' },
{ name: 'State', value: 'state' },
{ name: 'Last updated', value: 'updated' },
]}
selectedSort={sortBy}
onSortChange={setSortBy}
sortDirection={sortDirection}
onSortDirectionChange={setSortDirection}
filterTitle='Type'
filterOptions={[
{ name: 'All types', value: 'all' },
{ name: 'Text', value: 'text' },
{ name: 'Number', value: 'number' },
{ name: 'Boolean', value: 'boolean' },
]}
selectedFilter={typeFilter}
onFilterChange={value => {
setTypeFilter(value)
setShowMoreInfoId(null)
}}
isActive={
typeFilter !== 'all' ||
sortBy !== 'name' ||
sortDirection !== 'asc'
}
/>
</Box>
)}
<Box <Box
sx={{ sx={{
overflow: 'hidden', overflow: 'hidden',
@@ -425,8 +562,28 @@ const ThingsView = () => {
}} }}
/> />
)} )}
{things.length > 0 && filteredThings.length === 0 && (
<EmptyState
variant='no-results'
fullHeight
icon={<SearchOff />}
title='No things match'
description={
searchTerm
? `No thing matches "${searchTerm}".`
: 'No thing matches the current filter.'
}
primaryAction={{
label: searchTerm ? 'Clear search' : 'Show all things',
onClick: () => {
handleSearchClose()
setTypeFilter('all')
},
}}
/>
)}
<SwipeableList type={ListType.IOS} fullSwipe={false}> <SwipeableList type={ListType.IOS} fullSwipe={false}>
{things.map(thing => ( {filteredThings.map(thing => (
<SwipeableListItem <SwipeableListItem
onClick={() => navigate(`/things/${thing?.id}`)} onClick={() => navigate(`/things/${thing?.id}`)}
key={thing.id} key={thing.id}

View File

@@ -319,7 +319,7 @@ const AdvancedOptionsSection = ({
textColor='text.tertiary' textColor='text.tertiary'
sx={{ my: 0.5, fontStyle: 'italic' }} 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> </Typography>
)} )}
</Box> </Box>

View File

@@ -6,6 +6,7 @@ import {
Delete, Delete,
DriveFileMove, DriveFileMove,
Edit, Edit,
Flag,
ManageSearch, ManageSearch,
MoreTime, MoreTime,
MoreVert, MoreVert,
@@ -24,6 +25,8 @@ import {
} from '@mui/icons-material' } from '@mui/icons-material'
import { import {
Avatar, Avatar,
Button,
Chip,
Divider, Divider,
IconButton, IconButton,
List, List,
@@ -45,9 +48,21 @@ import LABEL_COLORS, {
getTextColorFromBackgroundColor, getTextColorFromBackgroundColor,
} from '../../utils/Colors' } from '../../utils/Colors'
import { isOfficialDonetickInstanceSync } from '../../utils/FeatureToggle' import { isOfficialDonetickInstanceSync } from '../../utils/FeatureToggle'
import Priorities from '../../utils/Priorities'
import { getIconComponent } from '../../utils/ProjectIcons' import { getIconComponent } from '../../utils/ProjectIcons'
import { useProjects } from '../Projects/ProjectQueries' import { useProjects } from '../Projects/ProjectQueries'
const NO_PRIORITY = { name: 'No priority', value: 0, color: 'neutral' }
// After hiding actions the caller does not support, the dividers around them
// would otherwise stack up or dangle at the edges of the list.
const collapseDividers = items =>
items.filter((item, index) => {
if (item.type !== 'divider') return true
if (index === 0 || index === items.length - 1) return false
return items[index - 1].type !== 'divider'
})
const ChoreActionMenu = ({ const ChoreActionMenu = ({
chore, chore,
onAction, onAction,
@@ -55,12 +70,15 @@ const ChoreActionMenu = ({
onCompleteWithPastDate, onCompleteWithPastDate,
onChangeAssignee, onChangeAssignee,
onChangeDueDate, onChangeDueDate,
onChangePriority,
onWriteNFC, onWriteNFC,
onNudge, onNudge,
onDelete, onDelete,
onOpen, onOpen,
onMouseEnter, onMouseEnter,
onMouseLeave, onMouseLeave,
hiddenActions = [],
trigger,
sx = {}, sx = {},
variant = 'soft', variant = 'soft',
}) => { }) => {
@@ -68,6 +86,7 @@ const ChoreActionMenu = ({
const [anchorEl, setAnchorEl] = React.useState(null) const [anchorEl, setAnchorEl] = React.useState(null)
const [isOfficialInstance, setIsOfficialInstance] = useState(false) const [isOfficialInstance, setIsOfficialInstance] = useState(false)
const [showProjectPicker, setShowProjectPicker] = useState(false) const [showProjectPicker, setShowProjectPicker] = useState(false)
const [showPriorityPicker, setShowPriorityPicker] = useState(false)
const menuRef = React.useRef(null) const menuRef = React.useRef(null)
const navigate = useNavigate() const navigate = useNavigate()
const { data: projects = [] } = useProjects() const { data: projects = [] } = useProjects()
@@ -119,6 +138,12 @@ const ChoreActionMenu = ({
const handleMenuClose = () => { const handleMenuClose = () => {
setAnchorEl(null) setAnchorEl(null)
setShowProjectPicker(false) setShowProjectPicker(false)
setShowPriorityPicker(false)
}
const handleChangePriority = priority => {
onChangePriority?.(priority)
handleMenuClose()
} }
const handleMoveToProject = project => { const handleMoveToProject = project => {
@@ -246,6 +271,9 @@ const ChoreActionMenu = ({
) )
} }
const currentPriority =
Priorities.find(p => p.value === chore?.priority) || null
// Shared action list, rendered as MenuItems on large screens and as a // Shared action list, rendered as MenuItems on large screens and as a
// ListItemButton list inside an AppModal sheet on small screens. // ListItemButton list inside an AppModal sheet on small screens.
const actionItems = [ const actionItems = [
@@ -310,6 +338,21 @@ const ChoreActionMenu = ({
handleMenuClose() handleMenuClose()
}, },
}, },
onChangePriority && {
key: 'priority',
icon: <Flag />,
label: 'Priority',
onClick: () => setShowPriorityPicker(true),
endDecorator: (
<Chip
size='sm'
variant='soft'
color={currentPriority?.color || 'neutral'}
>
{currentPriority?.name.trim() || 'None'}
</Chip>
),
},
{ {
key: 'writeNfc', key: 'writeNfc',
icon: <Nfc />, icon: <Nfc />,
@@ -343,7 +386,11 @@ const ChoreActionMenu = ({
onClick: handleDelete, onClick: handleDelete,
color: 'danger', color: 'danger',
}, },
].filter(Boolean) ]
.filter(Boolean)
.filter(item => !hiddenActions.includes(item.key))
const visibleActionItems = collapseDividers(actionItems)
const quickScheduleButtons = ( const quickScheduleButtons = (
<> <>
@@ -416,7 +463,7 @@ const ChoreActionMenu = ({
} }
const renderMenuActionItems = () => const renderMenuActionItems = () =>
actionItems.map(item => { visibleActionItems.map(item => {
if (item.type === 'divider') return <Divider key={item.key} /> if (item.type === 'divider') return <Divider key={item.key} />
if (item.type === 'quickSchedule') { if (item.type === 'quickSchedule') {
return ( return (
@@ -444,12 +491,17 @@ const ChoreActionMenu = ({
> >
{item.icon} {item.icon}
{item.label} {item.label}
{item.endDecorator && (
<ListItemDecorator sx={{ ml: 'auto', minInlineSize: 0 }}>
{item.endDecorator}
</ListItemDecorator>
)}
</MenuItem> </MenuItem>
) )
}) })
const renderModalActionItems = () => const renderModalActionItems = () =>
actionItems.map(item => { visibleActionItems.map(item => {
if (item.type === 'divider') return <Divider key={item.key} /> if (item.type === 'divider') return <Divider key={item.key} />
if (item.type === 'quickSchedule') { if (item.type === 'quickSchedule') {
return ( return (
@@ -463,13 +515,62 @@ const ChoreActionMenu = ({
<ListItemButton color={item.color} onClick={() => item.onClick()}> <ListItemButton color={item.color} onClick={() => item.onClick()}>
<ListItemDecorator>{item.icon}</ListItemDecorator> <ListItemDecorator>{item.icon}</ListItemDecorator>
<ListItemContent>{item.label}</ListItemContent> <ListItemContent>{item.label}</ListItemContent>
{item.endDecorator}
</ListItemButton> </ListItemButton>
</ListItem> </ListItem>
) )
}) })
const priorityOptions = [...Priorities, NO_PRIORITY]
const renderModalPriorityPicker = () =>
priorityOptions.map(priority => (
<ListItem key={priority.value}>
<ListItemButton
selected={(currentPriority?.value || 0) === priority.value}
color={priority.color || 'neutral'}
onClick={() => handleChangePriority(priority)}
>
<ListItemDecorator>{priority.icon || <Flag />}</ListItemDecorator>
<ListItemContent>{priority.name.trim()}</ListItemContent>
</ListItemButton>
</ListItem>
))
const renderMenuPriorityPicker = () => (
<>
<MenuItem
onClick={e => {
e.stopPropagation()
setShowPriorityPicker(false)
}}
sx={{ gap: 1 }}
>
<ArrowBack fontSize='small' />
<Typography level='body-sm' fontWeight={600}>
Priority
</Typography>
</MenuItem>
<Divider />
{priorityOptions.map(priority => (
<MenuItem
key={priority.value}
selected={(currentPriority?.value || 0) === priority.value}
color={priority.color || 'neutral'}
onClick={e => {
e.stopPropagation()
handleChangePriority(priority)
}}
>
{priority.icon || <Flag />}
{priority.name.trim()}
</MenuItem>
))}
</>
)
const renderModalProjectPicker = () => ( const renderModalProjectPicker = () => (
<List> <>
<ListItem> <ListItem>
<ListItemButton <ListItemButton
onClick={() => onClick={() =>
@@ -492,53 +593,75 @@ const ChoreActionMenu = ({
</ListItemButton> </ListItemButton>
</ListItem> </ListItem>
))} ))}
</List> </>
) )
return ( return (
<> <>
<IconButton {trigger ? (
variant={variant} React.cloneElement(trigger, {
color='success' onClick: handleMenuOpen,
onClick={handleMenuOpen} onMouseEnter,
onMouseEnter={onMouseEnter} onMouseLeave,
onMouseLeave={onMouseLeave} })
sx={{ ) : (
borderRadius: '50%', <IconButton
width: 25, variant={variant}
height: 25, color='success'
position: 'relative', onClick={handleMenuOpen}
left: -10, onMouseEnter={onMouseEnter}
...sx, onMouseLeave={onMouseLeave}
}} sx={{
> borderRadius: '50%',
<MoreVert /> width: 25,
</IconButton> height: 25,
position: 'relative',
left: -10,
...sx,
}}
>
<MoreVert />
</IconButton>
)}
{isSmallScreen ? ( {isSmallScreen ? (
<AppModal <AppModal
open={Boolean(anchorEl)} open={Boolean(anchorEl)}
onClose={handleMenuClose} onClose={handleMenuClose}
title={showProjectPicker ? 'Move to project' : chore?.name} title={
showProjectPicker
? 'Move to project'
: showPriorityPicker
? 'Priority'
: chore?.name
}
mobilePresentation='sheet' mobilePresentation='sheet'
showHandle showHandle
contentSx={{ px: 0, pb: 1 }} contentSx={{ px: 0, pb: 1 }}
> >
{showProjectPicker && ( {(showProjectPicker || showPriorityPicker) && (
<MenuItem <Button
onClick={() => setShowProjectPicker(false)} variant='plain'
sx={{ gap: 1, mx: 2, mb: 1 }} color='neutral'
size='sm'
startDecorator={<ArrowBack fontSize='small' />}
onClick={() => {
setShowProjectPicker(false)
setShowPriorityPicker(false)
}}
sx={{ mx: 2, mb: 1 }}
> >
<ArrowBack fontSize='small' />
<Typography level='body-sm' fontWeight={600}> <Typography level='body-sm' fontWeight={600}>
{t('common:back')} {t('common:back')}
</Typography> </Typography>
</MenuItem> </Button>
)} )}
<List sx={{ '--ListItem-radius': '8px', px: 1 }}> <List sx={{ '--ListItem-radius': '8px', px: 1 }}>
{showProjectPicker {showProjectPicker
? renderModalProjectPicker() ? renderModalProjectPicker()
: renderModalActionItems()} : showPriorityPicker
? renderModalPriorityPicker()
: renderModalActionItems()}
</List> </List>
</AppModal> </AppModal>
) : ( ) : (
@@ -554,7 +677,9 @@ const ChoreActionMenu = ({
left: '50%', left: '50%',
}} }}
> >
{showProjectPicker ? ( {showPriorityPicker ? (
renderMenuPriorityPicker()
) : showProjectPicker ? (
<> <>
<MenuItem <MenuItem
onClick={e => { onClick={e => {