feat: search events from the calendar toolbar, with / and a palette command

This commit is contained in:
Linus Rath
2026-10-28 07:17:16 +01:00
parent 3ff72c2c7f
commit d87678a7ba
39 changed files with 889 additions and 41 deletions
@@ -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(<><CalendarApp /><PaletteGroups /></>);
const items = screen.getByTestId('palette').textContent ?? '';
expect(items).toContain('calendar: search.title');
});
});
function toastTitles(): string[] {
return useToastStore.getState().toasts.map((t) => t.title);
}
+66 -7
View File
@@ -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<HTMLInputElement>(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 (
<CalendarSearchResults
query={searchQuery}
results={searchResults}
calendars={allCalendars}
timeFormat={timeFormat}
currentUserEmails={currentUserEmails}
onSelectEvent={handleSelectEvent}
onContextMenuEvent={handleContextMenuEvent}
/>
);
}
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 : (
<CalendarSearchField
ref={searchInputRef}
value={searchQuery}
onChange={setSearchQuery}
className="w-40 lg:w-56 xl:w-64 me-1"
/>
)}
onOpenSearch={normalizedViewMode === "tasks" ? undefined : () => setMobileSearchOpen(true)}
mobileSearchField={mobileSearchOpen ? (
<CalendarSearchField
ref={searchInputRef}
value={searchQuery}
onChange={setSearchQuery}
onDismiss={leaveSearch}
autoFocus
/>
) : null}
onCloseSearch={leaveSearch}
/>
);
+211
View File
@@ -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<HTMLInputElement, CalendarSearchFieldProps>(
function CalendarSearchField({ value, onChange, onDismiss, autoFocus, className }, ref) {
const t = useTranslations("calendar");
const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
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<HTMLElement>(RESULT_SELECTOR);
if (first) { e.preventDefault(); first.focus(); }
}
};
return (
<div className={cn("relative", className)} role="search">
<Search className="absolute start-2.5 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground pointer-events-none" aria-hidden="true" />
{/* type="search" keeps password managers out of it, as in Mail. */}
<Input
ref={ref}
type="search"
value={value}
autoFocus={autoFocus}
onChange={(e) => onChange(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={t("search.title")}
aria-label={t("search.title")}
autoComplete="off"
data-calendar-search
className={cn(
"h-8 ps-8 [&::-webkit-search-cancel-button]:appearance-none [&::-webkit-search-decoration]:appearance-none",
value && "pe-8",
)}
/>
{value && (
<IconButton
label={t("search.clear")}
size="icon-sm"
className="absolute end-0 top-1/2 -translate-y-1/2"
onClick={() => onChange("")}
>
<X />
</IconButton>
)}
</div>
);
},
);
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<HTMLDivElement>(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<HTMLButtonElement>) => {
if (e.key !== "ArrowDown" && e.key !== "ArrowUp") return;
e.preventDefault();
const rows = Array.from(listRef.current?.querySelectorAll<HTMLElement>(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<HTMLElement>("[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 (
<button
key={ev.id}
type="button"
data-search-result
onClick={(e) => onSelectEvent(ev, e.currentTarget.getBoundingClientRect())}
onContextMenu={onContextMenuEvent ? (e) => onContextMenuEvent(e, ev) : undefined}
onKeyDown={handleRowKeyDown}
className="w-full flex items-start gap-3 px-2 sm:px-4 rounded-md hover:bg-muted/60 focus-visible:bg-muted/60 outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring transition-colors text-start"
style={{ paddingBlock: "var(--density-item-py)" }}
>
<span className="w-24 sm:w-32 flex-shrink-0 text-sm leading-tight">
<span className="block text-foreground">
{intlFormatter.dateTime(start, sameYear
? { weekday: "short", month: "short", day: "numeric" }
: { month: "short", day: "numeric", year: "numeric" })}
</span>
<span className="block text-xs text-muted-foreground tabular-nums">
{ev.showWithoutTime ? t("events.all_day") : `${formatTime(start)} – ${formatTime(end)}`}
</span>
</span>
<span
className="w-[9px] h-[9px] mt-[5.5px] rounded-full flex-shrink-0"
style={isInactive ? { boxShadow: `inset 0 0 0 1.5px ${color}` } : { backgroundColor: color }}
aria-hidden="true"
/>
<span className="flex-1 min-w-0">
<span className={cn("block text-sm font-medium truncate", isInactive && "line-through text-muted-foreground")} title={title}>
{title}
</span>
{(meta || repeats) && (
<span className="flex items-center gap-2 text-xs text-muted-foreground mt-0.5 min-w-0">
{locationName && <MapPin className="w-3 h-3 flex-shrink-0" aria-hidden="true" />}
{meta && <span className="truncate" title={meta}>{meta}</span>}
{repeats && (
<span className="flex items-center gap-1 flex-shrink-0">
<Repeat className="w-3 h-3" aria-hidden="true" />
{t("search.repeats")}
</span>
)}
</span>
)}
</span>
</button>
);
};
const section = (id: string, label: string, events: CalendarEvent[]) => events.length > 0 && (
<section aria-labelledby={id} className="py-1">
<h2 id={id} className="px-2 sm:px-4 pt-3 pb-1 text-xs font-medium uppercase tracking-wide text-muted-foreground">
{label}
</h2>
<div className="px-1 sm:px-2">{events.map(renderRow)}</div>
</section>
);
return (
<div ref={listRef} className="flex-1 min-h-0 overflow-y-auto" data-testid="calendar-search-results">
<p className="sr-only" role="status" aria-live="polite">
{searching && count === 0 ? "" : t("search.results", { count })}
</p>
{count === 0 && searching && (
<div className="flex justify-center p-8"><Spinner /></div>
)}
{count === 0 && !searching && (
<EmptyState
icon={Search}
title={t("search.no_results", { query: query.trim() })}
description={t("search.no_results_hint")}
/>
)}
{section("calendar-search-upcoming", t("search.upcoming"), upcoming)}
{section("calendar-search-past", t("search.past"), past)}
{count > 0 && searching && (
<div className="flex justify-center py-3"><Spinner size="sm" /></div>
)}
</div>
);
}
+33 -3
View File
@@ -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 && (
<div className="flex flex-col gap-1 px-2 py-2">
{/* Row 1: Back / Date nav / Today */}
{/* Row 1 while searching: the search box with a way back. */}
{mobileSearchField ? (
<div className="flex items-center gap-1">
<IconButton size="icon-lg" label={t("search.close")} onClick={onCloseSearch} className="-ms-1 touch-manipulation">
<ArrowLeft />
</IconButton>
<div className="flex-1 min-w-0">{mobileSearchField}</div>
</div>
) : (
/* Row 1: Back / Date nav / Today */
<div className="flex items-center gap-1">
{onMenuClick && (
<IconButton size="icon-lg" label={t("nav_open_menu")} onClick={onMenuClick} className="-ms-1 touch-manipulation">
@@ -237,6 +258,7 @@ export function CalendarToolbar({
{/* The shell's phone ⋮ (notifications, palette, account) with the calendar's own entries on top. */}
<PhoneHeaderActions below="md" menuItems={phoneMenuItems} />
</div>
)}
{/* Row 2: View switcher pills + calendar toggle */}
<div className="flex items-center gap-1.5">
@@ -257,6 +279,12 @@ export function CalendarToolbar({
))}
</div>
{/* Beside the calendar filter: the date row has no room left. */}
{onOpenSearch && (
<IconButton size="icon-lg" variant="outline" label={t("search.title")} onClick={onOpenSearch} className="touch-manipulation">
<Search />
</IconButton>
)}
{calendars && selectedCalendarIds && onToggleVisibility && (
<div className="relative" ref={dropdownRef}>
<IconButton
@@ -419,6 +447,8 @@ export function CalendarToolbar({
<div className="flex-1" />
{searchField}
{moreButton}
<div className="relative ms-1">
@@ -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<typeof import('@/lib/timezone')>()),
displayNow: () => new Date(2026, 9, 28, 12, 0),
}));
function makeEvent(id: string, title: string, start: string, extra: Partial<CalendarEvent> = {}): 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<CalendarEvent[]>) {
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();
});
});
+107
View File
@@ -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<string, CalendarEvent>();
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]);
}
+3
View File
@@ -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', () => {
+4 -1
View File
@@ -11,7 +11,8 @@ export type CalendarShortcutAction =
| "day"
| "agenda"
| "tasks"
| "new";
| "new"
| "search";
const LETTERS: Record<string, CalendarShortcutAction> = {
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()
+2 -1
View File
@@ -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 ?? {})) {
+1
View File
@@ -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" },
+12 -1
View File
@@ -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": "شخص ما",
+12 -1
View File
@@ -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ú",
+12 -1
View File
@@ -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í",
+12 -1
View File
@@ -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",
+12 -1
View File
@@ -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",
+12 -1
View File
@@ -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",
+12 -1
View File
@@ -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",
+12 -1
View File
@@ -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",
+12 -1
View File
@@ -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": "کسی",
+12 -1
View File
@@ -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",
+12 -1
View File
@@ -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": "חיפוש מתקדם",
+12 -1
View File
@@ -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",
+12 -1
View File
@@ -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",
+12 -1
View File
@@ -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": "詳細検索",
+12 -1
View File
@@ -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": "상세 검색",
+12 -1
View File
@@ -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",
+12 -1
View File
@@ -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": "Хэн нэгэн",
+12 -1
View File
@@ -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",
+12 -1
View File
@@ -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",
+12 -1
View File
@@ -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",
+12 -1
View File
@@ -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",
+12 -1
View File
@@ -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",
+12 -1
View File
@@ -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": "Расширенный поиск",
+12 -1
View File
@@ -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",
+12 -1
View File
@@ -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": "การตั้งค่า",
+12 -1
View File
@@ -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",
+12 -1
View File
@@ -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": "Розширений пошук",
+12 -1
View File
@@ -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": "有人",
+12 -1
View File
@@ -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": "高级搜索",