diff --git a/components/calendar/__tests__/calendar-app.test.tsx b/components/calendar/__tests__/calendar-app.test.tsx index d3ad5e264..843e003d9 100644 --- a/components/calendar/__tests__/calendar-app.test.tsx +++ b/components/calendar/__tests__/calendar-app.test.tsx @@ -309,6 +309,44 @@ describe('CalendarApp browser Back with an editor open (R5)', () => { }); }); +describe('CalendarApp event search (CAL-16)', () => { + it('focuses the search on / and shows matching events in place of the grid', async () => { + setUp([makeEvent(), makeEvent({ id: 'e2', uid: 'u2', title: 'Dentist', start: '2026-10-13T08:00:00' })]); + renderApp(); + fireEvent.keyDown(document.body, { key: '/', code: 'Slash' }); + const box = screen.getByRole('searchbox', { name: 'search.title' }); + expect(document.activeElement).toBe(box); + + fireEvent.change(box, { target: { value: 'dent' } }); + const results = await screen.findByTestId('calendar-search-results'); + expect(within(results).getByText('Dentist')).toBeInTheDocument(); + expect(within(results).queryByText('Standup')).toBeNull(); + expect(screen.queryByRole('grid')).toBeNull(); + + // A result opens like an event in the grid + fireEvent.click(within(results).getByText('Dentist')); + expect(await screen.findByRole('dialog', { name: 'Dentist' })).toBeInTheDocument(); + fireEvent.keyDown(document.body, { key: 'Escape' }); + + // Escape in the box goes back to the grid + fireEvent.keyDown(box, { key: 'Escape' }); + await waitFor(() => expect(screen.queryByTestId('calendar-search-results')).toBeNull()); + expect(screen.getByRole('grid')).toBeInTheDocument(); + }); + + it('says so when nothing matches', async () => { + renderApp(); + fireEvent.change(screen.getByRole('searchbox', { name: 'search.title' }), { target: { value: 'zzz' } }); + expect(await screen.findByText('search.no_results', {}, { timeout: 3000 })).toBeInTheDocument(); + }); + + it('is in the command palette', () => { + render(<>); + const items = screen.getByTestId('palette').textContent ?? ''; + expect(items).toContain('calendar: search.title'); + }); +}); + function toastTitles(): string[] { return useToastStore.getState().toasts.map((t) => t.title); } diff --git a/components/calendar/calendar-app.tsx b/components/calendar/calendar-app.tsx index f626b4e4e..246a393ab 100644 --- a/components/calendar/calendar-app.tsx +++ b/components/calendar/calendar-app.tsx @@ -3,7 +3,7 @@ import { useState, useEffect, useCallback, useRef, useMemo, type TouchEvent as ReactTouchEvent } from "react"; import { useRouter } from "@/i18n/navigation"; import { useTranslations } from "next-intl"; -import { Plus, ListTodo, CalendarDays, Upload, Globe, RefreshCw } from "@/components/icons"; +import { Plus, ListTodo, CalendarDays, Upload, Globe, RefreshCw, Search } from "@/components/icons"; import { addMonths, subMonths, addWeeks, subWeeks, addDays, subDays, format, parseISO, @@ -87,6 +87,8 @@ import { appPath, buildCalendarPath, parseCalendarPath, type CalendarDeepLink } import { consumePendingDeepLinkEntry, subscribePendingDeepLink } from "@/lib/deep-link-handoff"; import { useDeepLinkUrl } from "@/hooks/use-deep-link-url"; import { useCloseOnBack } from "@/hooks/use-close-on-back"; +import { useCalendarEventSearch } from "@/hooks/use-calendar-event-search"; +import { CalendarSearchField, CalendarSearchResults } from "@/components/calendar/calendar-search"; import { useProInterfaceActive } from "@/components/pro/pro-interface-redirect"; import { useCalendarLocale } from "@/hooks/use-calendar-locale"; import { @@ -192,6 +194,11 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps = const [showEventModal, setShowEventModal] = useState(false); const [showImportModal, setShowImportModal] = useState(false); + // Event search (CAL-16): the query in the toolbar box; on phones the box + // opens as a row of its own. + const [searchQuery, setSearchQuery] = useState(""); + const [mobileSearchOpen, setMobileSearchOpen] = useState(false); + const searchInputRef = useRef(null); const [showSubscriptionModal, setShowSubscriptionModal] = useState(false); const [pendingSubscription, setPendingSubscription] = useState<{ url: string; name: string } | null>(null); const [showWebcalActionChoice, setShowWebcalActionChoice] = useState(false); @@ -1531,6 +1538,18 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps = await handleRsvp(detailEvent.id, participantId, status); }, [detailEvent, client, currentUserEmails, closeDetail, handleRsvp]); + // `/` and the palette's "Search events" go to the search box. + const openSearch = useCallback(() => { + if (isMobile) { setMobileSearchOpen(true); return; } + searchInputRef.current?.focus(); + searchInputRef.current?.select(); + }, [isMobile]); + // Moving the calendar (a view, a date) goes back to the grid. + const leaveSearch = useCallback(() => { + setSearchQuery(""); + setMobileSearchOpen(false); + }, []); + useEffect(() => { const handleKey = (e: KeyboardEvent) => { if (e.defaultPrevented) return; @@ -1543,6 +1562,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps = const action = calendarShortcutAction(e); if (!action) return; + if (action !== "new" && action !== "search") leaveSearch(); switch (action) { case "prev": e.preventDefault(); navigatePrev(); break; case "next": e.preventDefault(); navigateNext(); break; @@ -1554,11 +1574,12 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps = case "tasks": if (enableCalendarTasks) setViewMode("tasks"); break; // Kept from typing itself into the title field the editor focuses. case "new": e.preventDefault(); openCreateForView(); break; + case "search": e.preventDefault(); openSearch(); break; } }; window.addEventListener("keydown", handleKey); return () => window.removeEventListener("keydown", handleKey); - }, [navigatePrev, navigateNext, goToToday, setViewMode, openCreateForView, showEventModal, showTaskModal, detailPinned, enableCalendarTasks]); + }, [navigatePrev, navigateNext, goToToday, setViewMode, openCreateForView, openSearch, leaveSearch, showEventModal, showTaskModal, detailPinned, enableCalendarTasks]); // The command palette's Calendar group, while this calendar is the one in // front (in the Pro shell: its focused tab). @@ -1573,13 +1594,14 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps = useRegisterCommands(tSidebar("calendar"), [ { id: "new-event", label: t("events.new_event"), icon: Plus, shortcut: "n", run: () => openCreateModal() }, { id: "new-task", label: t("events.new_task"), icon: ListTodo, run: openCreateTaskModal, when: () => enableCalendarTasks }, + { id: "search-events", label: t("search.title"), icon: Search, shortcut: "/", run: openSearch }, { id: "today", label: t("events.go_to_today"), icon: CalendarDays, shortcut: "t", run: goToToday, when: () => normalizedViewMode !== "tasks" }, ...paletteViews.map(({ mode, key }) => ({ id: `view-${mode}`, label: t(`commands.${mode}`), icon: CalendarDays, shortcut: key, - run: () => { setMobileReturnToMonth(false); setViewMode(mode); }, + run: () => { setMobileReturnToMonth(false); leaveSearch(); setViewMode(mode); }, when: () => mode !== normalizedViewMode && (mode !== "tasks" || enableCalendarTasks), })), { id: "import", label: t("toolbar.import"), icon: Upload, run: () => setShowImportModal(true), when: () => !demoMode }, @@ -1592,6 +1614,10 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps = return generateBirthdayEvents(contacts, dateRange.start, dateRange.end); }, [showBirthdayCalendar, contacts, dateRange]); + // Loaded events answer a search at once; the server adds the rest. + const searchableEvents = useMemo(() => [...events, ...birthdayEvents], [events, birthdayEvents]); + const searchResults = useCalendarEventSearch(searchQuery, client, searchableEvents); + const birthdayCalendarName = (() => { try { return t('birthday_calendar'); } catch { return 'Birthdays'; } })(); @@ -1755,6 +1781,19 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps = } const viewContent = (() => { + if (searchResults && normalizedViewMode !== "tasks") { + return ( + + ); + } switch (normalizedViewMode) { case "month": return ( @@ -1913,10 +1952,10 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps = selectedDate={selectedDate} visibleDate={visibleDate} viewMode={normalizedViewMode} - onPrev={navigatePrev} - onNext={navigateNext} - onToday={goToToday} - onViewModeChange={(mode) => { setMobileReturnToMonth(false); setViewMode(mode); }} + onPrev={() => { leaveSearch(); navigatePrev(); }} + onNext={() => { leaveSearch(); navigateNext(); }} + onToday={() => { leaveSearch(); goToToday(); }} + onViewModeChange={(mode) => { setMobileReturnToMonth(false); leaveSearch(); setViewMode(mode); }} onCreateEvent={openCreateForView} createLabel={createLabel} onImport={demoMode ? undefined : () => setShowImportModal(true)} @@ -1932,6 +1971,26 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps = sidebarOpen={isNarrow ? narrowSidebarOpen : !sidebarCollapsed} menuTogglesSidebar={!isNarrow} showCreateButton={isNarrow || sidebarCollapsed} + // Tasks have their own list and no events to search. + searchField={normalizedViewMode === "tasks" ? undefined : ( + + )} + onOpenSearch={normalizedViewMode === "tasks" ? undefined : () => setMobileSearchOpen(true)} + mobileSearchField={mobileSearchOpen ? ( + + ) : null} + onCloseSearch={leaveSearch} /> ); diff --git a/components/calendar/calendar-search.tsx b/components/calendar/calendar-search.tsx new file mode 100644 index 000000000..283cb8551 --- /dev/null +++ b/components/calendar/calendar-search.tsx @@ -0,0 +1,211 @@ +"use client"; + +import { forwardRef, useMemo, useRef, type KeyboardEvent } from "react"; +import { useTranslations } from "next-intl"; +import { format } from "date-fns"; +import { Search, X, MapPin, Repeat } from "@/components/icons"; +import { Input } from "@/components/ui/input"; +import { IconButton } from "@/components/ui/icon-button"; +import { EmptyState } from "@/components/ui/empty-state"; +import { Spinner } from "@/components/ui/spinner"; +import { cn } from "@/lib/utils"; +import { useDisplayDateFormatter } from "@/hooks/use-display-date-formatter"; +import { getEventEndDate, getEventStartDate, getPrimaryCalendarId } from "@/lib/calendar-utils"; +import { isDeclinedByUser } from "@/lib/calendar-participants"; +import { isRecurringEvent } from "@/lib/global-search/providers/calendar"; +import { displayNow } from "@/lib/timezone"; +import type { Calendar, CalendarEvent } from "@/lib/jmap/types"; +import type { CalendarSearchResults as Results } from "@/hooks/use-calendar-event-search"; +import { getEventColor } from "./event-card"; + +const RESULT_SELECTOR = "[data-search-result]"; + +interface CalendarSearchFieldProps { + value: string; + onChange: (value: string) => void; + /** Escape on an empty field, or the phone's close: leave the search. */ + onDismiss?: () => void; + autoFocus?: boolean; + className?: string; +} + +/** The search box in the calendar toolbar (CAL-16). */ +export const CalendarSearchField = forwardRef( + function CalendarSearchField({ value, onChange, onDismiss, autoFocus, className }, ref) { + const t = useTranslations("calendar"); + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === "Escape") { + // Escape clears the search first, then leaves the field (R8). + e.preventDefault(); + e.stopPropagation(); + if (value) onChange(""); + else { e.currentTarget.blur(); onDismiss?.(); } + } else if (e.key === "ArrowDown") { + const first = document.querySelector(RESULT_SELECTOR); + if (first) { e.preventDefault(); first.focus(); } + } + }; + return ( +
+
+ ); + }, +); + +interface CalendarSearchResultsProps { + query: string; + results: Results; + calendars: Calendar[]; + timeFormat?: "12h" | "24h"; + currentUserEmails?: string[]; + onSelectEvent: (event: CalendarEvent, anchorRect: DOMRect) => void; + onContextMenuEvent?: (e: React.MouseEvent, event: CalendarEvent) => void; +} + +/** The events a calendar search found, upcoming first, then past ones. */ +export function CalendarSearchResults({ + query, results, calendars, timeFormat = "24h", currentUserEmails, onSelectEvent, onContextMenuEvent, +}: CalendarSearchResultsProps) { + const t = useTranslations("calendar"); + const intlFormatter = useDisplayDateFormatter(); + const listRef = useRef(null); + const calendarMap = useMemo(() => new Map(calendars.map((c) => [c.id, c])), [calendars]); + const { upcoming, past, searching } = results; + const count = upcoming.length + past.length; + const thisYear = displayNow().getFullYear(); + + const formatTime = (date: Date) => timeFormat === "12h" + ? intlFormatter.dateTime(date, { hour: "numeric", minute: "2-digit", hour12: true }) + : format(date, "HH:mm"); + + // ↑/↓ walk the list, ↑ on the first row goes back to the search box (R8). + const handleRowKeyDown = (e: KeyboardEvent) => { + if (e.key !== "ArrowDown" && e.key !== "ArrowUp") return; + e.preventDefault(); + const rows = Array.from(listRef.current?.querySelectorAll(RESULT_SELECTOR) ?? []); + const index = rows.indexOf(e.currentTarget); + const next = rows[index + (e.key === "ArrowDown" ? 1 : -1)]; + if (next) next.focus(); + else if (e.key === "ArrowUp") document.querySelector("[data-calendar-search]")?.focus(); + }; + + const renderRow = (ev: CalendarEvent) => { + const calId = getPrimaryCalendarId(ev); + const calendar = calId ? calendarMap.get(calId) : undefined; + const color = getEventColor(ev, calendar); + const start = getEventStartDate(ev); + const end = getEventEndDate(ev); + const isInactive = ev.status === "cancelled" || isDeclinedByUser(ev, currentUserEmails); + const locationName = ev.locations ? Object.values(ev.locations)[0]?.name : null; + const meta = [locationName, calendar?.name].filter(Boolean).join(" · "); + // A series the server found by its first occurrence. + const repeats = !ev.recurrenceId && isRecurringEvent(ev); + const sameYear = start.getFullYear() === thisYear; + const title = ev.title || t("events.no_title"); + return ( + + ); + }; + + const section = (id: string, label: string, events: CalendarEvent[]) => events.length > 0 && ( +
+

+ {label} +

+
{events.map(renderRow)}
+
+ ); + + return ( +
+

+ {searching && count === 0 ? "" : t("search.results", { count })} +

+ {count === 0 && searching && ( +
+ )} + {count === 0 && !searching && ( + + )} + {section("calendar-search-upcoming", t("search.upcoming"), upcoming)} + {section("calendar-search-past", t("search.past"), past)} + {count > 0 && searching && ( +
+ )} +
+ ); +} diff --git a/components/calendar/calendar-toolbar.tsx b/components/calendar/calendar-toolbar.tsx index 4cc3d2530..2ba60813c 100644 --- a/components/calendar/calendar-toolbar.tsx +++ b/components/calendar/calendar-toolbar.tsx @@ -1,10 +1,10 @@ "use client"; -import { useState, useRef, useEffect } from "react"; +import { useState, useRef, useEffect, type ReactNode } from "react"; import { useTranslations } from "next-intl"; import { Button } from "@/components/ui/button"; import { IconButton } from "@/components/ui/icon-button"; -import { ChevronLeft, ChevronRight, Plus, Upload, CalendarDays, Globe, ChevronDown, ArrowLeft, Menu, MoreVertical, RefreshCw } from "@/components/icons"; +import { ChevronLeft, ChevronRight, Plus, Upload, CalendarDays, Globe, ChevronDown, ArrowLeft, Menu, MoreVertical, RefreshCw, Search } from "@/components/icons"; import { ContextMenu, ContextMenuItem, ContextMenuSeparator } from "@/components/ui/context-menu"; import { useContextMenu } from "@/hooks/use-context-menu"; import { startOfWeek } from "date-fns"; @@ -49,6 +49,14 @@ interface CalendarToolbarProps { * at the top of the sidebar, so this is only needed while it is hidden. */ showCreateButton?: boolean; + /** The search box, shown before the ⋮ on wider screens. */ + searchField?: ReactNode; + /** Phones: opens the search row in place of the date row. */ + onOpenSearch?: () => void; + /** Phones: the open search row's field; null while it is closed. */ + mobileSearchField?: ReactNode; + /** Phones: closes the search row. */ + onCloseSearch?: () => void; } // Shortcut keys handled by the calendar app (see its keydown handler). @@ -84,6 +92,10 @@ export function CalendarToolbar({ sidebarOpen, menuTogglesSidebar, showCreateButton, + searchField, + onOpenSearch, + mobileSearchField, + onCloseSearch, }: CalendarToolbarProps) { const t = useTranslations("calendar"); const tCommon = useTranslations("common"); @@ -204,7 +216,16 @@ export function CalendarToolbar({ {/* ── MOBILE TOOLBAR ── */} {isMobile && (
- {/* Row 1: Back / Date nav / Today */} + {/* Row 1 while searching: the search box with a way back. */} + {mobileSearchField ? ( +
+ + + +
{mobileSearchField}
+
+ ) : ( + /* Row 1: Back / Date nav / Today */
{onMenuClick && ( @@ -237,6 +258,7 @@ export function CalendarToolbar({ {/* The shell's phone ⋮ (notifications, palette, account) with the calendar's own entries on top. */}
+ )} {/* Row 2: View switcher pills + calendar toggle */}
@@ -257,6 +279,12 @@ export function CalendarToolbar({ ))}
+ {/* Beside the calendar filter: the date row has no room left. */} + {onOpenSearch && ( + + + + )} {calendars && selectedCalendarIds && onToggleVisibility && (
+ {searchField} + {moreButton}
diff --git a/hooks/__tests__/use-calendar-event-search.test.tsx b/hooks/__tests__/use-calendar-event-search.test.tsx new file mode 100644 index 000000000..931d69726 --- /dev/null +++ b/hooks/__tests__/use-calendar-event-search.test.tsx @@ -0,0 +1,76 @@ +import { renderHook, waitFor } from '@testing-library/react'; +import { describe, it, expect, vi } from 'vitest'; +import { useCalendarEventSearch } from '../use-calendar-event-search'; +import type { CalendarEvent } from '@/lib/jmap/types'; +import type { IJMAPClient } from '@/lib/jmap/client-interface'; + +vi.mock('@/lib/timezone', async (importOriginal) => ({ + ...(await importOriginal()), + displayNow: () => new Date(2026, 9, 28, 12, 0), +})); + +function makeEvent(id: string, title: string, start: string, extra: Partial = {}): CalendarEvent { + return { + id, uid: id, '@type': 'Event', title, start, duration: 'PT1H', showWithoutTime: false, + calendarIds: { 'cal-1': true }, ...extra, + } as unknown as CalendarEvent; +} + +function clientWith(query: () => Promise) { + const queryAllCalendarEvents = vi.fn(query); + return { client: { queryAllCalendarEvents } as unknown as IJMAPClient, queryAllCalendarEvents }; +} + +const loaded = [ + makeEvent('standup-1', 'Standup', '2026-10-27T09:00:00', { uid: 'standup' }), + makeEvent('standup-2', 'Standup', '2026-10-29T09:00:00', { uid: 'standup' }), + makeEvent('standup-3', 'Standup', '2026-10-30T09:00:00', { uid: 'standup' }), + makeEvent('review', 'Design review', '2026-10-20T14:00:00', { + participants: { p: { '@type': 'Participant', name: 'Grizzelda Pentworth', email: 'griz@example.net' } } as never, + }), +]; + +describe('useCalendarEventSearch (CAL-16)', () => { + it('answers from the loaded events at once, participants included, one row per series', async () => { + const { client } = clientWith(() => new Promise(() => {})); + const { result } = renderHook(() => useCalendarEventSearch('griz', client, loaded)); + expect(result.current?.past.map((e) => e.id)).toEqual(['review']); + expect(result.current?.searching).toBe(true); + + const series = renderHook(() => useCalendarEventSearch('standup', client, loaded)); + // The next occurrence to come, not every loaded one + expect(series.result.current?.upcoming.map((e) => e.id)).toEqual(['standup-2']); + expect(series.result.current?.past).toEqual([]); + }); + + it('adds what the server finds outside the loaded weeks, upcoming soonest first and past latest first', async () => { + const { client, queryAllCalendarEvents } = clientWith(async () => [ + makeEvent('old', 'Standup retro', '2025-03-01T10:00:00'), + makeEvent('older', 'Standup kickoff', '2024-01-10T10:00:00'), + makeEvent('later', 'Standup offsite', '2027-02-01T10:00:00'), + // the series the loaded weeks already show + makeEvent('standup-master', 'Standup', '2026-01-05T09:00:00', { uid: 'standup' }), + ]); + const { result } = renderHook(() => useCalendarEventSearch('standup', client, loaded)); + await waitFor(() => expect(result.current?.searching).toBe(false)); + expect(queryAllCalendarEvents).toHaveBeenCalledWith( + { text: 'standup' }, [{ property: 'start', isAscending: false }], 100, expect.anything(), + ); + expect(result.current?.upcoming.map((e) => e.id)).toEqual(['standup-2', 'later']); + expect(result.current?.past.map((e) => e.id)).toEqual(['old', 'older']); + }); + + it('keeps the loaded matches when the server can not search', async () => { + const { client } = clientWith(async () => { throw new Error('unsupportedFilter'); }); + const { result } = renderHook(() => useCalendarEventSearch('review', client, loaded)); + await waitFor(() => expect(result.current?.searching).toBe(false)); + expect(result.current?.past.map((e) => e.id)).toEqual(['review']); + }); + + it('does nothing for an empty query', () => { + const { client, queryAllCalendarEvents } = clientWith(async () => []); + const { result } = renderHook(() => useCalendarEventSearch(' ', client, loaded)); + expect(result.current).toBeNull(); + expect(queryAllCalendarEvents).not.toHaveBeenCalled(); + }); +}); diff --git a/hooks/use-calendar-event-search.ts b/hooks/use-calendar-event-search.ts new file mode 100644 index 000000000..9d8122c28 --- /dev/null +++ b/hooks/use-calendar-event-search.ts @@ -0,0 +1,107 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; +import { matchesTerms, parseSearchQuery } from "@/lib/global-search/query-parser"; +import { calendarFilterFor, eventFields } from "@/lib/global-search/providers/calendar"; +import { getEventEndDate, getEventStartDate } from "@/lib/calendar-utils"; +import { displayNow } from "@/lib/timezone"; +import { debug } from "@/lib/debug"; +import type { CalendarEvent } from "@/lib/jmap/types"; +import type { IJMAPClient } from "@/lib/jmap/client-interface"; + +const SERVER_LIMIT = 100; +const DEBOUNCE_MS = 250; + +export interface CalendarSearchResults { + /** Events still to come (or going on), soonest first. */ + upcoming: CalendarEvent[]; + /** Events that are over, latest first. */ + past: CalendarEvent[]; + /** Waiting for the server's answer. */ + searching: boolean; +} + +// One row per event: a series found as loaded occurrences and as the +// server's base event shows once. +const seriesKey = (event: CalendarEvent) => `${event.accountId ?? ""} ${event.uid || event.originalId || event.id}`; + +/** + * Searches the user's events by title, place, description and participants + * (CAL-16). The events already loaded answer at once; the server's + * CalendarEvent/query `text` filter then adds the rest of the calendar. A + * server that can't answer leaves the loaded matches. + */ +export function useCalendarEventSearch( + query: string, + client: IJMAPClient | null, + loadedEvents: CalendarEvent[], +): CalendarSearchResults | null { + const parsed = useMemo(() => parseSearchQuery(query), [query]); + const active = parsed.terms.length > 0; + const text = parsed.text; + + const local = useMemo( + () => (active ? loadedEvents.filter((event) => matchesTerms(parsed.terms, eventFields(event))) : []), + [active, loadedEvents, parsed], + ); + + const [remote, setRemote] = useState<{ text: string; events: CalendarEvent[] | null } | null>(null); + useEffect(() => { + if (!active || !client) return; + const controller = new AbortController(); + const timer = setTimeout(() => { + // Inside the chain, so a client that can't query at all lands in catch. + Promise.resolve().then(() => client.queryAllCalendarEvents( + calendarFilterFor(parsed), + [{ property: "start", isAscending: false }], + SERVER_LIMIT, + { signal: controller.signal }, + )).then((events) => { + if (!controller.signal.aborted) setRemote({ text, events }); + }).catch((error: unknown) => { + if (controller.signal.aborted) return; + debug.warn("calendar", "Event search on the server failed; showing loaded events only", error); + setRemote({ text, events: null }); + }); + }, DEBOUNCE_MS); + return () => { + clearTimeout(timer); + controller.abort(); + }; + }, [active, client, parsed, text]); + + return useMemo(() => { + if (!active) return null; + const answered = remote?.text === text ? remote : null; + const now = displayNow().getTime(); + const ends = (event: CalendarEvent) => getEventEndDate(event).getTime(); + const starts = (event: CalendarEvent) => getEventStartDate(event).getTime(); + + // Of a series' loaded occurrences, the next one to come, else the last. + const bySeries = new Map(); + for (const event of local) { + const key = seriesKey(event); + const kept = bySeries.get(key); + if (!kept) { bySeries.set(key, event); continue; } + const keptUpcoming = ends(kept) >= now; + const upcoming = ends(event) >= now; + if (upcoming && (!keptUpcoming || starts(event) < starts(kept))) bySeries.set(key, event); + else if (!upcoming && !keptUpcoming && starts(event) > starts(kept)) bySeries.set(key, event); + } + for (const event of answered?.events ?? []) { + if (typeof event.start !== "string" || !event.start) continue; + const key = seriesKey(event); + if (!bySeries.has(key)) bySeries.set(key, event); + } + + const upcoming: CalendarEvent[] = []; + const past: CalendarEvent[] = []; + for (const event of bySeries.values()) { + if (Number.isNaN(starts(event))) continue; + (ends(event) >= now ? upcoming : past).push(event); + } + upcoming.sort((a, b) => starts(a) - starts(b)); + past.sort((a, b) => starts(b) - starts(a)); + return { upcoming, past, searching: !!client && !answered }; + }, [active, local, remote, text, client]); +} diff --git a/lib/__tests__/calendar-shortcuts.test.ts b/lib/__tests__/calendar-shortcuts.test.ts index 258b0fd00..419a4fe51 100644 --- a/lib/__tests__/calendar-shortcuts.test.ts +++ b/lib/__tests__/calendar-shortcuts.test.ts @@ -32,6 +32,9 @@ describe('Calendar keyboard shortcuts', () => { } expect(calendarShortcutAction(pressed('t', 'KeyT'))).toBe('today'); expect(calendarShortcutAction(pressed('n', 'KeyN'))).toBe('new'); + expect(calendarShortcutAction(pressed('/', 'Slash'))).toBe('search'); + // Shift + / is ?, the shell's help + expect(calendarShortcutAction({ ...pressed('?', 'Slash'), shiftKey: true })).toBeNull(); }); it('ignores letters held with a modifier', () => { diff --git a/lib/calendar-shortcuts.ts b/lib/calendar-shortcuts.ts index 01c0de843..0416c8f40 100644 --- a/lib/calendar-shortcuts.ts +++ b/lib/calendar-shortcuts.ts @@ -11,7 +11,8 @@ export type CalendarShortcutAction = | "day" | "agenda" | "tasks" - | "new"; + | "new" + | "search"; const LETTERS: Record = { t: "today", @@ -31,6 +32,8 @@ export function calendarShortcutAction( if (event.ctrlKey || event.metaKey || event.altKey || event.shiftKey) return null; if (event.key === "ArrowLeft") return "prev"; if (event.key === "ArrowRight") return "next"; + // `/` focuses the search, as in Mail (R8); on any layout, by its key. + if (event.code === "Slash" || (!event.code && event.key === "/")) return "search"; const code = event.code; const letter = code && code.length === 4 && code.startsWith("Key") ? code.charAt(3).toLowerCase() diff --git a/lib/global-search/providers/calendar.ts b/lib/global-search/providers/calendar.ts index 2a1df87ba..b02a0527f 100644 --- a/lib/global-search/providers/calendar.ts +++ b/lib/global-search/providers/calendar.ts @@ -5,7 +5,8 @@ import { textConditions } from '@/lib/jmap/search-utils'; import type { Calendar, CalendarEvent, CalendarEventFilter } from '@/lib/jmap/types'; import { useCalendarStore } from '@/stores/calendar-store'; -function eventFields(event: CalendarEvent): string[] { +/** The text an event is found by: title, description, places and participants. */ +export function eventFields(event: CalendarEvent): string[] { const fields: string[] = [event.title, event.description]; for (const location of Object.values(event.locations ?? {})) if (location?.name) fields.push(location.name); for (const participant of Object.values(event.participants ?? {})) { diff --git a/lib/keyboard-shortcuts/calendar.ts b/lib/keyboard-shortcuts/calendar.ts index eba51dd1e..65f11f619 100644 --- a/lib/keyboard-shortcuts/calendar.ts +++ b/lib/keyboard-shortcuts/calendar.ts @@ -29,6 +29,7 @@ export const CALENDAR_SECTIONS: readonly ShortcutSection[] = [ title: "calendar.shortcuts.actions", entries: [ { key: "↑ ↓ ← →", description: "calendar.shortcuts.move_in_grid" }, + { key: "/", description: "calendar.search.title" }, { key: "n / c", description: "calendar.shortcuts.new_item" }, { key: "e", description: "calendar.events.edit" }, { key: "Mod + Enter", description: "calendar.form.save" }, diff --git a/locales/ar/common.json b/locales/ar/common.json index 76fee66ba..9bd7adbce 100644 --- a/locales/ar/common.json +++ b/locales/ar/common.json @@ -3813,7 +3813,18 @@ "move_in_grid": "التنقل بين الأيام والساعات في الشبكة" }, "other_calendars": "تقويمات أخرى", - "calendar_options": "خيارات {name}" + "calendar_options": "خيارات {name}", + "search": { + "title": "البحث في الأحداث", + "clear": "مسح البحث", + "close": "إغلاق البحث", + "upcoming": "القادمة", + "past": "السابقة", + "no_results": "لا توجد أحداث تطابق «{query}»", + "no_results_hint": "يبحث في العناوين والأماكن والأوصاف والضيوف.", + "repeats": "متكرر", + "results": "{count, plural, zero {لم يُعثر على أي حدث} one {تم العثور على حدث واحد} two {تم العثور على حدثين} few {تم العثور على # أحداث} many {تم العثور على # حدثًا} other {تم العثور على # حدث}}" + } }, "calendar_event_notifications": { "someone": "شخص ما", diff --git a/locales/ca/common.json b/locales/ca/common.json index 18d759fa2..b33cd2da5 100644 --- a/locales/ca/common.json +++ b/locales/ca/common.json @@ -3813,7 +3813,18 @@ "move_in_grid": "Moure's entre dies i hores a la quadrícula" }, "other_calendars": "Altres calendaris", - "calendar_options": "Opcions de {name}" + "calendar_options": "Opcions de {name}", + "search": { + "title": "Cerca esdeveniments", + "clear": "Esborra la cerca", + "close": "Tanca la cerca", + "upcoming": "Propers", + "past": "Passats", + "no_results": "Cap esdeveniment coincideix amb «{query}»", + "no_results_hint": "La cerca mira títols, llocs, descripcions i convidats.", + "repeats": "Es repeteix", + "results": "{count, plural, one {# esdeveniment trobat} other {# esdeveniments trobats}}" + } }, "calendar_event_notifications": { "someone": "Algú", diff --git a/locales/cs/common.json b/locales/cs/common.json index 4f714d184..a1d79990a 100644 --- a/locales/cs/common.json +++ b/locales/cs/common.json @@ -3813,7 +3813,18 @@ "move_in_grid": "Přesun mezi dny a hodinami v mřížce" }, "other_calendars": "Další kalendáře", - "calendar_options": "Možnosti pro {name}" + "calendar_options": "Možnosti pro {name}", + "search": { + "title": "Hledat události", + "clear": "Vymazat hledání", + "close": "Zavřít hledání", + "upcoming": "Nadcházející", + "past": "Minulé", + "no_results": "Hledanému „{query}“ neodpovídá žádná událost", + "no_results_hint": "Hledá se v názvech, místech, popisech a hostech.", + "repeats": "Opakuje se", + "results": "{count, plural, one {Nalezena # událost} few {Nalezeny # události} many {Nalezeno # události} other {Nalezeno # událostí}}" + } }, "advanced_search": { "title": "Pokročilé hledání", diff --git a/locales/da/common.json b/locales/da/common.json index 303041961..125c534d2 100644 --- a/locales/da/common.json +++ b/locales/da/common.json @@ -3813,7 +3813,18 @@ "move_in_grid": "Gå mellem dage og timer i gitteret" }, "other_calendars": "Andre kalendere", - "calendar_options": "Indstillinger for {name}" + "calendar_options": "Indstillinger for {name}", + "search": { + "title": "Søg i begivenheder", + "clear": "Ryd søgning", + "close": "Luk søgning", + "upcoming": "Kommende", + "past": "Tidligere", + "no_results": "Ingen begivenheder matcher “{query}”", + "no_results_hint": "Søgningen ser på titler, steder, beskrivelser og gæster.", + "repeats": "Gentages", + "results": "{count, plural, one {# begivenhed fundet} other {# begivenheder fundet}}" + } }, "calendar_event_notifications": { "someone": "Nogen", diff --git a/locales/de/common.json b/locales/de/common.json index 0a90f1f52..9f27ddd1c 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -3813,7 +3813,18 @@ "move_in_grid": "Im Raster zwischen Tagen und Stunden wechseln" }, "other_calendars": "Weitere Kalender", - "calendar_options": "Optionen für {name}" + "calendar_options": "Optionen für {name}", + "search": { + "title": "Termine durchsuchen", + "clear": "Suche löschen", + "close": "Suche schließen", + "upcoming": "Demnächst", + "past": "Vergangen", + "no_results": "Keine Termine zu „{query}“", + "no_results_hint": "Gesucht wird in Titeln, Orten, Beschreibungen und Gästen.", + "repeats": "Wiederholt sich", + "results": "{count, plural, one {# Termin gefunden} other {# Termine gefunden}}" + } }, "advanced_search": { "title": "Erweiterte Suche", diff --git a/locales/en/common.json b/locales/en/common.json index d5dc1dc5b..44564b42a 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -3813,7 +3813,18 @@ "move_in_grid": "Move between days and hours in the grid" }, "other_calendars": "Other calendars", - "calendar_options": "Options for {name}" + "calendar_options": "Options for {name}", + "search": { + "title": "Search events", + "clear": "Clear search", + "close": "Close search", + "upcoming": "Upcoming", + "past": "Past", + "no_results": "No events match “{query}”", + "no_results_hint": "Search looks at titles, places, descriptions and guests.", + "repeats": "Repeats", + "results": "{count, plural, one {# event found} other {# events found}}" + } }, "calendar_event_notifications": { "someone": "Someone", diff --git a/locales/es/common.json b/locales/es/common.json index abf92a20d..74e60c8c3 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -3813,7 +3813,18 @@ "move_in_grid": "Moverse entre días y horas en la cuadrícula" }, "other_calendars": "Otros calendarios", - "calendar_options": "Opciones de {name}" + "calendar_options": "Opciones de {name}", + "search": { + "title": "Buscar eventos", + "clear": "Borrar búsqueda", + "close": "Cerrar búsqueda", + "upcoming": "Próximos", + "past": "Pasados", + "no_results": "Ningún evento coincide con «{query}»", + "no_results_hint": "La búsqueda mira títulos, lugares, descripciones e invitados.", + "repeats": "Se repite", + "results": "{count, plural, one {# evento encontrado} other {# eventos encontrados}}" + } }, "advanced_search": { "title": "Búsqueda avanzada", diff --git a/locales/et/common.json b/locales/et/common.json index 95085df2e..e33c15026 100644 --- a/locales/et/common.json +++ b/locales/et/common.json @@ -3813,7 +3813,18 @@ "move_in_grid": "Liigu ruudustikus päevade ja tundide vahel" }, "other_calendars": "Muud kalendrid", - "calendar_options": "Kalendri {name} valikud" + "calendar_options": "Kalendri {name} valikud", + "search": { + "title": "Otsi sündmusi", + "clear": "Tühjenda otsing", + "close": "Sulge otsing", + "upcoming": "Tulevased", + "past": "Möödunud", + "no_results": "Päringule „{query}“ ei vasta ükski sündmus", + "no_results_hint": "Otsitakse pealkirjadest, kohtadest, kirjeldustest ja külalistest.", + "repeats": "Kordub", + "results": "{count, plural, one {Leiti # sündmus} other {Leiti # sündmust}}" + } }, "calendar_event_notifications": { "someone": "Keegi", diff --git a/locales/fa/common.json b/locales/fa/common.json index 4a930517c..e09678f4b 100644 --- a/locales/fa/common.json +++ b/locales/fa/common.json @@ -3813,7 +3813,18 @@ "move_in_grid": "جابه‌جایی بین روزها و ساعت‌ها در جدول" }, "other_calendars": "تقویم‌های دیگر", - "calendar_options": "گزینه‌های {name}" + "calendar_options": "گزینه‌های {name}", + "search": { + "title": "جستجوی رویدادها", + "clear": "پاک کردن جستجو", + "close": "بستن جستجو", + "upcoming": "پیش رو", + "past": "گذشته", + "no_results": "هیچ رویدادی با «{query}» مطابقت ندارد", + "no_results_hint": "جستجو در عنوان‌ها، مکان‌ها، توضیحات و مهمان‌ها انجام می‌شود.", + "repeats": "تکرار می‌شود", + "results": "{count, plural, one {# رویداد پیدا شد} other {# رویداد پیدا شد}}" + } }, "calendar_event_notifications": { "someone": "کسی", diff --git a/locales/fr/common.json b/locales/fr/common.json index 70eb9cbaf..75b68dc1c 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -3813,7 +3813,18 @@ "move_in_grid": "Se déplacer entre les jours et les heures dans la grille" }, "other_calendars": "Autres agendas", - "calendar_options": "Options de {name}" + "calendar_options": "Options de {name}", + "search": { + "title": "Rechercher des événements", + "clear": "Effacer la recherche", + "close": "Fermer la recherche", + "upcoming": "À venir", + "past": "Passés", + "no_results": "Aucun événement ne correspond à « {query} »", + "no_results_hint": "La recherche porte sur les titres, lieux, descriptions et invités.", + "repeats": "Se répète", + "results": "{count, plural, one {# événement trouvé} other {# événements trouvés}}" + } }, "advanced_search": { "title": "Recherche avancée", diff --git a/locales/he/common.json b/locales/he/common.json index ef20d1f0e..8f633f05c 100644 --- a/locales/he/common.json +++ b/locales/he/common.json @@ -3731,7 +3731,18 @@ "move_in_grid": "מעבר בין ימים ושעות ברשת" }, "other_calendars": "יומנים אחרים", - "calendar_options": "אפשרויות עבור {name}" + "calendar_options": "אפשרויות עבור {name}", + "search": { + "title": "חיפוש אירועים", + "clear": "ניקוי החיפוש", + "close": "סגירת החיפוש", + "upcoming": "קרובים", + "past": "קודמים", + "no_results": "אין אירועים שתואמים ל„{query}”", + "no_results_hint": "החיפוש בודק כותרות, מקומות, תיאורים ומוזמנים.", + "repeats": "חוזר", + "results": "{count, plural, one {נמצא אירוע אחד} two {נמצאו שני אירועים} other {נמצאו # אירועים}}" + } }, "advanced_search": { "title": "חיפוש מתקדם", diff --git a/locales/hu/common.json b/locales/hu/common.json index e5b7118d6..c8ebd5c1a 100644 --- a/locales/hu/common.json +++ b/locales/hu/common.json @@ -3813,7 +3813,18 @@ "move_in_grid": "Mozgás a napok és órák között a rácsban" }, "other_calendars": "Egyéb naptárak", - "calendar_options": "{name} beállításai" + "calendar_options": "{name} beállításai", + "search": { + "title": "Események keresése", + "clear": "Keresés törlése", + "close": "Keresés bezárása", + "upcoming": "Közelgő", + "past": "Korábbi", + "no_results": "Egy esemény sem felel meg ennek: „{query}”", + "no_results_hint": "A keresés a címekben, helyszínekben, leírásokban és vendégekben keres.", + "repeats": "Ismétlődik", + "results": "{count, plural, one {# esemény található} other {# esemény található}}" + } }, "calendar_event_notifications": { "someone": "Valaki", diff --git a/locales/it/common.json b/locales/it/common.json index 83e296669..2f555dab5 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -3813,7 +3813,18 @@ "move_in_grid": "Spostarsi tra giorni e ore nella griglia" }, "other_calendars": "Altri calendari", - "calendar_options": "Opzioni per {name}" + "calendar_options": "Opzioni per {name}", + "search": { + "title": "Cerca eventi", + "clear": "Cancella ricerca", + "close": "Chiudi ricerca", + "upcoming": "Prossimi", + "past": "Passati", + "no_results": "Nessun evento corrisponde a «{query}»", + "no_results_hint": "La ricerca guarda titoli, luoghi, descrizioni e invitati.", + "repeats": "Si ripete", + "results": "{count, plural, one {# evento trovato} other {# eventi trovati}}" + } }, "advanced_search": { "title": "Ricerca avanzata", diff --git a/locales/ja/common.json b/locales/ja/common.json index 1fabfb6bd..85f1e0d97 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -3813,7 +3813,18 @@ "move_in_grid": "グリッド内で日と時間を移動" }, "other_calendars": "他のカレンダー", - "calendar_options": "{name} のオプション" + "calendar_options": "{name} のオプション", + "search": { + "title": "予定を検索", + "clear": "検索をクリア", + "close": "検索を閉じる", + "upcoming": "今後の予定", + "past": "過去の予定", + "no_results": "「{query}」に一致する予定はありません", + "no_results_hint": "タイトル、場所、説明、ゲストを検索します。", + "repeats": "繰り返し", + "results": "{count, plural, other {# 件の予定が見つかりました}}" + } }, "advanced_search": { "title": "詳細検索", diff --git a/locales/ko/common.json b/locales/ko/common.json index 02c167040..a455aae3b 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -3813,7 +3813,18 @@ "move_in_grid": "그리드에서 날짜와 시간 사이 이동" }, "other_calendars": "다른 캘린더", - "calendar_options": "{name} 옵션" + "calendar_options": "{name} 옵션", + "search": { + "title": "일정 검색", + "clear": "검색어 지우기", + "close": "검색 닫기", + "upcoming": "예정", + "past": "지난 일정", + "no_results": "‘{query}’와 일치하는 일정이 없습니다", + "no_results_hint": "제목, 장소, 설명, 참석자에서 검색합니다.", + "repeats": "반복", + "results": "{count, plural, other {일정 #개를 찾았습니다}}" + } }, "advanced_search": { "title": "상세 검색", diff --git a/locales/lv/common.json b/locales/lv/common.json index 171450c23..d876b07ed 100644 --- a/locales/lv/common.json +++ b/locales/lv/common.json @@ -3813,7 +3813,18 @@ "move_in_grid": "Pārvietoties starp dienām un stundām režģī" }, "other_calendars": "Citi kalendāri", - "calendar_options": "{name} opcijas" + "calendar_options": "{name} opcijas", + "search": { + "title": "Meklēt notikumus", + "clear": "Notīrīt meklēšanu", + "close": "Aizvērt meklēšanu", + "upcoming": "Gaidāmie", + "past": "Pagājušie", + "no_results": "Neviens notikums neatbilst “{query}”", + "no_results_hint": "Meklē nosaukumos, vietās, aprakstos un viesos.", + "repeats": "Atkārtojas", + "results": "{count, plural, zero {Atrasti # notikumi} one {Atrasts # notikums} other {Atrasti # notikumi}}" + } }, "advanced_search": { "title": "Izvērstā meklēšana", diff --git a/locales/mn/common.json b/locales/mn/common.json index 243abe693..47e569824 100644 --- a/locales/mn/common.json +++ b/locales/mn/common.json @@ -3813,7 +3813,18 @@ "move_in_grid": "Хүснэгтэд өдөр, цагийн хооронд шилжих" }, "other_calendars": "Бусад хуанли", - "calendar_options": "{name}-ийн тохиргоо" + "calendar_options": "{name}-ийн тохиргоо", + "search": { + "title": "Үйл явдал хайх", + "clear": "Хайлтыг арилгах", + "close": "Хайлтыг хаах", + "upcoming": "Удахгүй болох", + "past": "Өнгөрсөн", + "no_results": "“{query}”-д тохирох үйл явдал алга", + "no_results_hint": "Гарчиг, газар, тайлбар, зочдоос хайна.", + "repeats": "Давтагдана", + "results": "{count, plural, one {# үйл явдал олдлоо} other {# үйл явдал олдлоо}}" + } }, "calendar_event_notifications": { "someone": "Хэн нэгэн", diff --git a/locales/nb/common.json b/locales/nb/common.json index 9f98b979b..aa87e0470 100644 --- a/locales/nb/common.json +++ b/locales/nb/common.json @@ -3813,7 +3813,18 @@ "move_in_grid": "Gå mellom dager og timer i rutenettet" }, "other_calendars": "Andre kalendere", - "calendar_options": "Alternativer for {name}" + "calendar_options": "Alternativer for {name}", + "search": { + "title": "Søk i hendelser", + "clear": "Tøm søket", + "close": "Lukk søket", + "upcoming": "Kommende", + "past": "Tidligere", + "no_results": "Ingen hendelser samsvarer med «{query}»", + "no_results_hint": "Søket ser i titler, steder, beskrivelser og gjester.", + "repeats": "Gjentas", + "results": "{count, plural, one {# hendelse funnet} other {# hendelser funnet}}" + } }, "calendar_event_notifications": { "someone": "Noen", diff --git a/locales/nl/common.json b/locales/nl/common.json index add4370d0..7533c4abe 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -3813,7 +3813,18 @@ "move_in_grid": "Tussen dagen en uren in het raster bewegen" }, "other_calendars": "Andere agenda's", - "calendar_options": "Opties voor {name}" + "calendar_options": "Opties voor {name}", + "search": { + "title": "Afspraken zoeken", + "clear": "Zoekopdracht wissen", + "close": "Zoeken sluiten", + "upcoming": "Komend", + "past": "Voorbij", + "no_results": "Geen afspraken gevonden voor ‘{query}’", + "no_results_hint": "Er wordt gezocht in titels, locaties, beschrijvingen en gasten.", + "repeats": "Herhaalt", + "results": "{count, plural, one {# afspraak gevonden} other {# afspraken gevonden}}" + } }, "advanced_search": { "title": "Geavanceerd zoeken", diff --git a/locales/pl/common.json b/locales/pl/common.json index fa7f52974..4c4a2c35e 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -3813,7 +3813,18 @@ "move_in_grid": "Przechodzenie między dniami i godzinami w siatce" }, "other_calendars": "Inne kalendarze", - "calendar_options": "Opcje dla {name}" + "calendar_options": "Opcje dla {name}", + "search": { + "title": "Szukaj wydarzeń", + "clear": "Wyczyść wyszukiwanie", + "close": "Zamknij wyszukiwanie", + "upcoming": "Nadchodzące", + "past": "Minione", + "no_results": "Brak wydarzeń pasujących do „{query}”", + "no_results_hint": "Wyszukiwanie obejmuje tytuły, miejsca, opisy i gości.", + "repeats": "Powtarza się", + "results": "{count, plural, one {Znaleziono # wydarzenie} few {Znaleziono # wydarzenia} many {Znaleziono # wydarzeń} other {Znaleziono # wydarzenia}}" + } }, "advanced_search": { "title": "Wyszukiwanie zaawansowane", diff --git a/locales/pt/common.json b/locales/pt/common.json index e98e79de5..9985ef334 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -3813,7 +3813,18 @@ "move_in_grid": "Mover entre dias e horas na grelha" }, "other_calendars": "Outros calendários", - "calendar_options": "Opções de {name}" + "calendar_options": "Opções de {name}", + "search": { + "title": "Pesquisar eventos", + "clear": "Limpar pesquisa", + "close": "Fechar pesquisa", + "upcoming": "Próximos", + "past": "Passados", + "no_results": "Nenhum evento corresponde a “{query}”", + "no_results_hint": "A pesquisa procura em títulos, locais, descrições e convidados.", + "repeats": "Repete-se", + "results": "{count, plural, one {# evento encontrado} other {# eventos encontrados}}" + } }, "advanced_search": { "title": "Pesquisa avançada", diff --git a/locales/ro/common.json b/locales/ro/common.json index 1a0677ce5..2846b4fbb 100644 --- a/locales/ro/common.json +++ b/locales/ro/common.json @@ -3813,7 +3813,18 @@ "move_in_grid": "Deplasare între zile și ore în grilă" }, "other_calendars": "Alte calendare", - "calendar_options": "Opțiuni pentru {name}" + "calendar_options": "Opțiuni pentru {name}", + "search": { + "title": "Caută evenimente", + "clear": "Șterge căutarea", + "close": "Închide căutarea", + "upcoming": "Viitoare", + "past": "Trecute", + "no_results": "Niciun eveniment nu corespunde cu „{query}”", + "no_results_hint": "Căutarea se uită la titluri, locuri, descrieri și invitați.", + "repeats": "Se repetă", + "results": "{count, plural, one {# eveniment găsit} few {# evenimente găsite} other {# de evenimente găsite}}" + } }, "calendar_event_notifications": { "someone": "Cineva", diff --git a/locales/ru/common.json b/locales/ru/common.json index 6d8e0aa80..8ed102a4c 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -3813,7 +3813,18 @@ "move_in_grid": "Перемещение между днями и часами в сетке" }, "other_calendars": "Другие календари", - "calendar_options": "Параметры: {name}" + "calendar_options": "Параметры: {name}", + "search": { + "title": "Поиск событий", + "clear": "Очистить поиск", + "close": "Закрыть поиск", + "upcoming": "Предстоящие", + "past": "Прошедшие", + "no_results": "Нет событий по запросу «{query}»", + "no_results_hint": "Поиск идёт по названиям, местам, описаниям и гостям.", + "repeats": "Повторяется", + "results": "{count, plural, one {Найдено # событие} few {Найдено # события} many {Найдено # событий} other {Найдено # события}}" + } }, "advanced_search": { "title": "Расширенный поиск", diff --git a/locales/sk/common.json b/locales/sk/common.json index 74afce4ed..68684df9e 100644 --- a/locales/sk/common.json +++ b/locales/sk/common.json @@ -3813,7 +3813,18 @@ "move_in_grid": "Presun medzi dňami a hodinami v mriežke" }, "other_calendars": "Ďalšie kalendáre", - "calendar_options": "Možnosti pre {name}" + "calendar_options": "Možnosti pre {name}", + "search": { + "title": "Hľadať udalosti", + "clear": "Vymazať hľadanie", + "close": "Zavrieť hľadanie", + "upcoming": "Nadchádzajúce", + "past": "Minulé", + "no_results": "Hľadaniu „{query}“ nezodpovedá žiadna udalosť", + "no_results_hint": "Hľadá sa v názvoch, miestach, popisoch a hosťoch.", + "repeats": "Opakuje sa", + "results": "{count, plural, one {Nájdená # udalosť} few {Nájdené # udalosti} many {Nájdenej # udalosti} other {Nájdených # udalostí}}" + } }, "calendar_event_notifications": { "someone": "Niekto", diff --git a/locales/th/common.json b/locales/th/common.json index ab77e9ff3..f6fc2918f 100644 --- a/locales/th/common.json +++ b/locales/th/common.json @@ -3142,7 +3142,18 @@ "move_in_grid": "เลื่อนไปมาระหว่างวันและชั่วโมงในตาราง" }, "other_calendars": "ปฏิทินอื่นๆ", - "calendar_options": "ตัวเลือกสำหรับ {name}" + "calendar_options": "ตัวเลือกสำหรับ {name}", + "search": { + "title": "ค้นหากิจกรรม", + "clear": "ล้างการค้นหา", + "close": "ปิดการค้นหา", + "upcoming": "ที่กำลังจะมาถึง", + "past": "ที่ผ่านมา", + "no_results": "ไม่มีกิจกรรมที่ตรงกับ “{query}”", + "no_results_hint": "ค้นหาจากชื่อ สถานที่ คำอธิบาย และแขก", + "repeats": "เกิดซ้ำ", + "results": "{count, plural, other {พบ # กิจกรรม}}" + } }, "settings": { "title": "การตั้งค่า", diff --git a/locales/tr/common.json b/locales/tr/common.json index dff1b6133..5518dcf19 100644 --- a/locales/tr/common.json +++ b/locales/tr/common.json @@ -3813,7 +3813,18 @@ "move_in_grid": "Izgarada günler ve saatler arasında gezin" }, "other_calendars": "Diğer takvimler", - "calendar_options": "{name} seçenekleri" + "calendar_options": "{name} seçenekleri", + "search": { + "title": "Etkinlik ara", + "clear": "Aramayı temizle", + "close": "Aramayı kapat", + "upcoming": "Yaklaşan", + "past": "Geçmiş", + "no_results": "“{query}” ile eşleşen etkinlik yok", + "no_results_hint": "Arama başlıklarda, yerlerde, açıklamalarda ve davetlilerde yapılır.", + "repeats": "Tekrarlanıyor", + "results": "{count, plural, one {# etkinlik bulundu} other {# etkinlik bulundu}}" + } }, "calendar_event_notifications": { "someone": "Birisi", diff --git a/locales/uk/common.json b/locales/uk/common.json index 12eb47ff2..129392404 100644 --- a/locales/uk/common.json +++ b/locales/uk/common.json @@ -3813,7 +3813,18 @@ "move_in_grid": "Переміщення між днями й годинами в сітці" }, "other_calendars": "Інші календарі", - "calendar_options": "Параметри: {name}" + "calendar_options": "Параметри: {name}", + "search": { + "title": "Пошук подій", + "clear": "Очистити пошук", + "close": "Закрити пошук", + "upcoming": "Майбутні", + "past": "Минулі", + "no_results": "Немає подій за запитом «{query}»", + "no_results_hint": "Пошук іде за назвами, місцями, описами й гостями.", + "repeats": "Повторюється", + "results": "{count, plural, one {Знайдено # подію} few {Знайдено # події} many {Знайдено # подій} other {Знайдено # події}}" + } }, "advanced_search": { "title": "Розширений пошук", diff --git a/locales/zh-TW/common.json b/locales/zh-TW/common.json index b6260294d..8f661dec8 100644 --- a/locales/zh-TW/common.json +++ b/locales/zh-TW/common.json @@ -3813,7 +3813,18 @@ "move_in_grid": "在格線中按天與小時移動" }, "other_calendars": "其他日曆", - "calendar_options": "{name} 的選項" + "calendar_options": "{name} 的選項", + "search": { + "title": "搜尋活動", + "clear": "清除搜尋", + "close": "關閉搜尋", + "upcoming": "即將到來", + "past": "已過去", + "no_results": "沒有符合「{query}」的活動", + "no_results_hint": "搜尋範圍包括標題、地點、說明和邀請對象。", + "repeats": "重複", + "results": "{count, plural, other {找到 # 個活動}}" + } }, "calendar_event_notifications": { "someone": "有人", diff --git a/locales/zh/common.json b/locales/zh/common.json index f4019600e..390e791c7 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -3813,7 +3813,18 @@ "move_in_grid": "在网格中按天和小时移动" }, "other_calendars": "其他日历", - "calendar_options": "{name} 的选项" + "calendar_options": "{name} 的选项", + "search": { + "title": "搜索活动", + "clear": "清除搜索", + "close": "关闭搜索", + "upcoming": "即将到来", + "past": "已过去", + "no_results": "没有与“{query}”匹配的活动", + "no_results_hint": "搜索范围包括标题、地点、描述和邀请对象。", + "repeats": "重复", + "results": "{count, plural, other {找到 # 个活动}}" + } }, "advanced_search": { "title": "高级搜索",