mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 00:12:39 +00:00
feat: search events from the calendar toolbar, with / and a palette command
This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 ?? {})) {
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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": "Розширений пошук",
|
||||
|
||||
@@ -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
@@ -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": "高级搜索",
|
||||
|
||||
Reference in New Issue
Block a user