diff --git a/components/calendar/__tests__/calendar-app.test.tsx b/components/calendar/__tests__/calendar-app.test.tsx index 7abff772a..843e003d9 100644 --- a/components/calendar/__tests__/calendar-app.test.tsx +++ b/components/calendar/__tests__/calendar-app.test.tsx @@ -222,6 +222,15 @@ describe('CalendarApp unsaved editor changes', () => { await waitFor(() => expect(screen.queryByPlaceholderText('form.title')).toBeNull()); }); + it('opens the editor on n without typing the n into its title', async () => { + renderApp(); + const press = new KeyboardEvent('keydown', { key: 'n', code: 'KeyN', bubbles: true, cancelable: true }); + document.body.dispatchEvent(press); + // A key whose keydown is not cancelled goes on to type its character + expect(press.defaultPrevented).toBe(true); + expect(await screen.findByPlaceholderText('form.title')).toHaveValue(''); + }); + it('closes an untouched editor without asking', () => { renderApp(); fireEvent.keyDown(document.body, { key: 'n', code: 'KeyN' }); @@ -254,6 +263,90 @@ describe('CalendarApp unsaved editor changes', () => { }); }); +describe('CalendarApp browser Back with an editor open (R5)', () => { + const backEntry = () => (window.history.state as Record | null)?.__closeOnBack; + // Earlier tests leave history behind: wait for this editor's own entry. + const openEditorEntry = async () => { + const before = backEntry(); + fireEvent.keyDown(document.body, { key: 'n', code: 'KeyN' }); + await waitFor(() => expect(backEntry()).toBeTruthy()); + await waitFor(() => expect(backEntry()).not.toBe(before)); + }; + + it('closes an untouched editor instead of leaving the calendar', async () => { + renderApp(); + await openEditorEntry(); + window.history.back(); + await waitFor(() => expect(screen.queryByPlaceholderText('form.title')).toBeNull()); + expect(screen.queryByRole('alertdialog')).toBeNull(); + expect(backEntry()).toBeUndefined(); + }); + + it('asks before Back drops changes, and asks again after Keep editing', async () => { + renderApp(); + await openEditorEntry(); + fireEvent.change(screen.getByPlaceholderText('form.title'), { target: { value: 'Lunch' } }); + window.history.back(); + const ask = await screen.findByRole('alertdialog', { name: 'discard.title' }); + fireEvent.click(within(ask).getByRole('button', { name: 'discard.keep' })); + await waitFor(() => expect(screen.queryByRole('alertdialog')).toBeNull()); + expect(screen.getByPlaceholderText('form.title')).toHaveValue('Lunch'); + // The entry is back, so the next Back asks again + expect(backEntry()).toBeTruthy(); + + window.history.back(); + const again = await screen.findByRole('alertdialog', { name: 'discard.title' }); + fireEvent.click(within(again).getByRole('button', { name: 'discard.confirm' })); + await waitFor(() => expect(screen.queryByPlaceholderText('form.title')).toBeNull()); + await waitFor(() => expect(backEntry()).toBeUndefined()); + }); + + it('takes its history entry back off when the editor closes another way', async () => { + renderApp(); + await openEditorEntry(); + fireEvent.click(screen.getByText('form.cancel')); + await waitFor(() => expect(backEntry()).toBeUndefined()); + }); +}); + +describe('CalendarApp event search (CAL-16)', () => { + it('focuses the search on / and shows matching events in place of the grid', async () => { + setUp([makeEvent(), makeEvent({ id: 'e2', uid: 'u2', title: 'Dentist', start: '2026-10-13T08:00:00' })]); + renderApp(); + fireEvent.keyDown(document.body, { key: '/', code: 'Slash' }); + const box = screen.getByRole('searchbox', { name: 'search.title' }); + expect(document.activeElement).toBe(box); + + fireEvent.change(box, { target: { value: 'dent' } }); + const results = await screen.findByTestId('calendar-search-results'); + expect(within(results).getByText('Dentist')).toBeInTheDocument(); + expect(within(results).queryByText('Standup')).toBeNull(); + expect(screen.queryByRole('grid')).toBeNull(); + + // A result opens like an event in the grid + fireEvent.click(within(results).getByText('Dentist')); + expect(await screen.findByRole('dialog', { name: 'Dentist' })).toBeInTheDocument(); + fireEvent.keyDown(document.body, { key: 'Escape' }); + + // Escape in the box goes back to the grid + fireEvent.keyDown(box, { key: 'Escape' }); + await waitFor(() => expect(screen.queryByTestId('calendar-search-results')).toBeNull()); + expect(screen.getByRole('grid')).toBeInTheDocument(); + }); + + it('says so when nothing matches', async () => { + renderApp(); + fireEvent.change(screen.getByRole('searchbox', { name: 'search.title' }), { target: { value: 'zzz' } }); + expect(await screen.findByText('search.no_results', {}, { timeout: 3000 })).toBeInTheDocument(); + }); + + it('is in the command palette', () => { + render(<>); + const items = screen.getByTestId('palette').textContent ?? ''; + expect(items).toContain('calendar: search.title'); + }); +}); + function toastTitles(): string[] { return useToastStore.getState().toasts.map((t) => t.title); } diff --git a/components/calendar/__tests__/calendar-display-range.test.tsx b/components/calendar/__tests__/calendar-display-range.test.tsx index 2135445a7..0bb49cab1 100644 --- a/components/calendar/__tests__/calendar-display-range.test.tsx +++ b/components/calendar/__tests__/calendar-display-range.test.tsx @@ -3,6 +3,7 @@ import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest'; import { CalendarWeekView } from '../calendar-week-view'; import { CalendarDayView } from '../calendar-day-view'; import { useSettingsStore } from '@/stores/settings-store'; +import { fillHourHeight } from '@/hooks/use-display-hours'; import type { CalendarEvent, Calendar } from '@/lib/jmap/types'; const calendars = [{ id: 'cal-1', name: 'Work', color: '#123456' }] as unknown as Calendar[]; @@ -154,3 +155,79 @@ describe('calendar display hours and working days (#1164)', () => { expect(document.querySelector('[data-hidden-events="before"]')).toHaveTextContent('1'); }); }); + +describe('time grid height (CAL-27)', () => { + // A tablet-tall pane: the grid's scroll root reports 1100px of room and + // its sticky header 100px, leaving 1000px for 12 hours. + beforeEach(() => { + useSettingsStore.setState(initialSettings, true); + vi.spyOn(HTMLElement.prototype, 'clientHeight', 'get').mockImplementation(function (this: HTMLElement) { + return this.getAttribute('role') === 'grid' ? 1100 : 0; + }); + vi.spyOn(HTMLElement.prototype, 'offsetHeight', 'get').mockImplementation(function (this: HTMLElement) { + return this.classList.contains('sticky') && this.classList.contains('top-0') ? 100 : 0; + }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + cleanup(); + }); + + it('stretches the hour rows to fill the pane below a short range', () => { + renderWeek({ events: [makeEvent('noon', '2026-09-09T12:00:00', 'PT1H')] }); + // floor(1000 / 12) = 83px an hour instead of leaving 280px empty + expect(screen.getAllByRole('gridcell')[0].style.height).toBe('83px'); + expect(eventBlock('noon')?.style.top).toBe(`${4 * 83}px`); + expect(eventBlock('noon')?.style.height).toBe('83px'); + }); + + it('fits the rows to the pane, shrinking them only a little', () => { + expect(fillHourHeight(60, 1000, 12)).toBe(83); + // 59px rows instead of 4px of scrolling + expect(fillHourHeight(60, 716, 12)).toBe(59); + // a whole day on a short pane scrolls at the normal height + expect(fillHourHeight(60, 716, 24)).toBe(60); + expect(fillHourHeight(60, 0, 12)).toBe(60); + }); + + it('keeps the normal row height when the whole day is shown', () => { + useSettingsStore.setState({ calendarLimitHours: false }); + renderWeek(); + expect(screen.getAllByRole('gridcell')[0].style.height).toBe('60px'); + }); + + it('marks events that run past the visible hours instead of cutting them silently', () => { + renderWeek({ + events: [ + makeEvent('late', '2026-09-09T19:00:00', 'PT2H'), + makeEvent('early', '2026-09-09T07:00:00', 'PT2H'), + makeEvent('inside', '2026-09-09T12:00:00', 'PT1H'), + ], + }); + const card = (id: string) => screen.getByText('Event ' + id).closest('button')!; + expect(card('late')).toHaveAttribute('data-continues-after'); + expect(card('late')).not.toHaveAttribute('data-continues-before'); + expect(card('early')).toHaveAttribute('data-continues-before'); + expect(card('inside')).not.toHaveAttribute('data-continues-after'); + expect(card('inside')).not.toHaveAttribute('data-continues-before'); + }); + + it('fills the day view too', () => { + render( + , + ); + expect(screen.getAllByRole('gridcell')[0].style.height).toBe('83px'); + expect(screen.getByText('Event late').closest('button')).toHaveAttribute('data-continues-after'); + }); +}); diff --git a/components/calendar/__tests__/calendar-time-grid-keyboard.test.tsx b/components/calendar/__tests__/calendar-time-grid-keyboard.test.tsx new file mode 100644 index 000000000..5af65d961 --- /dev/null +++ b/components/calendar/__tests__/calendar-time-grid-keyboard.test.tsx @@ -0,0 +1,166 @@ +import { render, screen, fireEvent, cleanup } from '@testing-library/react'; +import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest'; +import { CalendarWeekView } from '../calendar-week-view'; +import { CalendarDayView } from '../calendar-day-view'; +import { useSettingsStore } from '@/stores/settings-store'; +import type { Calendar } from '@/lib/jmap/types'; + +const calendars = [{ id: 'cal-1', name: 'Work', color: '#123456' }] as unknown as Calendar[]; +const initialSettings = useSettingsStore.getState(); + +type WeekProps = React.ComponentProps; + +function renderWeek(overrides: Partial = {}) { + const props: WeekProps = { + focus: { date: new Date(2026, 8, 9), nonce: 0 }, + windowKey: 'week:2026-09-07', + // Monday 7 Sep to Sunday 13 Sep 2026 + rangeStart: new Date(2026, 8, 7), + rangeEnd: new Date(2026, 8, 13), + selectedDate: new Date(2026, 8, 9), + events: [], + calendars, + onSelectDate: vi.fn(), + onSelectEvent: vi.fn(), + onCreateAtTime: vi.fn(), + ...overrides, + }; + render(); + return props; +} + +function tabStops(): HTMLElement[] { + return screen.getAllByRole('gridcell').filter((cell) => cell.tabIndex === 0); +} + +function focusedSlot(): string | null { + return (document.activeElement as HTMLElement | null)?.getAttribute('data-slot') ?? null; +} + +describe('keyboard access to the time grid (CAL-13)', () => { + beforeEach(() => { + useSettingsStore.setState(initialSettings, true); + }); + + afterEach(() => { + cleanup(); + }); + + it('gives the week grid one tab stop on the selected day', () => { + renderWeek(); + const stops = tabStops(); + expect(stops).toHaveLength(1); + expect(stops[0].getAttribute('data-slot')).toMatch(/^2026-09-09T\d+$/); + // The slot is read out with its date and time (the test formatter + // prints the whole Date), not just "Wed 08:00" + expect(stops[0]).toHaveAccessibleName(/Sep 09 2026.*, \d\d:00$/); + // ...and one date in the header, not each loaded day's + const dates = screen.getAllByRole('columnheader').map((h) => h.querySelector('button')!); + expect(dates.filter((b) => b.tabIndex === 0)).toHaveLength(1); + }); + + it('moves by hour and day with the arrows and Home/End', () => { + renderWeek(); + const start = tabStops()[0]; + start.focus(); + fireEvent.keyDown(start, { key: 'Home' }); + expect(focusedSlot()).toBe('2026-09-09T8'); + fireEvent.keyDown(document.activeElement!, { key: 'ArrowDown' }); + expect(focusedSlot()).toBe('2026-09-09T9'); + fireEvent.keyDown(document.activeElement!, { key: 'ArrowRight' }); + expect(focusedSlot()).toBe('2026-09-10T9'); + fireEvent.keyDown(document.activeElement!, { key: 'ArrowLeft' }); + fireEvent.keyDown(document.activeElement!, { key: 'ArrowLeft' }); + expect(focusedSlot()).toBe('2026-09-08T9'); + fireEvent.keyDown(document.activeElement!, { key: 'End' }); + expect(focusedSlot()).toBe('2026-09-08T19'); + // The edge of the visible hours stops the arrow + fireEvent.keyDown(document.activeElement!, { key: 'ArrowDown' }); + expect(focusedSlot()).toBe('2026-09-08T19'); + // The tab stop follows the focus + expect(tabStops().map((cell) => cell.getAttribute('data-slot'))).toEqual(['2026-09-08T19']); + }); + + it('keeps the arrows from paging the calendar', () => { + renderWeek(); + const start = tabStops()[0]; + start.focus(); + const event = new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true, cancelable: true }); + start.dispatchEvent(event); + expect(event.defaultPrevented).toBe(true); + }); + + it('creates an event at the focused slot with Enter or n', () => { + const props = renderWeek(); + const start = tabStops()[0]; + start.focus(); + fireEvent.keyDown(start, { key: 'Home' }); + fireEvent.keyDown(document.activeElement!, { key: 'Enter' }); + expect(props.onCreateAtTime).toHaveBeenLastCalledWith(new Date(2026, 8, 9, 8, 0, 0, 0)); + fireEvent.keyDown(document.activeElement!, { key: 'ArrowDown' }); + fireEvent.keyDown(document.activeElement!, { key: 'n', code: 'KeyN' }); + expect(props.onCreateAtTime).toHaveBeenLastCalledWith(new Date(2026, 8, 9, 9, 0, 0, 0)); + // Not with a modifier: Ctrl+N belongs to the browser + fireEvent.keyDown(document.activeElement!, { key: 'n', code: 'KeyN', ctrlKey: true }); + expect(props.onCreateAtTime).toHaveBeenCalledTimes(2); + }); + + it('leaves the slots out of the tab order on phones', () => { + renderWeek({ isMobile: true }); + expect(tabStops()).toHaveLength(0); + }); + + it('works the same in the day view', () => { + const onCreateAtTime = vi.fn(); + render( + , + ); + const stops = tabStops(); + expect(stops).toHaveLength(1); + expect(stops[0]).toHaveAccessibleName(/Sep 09 2026.*, \d\d:00$/); + stops[0].focus(); + fireEvent.keyDown(stops[0], { key: 'End' }); + fireEvent.keyDown(document.activeElement!, { key: 'ArrowRight' }); + expect(focusedSlot()).toBe('2026-09-10T19'); + fireEvent.keyDown(document.activeElement!, { key: 'Enter' }); + expect(onCreateAtTime).toHaveBeenCalledWith(new Date(2026, 8, 10, 19, 0, 0, 0)); + }); +}); + +describe('quick editor focus (CAL-13)', () => { + afterEach(() => { + cleanup(); + }); + + it('returns focus to the slot it was opened from', async () => { + const { QuickCreatePopover } = await import('../quick-create-popover'); + const slot = document.createElement('div'); + slot.tabIndex = -1; + document.body.appendChild(slot); + slot.focus(); + const { unmount } = render( + , + ); + (screen.getAllByRole('textbox')[0] as HTMLElement).focus(); + unmount(); + expect(document.activeElement).toBe(slot); + slot.remove(); + }); +}); diff --git a/components/calendar/calendar-app.tsx b/components/calendar/calendar-app.tsx index 35c21002d..db516779c 100644 --- a/components/calendar/calendar-app.tsx +++ b/components/calendar/calendar-app.tsx @@ -3,7 +3,7 @@ import { useState, useEffect, useCallback, useRef, useMemo, type TouchEvent as ReactTouchEvent } from "react"; import { useRouter } from "@/i18n/navigation"; import { useTranslations } from "next-intl"; -import { Plus, ListTodo, CalendarDays, Upload, Globe, RefreshCw } from "@/components/icons"; +import { Plus, ListTodo, CalendarDays, Upload, Globe, RefreshCw, Search } from "@/components/icons"; import { addMonths, subMonths, addWeeks, subWeeks, addDays, subDays, format, parseISO, @@ -86,6 +86,9 @@ import type { ParsedWebcal } from "@/lib/protocol-handlers/webcal"; import { appPath, buildCalendarPath, parseCalendarPath, type CalendarDeepLink } from "@/lib/deep-links"; 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 { @@ -191,6 +194,11 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps = const [showEventModal, setShowEventModal] = useState(false); const [showImportModal, setShowImportModal] = useState(false); + // Event search (CAL-16): the query in the toolbar box; on phones the box + // opens as a row of its own. + const [searchQuery, setSearchQuery] = useState(""); + const [mobileSearchOpen, setMobileSearchOpen] = useState(false); + const searchInputRef = useRef(null); const [showSubscriptionModal, setShowSubscriptionModal] = useState(false); const [pendingSubscription, setPendingSubscription] = useState<{ url: string; name: string } | null>(null); const [showWebcalActionChoice, setShowWebcalActionChoice] = useState(false); @@ -942,6 +950,23 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps = : proInterfaceActive ? null : calendarLinkPath, ); + // Browser Back closes the event or task editor (asking first when it has + // unsaved changes) instead of leaving the calendar (R5). + const closeEditors = useCallback(() => { + setShowEventModal(false); + setEditEvent(null); + setPendingPreview(null); + setDefaultCalendarIdForCreate(undefined); + setDefaultModalAllDay(false); + setShowTaskModal(false); + setEditTask(null); + }, []); + useCloseOnBack({ + active: (showEventModal || showTaskModal) && (isEmbedded ? isFocusedProTab : !proInterfaceActive), + shouldHold: () => eventEditorDirtyRef.current || taskEditorDirtyRef.current, + onBack: () => whenEditorsDiscarded(closeEditors), + }); + const handleEditFromDetail = useCallback(() => { if (detailEvent) { const ev = detailEvent; @@ -1512,6 +1537,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; @@ -1524,6 +1561,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; @@ -1533,12 +1571,14 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps = case "day": setViewMode("day"); break; case "agenda": setViewMode("agenda"); break; case "tasks": if (enableCalendarTasks) setViewMode("tasks"); break; - case "new": openCreateForView(); 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). @@ -1553,13 +1593,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 }, @@ -1572,6 +1613,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'; } })(); @@ -1735,6 +1780,19 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps = } const viewContent = (() => { + if (searchResults && normalizedViewMode !== "tasks") { + return ( + + ); + } switch (normalizedViewMode) { case "month": return ( @@ -1893,10 +1951,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)} @@ -1912,6 +1970,26 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps = sidebarOpen={isNarrow ? narrowSidebarOpen : !sidebarCollapsed} menuTogglesSidebar={!isNarrow} showCreateButton={isNarrow || sidebarCollapsed} + // Tasks have their own list and no events to search. + searchField={normalizedViewMode === "tasks" ? undefined : ( + + )} + onOpenSearch={normalizedViewMode === "tasks" ? undefined : () => setMobileSearchOpen(true)} + mobileSearchField={mobileSearchOpen ? ( + + ) : null} + onCloseSearch={leaveSearch} /> ); diff --git a/components/calendar/calendar-day-view.tsx b/components/calendar/calendar-day-view.tsx index 0d85aa1a9..d927ee602 100644 --- a/components/calendar/calendar-day-view.tsx +++ b/components/calendar/calendar-day-view.tsx @@ -15,6 +15,7 @@ import { displayNow, isDisplayToday } from "@/lib/timezone"; import { groupTasksByDueDay } from "@/lib/calendar-tasks"; import type { CalendarEvent, Calendar, CalendarTask } from "@/lib/jmap/types"; import { useDisplayHours, type RevealTime } from "@/hooks/use-display-hours"; +import { useTimeGridFocus } from "@/hooks/use-time-grid-focus"; import { useTimeGridInteractions, type MoveEventHandler } from "@/hooks/use-time-grid-interactions"; import { useScrollWindow, getScrollStart, setScrollStart, scrollToStart } from "@/hooks/use-scroll-window"; import { dayKey, type ScrollWindowViewProps } from "@/lib/calendar-scroll-window"; @@ -57,7 +58,7 @@ interface CalendarDayViewProps extends ScrollWindowViewProps { revealAt?: RevealTime | null; } -const HOUR_HEIGHT = 64; +const MIN_HOUR_HEIGHT = 64; const HOURS = Array.from({ length: 24 }, (_, i) => i); const FALLBACK_COL_WIDTH = 600; @@ -100,6 +101,8 @@ export function CalendarDayView({ // the hours scroll down, and the sticky header block and hour gutter stay // put. (A nested vertical scroller would capture the gutter's stickiness.) const rootRef = useRef(null); + // The sticky date header (and all-day row): the hour rows fill what is left. + const headerRef = useRef(null); const startSentinelRef = useRef(null); const endSentinelRef = useRef(null); const gutterWidth = isMobile ? 40 : 64; @@ -176,11 +179,11 @@ export function CalendarDayView({ const tasksByDay = useMemo(() => groupTasksByDueDay(tasks), [tasks]); const { - hours, canToggle: canToggleHours, configured: configuredHours, showAllHours, toggleAllHours, revealMinutes, - } = useDisplayHours({ scrollRef: rootRef, hourHeight: HOUR_HEIGHT, revealAt }); + hours, hourHeight, canToggle: canToggleHours, configured: configuredHours, showAllHours, toggleAllHours, revealMinutes, + } = useDisplayHours({ scrollRef: rootRef, headerRef, minHourHeight: MIN_HOUR_HEIGHT, revealAt }); const firstHour = hours.startMinutes / 60; const visibleHours = HOURS.slice(firstHour, hours.endMinutes / 60); - const gridHeight = visibleHours.length * HOUR_HEIGHT; + const gridHeight = visibleHours.length * hourHeight; // Column layouts are the costly part of a render; with months of columns // they must not be redone on every scroll-driven re-render. @@ -278,7 +281,7 @@ export function CalendarDayView({ quickCreate, handleSlotClick, handleSlotDoubleClick, handleQuickCreateSubmit, handleQuickCreateCancel, dropTarget, handleColumnDragOver, handleColumnDragLeave, handleColumnDrop, } = useTimeGridInteractions({ - hourHeight: HOUR_HEIGHT, + hourHeight, gridStartMinutes: hours.startMinutes, gridEndMinutes: hours.endMinutes, calendars, @@ -295,6 +298,16 @@ export function CalendarDayView({ onMoveEvent, }); + const { slotProps } = useTimeGridFocus({ + rootRef, + headerRef, + gutterWidth, + days, + hours, + selectedDate, + onCreate: onCreateAtTime, + }); + const formatHour = (h: number): string => { if (timeFormat === "12h") { const d = new Date(2000, 0, 1, h); @@ -302,6 +315,8 @@ export function CalendarDayView({ } return format(new Date(2000, 0, 1, h), "HH:mm"); }; + // A slot is read out with its whole date: "Wednesday, October 28, 10:00". + const slotDateLabel = (day: Date) => intlFormatter.dateTime(day, { weekday: "long", month: "long", day: "numeric" }); // Above every in-column overlay (events z-10, handles z-20, drag z-30) so // columns scrolled past the start do not show through the gutter. @@ -319,7 +334,7 @@ export function CalendarDayView({
-
+
{canToggleHours && ( {/* Midnight goes unlabelled; a later first hour is labelled inside its row, where the header cannot cover it. */} @@ -467,12 +482,13 @@ export function CalendarDayView({
handleSlotClick(day, h)} onDoubleClick={() => handleSlotDoubleClick(day, h)} onContextMenu={onContextMenuEmpty ? (e) => onContextMenuEmpty(e, day, h, false) : undefined} - className="border-b border-border/50 hover:bg-muted/30 cursor-pointer transition-colors" - style={{ height: HOUR_HEIGHT }} + className="border-b border-border/50 hover:bg-muted/30 cursor-pointer transition-colors outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-primary" + style={{ height: hourHeight }} /> ))} @@ -494,10 +510,10 @@ export function CalendarDayView({ {layouted.map(({ event: ev, column, totalColumns, startMinutes, endMinutes }) => { const durMin = Math.max(15, endMinutes - startMinutes); const clip = clipToDisplayHours(startMinutes, endMinutes, hours); - const baseHeight = Math.max(24, (Math.max(15, clip.endMinutes - clip.startMinutes) / 60) * HOUR_HEIGHT); + const baseHeight = Math.max(24, (Math.max(15, clip.endMinutes - clip.startMinutes) / 60) * hourHeight); // Short events at the bottom edge stay inside the grid. const baseTop = Math.max(0, Math.min( - ((clip.startMinutes - hours.startMinutes) / 60) * HOUR_HEIGHT, + ((clip.startMinutes - hours.startMinutes) / 60) * hourHeight, gridHeight - baseHeight, )); const isResizing = resizeVisual?.eventId === ev.id; @@ -526,6 +542,8 @@ export function CalendarDayView({ onContextMenu={onContextMenuEvent} currentUserEmails={currentUserEmails} draggable={editable} + continuesBefore={clip.clippedStart} + continuesAfter={clip.clippedEnd} /> {/* An edge beyond the visible hours has nothing to grab. */} {editable && !clip.clippedStart && ( @@ -559,7 +577,7 @@ export function CalendarDayView({ {today && nowMinutes >= hours.startMinutes && nowMinutes <= hours.endMinutes && (
@@ -580,8 +598,8 @@ export function CalendarDayView({
@@ -593,7 +611,7 @@ export function CalendarDayView({ {dropTarget?.dayKey === key && (
@@ -621,8 +639,8 @@ export function CalendarDayView({ data-pending-preview className="absolute left-0.5 right-0.5 z-30 rounded-xs pointer-events-none overflow-hidden px-1.5 py-0.5 text-xs shadow-md" style={{ - top: ((clip.startMinutes - hours.startMinutes) / 60) * HOUR_HEIGHT, - height: Math.max(24, ((clip.endMinutes - clip.startMinutes) / 60) * HOUR_HEIGHT), + top: ((clip.startMinutes - hours.startMinutes) / 60) * hourHeight, + height: Math.max(24, ((clip.endMinutes - clip.startMinutes) / 60) * hourHeight), backgroundColor: color, color: readableTextOn(color), }} diff --git a/components/calendar/calendar-search.tsx b/components/calendar/calendar-search.tsx new file mode 100644 index 000000000..283cb8551 --- /dev/null +++ b/components/calendar/calendar-search.tsx @@ -0,0 +1,211 @@ +"use client"; + +import { forwardRef, useMemo, useRef, type KeyboardEvent } from "react"; +import { useTranslations } from "next-intl"; +import { format } from "date-fns"; +import { Search, X, MapPin, Repeat } from "@/components/icons"; +import { Input } from "@/components/ui/input"; +import { IconButton } from "@/components/ui/icon-button"; +import { EmptyState } from "@/components/ui/empty-state"; +import { Spinner } from "@/components/ui/spinner"; +import { cn } from "@/lib/utils"; +import { useDisplayDateFormatter } from "@/hooks/use-display-date-formatter"; +import { getEventEndDate, getEventStartDate, getPrimaryCalendarId } from "@/lib/calendar-utils"; +import { isDeclinedByUser } from "@/lib/calendar-participants"; +import { isRecurringEvent } from "@/lib/global-search/providers/calendar"; +import { displayNow } from "@/lib/timezone"; +import type { Calendar, CalendarEvent } from "@/lib/jmap/types"; +import type { CalendarSearchResults as Results } from "@/hooks/use-calendar-event-search"; +import { getEventColor } from "./event-card"; + +const RESULT_SELECTOR = "[data-search-result]"; + +interface CalendarSearchFieldProps { + value: string; + onChange: (value: string) => void; + /** Escape on an empty field, or the phone's close: leave the search. */ + onDismiss?: () => void; + autoFocus?: boolean; + className?: string; +} + +/** The search box in the calendar toolbar (CAL-16). */ +export const CalendarSearchField = forwardRef( + function CalendarSearchField({ value, onChange, onDismiss, autoFocus, className }, ref) { + const t = useTranslations("calendar"); + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === "Escape") { + // Escape clears the search first, then leaves the field (R8). + e.preventDefault(); + e.stopPropagation(); + if (value) onChange(""); + else { e.currentTarget.blur(); onDismiss?.(); } + } else if (e.key === "ArrowDown") { + const first = document.querySelector(RESULT_SELECTOR); + if (first) { e.preventDefault(); first.focus(); } + } + }; + return ( +
+
+ ); + }, +); + +interface CalendarSearchResultsProps { + query: string; + results: Results; + calendars: Calendar[]; + timeFormat?: "12h" | "24h"; + currentUserEmails?: string[]; + onSelectEvent: (event: CalendarEvent, anchorRect: DOMRect) => void; + onContextMenuEvent?: (e: React.MouseEvent, event: CalendarEvent) => void; +} + +/** The events a calendar search found, upcoming first, then past ones. */ +export function CalendarSearchResults({ + query, results, calendars, timeFormat = "24h", currentUserEmails, onSelectEvent, onContextMenuEvent, +}: CalendarSearchResultsProps) { + const t = useTranslations("calendar"); + const intlFormatter = useDisplayDateFormatter(); + const listRef = useRef(null); + const calendarMap = useMemo(() => new Map(calendars.map((c) => [c.id, c])), [calendars]); + const { upcoming, past, searching } = results; + const count = upcoming.length + past.length; + const thisYear = displayNow().getFullYear(); + + const formatTime = (date: Date) => timeFormat === "12h" + ? intlFormatter.dateTime(date, { hour: "numeric", minute: "2-digit", hour12: true }) + : format(date, "HH:mm"); + + // ↑/↓ walk the list, ↑ on the first row goes back to the search box (R8). + const handleRowKeyDown = (e: KeyboardEvent) => { + if (e.key !== "ArrowDown" && e.key !== "ArrowUp") return; + e.preventDefault(); + const rows = Array.from(listRef.current?.querySelectorAll(RESULT_SELECTOR) ?? []); + const index = rows.indexOf(e.currentTarget); + const next = rows[index + (e.key === "ArrowDown" ? 1 : -1)]; + if (next) next.focus(); + else if (e.key === "ArrowUp") document.querySelector("[data-calendar-search]")?.focus(); + }; + + const renderRow = (ev: CalendarEvent) => { + const calId = getPrimaryCalendarId(ev); + const calendar = calId ? calendarMap.get(calId) : undefined; + const color = getEventColor(ev, calendar); + const start = getEventStartDate(ev); + const end = getEventEndDate(ev); + const isInactive = ev.status === "cancelled" || isDeclinedByUser(ev, currentUserEmails); + const locationName = ev.locations ? Object.values(ev.locations)[0]?.name : null; + const meta = [locationName, calendar?.name].filter(Boolean).join(" · "); + // A series the server found by its first occurrence. + const repeats = !ev.recurrenceId && isRecurringEvent(ev); + const sameYear = start.getFullYear() === thisYear; + const title = ev.title || t("events.no_title"); + return ( + + ); + }; + + const section = (id: string, label: string, events: CalendarEvent[]) => events.length > 0 && ( +
+

+ {label} +

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

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

+ {count === 0 && searching && ( +
+ )} + {count === 0 && !searching && ( + + )} + {section("calendar-search-upcoming", t("search.upcoming"), upcoming)} + {section("calendar-search-past", t("search.past"), past)} + {count > 0 && searching && ( +
+ )} +
+ ); +} diff --git a/components/calendar/calendar-toolbar.tsx b/components/calendar/calendar-toolbar.tsx index 4cc3d2530..2ba60813c 100644 --- a/components/calendar/calendar-toolbar.tsx +++ b/components/calendar/calendar-toolbar.tsx @@ -1,10 +1,10 @@ "use client"; -import { useState, useRef, useEffect } from "react"; +import { useState, useRef, useEffect, type ReactNode } from "react"; import { useTranslations } from "next-intl"; import { Button } from "@/components/ui/button"; import { IconButton } from "@/components/ui/icon-button"; -import { ChevronLeft, ChevronRight, Plus, Upload, CalendarDays, Globe, ChevronDown, ArrowLeft, Menu, MoreVertical, RefreshCw } from "@/components/icons"; +import { ChevronLeft, ChevronRight, Plus, Upload, CalendarDays, Globe, ChevronDown, ArrowLeft, Menu, MoreVertical, RefreshCw, Search } from "@/components/icons"; import { ContextMenu, ContextMenuItem, ContextMenuSeparator } from "@/components/ui/context-menu"; import { useContextMenu } from "@/hooks/use-context-menu"; import { startOfWeek } from "date-fns"; @@ -49,6 +49,14 @@ interface CalendarToolbarProps { * at the top of the sidebar, so this is only needed while it is hidden. */ showCreateButton?: boolean; + /** The search box, shown before the ⋮ on wider screens. */ + searchField?: ReactNode; + /** Phones: opens the search row in place of the date row. */ + onOpenSearch?: () => void; + /** Phones: the open search row's field; null while it is closed. */ + mobileSearchField?: ReactNode; + /** Phones: closes the search row. */ + onCloseSearch?: () => void; } // Shortcut keys handled by the calendar app (see its keydown handler). @@ -84,6 +92,10 @@ export function CalendarToolbar({ sidebarOpen, menuTogglesSidebar, showCreateButton, + searchField, + onOpenSearch, + mobileSearchField, + onCloseSearch, }: CalendarToolbarProps) { const t = useTranslations("calendar"); const tCommon = useTranslations("common"); @@ -204,7 +216,16 @@ export function CalendarToolbar({ {/* ── MOBILE TOOLBAR ── */} {isMobile && (
- {/* Row 1: Back / Date nav / Today */} + {/* Row 1 while searching: the search box with a way back. */} + {mobileSearchField ? ( +
+ + + +
{mobileSearchField}
+
+ ) : ( + /* Row 1: Back / Date nav / Today */
{onMenuClick && ( @@ -237,6 +258,7 @@ export function CalendarToolbar({ {/* The shell's phone ⋮ (notifications, palette, account) with the calendar's own entries on top. */}
+ )} {/* Row 2: View switcher pills + calendar toggle */}
@@ -257,6 +279,12 @@ export function CalendarToolbar({ ))}
+ {/* Beside the calendar filter: the date row has no room left. */} + {onOpenSearch && ( + + + + )} {calendars && selectedCalendarIds && onToggleVisibility && (
+ {searchField} + {moreButton}
diff --git a/components/calendar/calendar-week-view.tsx b/components/calendar/calendar-week-view.tsx index a985681ed..506e394d4 100644 --- a/components/calendar/calendar-week-view.tsx +++ b/components/calendar/calendar-week-view.tsx @@ -18,6 +18,7 @@ import { displayNow, isDisplayToday } from "@/lib/timezone"; import type { CalendarEvent, Calendar, CalendarTask } from "@/lib/jmap/types"; import { useSettingsStore } from "@/stores/settings-store"; import { useDisplayHours, type RevealTime } from "@/hooks/use-display-hours"; +import { useTimeGridFocus } from "@/hooks/use-time-grid-focus"; import { useTimeGridInteractions, type MoveEventHandler } from "@/hooks/use-time-grid-interactions"; import { useScrollWindow, getScrollStart, setScrollStart, scrollToStart } from "@/hooks/use-scroll-window"; import { dayKey, type ScrollWindowViewProps } from "@/lib/calendar-scroll-window"; @@ -66,7 +67,7 @@ interface CalendarWeekViewProps extends ScrollWindowViewProps { revealAt?: RevealTime | null; } -const HOUR_HEIGHT = 60; +const MIN_HOUR_HEIGHT = 60; const HOURS = Array.from({ length: 24 }, (_, i) => i); const MOBILE_COL_WIDTH = 120; const MIN_COL_WIDTH = 80; @@ -114,6 +115,8 @@ export function CalendarWeekView({ // the hours scroll down, and the sticky header rows and hour gutter stay // put. (A nested vertical scroller would capture the gutter's stickiness.) const rootRef = useRef(null); + // The sticky date header (and all-day row): the hour rows fill what is left. + const headerRef = useRef(null); const startSentinelRef = useRef(null); const endSentinelRef = useRef(null); const weekStart = (firstDayOfWeek === 0 ? 0 : firstDayOfWeek === 6 ? 6 : 1) as 0 | 1 | 6; @@ -211,11 +214,11 @@ export function CalendarWeekView({ }, [events]); const { - hours, canToggle: canToggleHours, configured: configuredHours, showAllHours, toggleAllHours, revealMinutes, - } = useDisplayHours({ scrollRef: rootRef, hourHeight: HOUR_HEIGHT, revealAt }); + hours, hourHeight, canToggle: canToggleHours, configured: configuredHours, showAllHours, toggleAllHours, revealMinutes, + } = useDisplayHours({ scrollRef: rootRef, headerRef, minHourHeight: MIN_HOUR_HEIGHT, revealAt }); const firstHour = hours.startMinutes / 60; const visibleHours = HOURS.slice(firstHour, hours.endMinutes / 60); - const gridHeight = visibleHours.length * HOUR_HEIGHT; + const gridHeight = visibleHours.length * hourHeight; // Column layouts are the costly part of a render; with months of columns // they must not be redone on every scroll-driven re-render. @@ -374,7 +377,7 @@ export function CalendarWeekView({ quickCreate, handleSlotClick, handleSlotDoubleClick, handleQuickCreateSubmit, handleQuickCreateCancel, dropTarget, handleColumnDragOver, handleColumnDragLeave, handleColumnDrop, } = useTimeGridInteractions({ - hourHeight: HOUR_HEIGHT, + hourHeight, gridStartMinutes: hours.startMinutes, gridEndMinutes: hours.endMinutes, calendars, @@ -391,6 +394,16 @@ export function CalendarWeekView({ onMoveEvent, }); + const { slotProps, tabStopDayKey } = useTimeGridFocus({ + rootRef, + headerRef, + gutterWidth, + days, + hours, + selectedDate, + onCreate: onCreateAtTime, + }); + const formatHour = (h: number): string => { if (timeFormat === "12h") { const d = new Date(2000, 0, 1, h); @@ -398,6 +411,8 @@ export function CalendarWeekView({ } return format(new Date(2000, 0, 1, h), "HH:mm"); }; + // A slot is read out with its whole date: "Wednesday, October 28, 10:00". + const slotDateLabel = (day: Date) => intlFormatter.dateTime(day, { weekday: "long", month: "long", day: "numeric" }); // Above every in-column overlay (events z-10, handles z-20, drag z-30) so // columns scrolled past the start do not show through the gutter. @@ -414,7 +429,7 @@ export function CalendarWeekView({
-
+
{canToggleHours && ( @@ -449,6 +464,8 @@ export function CalendarWeekView({ diff --git a/locales/ar/common.json b/locales/ar/common.json index db901c39e..8362d7178 100644 --- a/locales/ar/common.json +++ b/locales/ar/common.json @@ -4148,10 +4148,22 @@ "actions": "الإجراءات", "previous": "اليوم أو الأسبوع أو الشهر السابق", "next": "اليوم أو الأسبوع أو الشهر التالي", - "new_item": "حدث جديد، أو مهمة جديدة في المهام" + "new_item": "حدث جديد، أو مهمة جديدة في المهام", + "move_in_grid": "التنقل بين الأيام والساعات في الشبكة" }, "other_calendars": "تقويمات أخرى", - "calendar_options": "خيارات {name}" + "calendar_options": "خيارات {name}", + "search": { + "title": "البحث في الأحداث", + "clear": "مسح البحث", + "close": "إغلاق البحث", + "upcoming": "القادمة", + "past": "السابقة", + "no_results": "لا توجد أحداث تطابق «{query}»", + "no_results_hint": "يبحث في العناوين والأماكن والأوصاف والضيوف.", + "repeats": "متكرر", + "results": "{count, plural, zero {لم يُعثر على أي حدث} one {تم العثور على حدث واحد} two {تم العثور على حدثين} few {تم العثور على # أحداث} many {تم العثور على # حدثًا} other {تم العثور على # حدث}}" + } }, "calendar_event_notifications": { "someone": "شخص ما", diff --git a/locales/ca/common.json b/locales/ca/common.json index 2791e05b2..93443e066 100644 --- a/locales/ca/common.json +++ b/locales/ca/common.json @@ -4148,10 +4148,22 @@ "actions": "Accions", "previous": "Dia, setmana o mes anterior", "next": "Dia, setmana o mes següent", - "new_item": "Esdeveniment nou, o una tasca nova a Tasques" + "new_item": "Esdeveniment nou, o una tasca nova a Tasques", + "move_in_grid": "Moure's entre dies i hores a la quadrícula" }, "other_calendars": "Altres calendaris", - "calendar_options": "Opcions de {name}" + "calendar_options": "Opcions de {name}", + "search": { + "title": "Cerca esdeveniments", + "clear": "Esborra la cerca", + "close": "Tanca la cerca", + "upcoming": "Propers", + "past": "Passats", + "no_results": "Cap esdeveniment coincideix amb «{query}»", + "no_results_hint": "La cerca mira títols, llocs, descripcions i convidats.", + "repeats": "Es repeteix", + "results": "{count, plural, one {# esdeveniment trobat} other {# esdeveniments trobats}}" + } }, "calendar_event_notifications": { "someone": "Algú", diff --git a/locales/cs/common.json b/locales/cs/common.json index 5ecb27c6a..9a36130c2 100644 --- a/locales/cs/common.json +++ b/locales/cs/common.json @@ -4148,10 +4148,22 @@ "actions": "Akce", "previous": "Předchozí den, týden nebo měsíc", "next": "Další den, týden nebo měsíc", - "new_item": "Nová událost, v Úkolech nový úkol" + "new_item": "Nová událost, v Úkolech nový úkol", + "move_in_grid": "Přesun mezi dny a hodinami v mřížce" }, "other_calendars": "Další kalendáře", - "calendar_options": "Možnosti pro {name}" + "calendar_options": "Možnosti pro {name}", + "search": { + "title": "Hledat události", + "clear": "Vymazat hledání", + "close": "Zavřít hledání", + "upcoming": "Nadcházející", + "past": "Minulé", + "no_results": "Hledanému „{query}“ neodpovídá žádná událost", + "no_results_hint": "Hledá se v názvech, místech, popisech a hostech.", + "repeats": "Opakuje se", + "results": "{count, plural, one {Nalezena # událost} few {Nalezeny # události} many {Nalezeno # události} other {Nalezeno # událostí}}" + } }, "advanced_search": { "title": "Pokročilé hledání", diff --git a/locales/da/common.json b/locales/da/common.json index d4a634199..3a02cdc78 100644 --- a/locales/da/common.json +++ b/locales/da/common.json @@ -4148,10 +4148,22 @@ "actions": "Handlinger", "previous": "Forrige dag, uge eller måned", "next": "Næste dag, uge eller måned", - "new_item": "Ny begivenhed, eller en ny opgave i Opgaver" + "new_item": "Ny begivenhed, eller en ny opgave i Opgaver", + "move_in_grid": "Gå mellem dage og timer i gitteret" }, "other_calendars": "Andre kalendere", - "calendar_options": "Indstillinger for {name}" + "calendar_options": "Indstillinger for {name}", + "search": { + "title": "Søg i begivenheder", + "clear": "Ryd søgning", + "close": "Luk søgning", + "upcoming": "Kommende", + "past": "Tidligere", + "no_results": "Ingen begivenheder matcher “{query}”", + "no_results_hint": "Søgningen ser på titler, steder, beskrivelser og gæster.", + "repeats": "Gentages", + "results": "{count, plural, one {# begivenhed fundet} other {# begivenheder fundet}}" + } }, "calendar_event_notifications": { "someone": "Nogen", diff --git a/locales/de/common.json b/locales/de/common.json index 73ec5f8b0..c48ca51e2 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -4148,10 +4148,22 @@ "actions": "Aktionen", "previous": "Zurück um einen Tag, eine Woche oder einen Monat", "next": "Weiter um einen Tag, eine Woche oder einen Monat", - "new_item": "Neuer Termin, in Aufgaben eine neue Aufgabe" + "new_item": "Neuer Termin, in Aufgaben eine neue Aufgabe", + "move_in_grid": "Im Raster zwischen Tagen und Stunden wechseln" }, "other_calendars": "Weitere Kalender", - "calendar_options": "Optionen für {name}" + "calendar_options": "Optionen für {name}", + "search": { + "title": "Termine durchsuchen", + "clear": "Suche löschen", + "close": "Suche schließen", + "upcoming": "Demnächst", + "past": "Vergangen", + "no_results": "Keine Termine zu „{query}“", + "no_results_hint": "Gesucht wird in Titeln, Orten, Beschreibungen und Gästen.", + "repeats": "Wiederholt sich", + "results": "{count, plural, one {# Termin gefunden} other {# Termine gefunden}}" + } }, "advanced_search": { "title": "Erweiterte Suche", diff --git a/locales/en/common.json b/locales/en/common.json index c17c1ba2f..4329039cf 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -4148,10 +4148,22 @@ "actions": "Actions", "previous": "Previous day, week or month", "next": "Next day, week or month", - "new_item": "New event, or a task in Tasks" + "new_item": "New event, or a task in Tasks", + "move_in_grid": "Move between days and hours in the grid" }, "other_calendars": "Other calendars", - "calendar_options": "Options for {name}" + "calendar_options": "Options for {name}", + "search": { + "title": "Search events", + "clear": "Clear search", + "close": "Close search", + "upcoming": "Upcoming", + "past": "Past", + "no_results": "No events match “{query}”", + "no_results_hint": "Search looks at titles, places, descriptions and guests.", + "repeats": "Repeats", + "results": "{count, plural, one {# event found} other {# events found}}" + } }, "calendar_event_notifications": { "someone": "Someone", diff --git a/locales/es/common.json b/locales/es/common.json index 5777999bf..681722895 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -4148,10 +4148,22 @@ "actions": "Acciones", "previous": "Día, semana o mes anterior", "next": "Día, semana o mes siguiente", - "new_item": "Nuevo evento, o una tarea nueva en Tareas" + "new_item": "Nuevo evento, o una tarea nueva en Tareas", + "move_in_grid": "Moverse entre días y horas en la cuadrícula" }, "other_calendars": "Otros calendarios", - "calendar_options": "Opciones de {name}" + "calendar_options": "Opciones de {name}", + "search": { + "title": "Buscar eventos", + "clear": "Borrar búsqueda", + "close": "Cerrar búsqueda", + "upcoming": "Próximos", + "past": "Pasados", + "no_results": "Ningún evento coincide con «{query}»", + "no_results_hint": "La búsqueda mira títulos, lugares, descripciones e invitados.", + "repeats": "Se repite", + "results": "{count, plural, one {# evento encontrado} other {# eventos encontrados}}" + } }, "advanced_search": { "title": "Búsqueda avanzada", diff --git a/locales/et/common.json b/locales/et/common.json index 2fe6ba3ae..6677299c0 100644 --- a/locales/et/common.json +++ b/locales/et/common.json @@ -4148,10 +4148,22 @@ "actions": "Toimingud", "previous": "Eelmine päev, nädal või kuu", "next": "Järgmine päev, nädal või kuu", - "new_item": "Uus sündmus, ülesannetes uus ülesanne" + "new_item": "Uus sündmus, ülesannetes uus ülesanne", + "move_in_grid": "Liigu ruudustikus päevade ja tundide vahel" }, "other_calendars": "Muud kalendrid", - "calendar_options": "Kalendri {name} valikud" + "calendar_options": "Kalendri {name} valikud", + "search": { + "title": "Otsi sündmusi", + "clear": "Tühjenda otsing", + "close": "Sulge otsing", + "upcoming": "Tulevased", + "past": "Möödunud", + "no_results": "Päringule „{query}“ ei vasta ükski sündmus", + "no_results_hint": "Otsitakse pealkirjadest, kohtadest, kirjeldustest ja külalistest.", + "repeats": "Kordub", + "results": "{count, plural, one {Leiti # sündmus} other {Leiti # sündmust}}" + } }, "calendar_event_notifications": { "someone": "Keegi", diff --git a/locales/fa/common.json b/locales/fa/common.json index 9ab5ab0ef..32557ba10 100644 --- a/locales/fa/common.json +++ b/locales/fa/common.json @@ -4148,10 +4148,22 @@ "actions": "اقدامات", "previous": "روز، هفته یا ماه قبلی", "next": "روز، هفته یا ماه بعدی", - "new_item": "رویداد جدید، یا وظیفه جدید در وظایف" + "new_item": "رویداد جدید، یا وظیفه جدید در وظایف", + "move_in_grid": "جابه‌جایی بین روزها و ساعت‌ها در جدول" }, "other_calendars": "تقویم‌های دیگر", - "calendar_options": "گزینه‌های {name}" + "calendar_options": "گزینه‌های {name}", + "search": { + "title": "جستجوی رویدادها", + "clear": "پاک کردن جستجو", + "close": "بستن جستجو", + "upcoming": "پیش رو", + "past": "گذشته", + "no_results": "هیچ رویدادی با «{query}» مطابقت ندارد", + "no_results_hint": "جستجو در عنوان‌ها، مکان‌ها، توضیحات و مهمان‌ها انجام می‌شود.", + "repeats": "تکرار می‌شود", + "results": "{count, plural, one {# رویداد پیدا شد} other {# رویداد پیدا شد}}" + } }, "calendar_event_notifications": { "someone": "کسی", diff --git a/locales/fr/common.json b/locales/fr/common.json index ac045dbe9..45c46b169 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -4148,10 +4148,22 @@ "actions": "Actions", "previous": "Jour, semaine ou mois précédent", "next": "Jour, semaine ou mois suivant", - "new_item": "Nouvel événement, ou une nouvelle tâche dans Tâches" + "new_item": "Nouvel événement, ou une nouvelle tâche dans Tâches", + "move_in_grid": "Se déplacer entre les jours et les heures dans la grille" }, "other_calendars": "Autres agendas", - "calendar_options": "Options de {name}" + "calendar_options": "Options de {name}", + "search": { + "title": "Rechercher des événements", + "clear": "Effacer la recherche", + "close": "Fermer la recherche", + "upcoming": "À venir", + "past": "Passés", + "no_results": "Aucun événement ne correspond à « {query} »", + "no_results_hint": "La recherche porte sur les titres, lieux, descriptions et invités.", + "repeats": "Se répète", + "results": "{count, plural, one {# événement trouvé} other {# événements trouvés}}" + } }, "advanced_search": { "title": "Recherche avancée", diff --git a/locales/he/common.json b/locales/he/common.json index 67b1857ba..af15c6fe9 100644 --- a/locales/he/common.json +++ b/locales/he/common.json @@ -4066,10 +4066,22 @@ "actions": "פעולות", "previous": "היום, השבוע או החודש הקודם", "next": "היום, השבוע או החודש הבא", - "new_item": "אירוע חדש, או משימה חדשה במשימות" + "new_item": "אירוע חדש, או משימה חדשה במשימות", + "move_in_grid": "מעבר בין ימים ושעות ברשת" }, "other_calendars": "יומנים אחרים", - "calendar_options": "אפשרויות עבור {name}" + "calendar_options": "אפשרויות עבור {name}", + "search": { + "title": "חיפוש אירועים", + "clear": "ניקוי החיפוש", + "close": "סגירת החיפוש", + "upcoming": "קרובים", + "past": "קודמים", + "no_results": "אין אירועים שתואמים ל„{query}”", + "no_results_hint": "החיפוש בודק כותרות, מקומות, תיאורים ומוזמנים.", + "repeats": "חוזר", + "results": "{count, plural, one {נמצא אירוע אחד} two {נמצאו שני אירועים} other {נמצאו # אירועים}}" + } }, "advanced_search": { "title": "חיפוש מתקדם", diff --git a/locales/hu/common.json b/locales/hu/common.json index 72724c662..4aa36c35c 100644 --- a/locales/hu/common.json +++ b/locales/hu/common.json @@ -4148,10 +4148,22 @@ "actions": "Műveletek", "previous": "Előző nap, hét vagy hónap", "next": "Következő nap, hét vagy hónap", - "new_item": "Új esemény, a Feladatokban új feladat" + "new_item": "Új esemény, a Feladatokban új feladat", + "move_in_grid": "Mozgás a napok és órák között a rácsban" }, "other_calendars": "Egyéb naptárak", - "calendar_options": "{name} beállításai" + "calendar_options": "{name} beállításai", + "search": { + "title": "Események keresése", + "clear": "Keresés törlése", + "close": "Keresés bezárása", + "upcoming": "Közelgő", + "past": "Korábbi", + "no_results": "Egy esemény sem felel meg ennek: „{query}”", + "no_results_hint": "A keresés a címekben, helyszínekben, leírásokban és vendégekben keres.", + "repeats": "Ismétlődik", + "results": "{count, plural, one {# esemény található} other {# esemény található}}" + } }, "calendar_event_notifications": { "someone": "Valaki", diff --git a/locales/it/common.json b/locales/it/common.json index 2534677aa..4d5ec0030 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -4148,10 +4148,22 @@ "actions": "Azioni", "previous": "Giorno, settimana o mese precedente", "next": "Giorno, settimana o mese successivo", - "new_item": "Nuovo evento, o una nuova attività in Attività" + "new_item": "Nuovo evento, o una nuova attività in Attività", + "move_in_grid": "Spostarsi tra giorni e ore nella griglia" }, "other_calendars": "Altri calendari", - "calendar_options": "Opzioni per {name}" + "calendar_options": "Opzioni per {name}", + "search": { + "title": "Cerca eventi", + "clear": "Cancella ricerca", + "close": "Chiudi ricerca", + "upcoming": "Prossimi", + "past": "Passati", + "no_results": "Nessun evento corrisponde a «{query}»", + "no_results_hint": "La ricerca guarda titoli, luoghi, descrizioni e invitati.", + "repeats": "Si ripete", + "results": "{count, plural, one {# evento trovato} other {# eventi trovati}}" + } }, "advanced_search": { "title": "Ricerca avanzata", diff --git a/locales/ja/common.json b/locales/ja/common.json index 40db46fbb..5d6d4771c 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -4148,10 +4148,22 @@ "actions": "操作", "previous": "前の日・週・月", "next": "次の日・週・月", - "new_item": "新しいイベント(タスク画面では新しいタスク)" + "new_item": "新しいイベント(タスク画面では新しいタスク)", + "move_in_grid": "グリッド内で日と時間を移動" }, "other_calendars": "他のカレンダー", - "calendar_options": "{name} のオプション" + "calendar_options": "{name} のオプション", + "search": { + "title": "予定を検索", + "clear": "検索をクリア", + "close": "検索を閉じる", + "upcoming": "今後の予定", + "past": "過去の予定", + "no_results": "「{query}」に一致する予定はありません", + "no_results_hint": "タイトル、場所、説明、ゲストを検索します。", + "repeats": "繰り返し", + "results": "{count, plural, other {# 件の予定が見つかりました}}" + } }, "advanced_search": { "title": "詳細検索", diff --git a/locales/ko/common.json b/locales/ko/common.json index 22550b105..5f5e78516 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -4148,10 +4148,22 @@ "actions": "작업", "previous": "이전 일/주/월", "next": "다음 일/주/월", - "new_item": "새 이벤트 (할 일 보기에서는 새 작업)" + "new_item": "새 이벤트 (할 일 보기에서는 새 작업)", + "move_in_grid": "그리드에서 날짜와 시간 사이 이동" }, "other_calendars": "다른 캘린더", - "calendar_options": "{name} 옵션" + "calendar_options": "{name} 옵션", + "search": { + "title": "일정 검색", + "clear": "검색어 지우기", + "close": "검색 닫기", + "upcoming": "예정", + "past": "지난 일정", + "no_results": "‘{query}’와 일치하는 일정이 없습니다", + "no_results_hint": "제목, 장소, 설명, 참석자에서 검색합니다.", + "repeats": "반복", + "results": "{count, plural, other {일정 #개를 찾았습니다}}" + } }, "advanced_search": { "title": "상세 검색", diff --git a/locales/lv/common.json b/locales/lv/common.json index e094316bd..4abab7cdc 100644 --- a/locales/lv/common.json +++ b/locales/lv/common.json @@ -4148,10 +4148,22 @@ "actions": "Darbības", "previous": "Iepriekšējā diena, nedēļa vai mēnesis", "next": "Nākamā diena, nedēļa vai mēnesis", - "new_item": "Jauns notikums vai, sadaļā Uzdevumi, jauns uzdevums" + "new_item": "Jauns notikums vai, sadaļā Uzdevumi, jauns uzdevums", + "move_in_grid": "Pārvietoties starp dienām un stundām režģī" }, "other_calendars": "Citi kalendāri", - "calendar_options": "{name} opcijas" + "calendar_options": "{name} opcijas", + "search": { + "title": "Meklēt notikumus", + "clear": "Notīrīt meklēšanu", + "close": "Aizvērt meklēšanu", + "upcoming": "Gaidāmie", + "past": "Pagājušie", + "no_results": "Neviens notikums neatbilst “{query}”", + "no_results_hint": "Meklē nosaukumos, vietās, aprakstos un viesos.", + "repeats": "Atkārtojas", + "results": "{count, plural, zero {Atrasti # notikumi} one {Atrasts # notikums} other {Atrasti # notikumi}}" + } }, "advanced_search": { "title": "Izvērstā meklēšana", diff --git a/locales/mn/common.json b/locales/mn/common.json index 2fbf26e88..6e29449b6 100644 --- a/locales/mn/common.json +++ b/locales/mn/common.json @@ -4148,10 +4148,22 @@ "actions": "Үйлдлүүд", "previous": "Өмнөх өдөр, долоо хоног эсвэл сар", "next": "Дараах өдөр, долоо хоног эсвэл сар", - "new_item": "Шинэ арга хэмжээ, Даалгаварууд хэсэгт шинэ даалгавар" + "new_item": "Шинэ арга хэмжээ, Даалгаварууд хэсэгт шинэ даалгавар", + "move_in_grid": "Хүснэгтэд өдөр, цагийн хооронд шилжих" }, "other_calendars": "Бусад хуанли", - "calendar_options": "{name}-ийн тохиргоо" + "calendar_options": "{name}-ийн тохиргоо", + "search": { + "title": "Үйл явдал хайх", + "clear": "Хайлтыг арилгах", + "close": "Хайлтыг хаах", + "upcoming": "Удахгүй болох", + "past": "Өнгөрсөн", + "no_results": "“{query}”-д тохирох үйл явдал алга", + "no_results_hint": "Гарчиг, газар, тайлбар, зочдоос хайна.", + "repeats": "Давтагдана", + "results": "{count, plural, one {# үйл явдал олдлоо} other {# үйл явдал олдлоо}}" + } }, "calendar_event_notifications": { "someone": "Хэн нэгэн", diff --git a/locales/nb/common.json b/locales/nb/common.json index bc5cf1158..316709528 100644 --- a/locales/nb/common.json +++ b/locales/nb/common.json @@ -4148,10 +4148,22 @@ "actions": "Handlinger", "previous": "Forrige dag, uke eller måned", "next": "Neste dag, uke eller måned", - "new_item": "Ny hendelse, eller en ny oppgave i Oppgaver" + "new_item": "Ny hendelse, eller en ny oppgave i Oppgaver", + "move_in_grid": "Gå mellom dager og timer i rutenettet" }, "other_calendars": "Andre kalendere", - "calendar_options": "Alternativer for {name}" + "calendar_options": "Alternativer for {name}", + "search": { + "title": "Søk i hendelser", + "clear": "Tøm søket", + "close": "Lukk søket", + "upcoming": "Kommende", + "past": "Tidligere", + "no_results": "Ingen hendelser samsvarer med «{query}»", + "no_results_hint": "Søket ser i titler, steder, beskrivelser og gjester.", + "repeats": "Gjentas", + "results": "{count, plural, one {# hendelse funnet} other {# hendelser funnet}}" + } }, "calendar_event_notifications": { "someone": "Noen", diff --git a/locales/nl/common.json b/locales/nl/common.json index 3c5c005c4..a9d8f202d 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -4148,10 +4148,22 @@ "actions": "Acties", "previous": "Vorige dag, week of maand", "next": "Volgende dag, week of maand", - "new_item": "Nieuwe afspraak, of een nieuwe taak in Taken" + "new_item": "Nieuwe afspraak, of een nieuwe taak in Taken", + "move_in_grid": "Tussen dagen en uren in het raster bewegen" }, "other_calendars": "Andere agenda's", - "calendar_options": "Opties voor {name}" + "calendar_options": "Opties voor {name}", + "search": { + "title": "Afspraken zoeken", + "clear": "Zoekopdracht wissen", + "close": "Zoeken sluiten", + "upcoming": "Komend", + "past": "Voorbij", + "no_results": "Geen afspraken gevonden voor ‘{query}’", + "no_results_hint": "Er wordt gezocht in titels, locaties, beschrijvingen en gasten.", + "repeats": "Herhaalt", + "results": "{count, plural, one {# afspraak gevonden} other {# afspraken gevonden}}" + } }, "advanced_search": { "title": "Geavanceerd zoeken", diff --git a/locales/pl/common.json b/locales/pl/common.json index 902a0011a..69c043ef9 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -4148,10 +4148,22 @@ "actions": "Akcje", "previous": "Poprzedni dzień, tydzień lub miesiąc", "next": "Następny dzień, tydzień lub miesiąc", - "new_item": "Nowe wydarzenie lub, w Zadaniach, nowe zadanie" + "new_item": "Nowe wydarzenie lub, w Zadaniach, nowe zadanie", + "move_in_grid": "Przechodzenie między dniami i godzinami w siatce" }, "other_calendars": "Inne kalendarze", - "calendar_options": "Opcje dla {name}" + "calendar_options": "Opcje dla {name}", + "search": { + "title": "Szukaj wydarzeń", + "clear": "Wyczyść wyszukiwanie", + "close": "Zamknij wyszukiwanie", + "upcoming": "Nadchodzące", + "past": "Minione", + "no_results": "Brak wydarzeń pasujących do „{query}”", + "no_results_hint": "Wyszukiwanie obejmuje tytuły, miejsca, opisy i gości.", + "repeats": "Powtarza się", + "results": "{count, plural, one {Znaleziono # wydarzenie} few {Znaleziono # wydarzenia} many {Znaleziono # wydarzeń} other {Znaleziono # wydarzenia}}" + } }, "advanced_search": { "title": "Wyszukiwanie zaawansowane", diff --git a/locales/pt/common.json b/locales/pt/common.json index 1a2dc7b77..34febc6cd 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -4148,10 +4148,22 @@ "actions": "Ações", "previous": "Dia, semana ou mês anterior", "next": "Dia, semana ou mês seguinte", - "new_item": "Novo evento, ou uma nova tarefa em Tarefas" + "new_item": "Novo evento, ou uma nova tarefa em Tarefas", + "move_in_grid": "Mover entre dias e horas na grelha" }, "other_calendars": "Outros calendários", - "calendar_options": "Opções de {name}" + "calendar_options": "Opções de {name}", + "search": { + "title": "Pesquisar eventos", + "clear": "Limpar pesquisa", + "close": "Fechar pesquisa", + "upcoming": "Próximos", + "past": "Passados", + "no_results": "Nenhum evento corresponde a “{query}”", + "no_results_hint": "A pesquisa procura em títulos, locais, descrições e convidados.", + "repeats": "Repete-se", + "results": "{count, plural, one {# evento encontrado} other {# eventos encontrados}}" + } }, "advanced_search": { "title": "Pesquisa avançada", diff --git a/locales/ro/common.json b/locales/ro/common.json index 6c8552c30..c888e1c34 100644 --- a/locales/ro/common.json +++ b/locales/ro/common.json @@ -4148,10 +4148,22 @@ "actions": "Acțiuni", "previous": "Ziua, săptămâna sau luna anterioară", "next": "Ziua, săptămâna sau luna următoare", - "new_item": "Eveniment nou sau, în Sarcini, o sarcină nouă" + "new_item": "Eveniment nou sau, în Sarcini, o sarcină nouă", + "move_in_grid": "Deplasare între zile și ore în grilă" }, "other_calendars": "Alte calendare", - "calendar_options": "Opțiuni pentru {name}" + "calendar_options": "Opțiuni pentru {name}", + "search": { + "title": "Caută evenimente", + "clear": "Șterge căutarea", + "close": "Închide căutarea", + "upcoming": "Viitoare", + "past": "Trecute", + "no_results": "Niciun eveniment nu corespunde cu „{query}”", + "no_results_hint": "Căutarea se uită la titluri, locuri, descrieri și invitați.", + "repeats": "Se repetă", + "results": "{count, plural, one {# eveniment găsit} few {# evenimente găsite} other {# de evenimente găsite}}" + } }, "calendar_event_notifications": { "someone": "Cineva", diff --git a/locales/ru/common.json b/locales/ru/common.json index b49d278b8..26248fc12 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -4148,10 +4148,22 @@ "actions": "Действия", "previous": "Предыдущий день, неделя или месяц", "next": "Следующий день, неделя или месяц", - "new_item": "Новое событие, в Задачах — новая задача" + "new_item": "Новое событие, в Задачах — новая задача", + "move_in_grid": "Перемещение между днями и часами в сетке" }, "other_calendars": "Другие календари", - "calendar_options": "Параметры: {name}" + "calendar_options": "Параметры: {name}", + "search": { + "title": "Поиск событий", + "clear": "Очистить поиск", + "close": "Закрыть поиск", + "upcoming": "Предстоящие", + "past": "Прошедшие", + "no_results": "Нет событий по запросу «{query}»", + "no_results_hint": "Поиск идёт по названиям, местам, описаниям и гостям.", + "repeats": "Повторяется", + "results": "{count, plural, one {Найдено # событие} few {Найдено # события} many {Найдено # событий} other {Найдено # события}}" + } }, "advanced_search": { "title": "Расширенный поиск", diff --git a/locales/sk/common.json b/locales/sk/common.json index 7f49c3ba9..5f78df7b6 100644 --- a/locales/sk/common.json +++ b/locales/sk/common.json @@ -4148,10 +4148,22 @@ "actions": "Akcie", "previous": "Predchádzajúci deň, týždeň alebo mesiac", "next": "Nasledujúci deň, týždeň alebo mesiac", - "new_item": "Nová udalosť, v Úlohách nová úloha" + "new_item": "Nová udalosť, v Úlohách nová úloha", + "move_in_grid": "Presun medzi dňami a hodinami v mriežke" }, "other_calendars": "Ďalšie kalendáre", - "calendar_options": "Možnosti pre {name}" + "calendar_options": "Možnosti pre {name}", + "search": { + "title": "Hľadať udalosti", + "clear": "Vymazať hľadanie", + "close": "Zavrieť hľadanie", + "upcoming": "Nadchádzajúce", + "past": "Minulé", + "no_results": "Hľadaniu „{query}“ nezodpovedá žiadna udalosť", + "no_results_hint": "Hľadá sa v názvoch, miestach, popisoch a hosťoch.", + "repeats": "Opakuje sa", + "results": "{count, plural, one {Nájdená # udalosť} few {Nájdené # udalosti} many {Nájdenej # udalosti} other {Nájdených # udalostí}}" + } }, "calendar_event_notifications": { "someone": "Niekto", diff --git a/locales/th/common.json b/locales/th/common.json index a2c9000d5..191606baa 100644 --- a/locales/th/common.json +++ b/locales/th/common.json @@ -3272,10 +3272,22 @@ "actions": "การทำงาน", "previous": "วัน สัปดาห์ หรือเดือนก่อนหน้า", "next": "วัน สัปดาห์ หรือเดือนถัดไป", - "new_item": "กิจกรรมใหม่ หรืองานใหม่ในมุมมองงาน" + "new_item": "กิจกรรมใหม่ หรืองานใหม่ในมุมมองงาน", + "move_in_grid": "เลื่อนไปมาระหว่างวันและชั่วโมงในตาราง" }, "other_calendars": "ปฏิทินอื่นๆ", - "calendar_options": "ตัวเลือกสำหรับ {name}" + "calendar_options": "ตัวเลือกสำหรับ {name}", + "search": { + "title": "ค้นหากิจกรรม", + "clear": "ล้างการค้นหา", + "close": "ปิดการค้นหา", + "upcoming": "ที่กำลังจะมาถึง", + "past": "ที่ผ่านมา", + "no_results": "ไม่มีกิจกรรมที่ตรงกับ “{query}”", + "no_results_hint": "ค้นหาจากชื่อ สถานที่ คำอธิบาย และแขก", + "repeats": "เกิดซ้ำ", + "results": "{count, plural, other {พบ # กิจกรรม}}" + } }, "settings": { "title": "การตั้งค่า", diff --git a/locales/tr/common.json b/locales/tr/common.json index 059c10322..ccb1b0544 100644 --- a/locales/tr/common.json +++ b/locales/tr/common.json @@ -4148,10 +4148,22 @@ "actions": "Eylemler", "previous": "Önceki gün, hafta veya ay", "next": "Sonraki gün, hafta veya ay", - "new_item": "Yeni etkinlik, Görevler'de yeni görev" + "new_item": "Yeni etkinlik, Görevler'de yeni görev", + "move_in_grid": "Izgarada günler ve saatler arasında gezin" }, "other_calendars": "Diğer takvimler", - "calendar_options": "{name} seçenekleri" + "calendar_options": "{name} seçenekleri", + "search": { + "title": "Etkinlik ara", + "clear": "Aramayı temizle", + "close": "Aramayı kapat", + "upcoming": "Yaklaşan", + "past": "Geçmiş", + "no_results": "“{query}” ile eşleşen etkinlik yok", + "no_results_hint": "Arama başlıklarda, yerlerde, açıklamalarda ve davetlilerde yapılır.", + "repeats": "Tekrarlanıyor", + "results": "{count, plural, one {# etkinlik bulundu} other {# etkinlik bulundu}}" + } }, "calendar_event_notifications": { "someone": "Birisi", diff --git a/locales/uk/common.json b/locales/uk/common.json index dd9e9bbcb..735d06df1 100644 --- a/locales/uk/common.json +++ b/locales/uk/common.json @@ -4148,10 +4148,22 @@ "actions": "Дії", "previous": "Попередній день, тиждень або місяць", "next": "Наступний день, тиждень або місяць", - "new_item": "Нова подія, у Завданнях — нове завдання" + "new_item": "Нова подія, у Завданнях — нове завдання", + "move_in_grid": "Переміщення між днями й годинами в сітці" }, "other_calendars": "Інші календарі", - "calendar_options": "Параметри: {name}" + "calendar_options": "Параметри: {name}", + "search": { + "title": "Пошук подій", + "clear": "Очистити пошук", + "close": "Закрити пошук", + "upcoming": "Майбутні", + "past": "Минулі", + "no_results": "Немає подій за запитом «{query}»", + "no_results_hint": "Пошук іде за назвами, місцями, описами й гостями.", + "repeats": "Повторюється", + "results": "{count, plural, one {Знайдено # подію} few {Знайдено # події} many {Знайдено # подій} other {Знайдено # події}}" + } }, "advanced_search": { "title": "Розширений пошук", diff --git a/locales/zh-TW/common.json b/locales/zh-TW/common.json index 451ef81c1..904bc88de 100644 --- a/locales/zh-TW/common.json +++ b/locales/zh-TW/common.json @@ -4148,10 +4148,22 @@ "actions": "動作", "previous": "上一天、週或月", "next": "下一天、週或月", - "new_item": "新增活動(在任務中新增任務)" + "new_item": "新增活動(在任務中新增任務)", + "move_in_grid": "在格線中按天與小時移動" }, "other_calendars": "其他日曆", - "calendar_options": "{name} 的選項" + "calendar_options": "{name} 的選項", + "search": { + "title": "搜尋活動", + "clear": "清除搜尋", + "close": "關閉搜尋", + "upcoming": "即將到來", + "past": "已過去", + "no_results": "沒有符合「{query}」的活動", + "no_results_hint": "搜尋範圍包括標題、地點、說明和邀請對象。", + "repeats": "重複", + "results": "{count, plural, other {找到 # 個活動}}" + } }, "calendar_event_notifications": { "someone": "有人", diff --git a/locales/zh/common.json b/locales/zh/common.json index 7cfba7fb5..c0d17e304 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -4148,10 +4148,22 @@ "actions": "操作", "previous": "上一天、周或月", "next": "下一天、周或月", - "new_item": "新建事件(在任务中新建任务)" + "new_item": "新建事件(在任务中新建任务)", + "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": "高级搜索",