diff --git a/public/locales/en/chores.json b/public/locales/en/chores.json
index cbf48a0..6555089 100644
--- a/public/locales/en/chores.json
+++ b/public/locales/en/chores.json
@@ -63,6 +63,8 @@
"skip": "Skip",
"cancel": "Cancel",
"noPriority": "No Priority",
+ "more": "More",
+ "changeDueDate": "Change due date",
"subtasks": "Subtasks",
"noDescription": "No description available",
"timer": {
@@ -103,7 +105,9 @@
"showTasksFor": "Show tasks for",
"type": {
"assignee": "Assignee"
- }
+ },
+ "display": "Display",
+ "displayOptions": "Display options"
},
"sort": {
"assignedToMe": "Assigned to me",
diff --git a/public/locales/en/common.json b/public/locales/en/common.json
index d52124d..d95c1d1 100644
--- a/public/locales/en/common.json
+++ b/public/locales/en/common.json
@@ -56,8 +56,17 @@
"quickAction": "Quick action",
"navigation": "Navigation",
"createTask": "Create a task",
+ "createLabel": "Create a label",
+ "createProject": "Create a project",
+ "createFilter": "Create a filter",
"viewAllTasks": "View all tasks",
"viewArchivedTasks": "View archived tasks",
+ "viewThings": "View things",
+ "viewLabels": "View labels",
+ "viewProjects": "View projects",
+ "viewFilters": "View filters",
+ "viewActivities": "View activities",
+ "viewPoints": "View points",
"openSettings": "Open settings",
"filterTasks": "Show tasks matching “{{query}}”",
"filterTasksSubtitle": "Filter the task list"
diff --git a/public/locales/en/labels.json b/public/locales/en/labels.json
index 165851b..0d76f91 100644
--- a/public/locales/en/labels.json
+++ b/public/locales/en/labels.json
@@ -5,6 +5,36 @@
"message": "Are you sure you want to delete this label? This will remove the label from all tasks."
},
"loadError": "Failed to load labels. Please try again.",
+ "search": {
+ "placeholder": "Search labels",
+ "noResultsTitle": "No labels match",
+ "noResultsDescription": "No label matches \"{{searchTerm}}\".",
+ "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.",
"modal": {
"errorEmptyName": "Name cannot be empty",
diff --git a/public/locales/en/projects.json b/public/locales/en/projects.json
index 276d267..d9c34d8 100644
--- a/public/locales/en/projects.json
+++ b/public/locales/en/projects.json
@@ -12,6 +12,14 @@
"message": "Are you sure you want to delete \"{{name}}\"? This will remove the project but keep all tasks (they'll move to the Default Project)."
},
"loadError": "Failed to load projects. Please try again.",
+ "search": {
+ "placeholder": "Search projects",
+ "noResultsTitle": "No projects match",
+ "noResultsDescription": "No project matches \"{{searchTerm}}\".",
+ "noFilterResultsDescription": "No project matches the current filter.",
+ "clear": "Clear search",
+ "showAll": "Show all projects"
+ },
"blurb": "Organize your tasks into projects. Create custom workspaces to keep your tasks organized and easily accessible.",
"defaultDescription": "All tasks without a specific project",
"selector": {
diff --git a/src/components/common/AppModal.jsx b/src/components/common/AppModal.jsx
index 72c84a7..84b1677 100644
--- a/src/components/common/AppModal.jsx
+++ b/src/components/common/AppModal.jsx
@@ -183,6 +183,7 @@ const AppModal = forwardRef(
onClick={handleClose}
sx={{
position: 'absolute',
+ zIndex: 1,
top: isSheet && showHandle ? 6 : 12,
right: { xs: 10, sm: 16 },
borderRadius: '50%',
diff --git a/src/components/common/FilterBar.jsx b/src/components/common/FilterBar.jsx
index bbc3a76..25472d6 100644
--- a/src/components/common/FilterBar.jsx
+++ b/src/components/common/FilterBar.jsx
@@ -147,8 +147,19 @@ const FilterBar = ({
onClearAll,
resultCount,
totalCount,
+ // When the host renders its own trigger (e.g. an icon button in a toolbar
+ // row), it drives the sheet through `open`/`onOpenChange` and hides ours.
+ open,
+ onOpenChange,
+ showTrigger = true,
}) => {
- const [isOpen, setIsOpen] = useState(false)
+ const [internalOpen, setInternalOpen] = useState(false)
+ const isControlled = open !== undefined
+ const isOpen = isControlled ? open : internalOpen
+ const setIsOpen = next => {
+ if (!isControlled) setInternalOpen(next)
+ onOpenChange?.(next)
+ }
// ── Active count ───────────────────────────────────────────────────────────
@@ -293,65 +304,75 @@ const FilterBar = ({
// ── Render ─────────────────────────────────────────────────────────────────
+ const activeChips = filterDefs
+ .map(def => ({ def, label: getActiveChipLabel(def) }))
+ .filter(({ label }) => !!label)
+ .map(({ def, label }) => ({
+ key: def.id,
+ label,
+ onClear: () => onSetFilter(def.id, null),
+ }))
+
+ // With the trigger hoisted into a toolbar, the inline row has nothing to show
+ // until a filter is on — rendering it anyway would leave a phantom gap.
+ const showInlineBar = showTrigger || activeChips.length > 0
+
return (
<>
{/* ── Inline bar ─────────────────────────────────────── */}
-
-
- }
- onClick={() => setIsOpen(true)}
- sx={{
- borderRadius: 'xl',
- py: 0.5,
- px: 1,
- gap: 0.5,
- alignItems: 'center',
- '& .MuiButton-startDecorator': {
- display: 'flex',
- alignItems: 'center',
- mr: 0.5,
- },
- }}
- >
- Filters
-
-
+ {showTrigger && (
+
+ }
+ onClick={() => setIsOpen(true)}
+ sx={{
+ borderRadius: 'xl',
+ py: 0.5,
+ px: 1,
+ gap: 0.5,
+ alignItems: 'center',
+ '& .MuiButton-startDecorator': {
+ display: 'flex',
+ alignItems: 'center',
+ mr: 0.5,
+ },
+ }}
+ >
+ Filters
+
+
+ )}
- ({ def, label: getActiveChipLabel(def) }))
- .filter(({ label }) => !!label)
- .map(({ def, label }) => ({
- key: def.id,
- label,
- onClear: () => onSetFilter(def.id, null),
- }))}
- onOpen={() => setIsOpen(true)}
- onClearAll={hasActive ? onClearAll : undefined}
- resultCount={hasActive ? resultCount : undefined}
- totalCount={hasActive ? totalCount : undefined}
- maxVisible={2}
- chipSize='md'
- />
-
+ setIsOpen(true)}
+ onClearAll={hasActive ? onClearAll : undefined}
+ resultCount={hasActive ? resultCount : undefined}
+ totalCount={hasActive ? totalCount : undefined}
+ maxVisible={2}
+ chipSize='md'
+ />
+
+ )}
{/* ── Bottom sheet ────────────────────────────────────── */}
void
+ * sortDirection - 'asc' | 'desc'
+ * onSortDirectionChange - (direction) => void
+ * filterTitle - optional header for the filter section
+ * filterOptions - optional [{ name, value }] rendered as radios
+ * selectedFilter - currently selected filter value
+ * onFilterChange - (value) => void
+ * isActive - highlights the trigger button when a non-default choice is on
+ */
+const SortAndFilterMenu = ({
+ filterOptions,
+ filterTitle,
+ icon = ,
+ isActive,
+ onFilterChange,
+ onSortChange,
+ onSortDirectionChange,
+ selectedFilter,
+ selectedSort,
+ sortDirection = 'asc',
+ sortOptions = [],
+ title = 'Sort by',
+}) => {
+ const [anchorEl, setAnchorEl] = useState(null)
+ const menuRef = useRef(null)
+ const buttonRef = useRef(null)
+
+ const handleMenuClose = () => setAnchorEl(null)
+
+ useEffect(() => {
+ const handleMenuOutsideClick = event => {
+ if (
+ menuRef.current &&
+ !menuRef.current.contains(event.target) &&
+ !buttonRef.current?.contains(event.target)
+ ) {
+ handleMenuClose()
+ }
+ }
+
+ document.addEventListener('mousedown', handleMenuOutsideClick)
+ return () => {
+ document.removeEventListener('mousedown', handleMenuOutsideClick)
+ }
+ }, [])
+
+ const SectionHeader = ({ children }) => (
+
+ )
+
+ return (
+ <>
+ setAnchorEl(anchorEl ? null : event.currentTarget)}
+ variant='outlined'
+ color={isActive ? 'primary' : 'neutral'}
+ size='sm'
+ sx={{ height: 32, width: 32, borderRadius: '50%', flexShrink: 0 }}
+ aria-label='Sort and filter options'
+ title='Sort & Filter'
+ >
+ {icon}
+
+
+
+ >
+ )
+}
+
+export default SortAndFilterMenu
diff --git a/src/contexts/RouterContext.jsx b/src/contexts/RouterContext.jsx
index 6c5ef53..613e5bd 100644
--- a/src/contexts/RouterContext.jsx
+++ b/src/contexts/RouterContext.jsx
@@ -27,6 +27,7 @@ import JoinCircleView from '../views/Circles/JoinCircle'
import NotFound from '../views/components/NotFound'
import FilterView from '../views/Filters/FilterView'
import ChoreHistory from '../views/History/ChoreHistory'
+import LabelDetailView from '../views/Labels/LabelDetailView'
import LabelView from '../views/Labels/LabelView'
import Landing from '../views/Landing/Landing'
import CircleSetupView from '../views/Onboarding/CircleSetupView'
@@ -262,6 +263,10 @@ const Router = createBrowserRouter([
path: 'labels/',
element: ,
},
+ {
+ path: 'labels/:labelId',
+ element: ,
+ },
{
path: 'projects/',
element: ,
diff --git a/src/search/GlobalSearchContext.jsx b/src/search/GlobalSearchContext.jsx
index eabc6ed..1bf932d 100644
--- a/src/search/GlobalSearchContext.jsx
+++ b/src/search/GlobalSearchContext.jsx
@@ -152,7 +152,7 @@ export const GlobalSearchProvider = ({ children }) => {
useEffect(() => {
const onKeyDown = event => {
- if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'f') {
+ if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'k') {
event.preventDefault()
isOpen ? closeSearch() : openSearch()
}
diff --git a/src/search/GlobalSearchPalette.jsx b/src/search/GlobalSearchPalette.jsx
index dd7125b..1a96625 100644
--- a/src/search/GlobalSearchPalette.jsx
+++ b/src/search/GlobalSearchPalette.jsx
@@ -1,6 +1,8 @@
import {
AddRounded,
+ ArchiveOutlined,
CheckCircleOutline,
+ FilterAltOutlined,
FolderOutlined,
HistoryRounded,
InboxOutlined,
@@ -8,6 +10,8 @@ import {
PersonOutline,
SearchRounded,
SettingsOutlined,
+ TollOutlined,
+ WidgetsOutlined,
} from '@mui/icons-material'
import {
Box,
@@ -56,29 +60,106 @@ const buildQuickActions = t => [
provider: 'actions',
title: t('search.actions.createTask'),
subtitle: t('search.actions.quickAction'),
- route: '/chores/create',
+ keywords: 'new task chore add create',
+ // Reuses the widget deep-link param so this lands on the task list with the
+ // quick-add modal open, instead of the full create page.
+ route: '/chores?add_task=1',
},
{
- id: 'action:tasks',
+ id: 'action:create-label',
provider: 'actions',
- title: t('search.actions.viewAllTasks'),
- subtitle: t('search.actions.navigation'),
- route: '/chores',
+ title: t('search.actions.createLabel'),
+ subtitle: t('search.actions.quickAction'),
+ keywords: 'new label tag add create',
+ route: '/labels?create=1',
},
{
- id: 'action:archived',
+ id: 'action:create-project',
provider: 'actions',
- title: t('search.actions.viewArchivedTasks'),
- subtitle: t('search.actions.navigation'),
- route: '/archived',
+ title: t('search.actions.createProject'),
+ subtitle: t('search.actions.quickAction'),
+ keywords: 'new project folder add create',
+ route: '/projects?create=1',
},
{
- id: 'action:settings',
+ id: 'action:create-filter',
provider: 'actions',
- title: t('search.actions.openSettings'),
- subtitle: t('search.actions.navigation'),
- route: '/settings',
+ title: t('search.actions.createFilter'),
+ subtitle: t('search.actions.quickAction'),
+ 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: ,
+ },
+ {
+ id: 'action:archived',
+ title: t('search.actions.viewArchivedTasks'),
+ keywords: 'archive archived tasks completed open',
+ route: '/archived',
+ icon: ,
+ },
+ {
+ id: 'action:things',
+ title: t('search.actions.viewThings'),
+ keywords: 'things devices sensors trackers state open',
+ route: '/things',
+ icon: ,
+ },
+ {
+ id: 'action:labels',
+ title: t('search.actions.viewLabels'),
+ keywords: 'labels tags open',
+ route: '/labels',
+ icon: ,
+ },
+ {
+ id: 'action:projects',
+ title: t('search.actions.viewProjects'),
+ keywords: 'projects folders groups open',
+ route: '/projects',
+ icon: ,
+ },
+ {
+ id: 'action:filters',
+ title: t('search.actions.viewFilters'),
+ keywords: 'filters saved views open',
+ route: '/filters',
+ icon: ,
+ },
+ {
+ id: 'action:activities',
+ title: t('search.actions.viewActivities'),
+ keywords: 'activities history timeline log open',
+ route: '/activities',
+ icon: ,
+ },
+ {
+ id: 'action:points',
+ title: t('search.actions.viewPoints'),
+ keywords: 'points rewards score leaderboard open',
+ route: '/points',
+ icon: ,
+ },
+ {
+ id: 'action:settings',
+ title: t('search.actions.openSettings'),
+ keywords: 'settings preferences configuration open',
+ route: '/settings',
+ icon: ,
+ },
+ ].map(action => ({
+ ...action,
+ provider: 'actions',
+ subtitle: t('search.actions.navigation'),
+ })),
]
const readRecents = () => {
@@ -192,6 +273,22 @@ const GlobalSearchPalette = ({
const [recents] = useState(readRecents)
const selectedResultRef = useRef(null)
+ const quickActions = useMemo(() => buildQuickActions(t), [t])
+ const quickActionIndex = useMemo(
+ () =>
+ new Fuse(quickActions, {
+ threshold: 0.38,
+ distance: 120,
+ ignoreLocation: true,
+ includeScore: true,
+ keys: [
+ { name: 'title', weight: 0.7 },
+ { name: 'keywords', weight: 0.3 },
+ ],
+ }),
+ [quickActions],
+ )
+
const searchIndexes = useMemo(
() =>
new Map(
@@ -227,7 +324,7 @@ const GlobalSearchPalette = ({
const recentResults = recents
.map(item => currentById.get(item.id) || item)
.filter(item => item.provider !== 'history' || currentById.has(item.id))
- return [...recentResults, ...buildQuickActions(t)]
+ return [...recentResults, ...quickActions]
}
const grouped = GROUPS.filter(group => group !== 'actions').flatMap(group =>
@@ -250,15 +347,40 @@ const GlobalSearchPalette = ({
})
.sort((a, b) => a.score - b.score),
)
- grouped.push({
- id: 'action:filter-tasks',
- provider: 'actions',
- title: t('search.actions.filterTasks', { query: query.trim() }),
- subtitle: t('search.actions.filterTasksSubtitle'),
- route: `/chores?search=${encodeURIComponent(query.trim())}`,
- })
- return grouped
- }, [documents, query, recents, searchIndexes, t])
+ const actionMatches = (
+ quickActionIndex.search(normalized, { limit: 4 }) || []
+ )
+ .map(match => ({ ...match.item, score: match.score ?? 1 }))
+ .sort((a, b) => a.score - b.score)
+
+ // An action whose title the query starts spelling out ("create la…") is
+ // what the person is after, so it leads. Anything matched only through its
+ // keywords stays below the real content it shares words with.
+ const leadingActions = actionMatches.filter(action =>
+ action.title.toLocaleLowerCase().startsWith(normalized),
+ )
+ const trailingActions = actionMatches.filter(
+ action => !leadingActions.includes(action),
+ )
+
+ return [
+ ...leadingActions,
+ ...grouped,
+ ...trailingActions,
+ {
+ id: 'action:filter-tasks',
+ provider: 'actions',
+ title: t('search.actions.filterTasks', { query: query.trim() }),
+ subtitle: t('search.actions.filterTasksSubtitle'),
+ route: `/chores?search=${encodeURIComponent(query.trim())}`,
+ },
+ ]
+ }, [documents, query, quickActionIndex, recents, searchIndexes, t])
+
+ // Everything except the always-present "filter the task list" fallback.
+ const matchCount = results.filter(
+ result => result.id !== 'action:filter-tasks',
+ ).length
useEffect(() => {
selectedResultRef.current?.scrollIntoView({
@@ -339,7 +461,7 @@ const GlobalSearchPalette = ({
pb: 'var(--safe-area-inset-bottom, 0px)',
}}
>
- {!isLoading && query.trim() && results.length === 1 && (
+ {!isLoading && query.trim() && matchCount === 0 && (
- {ICONS[result.provider]}
+ {result.icon || ICONS[result.provider]}
↵ {t('search.footer.open')}
{query.trim()
- ? t('search.footer.results', {
- count: Math.max(0, results.length - 1),
- })
+ ? t('search.footer.results', { count: matchCount })
: t('search.footer.typeToSearch')}
diff --git a/src/search/searchProviders.js b/src/search/searchProviders.js
index 13f5288..27b5552 100644
--- a/src/search/searchProviders.js
+++ b/src/search/searchProviders.js
@@ -1,13 +1,5 @@
import { SETTINGS_SECTIONS } from '../constants/settingsSections'
-
-const stripHtml = value => {
- if (!value) return ''
- if (typeof globalThis.document === 'undefined')
- return String(value).replace(/<[^>]*>/g, ' ')
- const element = globalThis.document.createElement('div')
- element.innerHTML = String(value)
- return element.textContent || element.innerText || ''
-}
+import { stripHtml } from '../utils/Helpers'
const HISTORY_STATUS = {
0: 'in progress',
@@ -125,7 +117,7 @@ registerSearchProvider({
title: label.name || 'Untitled label',
subtitle: 'Label',
keywords: 'tag label',
- route: '/labels',
+ route: `/labels/${label.id}`,
color: label.color,
}),
),
diff --git a/src/service/ErrorReportService.js b/src/service/ErrorReportService.js
index 1d9689c..07c44c9 100644
--- a/src/service/ErrorReportService.js
+++ b/src/service/ErrorReportService.js
@@ -249,13 +249,28 @@ export const submitErrorReport = async ({
description,
report,
}) => {
+ // The relay rejects reports without an error. Manual bug reports have no
+ // thrown Error, so mark only the submitted copy while preserving the local
+ // diagnostics as a manual report.
+ const submittedReport =
+ report.kind === 'bug'
+ ? {
+ ...report,
+ error: {
+ ...report.error,
+ name: 'ManualBugReport',
+ message: 'Submitted manually from the app',
+ },
+ }
+ : report
+
const payload = {
source: 'donetick-app',
- kind: report.kind === 'bug' ? 'bug-report' : 'error-report',
+ kind: 'error-report',
reportId: report.reportId,
description: description?.trim() || null,
contactEmail: contactEmail?.trim() || null,
- report,
+ report: submittedReport,
}
// Enforced here, not only in the UI, so no future caller can relay a
diff --git a/src/utils/Helpers.jsx b/src/utils/Helpers.jsx
index abf17de..5589ddc 100644
--- a/src/utils/Helpers.jsx
+++ b/src/utils/Helpers.jsx
@@ -1,10 +1,22 @@
import moment from 'moment'
+
import { apiClient } from './ApiClient'
const isPlusAccount = userProfile => {
return userProfile?.expiration && moment(userProfile?.expiration).isAfter()
}
+// Turns rich-text/HTML content (task descriptions, notes) into plain text so it
+// can be indexed or matched by search.
+const stripHtml = value => {
+ if (!value) return ''
+ if (typeof globalThis.document === 'undefined')
+ return String(value).replace(/<[^>]*>/g, ' ')
+ const element = globalThis.document.createElement('div')
+ element.innerHTML = String(value)
+ return element.textContent || element.innerText || ''
+}
+
const resolvePhotoURL = url => {
if (!url) return ''
if (url.startsWith('http') || url.startsWith('https')) {
@@ -83,4 +95,5 @@ export {
isPlusAccount,
isSignedUrlExpired,
resolvePhotoURL,
+ stripHtml,
}
diff --git a/src/views/ChoreEdit/ChoreView.jsx b/src/views/ChoreEdit/ChoreView.jsx
index ba6f9b0..e8b49b7 100644
--- a/src/views/ChoreEdit/ChoreView.jsx
+++ b/src/views/ChoreEdit/ChoreView.jsx
@@ -1,3 +1,4 @@
+import { Capacitor } from '@capacitor/core'
import {
Archive,
AttachFile,
@@ -7,7 +8,7 @@ import {
Edit,
History,
HourglassEmpty,
- LowPriority,
+ MoreVert,
OpenInFull,
PeopleAlt,
Person,
@@ -25,18 +26,13 @@ import {
Checkbox,
Chip,
Container,
- Dropdown,
FormControl,
Grid,
IconButton,
Input,
- Menu,
- MenuButton,
- MenuItem,
Sheet,
Typography,
} from '@mui/joy'
-import { Divider } from '@mui/material'
import { useQueryClient } from '@tanstack/react-query'
import moment from 'moment'
import { useEffect, useState } from 'react'
@@ -68,20 +64,33 @@ import { getTextColorFromBackgroundColor } from '../../utils/Colors.jsx'
import { commandQueue, CommandType } from '../../utils/CommandQueue'
import {
ApproveChore,
+ ArchiveChore,
+ DeleteChore,
GetChoreDetailById,
MarkChoreComplete,
+ NudgeChore,
RejectChore,
+ SaveChore,
SkipChore,
UnArchiveChore,
UndoChoreAction,
+ UpdateChoreAssignee,
UpdateChorePriority,
+ UpdateDueDate,
} from '../../utils/Fetcher'
import { offlineDB } from '../../utils/OfflineDB'
-import Priorities from '../../utils/Priorities'
import { getSafeBottomPadding } from '../../utils/SafeAreaUtils.js'
import AttachmentBrowserModal from '../Modals/Inputs/AttachmentBrowserModal'
import ConfirmationModal from '../Modals/Inputs/ConfirmationModal'
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 PendingBadge from '../components/PendingBadge'
import RichTextEditor from '../components/RichTextEditor.jsx'
@@ -106,6 +115,11 @@ const decodeHtmlEntities = 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 { t } = useTranslation('chores')
const { fmt } = useLocalization()
@@ -124,7 +138,7 @@ const ChoreView = () => {
const [confirmModelConfig, setConfirmModelConfig] = useState({
isOpen: false,
})
- const [chorePriority, setChorePriority] = useState(null)
+ const [activeModal, setActiveModal] = useState(null)
const [noteViewerConfig, setNoteViewerConfig] = useState({ isOpen: false })
const [timerActionConfig, setTimerActionConfig] = useState({ isOpen: false })
const [attachmentBrowserOpen, setAttachmentBrowserOpen] = useState(false)
@@ -165,7 +179,6 @@ const ChoreView = () => {
return
}
setChore(choreData.res)
- setChorePriority(Priorities.find(p => p.value === choreData.res.priority))
document.title = 'Donetick: ' + choreData.res.name
setPerformers(circleMembersData.res)
@@ -236,7 +249,7 @@ const ChoreView = () => {
UpdateChorePriority(choreId, priority.value).then(response => {
if (response.ok) {
response.json().then(() => {
- setChorePriority(priority)
+ setChore(prev => ({ ...prev, priority: priority.value }))
queryClient.invalidateQueries(['chores'])
})
}
@@ -583,6 +596,168 @@ const ChoreView = () => {
}
}
+ const confirmSkipTask = () => {
+ setConfirmModelConfig({
+ isOpen: true,
+ title: t('choreView.skipTask'),
+ message: t('choreView.skipTaskConfirmation'),
+ confirmText: t('choreView.skip'),
+ cancelText: t('choreView.cancel'),
+ onClose: confirmed => {
+ if (confirmed) {
+ handleSkippingTask()
+ }
+ setConfirmModelConfig({})
+ },
+ })
+ }
+
+ const handleArchiveChore = async () => {
+ try {
+ const response = await ArchiveChore(choreId)
+ if (response.ok) {
+ await offlineDB.saveChores([{ ...chore, isActive: false }])
+ setChore({ ...chore, isActive: false })
+ queryClient.invalidateQueries(['chores'])
+ }
+ } catch (error) {
+ showError({
+ title: 'Failed to archive',
+ message: error?.message || 'Unable to archive task',
+ })
+ }
+ }
+
+ const confirmDeleteChore = () => {
+ setConfirmModelConfig({
+ isOpen: true,
+ title: 'Delete task',
+ message: 'Are you sure you want to delete this task?',
+ confirmText: 'Delete',
+ cancelText: t('choreView.cancel'),
+ onClose: async confirmed => {
+ setConfirmModelConfig({})
+ if (!confirmed) return
+ try {
+ const response = await DeleteChore(choreId)
+ if (response.ok) {
+ queryClient.invalidateQueries(['chores'])
+ showSuccess({
+ title: 'Task Deleted',
+ message: 'The task has been deleted successfully.',
+ })
+ navigate('/chores')
+ }
+ } catch (error) {
+ showError({
+ title: 'Failed to delete',
+ message: error?.message || 'Unable to delete task',
+ })
+ }
+ },
+ })
+ }
+
+ const handleDueDateChange = async newDate => {
+ try {
+ const response = await UpdateDueDate(choreId, newDate)
+ if (response.ok) {
+ setChore(prev => ({ ...prev, nextDueDate: newDate }))
+ queryClient.invalidateQueries(['chores'])
+ }
+ } catch (error) {
+ showError({
+ title: 'Failed to reschedule',
+ message: error?.message || 'Unable to change the due date',
+ })
+ }
+ }
+
+ const handleMoveToProject = async project => {
+ const projectId = project?.id ?? null
+ try {
+ const response = await SaveChore({ ...chore, projectId })
+ if (response.ok) {
+ setChore(prev => ({ ...prev, projectId }))
+ queryClient.invalidateQueries(['chores'])
+ showSuccess({
+ title: 'Task Moved',
+ message: `Task moved to ${project?.name || 'Default Project'}.`,
+ })
+ }
+ } catch (error) {
+ showError({
+ title: 'Failed to move task',
+ message: error?.message || 'Unable to move task to project',
+ })
+ }
+ }
+
+ const handleNudge = async ({ message, notifyAllAssignees }) => {
+ try {
+ const response = await NudgeChore(choreId, {
+ message,
+ notifyAllAssignees,
+ })
+ if (!response.ok) {
+ throw new Error('Failed to send nudge')
+ }
+ const data = await response.json()
+ showSuccess({
+ title: 'Nudge Sent!',
+ message: data.message || 'Nudge sent successfully',
+ })
+ } catch (error) {
+ showError({
+ title: 'Failed to Send Nudge',
+ message: error?.message || 'Unable to send nudge at this time',
+ })
+ }
+ }
+
+ const handleAssigneeChange = async assigneeId => {
+ try {
+ const response = await UpdateChoreAssignee(choreId, assigneeId)
+ if (response.ok) {
+ const data = await response.json()
+ setChore(data.res)
+ queryClient.invalidateQueries(['chores'])
+ }
+ } catch (error) {
+ showError({
+ title: 'Failed to delegate',
+ message: error?.message || 'Unable to change the assignee',
+ })
+ }
+ }
+
+ // Actions the menu raises that ChoreView owns; the rest of its items either
+ // navigate on their own or come in through the dedicated callbacks.
+ const handleMenuAction = (type, _chore, extraData) => {
+ switch (type) {
+ case 'skip':
+ confirmSkipTask()
+ break
+ case 'archive':
+ handleArchiveChore()
+ break
+ case 'unarchive':
+ handleUnarchiveChore()
+ break
+ case 'delete':
+ confirmDeleteChore()
+ break
+ case 'changeDueDate':
+ handleDueDateChange(extraData?.date?.toISOString() ?? null)
+ break
+ case 'moveToProject':
+ handleMoveToProject(extraData?.project)
+ break
+ default:
+ break
+ }
+ }
+
// Check if the current user can approve/reject (admin, manager, or task owner)
const canApproveReject = () => {
if (!circleMembersData?.res || !chore) return false
@@ -794,64 +969,6 @@ const ChoreView = () => {
mb: 1,
}}
>
-
-
- {chorePriority ? chorePriority.icon : }
- {chorePriority ? chorePriority.name : t('choreView.noPriority')}
-
-
-
-
+ setActiveModal('nudge')}
+ onWriteNFC={() => setActiveModal('writeNFC')}
+ onCompleteWithNote={() => setNote('')}
+ onCompleteWithPastDate={() =>
+ setCompletedDate(moment(new Date()).format('YYYY-MM-DDTHH:00:00'))
+ }
+ onChangeAssignee={() => setActiveModal('changeAssignee')}
+ onChangeDueDate={() => setActiveModal('changeDueDate')}
+ onChangePriority={handleUpdatePriority}
+ onDelete={confirmDeleteChore}
+ trigger={
+
+ }
+ />
{chore.description && (
@@ -1261,21 +1410,7 @@ const ChoreView = () => {
+
+ {onSetDueDate && (
+ <>
+
+ setDueMenuAnchor(prev => (prev ? null : dueMenuRef.current))
+ }
+ startDecorator={}
+ 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
+
+
+ >
+ )}
+
)}
+
{onMoveToProject && (
<>
+
+ setMoreOpen(true)}
+ startDecorator={}
+ disabled={selectedCount === 0}
+ sx={{
+ '--Button-paddingInline': { xs: '0.75rem', sm: '1rem' },
+ }}
+ title='Set priority, assignee, or labels'
+ >
+ More
+
+
+ {/* ── More sheet: the field editors that have no button in the bar ────── */}
+ setMoreOpen(false)}
+ maxHeight='92vh'
+ title={
+
+
+ {selectedCount} task{selectedCount !== 1 ? 's' : ''} selected
+
+ }
+ footer={
+ setMoreOpen(false)}
+ sx={{ minWidth: 140 }}
+ >
+ Done
+
+ }
+ >
+
+ {/* Due date, project and the destructive actions are deliberately
+ absent — each has its own button in the bar, and two entry points
+ would just make them ambiguous. */}
+ {onSetPriority && (
+ <>
+ }
+ label='Priority'
+ value={priorityValue}
+ />
+
+ {Priorities.map(priority => {
+ const isCurrent =
+ !summary.priority?.isMixed &&
+ summary.priority?.value === priority.value
+ return (
+ : undefined
+ }
+ onClick={runAndClose(() => onSetPriority(priority.value))}
+ sx={selectableChipSx}
+ >
+ {priority.name.trim()}
+
+ )
+ })}
+ onSetPriority(0))}
+ sx={selectableChipSx}
+ >
+ None
+
+
+ >
+ )}
+
+ {onSetAssignee && assignableMembers.length > 0 && (
+ <>
+
+ }
+ label='Assignee'
+ value={assigneeValue}
+ />
+
+ {assignableMembers.map(member => {
+ const isCurrent =
+ !summary.assignee?.isMixed &&
+ summary.assignee?.value === member.userId
+ return (
+
+ ) : (
+
+ {(member.displayName || '?')
+ .charAt(0)
+ .toUpperCase()}
+
+ )
+ }
+ onClick={runAndClose(() => onSetAssignee(member.userId))}
+ sx={selectableChipSx}
+ >
+ {member.displayName || member.username}
+
+ )
+ })}
+
+ >
+ )}
+
+ {onToggleLabel && labels.length > 0 && (
+ <>
+
+ {/* Tapping adds the label to every task; tapping one that is
+ already on all of them removes it. A half-filled chip means
+ only some of the selection has it — tapping completes the set. */}
+ }
+ label='Labels'
+ value='Tap to add · tap again to remove'
+ />
+
+ {labels.map(label => {
+ const onAll = commonLabelIds.has(label.id)
+ const onSome = partialLabelIds.has(label.id)
+ return (
+
+ ) : onSome ? (
+
+ ) : undefined
+ }
+ onClick={runAndClose(() =>
+ onToggleLabel(label, onAll ? 'remove' : 'add'),
+ )}
+ sx={{
+ ...selectableChipSx,
+ ...(label.color && !onAll
+ ? { borderColor: label.color }
+ : {}),
+ ...(label.color && onAll
+ ? {
+ backgroundColor: label.color,
+ color: getTextColorFromBackgroundColor(
+ label.color,
+ ),
+ }
+ : {}),
+ }}
+ >
+ {label.name}
+
+ )
+ })}
+
+ >
+ )}
+
+
+
+ {dueDatePickerOpen && (
+ setDueDatePickerOpen(false)}
+ onApply={parts => {
+ setDueDatePickerOpen(false)
+ setMoreOpen(false)
+ // Passed through as parts, not a timestamp: leaving the time on
+ // "Anytime" means "keep each task's own hour", which only the
+ // per-chore handler can resolve.
+ onSetDueDate?.(parts.dueDateOnly ? parts : null)
+ }}
+ onRemove={() => {
+ setDueDatePickerOpen(false)
+ setMoreOpen(false)
+ onSetDueDate?.(null)
+ }}
+ />
+ )}
)
}
diff --git a/src/views/Chores/components/SearchBar.jsx b/src/views/Chores/components/SearchBar.jsx
index 0ccce0e..156d1c2 100644
--- a/src/views/Chores/components/SearchBar.jsx
+++ b/src/views/Chores/components/SearchBar.jsx
@@ -43,7 +43,7 @@ const SearchBar = ({
startDecorator={
-
+
}
endDecorator={
diff --git a/src/views/Chores/hooks/useChoreActions.js b/src/views/Chores/hooks/useChoreActions.js
index f4b27f0..daacb58 100644
--- a/src/views/Chores/hooks/useChoreActions.js
+++ b/src/views/Chores/hooks/useChoreActions.js
@@ -1,4 +1,5 @@
import { useQueryClient } from '@tanstack/react-query'
+import moment from 'moment'
import { useCallback } from 'react'
import {
useArchiveChore,
@@ -16,6 +17,7 @@ import {
SkipChore,
UndoChoreAction,
UpdateChoreAssignee,
+ UpdateChorePriority,
UpdateDueDate,
} from '../../../utils/Fetcher'
import { offlineDB } from '../../../utils/OfflineDB'
@@ -29,6 +31,28 @@ const isNetworkError = err =>
err instanceof TypeError &&
err.message === 'Failed to fetch'
+const plural = count => (count === 1 ? '' : 's')
+const taskCount = count => `${count} task${plural(count)}`
+
+// "No specific time" is stored as end of day, and it has to be exactly
+// 23:59:59 — that stamp is what ChoreEdit writes and what the task card checks
+// to render "Today" rather than "Today 11:59 PM". Rebuilding from HH:mm alone
+// would land on :00 seconds and lose that meaning.
+const END_OF_DAY = '23:59'
+const atTimeOfDay = (date, time) =>
+ (!time || time === END_OF_DAY
+ ? moment(date, 'YYYY-MM-DD').endOf('day')
+ : moment(`${date} ${time}`, 'YYYY-MM-DD HH:mm')
+ ).toISOString()
+
+// Fetcher calls resolve with a Response even on 4xx/5xx, so a bulk run has to
+// check explicitly or it will report failures as successes.
+const expectOk = async request => {
+ const response = await request
+ if (!response?.ok) throw new Error('Request failed')
+ return response
+}
+
export const useChoreActions = ({
chores,
filteredChores,
@@ -869,346 +893,392 @@ export const useChoreActions = ({
[showSuccess, showError, closeModal],
)
- const handleBulkComplete = useCallback(async () => {
- const selectedData = getSelectedChoresData(chores)
- if (selectedData.length === 0) return
+ // ── bulk operations ────────────────────────────────────────────────────────
+ //
+ // Every bulk action is the same shape: optionally confirm, apply per chore,
+ // tally what worked, tell the user, refetch, drop the selection. `runBulk`
+ // owns that shape so each action only describes what it does to one chore.
+ //
+ // Failures are best-effort and partial: a chore that fails leaves the others
+ // applied, and the toast says how many of each.
- setConfirmModelConfig({
- isOpen: true,
- title: t('actions.bulk.completeTitle'),
- confirmText: t('list.complete'),
- cancelText: t('choreView.cancel'),
- message: `Mark ${selectedData.length} task${selectedData.length > 1 ? 's' : ''} as completed?`,
- onClose: async isConfirmed => {
- if (isConfirmed === true) {
- try {
- const completedTasks = []
- const failedTasks = []
-
- for (const chore of selectedData) {
- try {
- await MarkChoreComplete(
- chore.id,
- impersonatedUser
- ? { completedBy: impersonatedUser.userId }
- : null,
- null,
- null,
- )
- completedTasks.push(chore)
- } catch (error) {
- failedTasks.push(chore)
+ const patchLocalChores = useCallback(
+ (ids, patch) => {
+ const idSet = new Set(ids)
+ const apply = list =>
+ list.map(chore =>
+ idSet.has(chore.id)
+ ? {
+ ...chore,
+ ...(typeof patch === 'function' ? patch(chore) : patch),
}
- }
+ : chore,
+ )
+ setChores(apply)
+ setFilteredChores(apply)
+ },
+ [setChores, setFilteredChores],
+ )
- if (completedTasks.length > 0) {
- showSuccess({
- title: t('actions.bulk.completedTitle'),
- message: `Successfully completed ${completedTasks.length} task${completedTasks.length > 1 ? 's' : ''}.`,
- })
- }
+ const removeLocalChores = useCallback(
+ ids => {
+ const idSet = new Set(ids)
+ const drop = list => list.filter(chore => !idSet.has(chore.id))
+ setChores(drop)
+ setFilteredChores(drop)
+ },
+ [setChores, setFilteredChores],
+ )
- if (failedTasks.length > 0) {
- showError({
- title: t('archived.someFailedTitle'),
- message: `${failedTasks.length} task${failedTasks.length > 1 ? 's' : ''} could not be completed.`,
- })
- }
+ const runBulk = useCallback(
+ async ({
+ buildUndo,
+ // { title, confirmText, message } — omitted when the picker the user
+ // just used is itself the confirmation.
+ confirm,
+ // "completed", "rescheduled", … — reads as `2 tasks could not be ${verb}.`
+ // 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()
- clearSelection()
- } catch (error) {
- showError({
- title: t('actions.bulk.completeFailedTitle'),
- message: t('archived.unexpectedError'),
- })
- }
- }
- setConfirmModelConfig({})
- },
- })
- }, [
- getSelectedChoresData,
- impersonatedUser,
- showSuccess,
- showError,
- refetchChores,
- clearSelection,
- setConfirmModelConfig,
- ])
+ const execute = async () => {
+ const succeeded = []
+ const failed = []
- const handleBulkArchive = useCallback(async () => {
- const selectedData = getSelectedChoresData(chores)
- if (selectedData.length === 0) return
-
- setConfirmModelConfig({
- isOpen: true,
- title: 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) {
+ for (const chore of targets) {
try {
- const archivedTasks = []
- const failedTasks = []
- for (const chore of selectedData) {
- try {
- await new Promise((resolve, reject) => {
- archiveChore.mutate(chore.id, {
- onSuccess: data => {
- archivedTasks.push(data)
- setChores(prev => prev.filter(c => c.id !== chore.id))
- setFilteredChores(prev =>
- prev.filter(c => c.id !== chore.id),
- )
- resolve(data)
- },
- onError: error => {
- failedTasks.push(chore)
- reject(error)
- },
- })
- })
- } catch (error) {}
- }
- if (archivedTasks.length > 0) {
- showSuccess({
- title: 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()
+ await perChore(chore)
+ succeeded.push(chore)
} catch (error) {
- showError({
- title: t('actions.bulk.archiveFailedTitle'),
- message: t('archived.unexpectedError'),
- })
+ failed.push(chore)
}
}
- setConfirmModelConfig({})
- },
- })
- }, [
- getSelectedChoresData,
- archiveChore,
- setChores,
- setFilteredChores,
- showSuccess,
- showError,
- refetchChores,
- clearSelection,
- setConfirmModelConfig,
- ])
- const handleBulkDelete = useCallback(async () => {
- const selectedData = getSelectedChoresData(chores)
- if (selectedData.length === 0) return
-
- setConfirmModelConfig({
- isOpen: true,
- title: 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'),
- })
- }
+ if (succeeded.length > 0) {
+ onSucceeded?.(succeeded)
+ showSuccess({
+ title: successTitle,
+ message: `${successVerb} ${taskCount(succeeded.length)}.`,
+ ...(buildUndo ? { undoAction: buildUndo(succeeded) } : {}),
+ })
}
- setConfirmModelConfig({})
- },
- })
- }, [
- getSelectedChoresData,
- chores,
- filteredChores,
- setChores,
- setFilteredChores,
- showSuccess,
- showError,
- refetchChores,
- clearSelection,
- setConfirmModelConfig,
- ])
- const handleBulkSkip = useCallback(async () => {
- const selectedData = getSelectedChoresData(chores)
- if (selectedData.length === 0) return
-
- setConfirmModelConfig({
- isOpen: true,
- title: 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'),
- })
- }
+ if (failed.length > 0) {
+ showError({
+ title: t('archived.someFailedTitle'),
+ message: `${taskCount(failed.length)} could not be ${failureVerb}.`,
+ })
}
- setConfirmModelConfig({})
- },
- })
- }, [
- getSelectedChoresData,
- showSuccess,
- showError,
- showUndo,
- refetchChores,
- clearSelection,
- setConfirmModelConfig,
- ])
- const handleBulkMoveToProject = useCallback(
- async project => {
- const selectedData = getSelectedChoresData(chores)
- if (selectedData.length === 0) return
+ refetchChores()
+ clearSelection()
+ }
- const projectId = project?.id ?? null
- const movedTasks = []
- const failedTasks = []
-
- for (const chore of selectedData) {
+ if (!confirm) {
try {
- const response = await SaveChore({ ...chore, projectId })
- if (response.ok) {
- movedTasks.push(chore)
- } else {
- failedTasks.push(chore)
- }
+ await execute()
} catch (error) {
- failedTasks.push(chore)
+ showError({
+ title: failedTitle || `Bulk ${failureVerb} failed`,
+ message: t('archived.unexpectedError'),
+ })
}
+ return
}
- if (movedTasks.length > 0) {
- const movedIds = new Set(movedTasks.map(c => c.id))
- const applyMove = list =>
- list.map(c => (movedIds.has(c.id) ? { ...c, projectId } : c))
- setChores(applyMove)
- setFilteredChores(applyMove)
- showSuccess({
- title: 'Tasks Moved',
- message: `Moved ${movedTasks.length} task${movedTasks.length > 1 ? 's' : ''} to ${project?.name || 'Default Project'}.`,
- })
- }
- if (failedTasks.length > 0) {
- showError({
- title: t('archived.someFailedTitle'),
- message: `${failedTasks.length} task${failedTasks.length > 1 ? 's' : ''} could not be moved.`,
- })
- }
-
- refetchChores()
- clearSelection()
+ setConfirmModelConfig({
+ isOpen: true,
+ cancelText: t('choreView.cancel'),
+ ...confirm,
+ onClose: async isConfirmed => {
+ setConfirmModelConfig({})
+ if (isConfirmed !== true) return
+ try {
+ await execute()
+ } catch (error) {
+ showError({
+ title: failedTitle || `Bulk ${failureVerb} failed`,
+ message: t('archived.unexpectedError'),
+ })
+ }
+ },
+ })
},
[
- chores,
- getSelectedChoresData,
- setChores,
- setFilteredChores,
showSuccess,
showError,
refetchChores,
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,
handleBulkSkip,
handleBulkMoveToProject,
+ handleBulkDueDate,
+ handleBulkAssignee,
+ handleBulkPriority,
+ handleBulkLabels,
}
}
diff --git a/src/views/Chores/hooks/useChoreFilters.js b/src/views/Chores/hooks/useChoreFilters.js
index a8903a6..4361aa8 100644
--- a/src/views/Chores/hooks/useChoreFilters.js
+++ b/src/views/Chores/hooks/useChoreFilters.js
@@ -1,11 +1,13 @@
import Fuse from 'fuse.js'
import { useCallback, useMemo, useState } from 'react'
+
import { ChoreFilters, filterByProject } from '../../../utils/Chores'
+import { stripHtml } from '../../../utils/Helpers'
export const useChoreFilters = ({
chores,
- selectedProject,
impersonatedUser,
+ selectedProject,
userProfile,
}) => {
const [searchTerm, setSearchTerm] = useState('')
@@ -30,9 +32,14 @@ export const useChoreFilters = ({
const searchableChores = chores.map(chore => ({
...chore,
raw_label: chore.labelsV2?.map(label => label.name).join(' '),
+ raw_description: stripHtml(chore.description),
}))
return new Fuse(searchableChores, {
- keys: ['name', 'raw_label'],
+ keys: [
+ { name: 'name', weight: 0.6 },
+ { name: 'raw_label', weight: 0.25 },
+ { name: 'raw_description', weight: 0.15 },
+ ],
includeScore: true,
isCaseSensitive: false,
findAllMatches: true,
diff --git a/src/views/Chores/hooks/useKeyboardShortcuts.js b/src/views/Chores/hooks/useKeyboardShortcuts.js
index 72747cb..1fa0e9c 100644
--- a/src/views/Chores/hooks/useKeyboardShortcuts.js
+++ b/src/views/Chores/hooks/useKeyboardShortcuts.js
@@ -25,17 +25,16 @@ export const useKeyboardShortcuts = ({
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()
- handlers.onOpenTaskModal()
- return
- }
-
- if (addTaskModalOpen) return
-
- if (isHoldingCmdOrCtrl && event.key === 'j') {
- event.preventDefault()
- handlers.onNavigateToCreate()
+ if (event.shiftKey) {
+ handlers.onNavigateToCreate()
+ } else {
+ handlers.onOpenTaskModal()
+ }
return
} else if (isHoldingCmdOrCtrl && event.key === 'x') {
event.preventDefault()
diff --git a/src/views/Chores/hooks/useMultiSelect.js b/src/views/Chores/hooks/useMultiSelect.js
index cadb30f..1e4cbd7 100644
--- a/src/views/Chores/hooks/useMultiSelect.js
+++ b/src/views/Chores/hooks/useMultiSelect.js
@@ -1,30 +1,44 @@
-import { useState, useCallback } from 'react'
+import { useCallback, useRef, useState } from 'react'
+
+// A field is "shared" only when every selected chore agrees on it. Anything
+// else is mixed, which the bulk editor shows as an unset control rather than
+// pretending one of the values is the current one.
+const sharedValue = (items, pick) => {
+ if (items.length === 0) return { value: null, isMixed: false }
+ const first = pick(items[0])
+ const isMixed = items.some(item => pick(item) !== first)
+ return { value: isMixed ? null : first, isMixed }
+}
+
+const labelIdsOf = chore => (chore.labelsV2 || []).map(label => label.id)
export const useMultiSelect = () => {
const [isMultiSelectMode, setIsMultiSelectMode] = useState(false)
const [selectedChores, setSelectedChores] = useState(new Set())
+ // Anchor for shift-click range selection. A ref because it only matters at
+ // the moment of the next click and should never trigger a render.
+ const lastSelectedId = useRef(null)
const toggleMultiSelectMode = useCallback(() => {
- const newMode = !isMultiSelectMode
- setIsMultiSelectMode(newMode)
+ setIsMultiSelectMode(prev => {
+ if (!prev) setSelectedChores(new Set())
+ return !prev
+ })
+ lastSelectedId.current = null
+ }, [])
- if (newMode) {
- setSelectedChores(new Set())
- }
- }, [isMultiSelectMode])
-
- const toggleChoreSelection = useCallback(
- choreId => {
- const newSelection = new Set(selectedChores)
- if (newSelection.has(choreId)) {
- newSelection.delete(choreId)
+ const toggleChoreSelection = useCallback(choreId => {
+ setSelectedChores(prev => {
+ const next = new Set(prev)
+ if (next.has(choreId)) {
+ next.delete(choreId)
} else {
- newSelection.add(choreId)
+ next.add(choreId)
}
- setSelectedChores(newSelection)
- },
- [selectedChores],
- )
+ return next
+ })
+ lastSelectedId.current = choreId
+ }, [])
// Entry point for press-and-hold on a task card: turn multi-select on (if it
// isn't already) with that task selected.
@@ -33,6 +47,7 @@ export const useMultiSelect = () => {
if (!isMultiSelectMode) {
setIsMultiSelectMode(true)
setSelectedChores(new Set([choreId]))
+ lastSelectedId.current = choreId
return
}
toggleChoreSelection(choreId)
@@ -40,6 +55,39 @@ export const useMultiSelect = () => {
[isMultiSelectMode, toggleChoreSelection],
)
+ // Shift-click: add everything between the previous click and this one, in the
+ // order the user actually sees them. Falls back to a plain toggle when there
+ // is no anchor yet or the anchor has scrolled out of the current list.
+ const selectChoreRange = useCallback(
+ (choreId, orderedChores = []) => {
+ const anchorId = lastSelectedId.current
+ if (anchorId === null || anchorId === choreId) {
+ toggleChoreSelection(choreId)
+ return
+ }
+
+ const anchorIndex = orderedChores.findIndex(c => c.id === anchorId)
+ const targetIndex = orderedChores.findIndex(c => c.id === choreId)
+ if (anchorIndex === -1 || targetIndex === -1) {
+ toggleChoreSelection(choreId)
+ return
+ }
+
+ const [from, to] =
+ anchorIndex < targetIndex
+ ? [anchorIndex, targetIndex]
+ : [targetIndex, anchorIndex]
+
+ setSelectedChores(prev => {
+ const next = new Set(prev)
+ for (let i = from; i <= to; i++) next.add(orderedChores[i].id)
+ return next
+ })
+ lastSelectedId.current = choreId
+ },
+ [toggleChoreSelection],
+ )
+
const selectAllVisibleChores = useCallback(
(visibleChores, choreSections = [], openChoreSections = {}) => {
let choresToSelect = []
@@ -65,8 +113,7 @@ export const useMultiSelect = () => {
}
if (choresToSelect.length > 0) {
- const allIds = new Set(choresToSelect.map(chore => chore.id))
- setSelectedChores(allIds)
+ setSelectedChores(new Set(choresToSelect.map(chore => chore.id)))
}
return choresToSelect.length
@@ -75,6 +122,7 @@ export const useMultiSelect = () => {
)
const clearSelection = useCallback(() => {
+ lastSelectedId.current = null
if (selectedChores.size === 0) {
setIsMultiSelectMode(false)
return
@@ -84,22 +132,79 @@ export const useMultiSelect = () => {
const getSelectedChoresData = useCallback(
allChores => {
+ if (selectedChores.size === 0) return []
+ const byId = new Map(allChores.map(chore => [chore.id, chore]))
return Array.from(selectedChores)
- .map(id => allChores.find(chore => chore.id === id))
+ .map(id => byId.get(id))
.filter(Boolean)
},
[selectedChores],
)
+ // What the bulk editor needs to render its controls: the current value where
+ // the selection agrees, and which labels are on all / only some of them so
+ // "add" and "remove" can be offered accurately.
+ const getSelectionSummary = useCallback(
+ allChores => {
+ const selected = getSelectedChoresData(allChores)
+
+ const labelCounts = new Map()
+ const labelsById = new Map()
+ selected.forEach(chore => {
+ ;(chore.labelsV2 || []).forEach(label => {
+ labelsById.set(label.id, label)
+ labelCounts.set(label.id, (labelCounts.get(label.id) || 0) + 1)
+ })
+ })
+
+ const commonLabelIds = []
+ const partialLabelIds = []
+ labelCounts.forEach((count, id) => {
+ if (count === selected.length) commonLabelIds.push(id)
+ else partialLabelIds.push(id)
+ })
+
+ return {
+ count: selected.length,
+ assignee: sharedValue(selected, c => c.assignedTo),
+ priority: sharedValue(selected, c => c.priority),
+ dueDate: sharedValue(selected, c => c.nextDueDate),
+ project: sharedValue(selected, c => c.projectId ?? null),
+ labels: {
+ byId: labelsById,
+ common: commonLabelIds,
+ partial: partialLabelIds,
+ // Anything present on at least one chore can be removed.
+ removable: [...commonLabelIds, ...partialLabelIds],
+ },
+ // Candidate assignees common to the whole selection. An empty (or
+ // absent) `assignees` list on a chore means "anyone", so those chores
+ // place no restriction. null here means no restriction at all, and the
+ // caller should offer every circle member.
+ assignableUserIds: selected.reduce((acc, chore) => {
+ const ids = (chore.assignees || []).map(a => a.userId)
+ if (ids.length === 0) return acc
+ if (acc === null) return ids
+ return acc.filter(id => ids.includes(id))
+ }, null),
+ hasArchived: selected.some(chore => chore.isActive === false),
+ labelIdsOf,
+ }
+ },
+ [getSelectedChoresData],
+ )
+
return {
isMultiSelectMode,
selectedChores,
toggleMultiSelectMode,
toggleChoreSelection,
+ selectChoreRange,
enterMultiSelectWithChore,
selectAllVisibleChores,
clearSelection,
getSelectedChoresData,
+ getSelectionSummary,
setIsMultiSelectMode,
setSelectedChores,
}
diff --git a/src/views/Filters/FilterView.jsx b/src/views/Filters/FilterView.jsx
index fa6618a..7d2079c 100644
--- a/src/views/Filters/FilterView.jsx
+++ b/src/views/Filters/FilterView.jsx
@@ -1,11 +1,23 @@
+import '@meauxt/react-swipeable-list/dist/styles.css'
+
import {
- Type as ListType,
SwipeableList,
SwipeableListItem,
SwipeAction,
TrailingActions,
+ Type as ListType,
} from '@meauxt/react-swipeable-list'
-import '@meauxt/react-swipeable-list/dist/styles.css'
+import {
+ Add,
+ Close,
+ FilterAlt,
+ MoreVert,
+ Search,
+ SearchOff,
+ Star,
+ StarBorder,
+ Task,
+} from '@mui/icons-material'
import DeleteIcon from '@mui/icons-material/Delete'
import EditIcon from '@mui/icons-material/Edit'
import {
@@ -15,26 +27,20 @@ import {
CircularProgress,
Container,
IconButton,
+ Input,
Stack,
Typography,
} from '@mui/joy'
-import { useEffect, useMemo, useState } from 'react'
-import { useNavigate } from 'react-router-dom'
+import Fuse from 'fuse.js'
+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 SortAndFilterMenu from '../../components/common/SortAndFilterMenu'
import { useChores } from '../../queries/ChoreQueries'
import { useCircleMembers, useUserProfile } from '../../queries/UserQueries'
import { getFilterCount, getFilterOverdueCount } from '../../utils/FilterEngine'
import { getSafeBottomStyles } from '../../utils/SafeAreaUtils'
-
import { useLabels } from '../Labels/LabelQueries'
import AdvancedFilterBuilder from '../Modals/Inputs/AdvancedFilterBuilder'
import ConfirmationModal from '../Modals/Inputs/ConfirmationModal'
@@ -50,9 +56,9 @@ import { useTranslation } from 'react-i18next'
const FilterCardContent = ({
filter,
- taskCount = 0,
- overdueCount = 0,
onToggleActions,
+ overdueCount = 0,
+ taskCount = 0,
}) => {
// Get condition labels for display
const getConditionSummary = () => {
@@ -248,6 +254,7 @@ const FilterCardContent = ({
const FilterView = () => {
const { t } = useTranslation('filters')
const navigate = useNavigate()
+ const [searchParams, setSearchParams] = useSearchParams()
const { data: userProfile } = useUserProfile()
const { data: chores = { res: [] } } = useChores(false)
const { data: labels = [] } = useLabels()
@@ -267,6 +274,21 @@ const FilterView = () => {
const [editingFilter, setEditingFilter] = useState(null)
const [confirmationModel, setConfirmationModel] = useState({})
const [showMoreInfoId, setShowMoreInfoId] = useState(null)
+ const [searchTerm, setSearchTerm] = useState('')
+ const [sortBy, setSortBy] = useState(
+ () => localStorage.getItem('filtersSortBy') || 'smart',
+ )
+ const [sortDirection, setSortDirection] = useState(
+ () => localStorage.getItem('filtersSortDirection') || 'asc',
+ )
+ const [pinnedFilter, setPinnedFilter] = useState('all')
+ const searchInputRef = useRef(null)
+
+ useEffect(() => {
+ localStorage.setItem('filtersSortBy', sortBy)
+ localStorage.setItem('filtersSortDirection', sortDirection)
+ }, [sortBy, sortDirection])
+
// Sort filters: pinned first, then by usage count, then by last used
const savedFilters = useMemo(() => {
return [...filtersData].sort((a, b) => {
@@ -283,6 +305,71 @@ const FilterView = () => {
})
}, [filtersData])
+ const visibleFilters = useMemo(() => {
+ if (pinnedFilter === 'pinned') {
+ return savedFilters.filter(filter => filter.isPinned)
+ }
+ if (pinnedFilter === 'unpinned') {
+ return savedFilters.filter(filter => !filter.isPinned)
+ }
+ return savedFilters
+ }, [pinnedFilter, savedFilters])
+
+ const fuse = useMemo(
+ () =>
+ new Fuse(visibleFilters, {
+ keys: ['name', 'description'],
+ includeScore: true,
+ isCaseSensitive: false,
+ findAllMatches: true,
+ }),
+ [visibleFilters],
+ )
+
+ const filteredFilters = useMemo(() => {
+ const matched = searchTerm
+ ? fuse.search(searchTerm).map(result => result.item)
+ : visibleFilters
+
+ const direction = sortDirection === 'desc' ? -1 : 1
+
+ // "Smart" keeps the pinned-then-usage order the list already arrives in.
+ if (sortBy === 'smart') {
+ return direction === -1 ? [...matched].reverse() : matched
+ }
+
+ return [...matched].sort((a, b) => {
+ switch (sortBy) {
+ case 'name':
+ return direction * (a.name || '').localeCompare(b.name || '')
+ case 'usage':
+ return direction * ((a.usageCount || 0) - (b.usageCount || 0))
+ case 'lastUsed': {
+ const aUsed = new Date(a.lastUsedAt || 0).getTime()
+ const bUsed = new Date(b.lastUsedAt || 0).getTime()
+ return direction * (aUsed - bUsed)
+ }
+ case 'created': {
+ const aCreated = new Date(a.createdAt || 0).getTime()
+ const bCreated = new Date(b.createdAt || 0).getTime()
+ return direction * (aCreated - bCreated)
+ }
+ default:
+ return 0
+ }
+ })
+ }, [fuse, searchTerm, visibleFilters, sortBy, sortDirection])
+
+ const handleSearchChange = e => {
+ setSearchTerm(e.target.value)
+ setShowMoreInfoId(null)
+ }
+
+ const handleSearchClose = () => {
+ setSearchTerm('')
+ searchInputRef.current?.blur()
+ }
+
// Calculate task counts for each filter
useEffect(() => {
if (chores && chores.res && savedFilters.length > 0) {
@@ -323,6 +410,21 @@ const FilterView = () => {
setShowAdvancedFilterBuilder(true)
}
+ // ?create=1 lets other surfaces (global search quick actions) land here with
+ // the filter builder already open.
+ useEffect(() => {
+ if (searchParams.get('create') !== '1') return
+ setEditingFilter(null)
+ setShowAdvancedFilterBuilder(true)
+ setSearchParams(
+ params => {
+ params.delete('create')
+ return params
+ },
+ { replace: true },
+ )
+ }, [searchParams, setSearchParams])
+
const handleEditFilter = filter => {
setEditingFilter(filter)
setShowAdvancedFilterBuilder(true)
@@ -414,6 +516,68 @@ const FilterView = () => {
+ {savedFilters.length > 0 && (
+
+ }
+ endDecorator={
+ searchTerm && (
+
+
+
+ )
+ }
+ />
+ {
+ setPinnedFilter(value)
+ setShowMoreInfoId(null)
+ }}
+ isActive={
+ pinnedFilter !== 'all' ||
+ sortBy !== 'smart' ||
+ sortDirection !== 'asc'
+ }
+ />
+
+ )}
+
{
onClick: handleAddFilter,
}}
/>
+ ) : filteredFilters.length === 0 ? (
+ }
+ title='No filters match'
+ description={
+ searchTerm
+ ? `No saved filter matches "${searchTerm}".`
+ : 'No saved filter matches the current filter.'
+ }
+ primaryAction={{
+ label: searchTerm ? 'Clear search' : 'Show all filters',
+ onClick: () => {
+ handleSearchClose()
+ setPinnedFilter('all')
+ },
+ }}
+ />
) : (
- {savedFilters.map(filter => {
+ {filteredFilters.map(filter => {
return (
{
+ const { t } = useTranslation('labels')
+ const { labelId } = useParams()
+ const navigate = useNavigate()
+ const queryClient = useQueryClient()
+
+ const { data: labels, isLoading: isLabelsLoading } = useLabels()
+ const { data: choresData, isLoading: isChoresLoading } = useChores(false)
+ const { data: membersData, isLoading: isMembersLoading } = useCircleMembers()
+ const { data: userProfile } = useUserProfile()
+
+ const [searchTerm, setSearchTerm] = useState('')
+ const [statusFilter, setStatusFilter] = useState('all')
+ const [modalOpen, setModalOpen] = useState(false)
+ const [confirmationModel, setConfirmationModel] = useState({})
+ const [viewMode, setViewMode] = useState(
+ localStorage.getItem('labelDetailViewMode') || 'default',
+ )
+ const searchInputRef = useRef(null)
+
+ const label = useMemo(
+ () => (labels || []).find(item => String(item.id) === String(labelId)),
+ [labels, labelId],
+ )
+
+ // Tasks carrying this label, soonest due first — undated tasks sink to the
+ // bottom rather than sorting as epoch 0.
+ const labelChores = useMemo(() => {
+ const chores = choresData?.res || []
+ return chores
+ .filter(chore =>
+ chore.labelsV2?.some(item => String(item.id) === String(labelId)),
+ )
+ .sort((a, b) => {
+ if (!a.nextDueDate) return 1
+ if (!b.nextDueDate) return -1
+ return new Date(a.nextDueDate) - new Date(b.nextDueDate)
+ })
+ }, [choresData, labelId])
+
+ // Buckets are exclusive: a task due at 9am today is overdue by 3pm, and
+ // counting it under both "overdue" and "today" would make the chips add up
+ // to more than the task count.
+ const bucketOf = chore => {
+ if (!chore.nextDueDate) return 'undated'
+ if (moment(chore.nextDueDate).isBefore()) return 'overdue'
+ if (moment(chore.nextDueDate).isSame(moment(), 'day')) return 'today'
+ return 'upcoming'
+ }
+
+ const counts = useMemo(() => {
+ const tally = { overdue: 0, today: 0, undated: 0 }
+ labelChores.forEach(chore => {
+ const bucket = bucketOf(chore)
+ if (bucket in tally) tally[bucket] += 1
+ })
+ return { ...tally, all: labelChores.length }
+ }, [labelChores])
+
+ const fuse = useMemo(
+ () =>
+ new Fuse(labelChores, {
+ keys: ['name', 'description'],
+ includeScore: true,
+ isCaseSensitive: false,
+ findAllMatches: true,
+ }),
+ [labelChores],
+ )
+
+ const visibleChores = useMemo(() => {
+ const searched = searchTerm
+ ? fuse.search(searchTerm).map(result => result.item)
+ : labelChores
+ if (statusFilter === 'all') return searched
+ return searched.filter(chore => bucketOf(chore) === statusFilter)
+ }, [fuse, searchTerm, labelChores, statusFilter])
+
+ const handleSearchClose = () => {
+ setSearchTerm('')
+ searchInputRef.current?.blur()
+ }
+
+ const resetFilters = () => {
+ setSearchTerm('')
+ setStatusFilter('all')
+ searchInputRef.current?.blur()
+ }
+
+ const toggleViewMode = () => {
+ const newMode = viewMode === 'default' ? 'compact' : 'default'
+ setViewMode(newMode)
+ localStorage.setItem('labelDetailViewMode', newMode)
+ }
+
+ const handleSaveLabel = () => {
+ queryClient.invalidateQueries({ queryKey: ['labels'] })
+ setModalOpen(false)
+ }
+
+ const handleDeleteClicked = () => {
+ setConfirmationModel({
+ isOpen: true,
+ title: t('delete.title'),
+ message: t('delete.message'),
+ confirmText: t('common:delete'),
+ color: 'danger',
+ cancelText: t('common:cancel'),
+ onClose: confirmed => {
+ if (confirmed === true) {
+ DeleteLabel(label.id).then(() => {
+ queryClient.invalidateQueries({ queryKey: ['labels'] })
+ navigate('/labels')
+ })
+ }
+ setConfirmationModel({})
+ },
+ })
+ }
+
+ if (isLabelsLoading || isChoresLoading || isMembersLoading) {
+ return
+ }
+
+ if (!label) {
+ return (
+
+ }
+ title={t('detail.notFoundTitle')}
+ description={t('detail.notFoundDescription')}
+ primaryAction={{ label: t('detail.backToLabels'), to: '/labels' }}
+ />
+
+ )
+ }
+
+ const isOwnedByCurrentUser = label.created_by === userProfile?.id
+
+ return (
+
+ {/* Identity: the label's own color is what names this page, so it leads
+ the title rather than sitting in a decorative avatar. */}
+
+
+
+
+
+ {label.name}
+
+ {!isOwnedByCurrentUser && (
+
+ {t('shared')}
+
+ )}
+
+
+ {t('detail.taskCount', { count: counts.all })}
+
+
+
+ {/* One trailing target. Delete is destructive, so it lives behind the
+ overflow instead of being a naked icon next to the title. */}
+
+
+
+
+
+
+
+
+ {/* Status chips double as the filter control: the counts users want to
+ read are the cuts they want to make. */}
+ {labelChores.length > 0 && (
+
+ {[
+ { id: 'all', label: t('detail.filters.all'), color: 'neutral' },
+ {
+ id: 'overdue',
+ label: t('detail.filters.overdue'),
+ color: 'danger',
+ },
+ { id: 'today', label: t('detail.filters.today'), color: 'primary' },
+ {
+ id: 'undated',
+ label: t('detail.filters.undated'),
+ color: 'neutral',
+ },
+ ]
+ .filter(chip => chip.id === 'all' || counts[chip.id] > 0)
+ .map(chip => {
+ const isSelected = statusFilter === chip.id
+ return (
+ setStatusFilter(chip.id)}
+ aria-pressed={isSelected}
+ sx={{ flexShrink: 0 }}
+ endDecorator={
+
+ {counts[chip.id]}
+
+ }
+ >
+ {chip.label}
+
+ )
+ })}
+
+ )}
+
+ {/* Search + view mode */}
+ {labelChores.length > 0 && (
+
+ setSearchTerm(e.target.value.toLowerCase())}
+ startDecorator={}
+ endDecorator={
+ searchTerm && (
+
+
+
+ )
+ }
+ />
+
+ {viewMode === 'default' ? : }
+
+
+ )}
+
+ {/* Tasks */}
+ {labelChores.length === 0 ? (
+ }
+ title={t('detail.emptyTitle')}
+ description={t('detail.emptyDescription', { label: label.name })}
+ primaryAction={{ label: t('detail.browseTasks'), to: '/chores' }}
+ />
+ ) : visibleChores.length === 0 ? (
+ }
+ title={t('detail.noResultsTitle')}
+ description={
+ searchTerm
+ ? t('detail.noResultsDescription', { searchTerm })
+ : t('detail.noMatchingStatus')
+ }
+ primaryAction={{
+ label: t('detail.clearFilters'),
+ onClick: resetFilters,
+ }}
+ />
+ ) : (
+
+
+
+ )}
+
+ {modalOpen && (
+ setModalOpen(false)}
+ onSave={handleSaveLabel}
+ label={label}
+ />
+ )}
+
+
+ )
+}
+
+export default LabelDetailView
diff --git a/src/views/Labels/LabelView.jsx b/src/views/Labels/LabelView.jsx
index 841215e..298eb8c 100644
--- a/src/views/Labels/LabelView.jsx
+++ b/src/views/Labels/LabelView.jsx
@@ -1,3 +1,20 @@
+import '@meauxt/react-swipeable-list/dist/styles.css'
+
+import {
+ SwipeableList,
+ SwipeableListItem,
+ SwipeAction,
+ TrailingActions,
+ Type as ListType,
+} from '@meauxt/react-swipeable-list'
+import {
+ Add,
+ Close,
+ MoreVert,
+ Search,
+ SearchOff,
+ Style,
+} from '@mui/icons-material'
import DeleteIcon from '@mui/icons-material/Delete'
import EditIcon from '@mui/icons-material/Edit'
import {
@@ -7,32 +24,27 @@ import {
CircularProgress,
Container,
IconButton,
+ Input,
Stack,
Typography,
} 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 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 { getTextColorFromBackgroundColor } from '../../utils/Colors'
import { DeleteLabel } from '../../utils/Fetcher'
import { getSafeBottomStyles } from '../../utils/SafeAreaUtils'
import ConfirmationModal from '../Modals/Inputs/ConfirmationModal'
+import LabelModal from '../Modals/Inputs/LabelModal'
import { useLabels } from './LabelQueries'
-const LabelCardContent = ({ label, currentUserId, onToggleActions }) => {
+const LabelCardContent = ({ currentUserId, label, onToggleActions }) => {
const { t } = useTranslation('labels')
// Check if current user owns this label
const isOwnedByCurrentUser = label.created_by === currentUserId
@@ -152,8 +164,10 @@ const LabelCardContent = ({ label, currentUserId, onToggleActions }) => {
const LabelView = () => {
const { t } = useTranslation('labels')
- const { data: labels, isLabelsLoading, isError } = useLabels()
+ const { data: labels, isError, isLabelsLoading } = useLabels()
const { data: userProfile } = useUserProfile()
+ const navigate = useNavigate()
+ const [searchParams, setSearchParams] = useSearchParams()
const [userLabels, setUserLabels] = useState([])
const [modalOpen, setModalOpen] = useState(false)
@@ -162,6 +176,70 @@ const LabelView = () => {
const queryClient = useQueryClient()
const [confirmationModel, setConfirmationModel] = useState({})
const [showMoreInfoId, setShowMoreInfoId] = useState(null)
+ const [searchTerm, setSearchTerm] = useState('')
+ const [sortBy, setSortBy] = useState(
+ () => localStorage.getItem('labelsSortBy') || 'name',
+ )
+ const [sortDirection, setSortDirection] = useState(
+ () => localStorage.getItem('labelsSortDirection') || 'asc',
+ )
+ const [ownershipFilter, setOwnershipFilter] = useState('all')
+ const searchInputRef = useRef(null)
+
+ useEffect(() => {
+ localStorage.setItem('labelsSortBy', sortBy)
+ localStorage.setItem('labelsSortDirection', sortDirection)
+ }, [sortBy, sortDirection])
+
+ const visibleLabels = useMemo(() => {
+ if (ownershipFilter === 'mine') {
+ return userLabels.filter(label => label.created_by === userProfile?.id)
+ }
+ if (ownershipFilter === 'shared') {
+ return userLabels.filter(label => label.created_by !== userProfile?.id)
+ }
+ return userLabels
+ }, [ownershipFilter, userLabels, userProfile?.id])
+
+ const fuse = useMemo(
+ () =>
+ new Fuse(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 = () => {
setCurrentLabel(null)
@@ -214,6 +292,21 @@ const LabelView = () => {
}
}, [labels])
+ // ?create=1 lets other surfaces (global search quick actions) land here with
+ // the create modal already open.
+ useEffect(() => {
+ if (searchParams.get('create') !== '1') return
+ setCurrentLabel(null)
+ setModalOpen(true)
+ setSearchParams(
+ params => {
+ params.delete('create')
+ return params
+ },
+ { replace: true },
+ )
+ }, [searchParams, setSearchParams])
+
if (isLabelsLoading) {
return (
{
+ {userLabels.length > 0 && (
+
+ }
+ endDecorator={
+ searchTerm && (
+
+
+
+ )
+ }
+ />
+ {
+ setOwnershipFilter(value)
+ setShowMoreInfoId(null)
+ }}
+ isActive={
+ ownershipFilter !== 'all' ||
+ sortBy !== 'name' ||
+ sortDirection !== 'asc'
+ }
+ />
+
+ )}
{
}}
/>
)}
+ {userLabels.length > 0 && filteredLabels.length === 0 && (
+ }
+ title={t('search.noResultsTitle')}
+ description={
+ searchTerm
+ ? t('search.noResultsDescription', { searchTerm })
+ : t('search.noFilterResultsDescription')
+ }
+ primaryAction={{
+ label: searchTerm ? t('search.clear') : t('search.showAll'),
+ onClick: () => {
+ handleSearchClose()
+ setOwnershipFilter('all')
+ },
+ }}
+ />
+ )}
- {userLabels.map(label => (
+ {filteredLabels.map(label => (
navigate(`/labels/${label.id}`)}
swipeActionOpen={showMoreInfoId === label.id ? 'trailing' : null}
trailingActions={
diff --git a/src/views/Modals/ErrorReportModal.jsx b/src/views/Modals/ErrorReportModal.jsx
index be4d41b..5c8333e 100644
--- a/src/views/Modals/ErrorReportModal.jsx
+++ b/src/views/Modals/ErrorReportModal.jsx
@@ -329,7 +329,7 @@ const ErrorReportModal = ({ error, errorInfo, onClose, open }) => {
level='body-sm'
sx={{ color: 'text.secondary', mt: 0.5 }}
>
- Thanks — this goes straight to the people who can fix it.
+ Thanks! this goes straight to the people who can fix it.
diff --git a/src/views/Modals/PolicyUpdateModal.jsx b/src/views/Modals/PolicyUpdateModal.jsx
index 6afdd9b..de22db7 100644
--- a/src/views/Modals/PolicyUpdateModal.jsx
+++ b/src/views/Modals/PolicyUpdateModal.jsx
@@ -1,11 +1,23 @@
+import { Browser } from '@capacitor/browser'
+import { Capacitor } from '@capacitor/core'
import { ChevronRight, Gavel, PrivacyTip } from '@mui/icons-material'
import { Button, Stack } from '@mui/joy'
import { useTranslation } from 'react-i18next'
-import { useNavigate } from 'react-router-dom'
import ModalActions from '../../components/common/ModalActions.jsx'
import { useResponsiveModal } from '../../hooks/useResponsiveModal.js'
+const POLICY_BASE_URL = 'https://app.donetick.com'
+
+// Native webviews swallow target="_blank"; route through the system browser.
+const openUrl = async url => {
+ if (Capacitor.isNativePlatform()) {
+ await Browser.open({ url })
+ } else {
+ window.open(url, '_blank', 'noopener,noreferrer')
+ }
+}
+
/**
* One-time notice that the Privacy Policy and Terms changed. The frame is
* generic and always points at the documents, so a future revision only needs
@@ -14,16 +26,18 @@ import { useResponsiveModal } from '../../hooks/useResponsiveModal.js'
const PolicyUpdateModal = ({ onAcknowledge, onClose, open }) => {
const { t } = useTranslation()
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?.()
onClose()
}
+ // Reading a document must not dismiss the notice; the modal is still waiting
+ // on an acknowledgement when the user returns from the browser.
const openDocument = path => {
- handleClose()
- navigate(path)
+ openUrl(`${POLICY_BASE_URL}${path}`)
}
const documentButtonSx = {
@@ -36,7 +50,10 @@ const PolicyUpdateModal = ({ onAcknowledge, onClose, open }) => {
return (
{
diff --git a/src/views/Projects/ProjectView.jsx b/src/views/Projects/ProjectView.jsx
index ac48fdf..1baffde 100644
--- a/src/views/Projects/ProjectView.jsx
+++ b/src/views/Projects/ProjectView.jsx
@@ -1,3 +1,20 @@
+import '@meauxt/react-swipeable-list/dist/styles.css'
+
+import {
+ SwipeableList,
+ SwipeableListItem,
+ SwipeAction,
+ TrailingActions,
+ Type as ListType,
+} from '@meauxt/react-swipeable-list'
+import {
+ Add,
+ Close,
+ MoreVert,
+ Search,
+ SearchOff,
+ Task,
+} from '@mui/icons-material'
import DeleteIcon from '@mui/icons-material/Delete'
import EditIcon from '@mui/icons-material/Edit'
import {
@@ -7,24 +24,18 @@ import {
CircularProgress,
Container,
IconButton,
+ Input,
Stack,
Typography,
} 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 Fuse from 'fuse.js'
+import { useEffect, useMemo, useRef, useState } from 'react'
+import { useTranslation } from 'react-i18next'
+import { useNavigate, useSearchParams } from 'react-router-dom'
+
+import EmptyState from '../../components/common/EmptyState'
+import SortAndFilterMenu from '../../components/common/SortAndFilterMenu'
import { useChores } from '../../queries/ChoreQueries'
import { useUserProfile } from '../../queries/UserQueries'
import { getTextColorFromBackgroundColor } from '../../utils/Colors'
@@ -33,13 +44,14 @@ import { getIconComponent } from '../../utils/ProjectIcons'
import { getSafeBottomStyles } from '../../utils/SafeAreaUtils'
import { useProjectFilter } from '../Chores/hooks/useProjectFilter'
import ConfirmationModal from '../Modals/Inputs/ConfirmationModal'
+import ProjectModal from '../Modals/Inputs/ProjectModal'
import { useProjects } from './ProjectQueries'
const ProjectCardContent = ({
- project,
currentUserId,
- taskCounts = {},
onCardClick,
onToggleActions,
+ project,
+ taskCounts = {},
}) => {
const { t } = useTranslation('projects')
// Check if current user owns this project
@@ -221,7 +233,7 @@ const ProjectCardContent = ({
const ProjectView = () => {
const { t } = useTranslation('projects')
- const { data: projects, isProjectsLoading, isError } = useProjects()
+ const { data: projects, isError, isProjectsLoading } = useProjects()
const { data: userProfile } = useUserProfile()
const { data: chores = { res: [] } } = useChores(false) // false to exclude archived
const { data: projectsData = [], isLoading: projectsLoading } = useProjects()
@@ -230,6 +242,7 @@ const ProjectView = () => {
!projectsLoading,
)
const navigate = useNavigate()
+ const [searchParams, setSearchParams] = useSearchParams()
const [userProjects, setUserProjects] = useState([])
const [modalOpen, setModalOpen] = useState(false)
@@ -238,6 +251,83 @@ const ProjectView = () => {
const queryClient = useQueryClient()
const [confirmationModel, setConfirmationModel] = useState({})
const [showMoreInfoId, setShowMoreInfoId] = useState(null)
+ const [searchTerm, setSearchTerm] = useState('')
+ const [sortBy, setSortBy] = useState(
+ () => localStorage.getItem('projectsSortBy') || 'name',
+ )
+ const [sortDirection, setSortDirection] = useState(
+ () => localStorage.getItem('projectsSortDirection') || 'asc',
+ )
+ const [ownershipFilter, setOwnershipFilter] = useState('all')
+ const searchInputRef = useRef(null)
+
+ useEffect(() => {
+ localStorage.setItem('projectsSortBy', sortBy)
+ localStorage.setItem('projectsSortDirection', sortDirection)
+ }, [sortBy, sortDirection])
+
+ const visibleProjects = useMemo(() => {
+ if (ownershipFilter === 'mine') {
+ return userProjects.filter(
+ project => project.created_by === userProfile?.id,
+ )
+ }
+ if (ownershipFilter === 'shared') {
+ return userProjects.filter(
+ project => project.created_by !== userProfile?.id,
+ )
+ }
+ return userProjects
+ }, [ownershipFilter, userProjects, userProfile?.id])
+
+ const fuse = useMemo(
+ () =>
+ new Fuse(visibleProjects, {
+ keys: ['name', 'description'],
+ includeScore: true,
+ isCaseSensitive: false,
+ findAllMatches: true,
+ }),
+ [visibleProjects],
+ )
+
+ const filteredProjects = useMemo(() => {
+ const matched = searchTerm
+ ? fuse.search(searchTerm).map(result => result.item)
+ : visibleProjects
+
+ const direction = sortDirection === 'desc' ? -1 : 1
+ return [...matched].sort((a, b) => {
+ switch (sortBy) {
+ case 'tasks':
+ return direction * ((taskCounts[a.id] || 0) - (taskCounts[b.id] || 0))
+ case 'created':
+ return direction * ((a.id || 0) - (b.id || 0))
+ case 'name':
+ default:
+ return direction * (a.name || '').localeCompare(b.name || '')
+ }
+ })
+ }, [fuse, searchTerm, visibleProjects, sortBy, sortDirection, taskCounts])
+
+ // The default project is pinned above the list, so it is matched separately.
+ const showDefaultProject = useMemo(() => {
+ if (ownershipFilter === 'shared') return false
+ if (!searchTerm) return true
+ return t('chores:toolbar.defaultProject')
+ .toLowerCase()
+ .includes(searchTerm.toLowerCase())
+ }, [ownershipFilter, searchTerm, t])
+
+ const handleSearchChange = e => {
+ setSearchTerm(e.target.value)
+ setShowMoreInfoId(null)
+ }
+
+ const handleSearchClose = () => {
+ setSearchTerm('')
+ searchInputRef.current?.blur()
+ }
const handleAddProject = () => {
setCurrentProject(null)
@@ -302,6 +392,21 @@ const ProjectView = () => {
}
}, [projects])
+ // ?create=1 lets other surfaces (global search quick actions) land here with
+ // the create modal already open.
+ useEffect(() => {
+ if (searchParams.get('create') !== '1') return
+ setCurrentProject(null)
+ setModalOpen(true)
+ setSearchParams(
+ params => {
+ params.delete('create')
+ return params
+ },
+ { replace: true },
+ )
+ }, [searchParams, setSearchParams])
+
// Calculate real task counts from chores data
useEffect(() => {
if (chores && chores.res) {
@@ -371,36 +476,114 @@ const ProjectView = () => {
+
+ }
+ endDecorator={
+ searchTerm && (
+
+
+
+ )
+ }
+ />
+ {
+ setOwnershipFilter(value)
+ setShowMoreInfoId(null)
+ }}
+ isActive={
+ ownershipFilter !== 'all' ||
+ sortBy !== 'name' ||
+ sortDirection !== 'asc'
+ }
+ />
+
+
+ {!showDefaultProject && filteredProjects.length === 0 && (
+ }
+ title={t('search.noResultsTitle')}
+ description={
+ searchTerm
+ ? t('search.noResultsDescription', { searchTerm })
+ : t('search.noFilterResultsDescription')
+ }
+ primaryAction={{
+ label: searchTerm ? t('search.clear') : t('search.showAll'),
+ onClick: () => {
+ handleSearchClose()
+ setOwnershipFilter('all')
+ },
+ }}
+ />
+ )}
{/* Default project - not swipeable */}
-
- handleCardClick({
+ {showDefaultProject && (
+
+ created_by: userProfile?.id,
+ }}
+ currentUserId={userProfile?.id}
+ taskCounts={{ default: taskCounts.default || 0 }}
+ onCardClick={() =>
+ handleCardClick({
+ id: 'default',
+ name: t('chores:toolbar.defaultProject'),
+ icon: 'FolderOpen',
+ color: '#1976d2',
+ })
+ }
+ />
+ )}
{/* User projects - swipeable */}
- {userProjects.map(project => (
+ {filteredProjects.map(project => (
handleCardClick(project)}
key={project.id}
diff --git a/src/views/Things/ThingsView.jsx b/src/views/Things/ThingsView.jsx
index 2136bbc..68d13aa 100644
--- a/src/views/Things/ThingsView.jsx
+++ b/src/views/Things/ThingsView.jsx
@@ -9,11 +9,14 @@ import {
} from '@meauxt/react-swipeable-list'
import {
Add,
+ Close,
Delete,
Edit,
Flip,
MoreVert,
PlusOne,
+ Search,
+ SearchOff,
ToggleOff,
ToggleOn,
Widgets,
@@ -24,14 +27,17 @@ import {
Chip,
Container,
IconButton,
+ Input,
Stack,
Typography,
} from '@mui/joy'
-import { useEffect, useState } from 'react'
+import Fuse from 'fuse.js'
+import { useEffect, useMemo, useRef, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { track } from '../../analytics'
import EmptyState from '../../components/common/EmptyState'
+import SortAndFilterMenu from '../../components/common/SortAndFilterMenu'
import { useNotification } from '../../service/NotificationProvider'
import {
CreateThing,
@@ -215,8 +221,78 @@ const ThingsView = () => {
const [createModalThing, setCreateModalThing] = useState(null)
const [confirmModelConfig, setConfirmModelConfig] = useState({})
const [showMoreInfoId, setShowMoreInfoId] = useState(null)
+ const [searchTerm, setSearchTerm] = useState('')
+ const [sortBy, setSortBy] = useState(
+ () => localStorage.getItem('thingsSortBy') || 'name',
+ )
+ const [sortDirection, setSortDirection] = useState(
+ () => localStorage.getItem('thingsSortDirection') || 'asc',
+ )
+ const [typeFilter, setTypeFilter] = useState('all')
+ const searchInputRef = useRef(null)
const { showError, showNotification } = useNotification()
+ useEffect(() => {
+ localStorage.setItem('thingsSortBy', sortBy)
+ localStorage.setItem('thingsSortDirection', sortDirection)
+ }, [sortBy, sortDirection])
+
+ const visibleThings = useMemo(
+ () =>
+ typeFilter === 'all'
+ ? things
+ : things.filter(thing => thing?.type === typeFilter),
+ [things, typeFilter],
+ )
+
+ const fuse = useMemo(
+ () =>
+ new Fuse(visibleThings, {
+ keys: ['name', 'state'],
+ includeScore: true,
+ isCaseSensitive: false,
+ findAllMatches: true,
+ }),
+ [visibleThings],
+ )
+
+ const filteredThings = useMemo(() => {
+ const matched = searchTerm
+ ? fuse.search(searchTerm).map(result => result.item)
+ : visibleThings
+
+ const direction = sortDirection === 'desc' ? -1 : 1
+ return [...matched].sort((a, b) => {
+ switch (sortBy) {
+ case 'type':
+ return direction * (a.type || '').localeCompare(b.type || '')
+ case 'state':
+ return (
+ direction *
+ String(a.state ?? '').localeCompare(String(b.state ?? ''))
+ )
+ case 'updated': {
+ const aDate = new Date(a.updatedAt || a.updated_at || 0).getTime()
+ const bDate = new Date(b.updatedAt || b.updated_at || 0).getTime()
+ return direction * (aDate - bDate)
+ }
+ case 'name':
+ default:
+ return direction * (a.name || '').localeCompare(b.name || '')
+ }
+ })
+ }, [fuse, searchTerm, visibleThings, sortBy, sortDirection])
+
+ const handleSearchChange = e => {
+ setSearchTerm(e.target.value)
+ setShowMoreInfoId(null)
+ }
+
+ const handleSearchClose = () => {
+ setSearchTerm('')
+ searchInputRef.current?.blur()
+ }
+
useEffect(() => {
// fetch things
GetThings().then(result => {
@@ -404,6 +480,67 @@ const ThingsView = () => {
+ {things.length > 0 && (
+
+ }
+ endDecorator={
+ searchTerm && (
+
+
+
+ )
+ }
+ />
+ {
+ setTypeFilter(value)
+ setShowMoreInfoId(null)
+ }}
+ isActive={
+ typeFilter !== 'all' ||
+ sortBy !== 'name' ||
+ sortDirection !== 'asc'
+ }
+ />
+
+ )}
{
}}
/>
)}
+ {things.length > 0 && filteredThings.length === 0 && (
+ }
+ title='No things match'
+ description={
+ searchTerm
+ ? `No thing matches "${searchTerm}".`
+ : 'No thing matches the current filter.'
+ }
+ primaryAction={{
+ label: searchTerm ? 'Clear search' : 'Show all things',
+ onClick: () => {
+ handleSearchClose()
+ setTypeFilter('all')
+ },
+ }}
+ />
+ )}
- {things.map(thing => (
+ {filteredThings.map(thing => (
navigate(`/things/${thing?.id}`)}
key={thing.id}
diff --git a/src/views/components/AdvancedOptionsSection.jsx b/src/views/components/AdvancedOptionsSection.jsx
index 4bf4734..68a662c 100644
--- a/src/views/components/AdvancedOptionsSection.jsx
+++ b/src/views/components/AdvancedOptionsSection.jsx
@@ -319,7 +319,7 @@ const AdvancedOptionsSection = ({
textColor='text.tertiary'
sx={{ my: 0.5, fontStyle: 'italic' }}
>
- Set a due date to configure completion window and deadline.
+ Set a due date to configure completion window
)}
diff --git a/src/views/components/ChoreActionMenu.jsx b/src/views/components/ChoreActionMenu.jsx
index e231f0a..5b30d14 100644
--- a/src/views/components/ChoreActionMenu.jsx
+++ b/src/views/components/ChoreActionMenu.jsx
@@ -6,6 +6,7 @@ import {
Delete,
DriveFileMove,
Edit,
+ Flag,
ManageSearch,
MoreTime,
MoreVert,
@@ -24,6 +25,8 @@ import {
} from '@mui/icons-material'
import {
Avatar,
+ Button,
+ Chip,
Divider,
IconButton,
List,
@@ -45,9 +48,21 @@ import LABEL_COLORS, {
getTextColorFromBackgroundColor,
} from '../../utils/Colors'
import { isOfficialDonetickInstanceSync } from '../../utils/FeatureToggle'
+import Priorities from '../../utils/Priorities'
import { getIconComponent } from '../../utils/ProjectIcons'
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 = ({
chore,
onAction,
@@ -55,12 +70,15 @@ const ChoreActionMenu = ({
onCompleteWithPastDate,
onChangeAssignee,
onChangeDueDate,
+ onChangePriority,
onWriteNFC,
onNudge,
onDelete,
onOpen,
onMouseEnter,
onMouseLeave,
+ hiddenActions = [],
+ trigger,
sx = {},
variant = 'soft',
}) => {
@@ -68,6 +86,7 @@ const ChoreActionMenu = ({
const [anchorEl, setAnchorEl] = React.useState(null)
const [isOfficialInstance, setIsOfficialInstance] = useState(false)
const [showProjectPicker, setShowProjectPicker] = useState(false)
+ const [showPriorityPicker, setShowPriorityPicker] = useState(false)
const menuRef = React.useRef(null)
const navigate = useNavigate()
const { data: projects = [] } = useProjects()
@@ -119,6 +138,12 @@ const ChoreActionMenu = ({
const handleMenuClose = () => {
setAnchorEl(null)
setShowProjectPicker(false)
+ setShowPriorityPicker(false)
+ }
+
+ const handleChangePriority = priority => {
+ onChangePriority?.(priority)
+ handleMenuClose()
}
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
// ListItemButton list inside an AppModal sheet on small screens.
const actionItems = [
@@ -310,6 +338,21 @@ const ChoreActionMenu = ({
handleMenuClose()
},
},
+ onChangePriority && {
+ key: 'priority',
+ icon: ,
+ label: 'Priority',
+ onClick: () => setShowPriorityPicker(true),
+ endDecorator: (
+
+ {currentPriority?.name.trim() || 'None'}
+
+ ),
+ },
{
key: 'writeNfc',
icon: ,
@@ -343,7 +386,11 @@ const ChoreActionMenu = ({
onClick: handleDelete,
color: 'danger',
},
- ].filter(Boolean)
+ ]
+ .filter(Boolean)
+ .filter(item => !hiddenActions.includes(item.key))
+
+ const visibleActionItems = collapseDividers(actionItems)
const quickScheduleButtons = (
<>
@@ -416,7 +463,7 @@ const ChoreActionMenu = ({
}
const renderMenuActionItems = () =>
- actionItems.map(item => {
+ visibleActionItems.map(item => {
if (item.type === 'divider') return
if (item.type === 'quickSchedule') {
return (
@@ -444,12 +491,17 @@ const ChoreActionMenu = ({
>
{item.icon}
{item.label}
+ {item.endDecorator && (
+
+ {item.endDecorator}
+
+ )}
)
})
const renderModalActionItems = () =>
- actionItems.map(item => {
+ visibleActionItems.map(item => {
if (item.type === 'divider') return
if (item.type === 'quickSchedule') {
return (
@@ -463,13 +515,62 @@ const ChoreActionMenu = ({
item.onClick()}>
{item.icon}
{item.label}
+ {item.endDecorator}
)
})
+ const priorityOptions = [...Priorities, NO_PRIORITY]
+
+ const renderModalPriorityPicker = () =>
+ priorityOptions.map(priority => (
+
+ handleChangePriority(priority)}
+ >
+ {priority.icon || }
+ {priority.name.trim()}
+
+
+ ))
+
+ const renderMenuPriorityPicker = () => (
+ <>
+ {
+ e.stopPropagation()
+ setShowPriorityPicker(false)
+ }}
+ sx={{ gap: 1 }}
+ >
+
+
+ Priority
+
+
+
+ {priorityOptions.map(priority => (
+ {
+ e.stopPropagation()
+ handleChangePriority(priority)
+ }}
+ >
+ {priority.icon || }
+ {priority.name.trim()}
+
+ ))}
+ >
+ )
+
const renderModalProjectPicker = () => (
-
+ <>
@@ -492,53 +593,75 @@ const ChoreActionMenu = ({
))}
-
+ >
)
return (
<>
-
-
-
+ {trigger ? (
+ React.cloneElement(trigger, {
+ onClick: handleMenuOpen,
+ onMouseEnter,
+ onMouseLeave,
+ })
+ ) : (
+
+
+
+ )}
{isSmallScreen ? (
- {showProjectPicker && (
- setShowProjectPicker(false)}
- sx={{ gap: 1, mx: 2, mb: 1 }}
+ {(showProjectPicker || showPriorityPicker) && (
+ }
+ onClick={() => {
+ setShowProjectPicker(false)
+ setShowPriorityPicker(false)
+ }}
+ sx={{ mx: 2, mb: 1 }}
>
-
{t('common:back')}
-
+
)}
{showProjectPicker
? renderModalProjectPicker()
- : renderModalActionItems()}
+ : showPriorityPicker
+ ? renderModalPriorityPicker()
+ : renderModalActionItems()}
) : (
@@ -554,7 +677,9 @@ const ChoreActionMenu = ({
left: '50%',
}}
>
- {showProjectPicker ? (
+ {showPriorityPicker ? (
+ renderMenuPriorityPicker()
+ ) : showProjectPicker ? (
<>
{