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 ─────────────────────────────────────── */} - - - - + {showTrigger && ( + + + + )} - ({ 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 }) => ( + + + + {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} + + + + {title} + + + {sortOptions.map(option => ( + { + onSortChange(option.value) + handleMenuClose() + }} + sx={{ + borderRadius: 'var(--joy-radius-sm)', + backgroundColor: + selectedSort === option.value + ? 'var(--joy-palette-primary-softBg)' + : 'transparent', + '&:hover': { + backgroundColor: + selectedSort === option.value + ? 'var(--joy-palette-primary-softBg)' + : 'var(--joy-palette-neutral-softHoverBg)', + }, + }} + > + + + + {option.name} + + {selectedSort === option.value && ( + + )} + + + + ))} + + {onSortDirectionChange && ( + <> + + + onSortDirectionChange(sortDirection === 'asc' ? 'desc' : 'asc') + } + sx={{ + borderRadius: 'var(--joy-radius-sm)', + '&:hover': { + backgroundColor: 'var(--joy-palette-neutral-softHoverBg)', + }, + }} + > + + {sortDirection === 'asc' ? ( + + ) : ( + + )} + + + + {sortDirection === 'asc' ? 'Ascending' : 'Descending'} + + + + + )} + + {filterOptions?.length > 0 && ( + <> + + {filterTitle || 'Filter'} + {filterOptions.map(option => ( + { + onFilterChange(option.value) + handleMenuClose() + }} + sx={{ + borderRadius: 'var(--joy-radius-sm)', + backgroundColor: + selectedFilter === option.value + ? 'var(--joy-palette-primary-softBg)' + : 'transparent', + '&:hover': { + backgroundColor: + selectedFilter === option.value + ? 'var(--joy-palette-primary-softBg)' + : 'var(--joy-palette-neutral-softHoverBg)', + }, + }} + > + + + + + + {option.name} + + + + ))} + + )} + + + ) +} + +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')} - - - {Priorities.map((priority, index) => ( - { - handleUpdatePriority(priority) - }} - color={priority.color} - > - {priority.icon} - {priority.name} - - ))} - - { - handleUpdatePriority({ - name: t('choreView.noPriority'), - value: 0, - }) - setChorePriority(null) - }} - > - {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 && ( + <> + + + {DUE_DATE_PRESETS.map(preset => ( + { + closeDueMenu() + onSetDueDate(preset.resolve()) + }} + > + + + + + {preset.label} + + {preset.hint()} + + + + ))} + + { + closeDueMenu() + setDueDatePickerOpen(true) + }} + > + + + + + Pick date… + + + { + closeDueMenu() + onSetDueDate(null) + }} + > + + + + + No due date + + + + + )} + + {onMoveToProject && ( <> + + + + {/* ── More sheet: the field editors that have no button in the bar ────── */} + setMoreOpen(false)} + maxHeight='92vh' + title={ + + + {selectedCount} task{selectedCount !== 1 ? 's' : ''} selected + + } + footer={ + + } + > + + {/* Due date, project and the destructive actions are deliberately + absent — each has its own button in the bar, and two entry points + would just make them ambiguous. */} + {onSetPriority && ( + <> + } + label='Priority' + value={priorityValue} + /> + + {Priorities.map(priority => { + const isCurrent = + !summary.priority?.isMixed && + summary.priority?.value === priority.value + return ( + : undefined + } + onClick={runAndClose(() => onSetPriority(priority.value))} + sx={selectableChipSx} + > + {priority.name.trim()} + + ) + })} + onSetPriority(0))} + sx={selectableChipSx} + > + None + + + + )} + + {onSetAssignee && assignableMembers.length > 0 && ( + <> + + } + label='Assignee' + value={assigneeValue} + /> + + {assignableMembers.map(member => { + const isCurrent = + !summary.assignee?.isMixed && + summary.assignee?.value === member.userId + return ( + + ) : ( + + {(member.displayName || '?') + .charAt(0) + .toUpperCase()} + + ) + } + onClick={runAndClose(() => onSetAssignee(member.userId))} + sx={selectableChipSx} + > + {member.displayName || member.username} + + ) + })} + + + )} + + {onToggleLabel && labels.length > 0 && ( + <> + + {/* Tapping adds the label to every task; tapping one that is + already on all of them removes it. A half-filled chip means + only some of the selection has it — tapping completes the set. */} + } + label='Labels' + value='Tap to add · tap again to remove' + /> + + {labels.map(label => { + const onAll = commonLabelIds.has(label.id) + const onSome = partialLabelIds.has(label.id) + return ( + + ) : onSome ? ( + + ) : undefined + } + onClick={runAndClose(() => + onToggleLabel(label, onAll ? 'remove' : 'add'), + )} + sx={{ + ...selectableChipSx, + ...(label.color && !onAll + ? { borderColor: label.color } + : {}), + ...(label.color && onAll + ? { + backgroundColor: label.color, + color: getTextColorFromBackgroundColor( + label.color, + ), + } + : {}), + }} + > + {label.name} + + ) + })} + + + )} + + + + {dueDatePickerOpen && ( + setDueDatePickerOpen(false)} + onApply={parts => { + setDueDatePickerOpen(false) + setMoreOpen(false) + // Passed through as parts, not a timestamp: leaving the time on + // "Anytime" means "keep each task's own hour", which only the + // per-chore handler can resolve. + onSetDueDate?.(parts.dueDateOnly ? parts : null) + }} + onRemove={() => { + setDueDatePickerOpen(false) + setMoreOpen(false) + onSetDueDate?.(null) + }} + /> + )} ) } diff --git a/src/views/Chores/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. */} + + + + + + setModalOpen(true)}> + + {t('common:edit')} + + + + + {t('common:delete')} + + + + + + {/* Status chips double as the filter control: the counts users want to + read are the cuts they want to make. */} + {labelChores.length > 0 && ( + + {[ + { id: 'all', label: t('detail.filters.all'), color: 'neutral' }, + { + id: 'overdue', + label: t('detail.filters.overdue'), + color: 'danger', + }, + { id: 'today', label: t('detail.filters.today'), color: 'primary' }, + { + id: 'undated', + label: t('detail.filters.undated'), + color: 'neutral', + }, + ] + .filter(chip => chip.id === 'all' || counts[chip.id] > 0) + .map(chip => { + const isSelected = statusFilter === chip.id + return ( + setStatusFilter(chip.id)} + aria-pressed={isSelected} + sx={{ flexShrink: 0 }} + endDecorator={ + + {counts[chip.id]} + + } + > + {chip.label} + + ) + })} + + )} + + {/* Search + view mode */} + {labelChores.length > 0 && ( + + setSearchTerm(e.target.value.toLowerCase())} + startDecorator={} + endDecorator={ + searchTerm && ( + + + + ) + } + /> + + {viewMode === 'default' ? : } + + + )} + + {/* Tasks */} + {labelChores.length === 0 ? ( + } + title={t('detail.emptyTitle')} + description={t('detail.emptyDescription', { label: label.name })} + primaryAction={{ label: t('detail.browseTasks'), to: '/chores' }} + /> + ) : visibleChores.length === 0 ? ( + } + title={t('detail.noResultsTitle')} + description={ + searchTerm + ? t('detail.noResultsDescription', { searchTerm }) + : t('detail.noMatchingStatus') + } + primaryAction={{ + label: t('detail.clearFilters'), + onClick: resetFilters, + }} + /> + ) : ( + + + + )} + + {modalOpen && ( + setModalOpen(false)} + onSave={handleSaveLabel} + label={label} + /> + )} + + + ) +} + +export default LabelDetailView diff --git a/src/views/Labels/LabelView.jsx b/src/views/Labels/LabelView.jsx index 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) && ( + )} {showProjectPicker ? renderModalProjectPicker() - : renderModalActionItems()} + : showPriorityPicker + ? renderModalPriorityPicker() + : renderModalActionItems()} ) : ( @@ -554,7 +677,9 @@ const ChoreActionMenu = ({ left: '50%', }} > - {showProjectPicker ? ( + {showPriorityPicker ? ( + renderMenuPriorityPicker() + ) : showProjectPicker ? ( <> {