diff --git a/public/locales/en/common.json b/public/locales/en/common.json
index 8df2fcf..dce97a7 100644
--- a/public/locales/en/common.json
+++ b/public/locales/en/common.json
@@ -20,6 +20,7 @@
"logout": "Logout",
"version": "Version",
"navigation": {
+ "search": "Search",
"allTasks": "All Tasks",
"archived": "Archived",
"things": "Things",
diff --git a/src/App.jsx b/src/App.jsx
index ec3684a..6f01c10 100644
--- a/src/App.jsx
+++ b/src/App.jsx
@@ -16,6 +16,7 @@ import useOnboardingGate from './hooks/useOnboardingGate'
import useStatusBar from './hooks/useStatusBar'
import { useSyncOnReconnect } from './hooks/useSyncOnReconnect'
import { useResource } from './queries/ResourceQueries'
+import { GlobalSearchProvider } from './search/GlobalSearchContext'
import { recordRoute } from './service/DiagnosticsSession'
import { useNotification } from './service/NotificationProvider'
import NetworkBanner from './views/components/NetworkBanner'
@@ -145,7 +146,9 @@ function App() {
-
+
+
+
diff --git a/src/contexts/RouterContext.jsx b/src/contexts/RouterContext.jsx
index bb5c8dd..190e31e 100644
--- a/src/contexts/RouterContext.jsx
+++ b/src/contexts/RouterContext.jsx
@@ -13,6 +13,7 @@ import SettingsOverview from '@/views/Settings/SettingsOverview'
import SettingsRoutes from '@/views/Settings/SettingsRoutes'
import ThemeSettings from '@/views/Settings/ThemeSettings'
+import GlobalSearchPage from '../search/GlobalSearchPage'
import AuthenticationLoading from '../views/Authorization/Authenticating'
import ForgotPasswordView from '../views/Authorization/ForgotPasswordView'
import LoginSettings from '../views/Authorization/LoginSettings'
@@ -131,6 +132,10 @@ const Router = createBrowserRouter([
path: '/chores',
element: ,
},
+ {
+ path: '/search',
+ element: ,
+ },
{
path: '/archived',
element: ,
diff --git a/src/search/GlobalSearchContext.jsx b/src/search/GlobalSearchContext.jsx
new file mode 100644
index 0000000..3ebae49
--- /dev/null
+++ b/src/search/GlobalSearchContext.jsx
@@ -0,0 +1,192 @@
+import useMediaQuery from '@mui/material/useMediaQuery'
+import { useQueryClient } from '@tanstack/react-query'
+import {
+ createContext,
+ useCallback,
+ useContext,
+ useEffect,
+ useMemo,
+ useState,
+} from 'react'
+import { useLocation, useNavigate } from 'react-router-dom'
+
+import { offlineDB } from '../utils/OfflineDB'
+import { isParentUser } from '../utils/UserHelpers'
+import GlobalSearchPalette from './GlobalSearchPalette'
+import { getSearchProviders } from './searchProviders'
+
+const GlobalSearchContext = createContext(null)
+const BLOCKED_ROUTES = [
+ '/login',
+ '/signup',
+ '/welcome',
+ '/onboarding',
+ '/get-started',
+ '/ready',
+]
+
+const unwrap = value => (Array.isArray(value) ? value : value?.res || [])
+const uniqueBy = (items, getId) => [
+ ...new Map(
+ items.filter(Boolean).map(item => [String(getId(item)), item]),
+ ).values(),
+]
+
+export const GlobalSearchProvider = ({ children }) => {
+ const queryClient = useQueryClient()
+ const location = useLocation()
+ const navigate = useNavigate()
+ const isMobile = useMediaQuery('(max-width:768px)')
+ const [isOpen, setIsOpen] = useState(false)
+ const [initialQuery, setInitialQuery] = useState('')
+ const [documents, setDocuments] = useState([])
+ const [isLoading, setIsLoading] = useState(false)
+
+ const loadDocuments = useCallback(async () => {
+ setIsLoading(true)
+ try {
+ const cachedChores = queryClient
+ .getQueriesData({ queryKey: ['chores'] })
+ .flatMap(([, data]) => unwrap(data))
+ const cachedHistory = [
+ ...queryClient.getQueriesData({ queryKey: ['choresHistory'] }),
+ ...queryClient.getQueriesData({ queryKey: ['choreHistory'] }),
+ ].flatMap(([, data]) => unwrap(data))
+
+ const [
+ offlineChores,
+ offlineHistory,
+ offlineProjects,
+ offlineLabels,
+ offlineMembers,
+ offlineProfile,
+ ] = await Promise.all([
+ offlineDB.getChores(true).catch(() => []),
+ offlineDB.getHistoryByDays(365).catch(() => []),
+ offlineDB.getKV('projects').catch(() => []),
+ offlineDB.getKV('labels').catch(() => []),
+ offlineDB.getKV('circle_members').catch(() => []),
+ offlineDB.getKV('user_profile').catch(() => null),
+ ])
+
+ const projects = uniqueBy(
+ [
+ ...unwrap(queryClient.getQueryData(['projects'])),
+ ...unwrap(offlineProjects),
+ ],
+ item => item.id,
+ )
+ const labels = uniqueBy(
+ [
+ ...unwrap(queryClient.getQueryData(['labels'])),
+ ...unwrap(offlineLabels),
+ ],
+ item => item.id,
+ )
+ const members = uniqueBy(
+ [
+ ...unwrap(queryClient.getQueryData(['allCircleMembers'])),
+ ...unwrap(offlineMembers),
+ ],
+ item => item.userId,
+ )
+ const chores = uniqueBy(
+ [...cachedChores, ...unwrap(offlineChores)],
+ item => item.id,
+ )
+ const history = uniqueBy(
+ [...cachedHistory, ...unwrap(offlineHistory)],
+ item => item.id,
+ )
+ const profile =
+ queryClient
+ .getQueriesData({ queryKey: ['userProfile'] })
+ .find(([, data]) => data)?.[1] || offlineProfile
+
+ const sources = {
+ chores,
+ history,
+ projects,
+ labels,
+ members,
+ isParent: isParentUser(profile),
+ choresById: new Map(chores.map(item => [String(item.id), item])),
+ projectsById: new Map(projects.map(item => [String(item.id), item])),
+ membersById: new Map(members.map(item => [String(item.userId), item])),
+ }
+
+ const nextDocuments = getSearchProviders().flatMap(provider => {
+ try {
+ return provider.getDocuments(sources) || []
+ } catch (error) {
+ console.warn(`Search provider ${provider.id} failed`, error)
+ return []
+ }
+ })
+ setDocuments(nextDocuments)
+ } finally {
+ setIsLoading(false)
+ }
+ }, [queryClient])
+
+ const openSearch = useCallback(
+ (query = '') => {
+ if (BLOCKED_ROUTES.some(route => location.pathname.startsWith(route)))
+ return
+ if (isMobile) {
+ loadDocuments()
+ navigate('/search', { state: { initialQuery: query } })
+ return
+ }
+ setInitialQuery(query)
+ setIsOpen(true)
+ loadDocuments()
+ },
+ [isMobile, loadDocuments, location.pathname, navigate],
+ )
+
+ const closeSearch = useCallback(() => setIsOpen(false), [])
+
+ useEffect(() => {
+ const onKeyDown = event => {
+ if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'f') {
+ event.preventDefault()
+ isOpen ? closeSearch() : openSearch()
+ }
+ }
+ window.addEventListener('keydown', onKeyDown)
+ return () => window.removeEventListener('keydown', onKeyDown)
+ }, [closeSearch, isOpen, openSearch])
+
+ const value = useMemo(
+ () => ({
+ closeSearch,
+ documents,
+ isLoading,
+ loadDocuments,
+ openSearch,
+ }),
+ [closeSearch, documents, isLoading, loadDocuments, openSearch],
+ )
+
+ return (
+
+ {children}
+ {isOpen && (
+
+ )}
+
+ )
+}
+
+export const useGlobalSearch = () => {
+ const context = useContext(GlobalSearchContext)
+ if (!context)
+ throw new Error('useGlobalSearch must be used inside GlobalSearchProvider')
+ return context
+}
diff --git a/src/search/GlobalSearchPage.jsx b/src/search/GlobalSearchPage.jsx
new file mode 100644
index 0000000..5c1b48b
--- /dev/null
+++ b/src/search/GlobalSearchPage.jsx
@@ -0,0 +1,32 @@
+import { useEffect } from 'react'
+import { useLocation, useNavigate } from 'react-router-dom'
+
+import { useGlobalSearch } from './GlobalSearchContext'
+import GlobalSearchPalette from './GlobalSearchPalette'
+
+const GlobalSearchPage = () => {
+ const location = useLocation()
+ const navigate = useNavigate()
+ const { documents, isLoading, loadDocuments } = useGlobalSearch()
+
+ useEffect(() => {
+ loadDocuments()
+ }, [loadDocuments])
+
+ const handleClose = () => {
+ if (window.history.state?.idx > 0) navigate(-1)
+ else navigate('/chores', { replace: true })
+ }
+
+ return (
+
+ )
+}
+
+export default GlobalSearchPage
diff --git a/src/search/GlobalSearchPalette.jsx b/src/search/GlobalSearchPalette.jsx
new file mode 100644
index 0000000..d903b0b
--- /dev/null
+++ b/src/search/GlobalSearchPalette.jsx
@@ -0,0 +1,463 @@
+import {
+ AddRounded,
+ CheckCircleOutline,
+ FolderOutlined,
+ HistoryRounded,
+ InboxOutlined,
+ LabelOutlined,
+ PersonOutline,
+ SearchRounded,
+ SettingsOutlined,
+} from '@mui/icons-material'
+import {
+ Box,
+ Chip,
+ CircularProgress,
+ Divider,
+ Input,
+ List,
+ ListItemButton,
+ ListItemContent,
+ ListItemDecorator,
+ Typography,
+} from '@mui/joy'
+import Fuse from 'fuse.js'
+import PropTypes from 'prop-types'
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
+import { useNavigate } from 'react-router-dom'
+
+import AppModal from '../components/common/AppModal'
+
+const RECENTS_KEY = 'donetick.globalSearch.recents'
+const GROUPS = [
+ 'tasks',
+ 'history',
+ 'projects',
+ 'labels',
+ 'people',
+ 'settings',
+ 'actions',
+]
+const GROUP_LABELS = {
+ tasks: 'Tasks',
+ history: 'Notes & activity',
+ projects: 'Projects',
+ labels: 'Labels',
+ people: 'People',
+ settings: 'Settings',
+ actions: 'Quick actions',
+}
+
+const ICONS = {
+ tasks: ,
+ history: ,
+ projects: ,
+ labels: ,
+ people: ,
+ settings: ,
+ actions: ,
+}
+
+const QUICK_ACTIONS = [
+ {
+ id: 'action:create',
+ provider: 'actions',
+ title: 'Create a task',
+ subtitle: 'Quick action',
+ route: '/chores/create',
+ },
+ {
+ id: 'action:tasks',
+ provider: 'actions',
+ title: 'View all tasks',
+ subtitle: 'Navigation',
+ route: '/chores',
+ },
+ {
+ id: 'action:archived',
+ provider: 'actions',
+ title: 'View archived tasks',
+ subtitle: 'Navigation',
+ route: '/archived',
+ },
+ {
+ id: 'action:settings',
+ provider: 'actions',
+ title: 'Open settings',
+ subtitle: 'Navigation',
+ route: '/settings',
+ },
+]
+
+const readRecents = () => {
+ try {
+ return JSON.parse(localStorage.getItem(RECENTS_KEY)) || []
+ } catch {
+ return []
+ }
+}
+
+const saveRecent = result => {
+ if (result.provider === 'actions') return
+ const recent = {
+ id: result.id,
+ provider: result.provider,
+ route: result.route,
+ title: result.title,
+ subtitle: result.subtitle,
+ }
+ localStorage.setItem(
+ RECENTS_KEY,
+ JSON.stringify(
+ [recent, ...readRecents().filter(item => item.id !== result.id)].slice(
+ 0,
+ 6,
+ ),
+ ),
+ )
+}
+
+const Highlight = ({ query, text }) => {
+ if (!text || !query.trim()) return text || null
+ const words = query.trim().split(/\s+/).filter(Boolean)
+ const escaped = words.map(word => word.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
+ if (!escaped.length) return text
+ const pattern = new RegExp(`(${escaped.join('|')})`, 'ig')
+ const isMatch = new RegExp(`^(${escaped.join('|')})$`, 'i')
+ return String(text)
+ .split(pattern)
+ .map((part, index) =>
+ isMatch.test(part) ? (
+
+ {part}
+
+ ) : (
+ part
+ ),
+ )
+}
+
+const SearchContainer = ({ children, onClose, presentation }) => {
+ if (presentation === 'page') {
+ return (
+
+ {children}
+
+ )
+ }
+
+ return (
+
+ {children}
+
+ )
+}
+
+const GlobalSearchPalette = ({
+ documents,
+ initialQuery,
+ isLoading,
+ onClose,
+ presentation = 'modal',
+}) => {
+ const navigate = useNavigate()
+ const focusInputRef = useCallback(node => {
+ if (node) requestAnimationFrame(() => node.focus())
+ }, [])
+ const [query, setQuery] = useState(initialQuery || '')
+ const [selectedIndex, setSelectedIndex] = useState(0)
+ const [recents] = useState(readRecents)
+ const selectedResultRef = useRef(null)
+
+ const fuse = useMemo(
+ () =>
+ new Fuse(documents, {
+ threshold: 0.38,
+ distance: 120,
+ ignoreLocation: true,
+ includeScore: true,
+ keys: [
+ { name: 'title', weight: 0.5 },
+ { name: 'keywords', weight: 0.25 },
+ { name: 'body', weight: 0.17 },
+ { name: 'subtitle', weight: 0.08 },
+ ],
+ }),
+ [documents],
+ )
+
+ const results = useMemo(() => {
+ const normalized = query.trim().toLocaleLowerCase()
+ if (!normalized) {
+ const currentById = new Map(documents.map(item => [item.id, item]))
+ const recentResults = recents.map(
+ item => currentById.get(item.id) || item,
+ )
+ return [...recentResults, ...QUICK_ACTIONS]
+ }
+
+ const matches = fuse
+ .search(normalized, { limit: 60 })
+ .map(match => {
+ const title = match.item.title?.toLocaleLowerCase() ?? ''
+ let score = match.score ?? 1
+
+ if (title === normalized) {
+ score -= 1
+ } else if (title.startsWith(normalized)) {
+ score -= 0.15
+ } else if (title.includes(normalized)) {
+ score -= 0.08
+ }
+
+ return {
+ ...match.item,
+ score,
+ }
+ })
+ .sort((a, b) => a.score - b.score)
+
+ const byGroup = new Map(GROUPS.map(group => [group, []]))
+ matches.forEach(item => {
+ const group = byGroup.get(item.provider)
+ if (group && group.length < 7) group.push(item)
+ })
+ const grouped = GROUPS.flatMap(group => byGroup.get(group))
+ grouped.push({
+ id: 'action:filter-tasks',
+ provider: 'actions',
+ title: `Show tasks matching “${query.trim()}”`,
+ subtitle: 'Filter the task list',
+ route: `/chores?search=${encodeURIComponent(query.trim())}`,
+ })
+ return grouped
+ }, [documents, fuse, query, recents])
+
+ useEffect(() => {
+ selectedResultRef.current?.scrollIntoView({
+ block: 'nearest',
+ inline: 'nearest',
+ })
+ }, [selectedIndex, results])
+
+ const selectResult = result => {
+ saveRecent(result)
+ navigate(result.route)
+ if (presentation === 'modal') onClose()
+ }
+
+ const onInputKeyDown = event => {
+ if (event.key === 'ArrowDown') {
+ event.preventDefault()
+ setSelectedIndex(index => Math.min(index + 1, results.length - 1))
+ } else if (event.key === 'ArrowUp') {
+ event.preventDefault()
+ setSelectedIndex(index => Math.max(index - 1, 0))
+ } else if (event.key === 'Enter' && results[selectedIndex]) {
+ event.preventDefault()
+ selectResult(results[selectedIndex])
+ } else if (event.key === 'Escape') {
+ event.preventDefault()
+ onClose()
+ }
+ }
+
+ return (
+
+
+ {
+ setQuery(event.target.value)
+ setSelectedIndex(0)
+ }}
+ onKeyDown={onInputKeyDown}
+ placeholder='Search Donetick'
+ startDecorator={}
+ endDecorator={
+ isLoading ? (
+
+ ) : presentation === 'modal' ? (
+
+ Esc
+
+ ) : null
+ }
+ sx={{
+ '--Input-minHeight': '48px',
+ fontSize: 'md',
+ borderRadius: 'lg',
+ }}
+ />
+
+ Searching content available on this device
+
+
+
+
+
+ {!isLoading && query.trim() && results.length === 1 && (
+
+
+ No direct matches
+
+ You can still filter the task list with this search.
+
+
+ )}
+
+
+ {results.map((result, index) => {
+ const hasQuery = Boolean(query.trim())
+ const showHeading = hasQuery
+ ? index === 0 || result.provider !== results[index - 1].provider
+ : index === 0 ||
+ (result.provider === 'actions' &&
+ results[index - 1].provider !== 'actions')
+ return (
+
+ {showHeading && (
+
+ {!query.trim() && result.provider !== 'actions'
+ ? 'Recent'
+ : GROUP_LABELS[result.provider]}
+
+ )}
+ setSelectedIndex(index)}
+ onClick={() => selectResult(result)}
+ sx={{
+ borderRadius: 'md',
+ py: 1.1,
+ alignItems: 'flex-start',
+ }}
+ >
+
+ {ICONS[result.provider]}
+
+
+
+
+
+
+ {[result.subtitle, result.body]
+ .filter(Boolean)
+ .join(' · ')}
+
+
+
+
+ )
+ })}
+
+
+
+
+ ↑↓ Navigate
+ ↵ Open
+
+ {query.trim()
+ ? `${Math.max(0, results.length - 1)} results`
+ : 'Type to search'}
+
+
+
+ )
+}
+
+SearchContainer.propTypes = {
+ children: PropTypes.node.isRequired,
+ onClose: PropTypes.func.isRequired,
+ presentation: PropTypes.oneOf(['modal', 'page']).isRequired,
+}
+
+Highlight.propTypes = {
+ query: PropTypes.string.isRequired,
+ text: PropTypes.string,
+}
+
+GlobalSearchPalette.propTypes = {
+ documents: PropTypes.arrayOf(PropTypes.object).isRequired,
+ initialQuery: PropTypes.string,
+ isLoading: PropTypes.bool.isRequired,
+ onClose: PropTypes.func.isRequired,
+ presentation: PropTypes.oneOf(['modal', 'page']),
+}
+
+export default GlobalSearchPalette
diff --git a/src/search/searchProviders.js b/src/search/searchProviders.js
new file mode 100644
index 0000000..0eb20f6
--- /dev/null
+++ b/src/search/searchProviders.js
@@ -0,0 +1,178 @@
+const stripHtml = value => {
+ if (!value) return ''
+ if (typeof globalThis.document === 'undefined')
+ return String(value).replace(/<[^>]*>/g, ' ')
+ const element = globalThis.document.createElement('div')
+ element.innerHTML = String(value)
+ return element.textContent || element.innerText || ''
+}
+
+const HISTORY_STATUS = {
+ 0: 'in progress',
+ 1: 'completed',
+ 2: 'skipped',
+ 3: 'pending approval',
+ 4: 'rejected',
+ 5: 'missed',
+ 6: 'rescheduled',
+}
+
+const SETTINGS = [
+ ['profile', 'Profile', 'Name, avatar and personal details'],
+ ['circle', 'Circle', 'Members and household settings', true],
+ ['account', 'Account', 'Subscription and account management', true],
+ ['subaccounts', 'Subaccounts', 'Manage child accounts'],
+ ['notifications', 'Notifications', 'Reminders and notification preferences'],
+ ['mfa', 'Multi-factor authentication', 'Secure your account', true],
+ ['apitokens', 'API tokens', 'Manage integrations and access tokens', true],
+ ['storage', 'Storage', 'Files, backups and device storage'],
+ ['sidepanel', 'Side panel', 'Customize navigation'],
+ ['theme', 'Appearance', 'Theme, dark mode and colors'],
+ ['localization', 'Language and region', 'Language, dates and time formats'],
+ [
+ 'advanced',
+ 'Advanced settings',
+ 'Offline support, webhooks and application behavior',
+ ],
+ ['developer', 'Developer settings', 'Diagnostics and experimental tools'],
+]
+
+const providers = []
+
+export const registerSearchProvider = provider => {
+ if (!provider?.id || typeof provider.getDocuments !== 'function') {
+ throw new Error('A search provider needs an id and getDocuments function')
+ }
+ const existing = providers.findIndex(item => item.id === provider.id)
+ if (existing >= 0) providers.splice(existing, 1, provider)
+ else providers.push(provider)
+ return () => {
+ const index = providers.indexOf(provider)
+ if (index >= 0) providers.splice(index, 1)
+ }
+}
+
+export const getSearchProviders = () => [...providers]
+
+const document = (provider, item) => ({ provider, ...item })
+
+registerSearchProvider({
+ id: 'tasks',
+ getDocuments: ({ chores, membersById, projectsById }) =>
+ chores.map(chore => {
+ const labels =
+ chore.labelsV2?.map(label => label.name).filter(Boolean) || []
+ const project = projectsById.get(String(chore.projectId))
+ const assignees = (chore.assignees || [])
+ .map(assignee => membersById.get(String(assignee.userId))?.displayName)
+ .filter(Boolean)
+ const description = stripHtml(chore.description)
+ return document('tasks', {
+ id: `task:${chore.id}`,
+ entityId: chore.id,
+ title: chore.name || 'Untitled task',
+ subtitle:
+ [project?.name, ...labels].filter(Boolean).join(' · ') || 'Task',
+ body: description,
+ keywords: [...labels, project?.name, ...assignees]
+ .filter(Boolean)
+ .join(' '),
+ route: `/chores/${chore.id}`,
+ updatedAt: chore.updatedAt || chore.createdAt,
+ })
+ }),
+})
+
+registerSearchProvider({
+ id: 'history',
+ getDocuments: ({ choresById, history, membersById }) =>
+ history.map(entry => {
+ const chore = choresById.get(String(entry.choreId))
+ const member = membersById.get(String(entry.completedBy))
+ const note = stripHtml(entry.notes)
+ return document('history', {
+ id: `history:${entry.id}`,
+ entityId: entry.id,
+ title: chore?.name || entry.choreName || 'Task activity',
+ subtitle: [
+ member?.displayName,
+ entry.performedAt
+ ? new Date(entry.performedAt).toLocaleDateString()
+ : null,
+ ]
+ .filter(Boolean)
+ .join(' · '),
+ body: note,
+ keywords: `${HISTORY_STATUS[entry.status] || 'activity'} ${member?.displayName || ''}`,
+ route: entry.choreId
+ ? `/chores/${entry.choreId}/history`
+ : '/activities',
+ updatedAt: entry.performedAt || entry.updatedAt,
+ })
+ }),
+})
+
+registerSearchProvider({
+ id: 'projects',
+ getDocuments: ({ projects }) =>
+ projects.map(project =>
+ document('projects', {
+ id: `project:${project.id}`,
+ entityId: project.id,
+ title: project.name || 'Untitled project',
+ subtitle: 'Project',
+ body: stripHtml(project.description),
+ keywords: 'folder project',
+ route: `/chores?project=${encodeURIComponent(project.id)}`,
+ updatedAt: project.updatedAt,
+ }),
+ ),
+})
+
+registerSearchProvider({
+ id: 'labels',
+ getDocuments: ({ labels }) =>
+ labels.map(label =>
+ document('labels', {
+ id: `label:${label.id}`,
+ entityId: label.id,
+ title: label.name || 'Untitled label',
+ subtitle: 'Label',
+ keywords: 'tag label',
+ route: '/labels',
+ color: label.color,
+ }),
+ ),
+})
+
+registerSearchProvider({
+ id: 'people',
+ getDocuments: ({ members }) =>
+ members.map(member =>
+ document('people', {
+ id: `person:${member.userId}`,
+ entityId: member.userId,
+ title: member.displayName || member.username || 'Circle member',
+ subtitle: 'Circle member',
+ keywords: `${member.username || ''} person member assignee`,
+ route: '/chores',
+ }),
+ ),
+})
+
+registerSearchProvider({
+ id: 'settings',
+ getDocuments: ({ isParent }) =>
+ SETTINGS.filter(([, , , parentOnly]) => !parentOnly || isParent).map(
+ ([id, title, description]) =>
+ document('settings', {
+ id: `setting:${id}`,
+ entityId: id,
+ title,
+ subtitle: 'Settings',
+ body: description,
+ keywords: `preferences configuration ${id}`,
+ route: `/settings/${id}`,
+ }),
+ ),
+})
diff --git a/src/views/Chores/MyChores.jsx b/src/views/Chores/MyChores.jsx
index bf6ec29..7d893e8 100644
--- a/src/views/Chores/MyChores.jsx
+++ b/src/views/Chores/MyChores.jsx
@@ -411,14 +411,29 @@ const MyChores = () => {
}
}, [searchInputFocus])
+ // A global-search result can hand a query back to the task list as a scoped filter.
+ useEffect(() => {
+ const query = searchParams.get('search')
+ if (query !== null) {
+ setSearchTerm(query.toLowerCase())
+ setSelectedCalendarDate(null)
+ clearActiveFilter()
+ clearQuickFilters()
+ }
+ // The setters above are intentionally applied only when URL search params change.
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [searchParams])
+
// Read and apply project from URL parameters
useEffect(() => {
if (!projects.length) return
const projectIdFromUrl = searchParams.get('project')
- if (projectIdFromUrl && projectIdFromUrl !== selectedProject?.id) {
- const project = projectsWithDefault.find(p => p.id === projectIdFromUrl)
+ if (projectIdFromUrl && projectIdFromUrl !== String(selectedProject?.id)) {
+ const project = projectsWithDefault.find(
+ p => String(p.id) === projectIdFromUrl,
+ )
if (project) {
setSelectedProjectWithCache(project)
}
@@ -759,6 +774,11 @@ const MyChores = () => {
setFilteredChores(selectedProject ? projectFilteredChores : chores)
setSearchInputFocus(0)
setSelectedCalendarDate(null)
+ if (searchParams.has('search')) {
+ const params = new URLSearchParams(searchParams)
+ params.delete('search')
+ setSearchParams(params, { replace: true })
+ }
}
const setSelectedChoreSectionWithCache = value => {
diff --git a/src/views/Chores/components/SearchBar.jsx b/src/views/Chores/components/SearchBar.jsx
index afced4d..0ccce0e 100644
--- a/src/views/Chores/components/SearchBar.jsx
+++ b/src/views/Chores/components/SearchBar.jsx
@@ -1,21 +1,33 @@
-import { CancelRounded } from '@mui/icons-material'
+import { CancelRounded, SearchRounded } from '@mui/icons-material'
import { Box, Input } from '@mui/joy'
+
import KeyboardShortcutHint from '../../../components/common/KeyboardShortcutHint'
+import { useGlobalSearch } from '../../../search/GlobalSearchContext'
const SearchBar = ({
- value,
+ inputRef,
onChange,
onClose,
onFocus,
showKeyboardShortcuts,
- inputRef,
+ value,
}) => {
+ const { openSearch } = useGlobalSearch()
+ const handleOpen = () => {
+ onFocus?.()
+ openSearch(value)
+ }
+
return (
{
+ event.preventDefault()
+ handleOpen()
+ }}
fullWidth
sx={{
mt: 1,
@@ -24,17 +36,29 @@ const SearchBar = ({
height: 24,
borderColor: 'text.disabled',
padding: 1,
+ cursor: 'pointer',
+ '& input': { cursor: 'pointer' },
}}
onChange={onChange}
startDecorator={
-
+
+
+
+
}
endDecorator={
{value && (
<>
-
+ event.stopPropagation()}
+ onClick={event => {
+ event.stopPropagation()
+ onClose()
+ }}
+ />
>
)}
diff --git a/src/views/components/NavBar.jsx b/src/views/components/NavBar.jsx
index c33e2c0..bb42ebc 100644
--- a/src/views/components/NavBar.jsx
+++ b/src/views/components/NavBar.jsx
@@ -9,6 +9,7 @@ import {
ListAlt,
Logout,
MenuRounded,
+ SearchRounded,
SettingsOutlined,
Toll,
Widgets,
@@ -23,30 +24,36 @@ import {
ListItemDecorator,
Typography,
} from '@mui/joy'
-
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useLocation, useNavigate, useSearchParams } from 'react-router-dom'
+
import { version } from '../../../package.json'
import UserProfileAvatar from '../../components/UserProfileAvatar'
+import Z_INDEX from '../../constants/zIndex'
import { useLocalization } from '../../contexts/LocalizationContext'
+import { useResource } from '../../queries/ResourceQueries'
+import { useGlobalSearch } from '../../search/GlobalSearchContext'
+import { apiClient } from '../../utils/ApiClient'
import NavBarLink from './NavBarLink'
import SyncStatusIndicator from './SyncStatusIndicator'
-import Z_INDEX from '../../constants/zIndex'
-import { useResource } from '../../queries/ResourceQueries'
-import { apiClient } from '../../utils/ApiClient'
-
const publicPages = ['/landing', '/privacy', '/terms']
const NavBar = () => {
const { t } = useTranslation('common')
const { isRTL } = useLocalization()
const { data: resource } = useResource()
+ const { openSearch } = useGlobalSearch()
const navigate = useNavigate()
const [drawerOpen, setDrawerOpen] = useState(false)
const links = [
+ {
+ label: t('navigation.search'),
+ icon: ,
+ onClick: () => openSearch(),
+ },
{
to: '/chores',
label: t('navigation.allTasks'),
@@ -105,6 +112,22 @@ const NavBar = () => {
)
+ if (location.pathname === '/search') {
+ return (
+ {
+ if (window.history.state?.idx > 0) navigate(-1)
+ else navigate('/chores', { replace: true })
+ }}
+ aria-label='Back from search'
+ title={t('back')}
+ >
+
+
+ )
+ }
if (!Capacitor.isNativePlatform()) {
return menuRounded
}
diff --git a/src/views/components/NavBarLink.jsx b/src/views/components/NavBarLink.jsx
index 3fe10d2..62dd64f 100644
--- a/src/views/components/NavBarLink.jsx
+++ b/src/views/components/NavBarLink.jsx
@@ -7,13 +7,12 @@ import {
import { Link } from 'react-router-dom'
const NavBarLink = ({ link }) => {
- const { to, icon, label } = link
+ const { to, icon, label, onClick } = link
return (