From 60e05c00413433946c4f97dc1679bc7231e176a4 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 9 Sep 2026 15:19:53 +0200 Subject: [PATCH] feat: infinite scroll for the calendar agenda view --- .../__tests__/calendar-agenda-view.test.tsx | 172 +++++++++++++++++ components/calendar/calendar-agenda-view.tsx | 173 +++++++++++++++--- components/calendar/calendar-app.tsx | 55 +++++- locales/ar/common.json | 4 + locales/ca/common.json | 4 + locales/cs/common.json | 4 + locales/da/common.json | 4 + locales/de/common.json | 4 + locales/en/common.json | 4 + locales/es/common.json | 4 + locales/fa/common.json | 4 + locales/fr/common.json | 4 + locales/he/common.json | 4 + locales/hu/common.json | 4 + locales/it/common.json | 4 + locales/ja/common.json | 4 + locales/ko/common.json | 4 + locales/lv/common.json | 4 + locales/mn/common.json | 4 + locales/nb/common.json | 4 + locales/nl/common.json | 4 + locales/pl/common.json | 4 + locales/pt/common.json | 4 + locales/ro/common.json | 4 + locales/ru/common.json | 4 + locales/sk/common.json | 4 + locales/tr/common.json | 4 + locales/uk/common.json | 4 + locales/zh-TW/common.json | 4 + locales/zh/common.json | 4 + 30 files changed, 474 insertions(+), 34 deletions(-) create mode 100644 components/calendar/__tests__/calendar-agenda-view.test.tsx diff --git a/components/calendar/__tests__/calendar-agenda-view.test.tsx b/components/calendar/__tests__/calendar-agenda-view.test.tsx new file mode 100644 index 000000000..f35cfc631 --- /dev/null +++ b/components/calendar/__tests__/calendar-agenda-view.test.tsx @@ -0,0 +1,172 @@ +import { render, screen, fireEvent, cleanup } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { CalendarAgendaView } from '../calendar-agenda-view'; +import type { CalendarEvent, Calendar } from '@/lib/jmap/types'; + +// The agenda is an infinite list: a "show earlier" trigger at the top, a +// sentinel at the bottom that widens the window when it scrolls into view, +// and a "Today" anchor row only while today is part of the loaded window. + +vi.mock('@/hooks/use-display-date-formatter', () => ({ + useDisplayDateFormatter: () => ({ dateTime: (d: Date) => d.toISOString().slice(0, 10) }), +})); + +type IOCallback = (entries: Array<{ isIntersecting: boolean }>) => void; +let observerCallbacks: IOCallback[] = []; +let observed: Element[] = []; + +class FakeIntersectionObserver { + constructor(cb: IOCallback) { observerCallbacks.push(cb); } + observe(el: Element) { observed.push(el); } + disconnect() {} + unobserve() {} +} + +const calendars = [{ id: 'cal-1', name: 'Work', color: '#123456' }] as unknown as Calendar[]; + +function makeEvent(id: string, start: string): CalendarEvent { + return { + id, + '@type': 'Event', + uid: id, + title: 'Event ' + id, + start, + duration: 'PT1H', + showWithoutTime: false, + calendarIds: { 'cal-1': true }, + } as unknown as CalendarEvent; +} + +function renderView(overrides: Partial> = {}) { + const props: React.ComponentProps = { + selectedDate: new Date(2026, 8, 9), + events: [], + calendars, + rangeStart: new Date(2026, 8, 9), + rangeEnd: new Date(2026, 9, 9), + onSelectEvent: vi.fn(), + ...overrides, + }; + return render(); +} + +describe('CalendarAgendaView infinite scroll', () => { + beforeEach(() => { + vi.useFakeTimers({ toFake: ['Date'] }); + vi.setSystemTime(new Date(2026, 8, 9, 10, 0, 0)); + observerCallbacks = []; + observed = []; + vi.stubGlobal('IntersectionObserver', FakeIntersectionObserver); + }); + + afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); + vi.useRealTimers(); + }); + + it('loads earlier days from the top button, once per fetch', () => { + const onExtendPast = vi.fn(); + const { rerender } = renderView({ onExtendPast }); + + fireEvent.click(screen.getByText('events.agenda_show_earlier')); + expect(onExtendPast).toHaveBeenCalledTimes(1); + + // A second request while the first is still outstanding is ignored ... + fireEvent.click(screen.getByText('events.agenda_show_earlier')); + expect(onExtendPast).toHaveBeenCalledTimes(1); + + // ... until the fetch has come and gone. + rerender(); + rerender(); + fireEvent.click(screen.getByText('events.agenda_show_earlier')); + expect(onExtendPast).toHaveBeenCalledTimes(2); + }); + + it('loads earlier days when wheeling up while already at the top', () => { + const onExtendPast = vi.fn(); + const { container } = renderView({ onExtendPast }); + const scroller = container.firstElementChild as HTMLElement; + + fireEvent.wheel(scroller, { deltaY: 40 }); + expect(onExtendPast).not.toHaveBeenCalled(); + + fireEvent.wheel(scroller, { deltaY: -40 }); + expect(onExtendPast).toHaveBeenCalledTimes(1); + }); + + it('shows the window start instead of the button once the past limit is reached', () => { + renderView({ onExtendPast: undefined }); + expect(screen.queryByText('events.agenda_show_earlier')).toBeNull(); + expect(screen.getByText('events.agenda_range_start')).toBeInTheDocument(); + }); + + it('widens the future when the bottom sentinel becomes visible, once per fetch', () => { + const onExtendFuture = vi.fn(); + const base = { + selectedDate: new Date(2026, 8, 9), events: [], calendars, onSelectEvent: vi.fn(), + rangeStart: new Date(2026, 8, 9), onExtendFuture, + }; + const { rerender } = render(); + + // Before the first fetch for this window has completed, the rows on + // screen are stale (or absent) and a visible sentinel must not grow it. + expect(observed).toHaveLength(0); + rerender(); + rerender(); + + expect(observed).toContain(screen.getByTestId('agenda-bottom-sentinel')); + observerCallbacks.at(-1)?.([{ isIntersecting: true }]); + expect(onExtendFuture).toHaveBeenCalledTimes(1); + + // The window already grew but the fetch has not flipped the loading flag + // yet: a re-created observer that still sees the sentinel must not + // extend a second time. + rerender(); + observerCallbacks.at(-1)?.([{ isIntersecting: true }]); + expect(onExtendFuture).toHaveBeenCalledTimes(1); + + // Once that fetch has finished, the next sighting extends again. + rerender(); + rerender(); + observerCallbacks.at(-1)?.([{ isIntersecting: true }]); + expect(onExtendFuture).toHaveBeenCalledTimes(2); + }); + + it('does not observe the sentinel while a fetch is in flight or at the future limit', () => { + renderView({ onExtendFuture: vi.fn(), isLoading: true }); + expect(observerCallbacks).toHaveLength(0); + cleanup(); + + renderView({ onExtendFuture: undefined }); + expect(observerCallbacks).toHaveLength(0); + expect(screen.getByText('events.agenda_range_end')).toBeInTheDocument(); + }); + + it('only anchors a "Today" row when today lies inside the loaded window', () => { + renderView(); + expect(screen.getByText('events.today_header')).toBeInTheDocument(); + cleanup(); + + renderView({ + selectedDate: new Date(2026, 0, 1), + rangeStart: new Date(2026, 0, 1), + rangeEnd: new Date(2026, 1, 1), + events: [makeEvent('a', '2026-01-05T09:00:00')], + }); + expect(screen.queryByText('events.today_header')).toBeNull(); + expect(screen.getByText('Event a')).toBeInTheDocument(); + }); + + it('groups events by day in chronological order', () => { + renderView({ + events: [makeEvent('later', '2026-09-20T09:00:00'), makeEvent('sooner', '2026-09-12T09:00:00')], + }); + const titles = screen.getAllByText(/^Event /).map((el) => el.textContent); + expect(titles).toEqual(['Event sooner', 'Event later']); + }); +}); diff --git a/components/calendar/calendar-agenda-view.tsx b/components/calendar/calendar-agenda-view.tsx index d61979d80..ec32cfacc 100644 --- a/components/calendar/calendar-agenda-view.tsx +++ b/components/calendar/calendar-agenda-view.tsx @@ -1,6 +1,6 @@ "use client"; -import { useMemo, useRef, useEffect, useCallback } from "react"; +import { useMemo, useRef, useEffect, useLayoutEffect, useCallback } from "react"; import { useTranslations } from "next-intl"; import { useDisplayDateFormatter } from "@/hooks/use-display-date-formatter"; import { format, isTomorrow, startOfDay } from "date-fns"; @@ -13,9 +13,18 @@ import { getParticipantCount } from "@/lib/calendar-participants"; import type { CalendarEvent, Calendar } from "@/lib/jmap/types"; interface CalendarAgendaViewProps { + /** Anchor day: the list starts here and scrolls back to the top when it changes. */ selectedDate: Date; events: CalendarEvent[]; calendars: Calendar[]; + /** Loaded window. "Today" only gets an (empty) anchor row when it lies inside. */ + rangeStart: Date; + rangeEnd: Date; + /** Widen the window into the past; omit when the past limit is reached. */ + onExtendPast?: () => void; + /** Widen the window into the future; omit when the future limit is reached. */ + onExtendFuture?: () => void; + isLoading?: boolean; onSelectEvent: (event: CalendarEvent, anchorRect: DOMRect) => void; onHoverEvent?: (event: CalendarEvent, anchorRect: DOMRect) => void; onHoverLeave?: () => void; @@ -33,6 +42,11 @@ export function CalendarAgendaView({ selectedDate, events, calendars, + rangeStart, + rangeEnd, + onExtendPast, + onExtendFuture, + isLoading = false, onSelectEvent, onHoverEvent, onHoverLeave, @@ -50,8 +64,13 @@ export function CalendarAgendaView({ return map; }, [calendars]); - const todayRef = useRef(null); const scrollContainerRef = useRef(null); + const bottomSentinelRef = useRef(null); + // One outstanding extension at a time. For the past it also remembers the + // scroll height, so rows prepended by the wider fetch don't shove the + // visible ones down. Cleared when the fetch it triggered has finished. + const pendingRef = useRef<{ side: "past" | "future"; scrollHeight: number } | null>(null); + const wasLoadingRef = useRef(isLoading); const grouped = useMemo(() => { const sorted = [...events].sort((a, b) => @@ -79,37 +98,101 @@ export function CalendarAgendaView({ } catch { /* skip invalid dates */ } }); - // Always include today's date in the groups so the view has a "Today" anchor - const todayKey = format(displayNow(), "yyyy-MM-dd"); - if (!groupMap.has(todayKey)) { - const todayGroup = { date: startOfDay(displayNow()), dateKey: todayKey, events: [] as CalendarEvent[] }; + // Keep a "Today" row as an anchor, but only when today is actually part + // of the loaded window - otherwise it would claim there is nothing on a + // day that was never fetched. + const today = startOfDay(displayNow()); + const todayKey = format(today, "yyyy-MM-dd"); + if (!groupMap.has(todayKey) && today >= startOfDay(rangeStart) && today <= rangeEnd) { + const todayGroup = { date: today, dateKey: todayKey, events: [] as CalendarEvent[] }; groupMap.set(todayKey, todayGroup); groups.push(todayGroup); } groups.sort((a, b) => a.date.getTime() - b.date.getTime()); return groups; - }, [events]); + }, [events, rangeStart, rangeEnd]); - // Auto-scroll to today's section on mount and when selectedDate changes to today - const scrollToToday = useCallback(() => { - if (todayRef.current) { - todayRef.current.scrollIntoView({ block: "start" }); - } + // The anchor row is the first day at or after the selected day. It is where + // the list starts out, and where "Today" brings the user back to. + const anchorKey = format(selectedDate, "yyyy-MM-dd"); + const anchorIndex = grouped.findIndex((group) => group.dateKey >= anchorKey); + const anchorRef = useRef(null); + // Whether the fetch for the current anchor's window has completed. Until + // then the rows belong to whatever was on screen before, so the bottom + // sentinel must not grow the window off them. + const loadedForAnchorRef = useRef(false); + const scrollToAnchor = useCallback(() => { + const el = scrollContainerRef.current; + const target = anchorRef.current; + if (!el) return; + el.scrollTop = target + ? el.scrollTop + target.getBoundingClientRect().top - el.getBoundingClientRect().top + : 0; }, []); - useEffect(() => { - // Scroll to today on mount - const frame = requestAnimationFrame(scrollToToday); - return () => cancelAnimationFrame(frame); - }, [scrollToToday]); + useLayoutEffect(() => { + loadedForAnchorRef.current = false; + }, [anchorKey]); - useEffect(() => { - // Scroll to today when selectedDate changes to today - if (isDisplayToday(selectedDate)) { - scrollToToday(); + // A new selected date (a fresh window, or "Today" pressed again): show the + // anchor day. For a fresh window this happens again once its rows arrive. + useLayoutEffect(() => { + pendingRef.current = null; + scrollToAnchor(); + }, [selectedDate, scrollToAnchor]); + + const requestPast = useCallback(() => { + if (!onExtendPast || isLoading || pendingRef.current) return; + pendingRef.current = { side: "past", scrollHeight: scrollContainerRef.current?.scrollHeight ?? 0 }; + onExtendPast(); + }, [onExtendPast, isLoading]); + + const requestFuture = useCallback(() => { + if (!onExtendFuture || isLoading || pendingRef.current) return; + pendingRef.current = { side: "future", scrollHeight: 0 }; + onExtendFuture(); + }, [onExtendFuture, isLoading]); + + // Wheeling up while already at the top reaches for earlier days. Touch + // users (and anyone whose list is too short to scroll) have the button. + const handleWheel = useCallback((e: React.WheelEvent) => { + if (e.deltaY < 0 && e.currentTarget.scrollTop <= 0) requestPast(); + }, [requestPast]); + + // The fetch an extension triggered has finished (data and the loading flag + // land in the same render). Rows prepended for the past would shove what + // the user was looking at downwards: put it back. Browser scroll anchoring + // is disabled on the container so the correction is not applied twice. + useLayoutEffect(() => { + const finished = wasLoadingRef.current && !isLoading; + wasLoadingRef.current = isLoading; + if (!finished) return; + const pending = pendingRef.current; + pendingRef.current = null; + if (!loadedForAnchorRef.current) { + loadedForAnchorRef.current = true; + scrollToAnchor(); + return; } - }, [selectedDate, scrollToToday]); + const el = scrollContainerRef.current; + if (el && pending?.side === "past") el.scrollTop += el.scrollHeight - pending.scrollHeight; + }, [isLoading, scrollToAnchor]); + + // The bottom sentinel loads more as soon as it scrolls into view. The + // observer is recreated whenever the data or loading state changes so a + // still-visible sentinel (short list) keeps filling until the limit. + useEffect(() => { + const root = scrollContainerRef.current; + const target = bottomSentinelRef.current; + if (!root || !target || !onExtendFuture || isLoading || !loadedForAnchorRef.current) return; + if (typeof IntersectionObserver === "undefined") return; + const observer = new IntersectionObserver((entries) => { + if (entries.some((entry) => entry.isIntersecting)) requestFuture(); + }, { root, rootMargin: "0px 0px 300px 0px" }); + observer.observe(target); + return () => observer.disconnect(); + }, [grouped, isLoading, onExtendFuture, requestFuture]); const formatDateHeader = (date: Date): string => { if (isDisplayToday(date)) return t("events.today_header"); @@ -124,10 +207,40 @@ export function CalendarAgendaView({ return format(date, "HH:mm"); }; + const formatRangeDate = (date: Date): string => + intlFormatter.dateTime(date, { month: "short", day: "numeric", year: "numeric" }); + + const loadingPast = isLoading && pendingRef.current?.side === "past"; + return ( -
- {grouped.map((group) => ( -
+
+
+ {onExtendPast ? ( + + ) : ( + {t("events.agenda_range_start", { date: formatRangeDate(rangeStart) })} + )} +
+ + {grouped.length === 0 && !isLoading && ( +
+ {t("events.no_events")} +
+ )} + + {grouped.map((group, index) => ( +
))} + +
+ {onExtendFuture + ? (isLoading && !loadingPast ? t("events.agenda_loading") : " ") + : t("events.agenda_range_end", { date: formatRangeDate(rangeEnd) })} +
); } diff --git a/components/calendar/calendar-app.tsx b/components/calendar/calendar-app.tsx index c06c135d2..f29abaa6d 100644 --- a/components/calendar/calendar-app.tsx +++ b/components/calendar/calendar-app.tsx @@ -7,7 +7,7 @@ import { Plus } from "lucide-react"; import { startOfMonth, endOfMonth, startOfWeek, endOfWeek, addMonths, subMonths, addWeeks, subWeeks, addDays, subDays, - startOfDay, format, parseISO, + format, parseISO, } from "date-fns"; import { useCalendarStore } from "@/stores/calendar-store"; import { isCalendarViewMode } from "@/stores/calendar-store"; @@ -76,6 +76,11 @@ import { consumePendingDeepLinkEntry, subscribePendingDeepLink } from "@/lib/dee import { useDeepLinkUrl } from "@/hooks/use-deep-link-url"; import { useProInterfaceActive } from "@/components/pro/pro-interface-redirect"; +/** Days the agenda loads ahead of the selected day at first. */ +const AGENDA_INITIAL_DAYS = 30; +/** Furthest the agenda window grows in either direction. */ +const AGENDA_MAX_DAYS = 365; + type PendingScopeAction = | { type: "edit"; event: CalendarEvent; updates: Partial; sendScheduling?: boolean } | { type: "delete"; event: CalendarEvent; sendScheduling?: boolean }; @@ -344,6 +349,37 @@ export function CalendarApp({ linkSegments }: CalendarAppProps = {}) { } }, [showBirthdayCalendar]); // eslint-disable-line react-hooks/exhaustive-deps + // Agenda: one window around the selected day that only ever grows. Reaching + // an edge of the list doubles that side (capped at a year) and the whole + // range is refetched; picking another day starts a fresh window there. + const agendaAnchorKey = format(selectedDate, "yyyy-MM-dd"); + const [agendaExtent, setAgendaExtent] = useState<{ anchor: string; before: number; after: number }>( + { anchor: agendaAnchorKey, before: 0, after: AGENDA_INITIAL_DAYS }, + ); + const agendaWindow = useMemo(() => { + const anchor = parseISO(agendaAnchorKey); + const extent = agendaExtent.anchor === agendaAnchorKey + ? agendaExtent + : { before: 0, after: AGENDA_INITIAL_DAYS }; + return { + start: subDays(anchor, extent.before), + end: addDays(anchor, extent.after), + canExtendPast: extent.before < AGENDA_MAX_DAYS, + canExtendFuture: extent.after < AGENDA_MAX_DAYS, + }; + }, [agendaAnchorKey, agendaExtent]); + const extendAgenda = useCallback((side: "before" | "after") => { + setAgendaExtent((prev) => { + const current = prev.anchor === agendaAnchorKey + ? prev + : { anchor: agendaAnchorKey, before: 0, after: AGENDA_INITIAL_DAYS }; + const grown = Math.min(AGENDA_MAX_DAYS, Math.max(AGENDA_INITIAL_DAYS, current[side] * 2)); + return grown === current[side] ? prev : { ...current, [side]: grown }; + }); + }, [agendaAnchorKey]); + const extendAgendaPast = useCallback(() => extendAgenda("before"), [extendAgenda]); + const extendAgendaFuture = useCallback(() => extendAgenda("after"), [extendAgenda]); + const dateRange = useMemo(() => { const d = selectedDate; switch (normalizedViewMode) { @@ -367,19 +403,15 @@ export function CalendarApp({ linkSegments }: CalendarAppProps = {}) { start: format(d, "yyyy-MM-dd'T'00:00:00"), end: format(d, "yyyy-MM-dd'T'23:59:59"), }; - case "agenda": { - // Agenda always starts from today at the earliest - const today = startOfDay(displayNow()); - const agendaStart = d >= today ? d : today; + case "agenda": return { - start: format(agendaStart, "yyyy-MM-dd'T'00:00:00"), - end: format(addDays(agendaStart, 30), "yyyy-MM-dd'T'23:59:59"), + start: format(agendaWindow.start, "yyyy-MM-dd'T'00:00:00"), + end: format(agendaWindow.end, "yyyy-MM-dd'T'23:59:59"), }; - } case "tasks": return null; } - }, [selectedDate, normalizedViewMode, firstDayOfWeek]); + }, [selectedDate, normalizedViewMode, firstDayOfWeek, agendaWindow]); // Fetch tasks when tasks view is active or when tasks are shown on calendar grid useEffect(() => { @@ -1390,6 +1422,11 @@ export function CalendarApp({ linkSegments }: CalendarAppProps = {}) { selectedDate={selectedDate} events={visibleEvents} calendars={allCalendars} + rangeStart={agendaWindow.start} + rangeEnd={agendaWindow.end} + onExtendPast={agendaWindow.canExtendPast ? extendAgendaPast : undefined} + onExtendFuture={agendaWindow.canExtendFuture ? extendAgendaFuture : undefined} + isLoading={isLoadingEvents} onSelectEvent={handleSelectEvent} onHoverEvent={handleHoverEvent} onHoverLeave={handleHoverLeave} diff --git a/locales/ar/common.json b/locales/ar/common.json index 358372425..97fe93671 100644 --- a/locales/ar/common.json +++ b/locales/ar/common.json @@ -2776,6 +2776,10 @@ "duplicate": "تكرار", "today_header": "اليوم", "tomorrow_header": "غدًا", + "agenda_show_earlier": "عرض الأحداث السابقة", + "agenda_loading": "جارٍ التحميل…", + "agenda_range_start": "الأحداث من {date}", + "agenda_range_end": "الأحداث حتى {date}", "export_ics": "التصدير كملف ‎.ics", "copy_title": "نسخ العنوان", "copy_link": "نسخ رابط الاجتماع", diff --git a/locales/ca/common.json b/locales/ca/common.json index 2bfd8300a..7561706a7 100644 --- a/locales/ca/common.json +++ b/locales/ca/common.json @@ -2776,6 +2776,10 @@ "duplicate": "Duplica", "today_header": "Avui", "tomorrow_header": "Demà", + "agenda_show_earlier": "Mostra esdeveniments anteriors", + "agenda_loading": "Carregant…", + "agenda_range_start": "Esdeveniments des del {date}", + "agenda_range_end": "Esdeveniments fins al {date}", "export_ics": "Exporta com a .ics", "copy_title": "Copia el títol", "copy_link": "Copia l'enllaç de la reunió", diff --git a/locales/cs/common.json b/locales/cs/common.json index 4a82ae053..1580ac60c 100644 --- a/locales/cs/common.json +++ b/locales/cs/common.json @@ -2776,6 +2776,10 @@ "duplicate": "Duplikovat", "today_header": "Dnes", "tomorrow_header": "Zítra", + "agenda_show_earlier": "Zobrazit dřívější události", + "agenda_loading": "Načítání…", + "agenda_range_start": "Události od {date}", + "agenda_range_end": "Události do {date}", "export_ics": "Exportovat jako .ics", "copy_title": "Kopírovat název", "copy_link": "Kopírovat odkaz na schůzku", diff --git a/locales/da/common.json b/locales/da/common.json index 47dbdbb96..de3e4af6e 100644 --- a/locales/da/common.json +++ b/locales/da/common.json @@ -2776,6 +2776,10 @@ "duplicate": "Duplikér", "today_header": "I dag", "tomorrow_header": "I morgen", + "agenda_show_earlier": "Vis tidligere begivenheder", + "agenda_loading": "Indlæser…", + "agenda_range_start": "Begivenheder fra {date}", + "agenda_range_end": "Begivenheder til {date}", "export_ics": "Eksportér som .ics", "copy_title": "Kopier titel", "copy_link": "Kopier mødelink", diff --git a/locales/de/common.json b/locales/de/common.json index 8b0f875c9..bf79b688e 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -2776,6 +2776,10 @@ "duplicate": "Duplizieren", "today_header": "Heute", "tomorrow_header": "Morgen", + "agenda_show_earlier": "Frühere Termine anzeigen", + "agenda_loading": "Wird geladen…", + "agenda_range_start": "Termine ab {date}", + "agenda_range_end": "Termine bis {date}", "export_ics": "Als .ics exportieren", "copy_title": "Titel kopieren", "copy_link": "Meeting-Link kopieren", diff --git a/locales/en/common.json b/locales/en/common.json index 1d2125d77..f102d3fac 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -2776,6 +2776,10 @@ "duplicate": "Duplicate", "today_header": "Today", "tomorrow_header": "Tomorrow", + "agenda_show_earlier": "Show earlier events", + "agenda_loading": "Loading…", + "agenda_range_start": "Showing events from {date}", + "agenda_range_end": "Showing events until {date}", "export_ics": "Export as .ics", "copy_title": "Copy title", "copy_link": "Copy meeting link", diff --git a/locales/es/common.json b/locales/es/common.json index ff9346b49..8d280b0d6 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -2776,6 +2776,10 @@ "duplicate": "Duplicar", "today_header": "Hoy", "tomorrow_header": "Mañana", + "agenda_show_earlier": "Mostrar eventos anteriores", + "agenda_loading": "Cargando…", + "agenda_range_start": "Eventos desde el {date}", + "agenda_range_end": "Eventos hasta el {date}", "export_ics": "Exportar como .ics", "copy_title": "Copiar título", "copy_link": "Copiar enlace de reunión", diff --git a/locales/fa/common.json b/locales/fa/common.json index 5b5ae3c7b..a61f7ffec 100644 --- a/locales/fa/common.json +++ b/locales/fa/common.json @@ -2776,6 +2776,10 @@ "duplicate": "کپی", "today_header": "امروز", "tomorrow_header": "فردا", + "agenda_show_earlier": "نمایش رویدادهای قبلی", + "agenda_loading": "در حال بارگذاری…", + "agenda_range_start": "رویدادها از {date}", + "agenda_range_end": "رویدادها تا {date}", "export_ics": "خروجی .ics", "copy_title": "کپی عنوان", "copy_link": "کپی لینک جلسه", diff --git a/locales/fr/common.json b/locales/fr/common.json index 911c96e33..cedcd458a 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -2776,6 +2776,10 @@ "duplicate": "Dupliquer", "today_header": "Aujourd'hui", "tomorrow_header": "Demain", + "agenda_show_earlier": "Afficher les événements précédents", + "agenda_loading": "Chargement…", + "agenda_range_start": "Événements à partir du {date}", + "agenda_range_end": "Événements jusqu'au {date}", "export_ics": "Exporter en .ics", "copy_title": "Copier le titre", "copy_link": "Copier le lien de réunion", diff --git a/locales/he/common.json b/locales/he/common.json index 39cedd7cf..07ab091ef 100644 --- a/locales/he/common.json +++ b/locales/he/common.json @@ -2700,6 +2700,10 @@ "duplicate": "שכפול", "today_header": "היום", "tomorrow_header": "מחר", + "agenda_show_earlier": "הצגת אירועים קודמים", + "agenda_loading": "טוען…", + "agenda_range_start": "אירועים מ-{date}", + "agenda_range_end": "אירועים עד {date}", "export_ics": "יצא כ .ics", "copy_title": "עותק כותרת", "copy_link": "עותק קישור פגישה", diff --git a/locales/hu/common.json b/locales/hu/common.json index 9496a03af..bd7390d89 100644 --- a/locales/hu/common.json +++ b/locales/hu/common.json @@ -2776,6 +2776,10 @@ "duplicate": "Duplikálás", "today_header": "Ma", "tomorrow_header": "Holnap", + "agenda_show_earlier": "Korábbi események megjelenítése", + "agenda_loading": "Betöltés…", + "agenda_range_start": "Események ettől: {date}", + "agenda_range_end": "Események eddig: {date}", "export_ics": "Exportálás .ics-ként", "copy_title": "Cím másolása", "copy_link": "Találkozó link másolása", diff --git a/locales/it/common.json b/locales/it/common.json index af6b6fc23..23ade4758 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -2776,6 +2776,10 @@ "duplicate": "Duplica", "today_header": "Oggi", "tomorrow_header": "Domani", + "agenda_show_earlier": "Mostra eventi precedenti", + "agenda_loading": "Caricamento…", + "agenda_range_start": "Eventi dal {date}", + "agenda_range_end": "Eventi fino al {date}", "export_ics": "Esporta come .ics", "copy_title": "Copia titolo", "copy_link": "Copia link riunione", diff --git a/locales/ja/common.json b/locales/ja/common.json index 6bfe43af1..5657d1a0c 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -2776,6 +2776,10 @@ "duplicate": "複製", "today_header": "今日", "tomorrow_header": "明日", + "agenda_show_earlier": "以前の予定を表示", + "agenda_loading": "読み込み中…", + "agenda_range_start": "{date}以降の予定", + "agenda_range_end": "{date}までの予定", "export_ics": ".icsとしてエクスポート", "copy_title": "タイトルをコピー", "copy_link": "会議リンクをコピー", diff --git a/locales/ko/common.json b/locales/ko/common.json index aa5ac3598..d42f13186 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -2776,6 +2776,10 @@ "duplicate": "복제", "today_header": "오늘", "tomorrow_header": "내일", + "agenda_show_earlier": "이전 일정 보기", + "agenda_loading": "불러오는 중…", + "agenda_range_start": "{date}부터의 일정", + "agenda_range_end": "{date}까지의 일정", "export_ics": ".ics로 내보내기", "copy_title": "제목 복사", "copy_link": "회의 링크 복사", diff --git a/locales/lv/common.json b/locales/lv/common.json index 7a1fd61fb..2b20dc583 100644 --- a/locales/lv/common.json +++ b/locales/lv/common.json @@ -2775,6 +2775,10 @@ "duplicate": "Dublēt", "today_header": "Šodien", "tomorrow_header": "Rīt", + "agenda_show_earlier": "Rādīt agrākos notikumus", + "agenda_loading": "Ielādē…", + "agenda_range_start": "Notikumi no {date}", + "agenda_range_end": "Notikumi līdz {date}", "export_ics": "Eksportēt kā .ics", "copy_title": "Kopēt nosaukumu", "copy_link": "Kopēt sapulces saiti", diff --git a/locales/mn/common.json b/locales/mn/common.json index d11c0ce13..55129457d 100644 --- a/locales/mn/common.json +++ b/locales/mn/common.json @@ -2776,6 +2776,10 @@ "duplicate": "Давхардсан", "today_header": "Өнөөдөр", "tomorrow_header": "Маргааш", + "agenda_show_earlier": "Өмнөх үйл явдлуудыг харуулах", + "agenda_loading": "Ачаалж байна…", + "agenda_range_start": "{date}-с хойшхи үйл явдлууд", + "agenda_range_end": "{date} хүртэлх үйл явдлууд", "export_ics": ".ics", "copy_title": "Гарчиг хуулах", "copy_link": "Уулзалтын холбоосыг хуулах", diff --git a/locales/nb/common.json b/locales/nb/common.json index 64e6c89d5..27286e621 100644 --- a/locales/nb/common.json +++ b/locales/nb/common.json @@ -2776,6 +2776,10 @@ "duplicate": "Dupliser", "today_header": "I dag", "tomorrow_header": "I morgen", + "agenda_show_earlier": "Vis tidligere hendelser", + "agenda_loading": "Laster…", + "agenda_range_start": "Hendelser fra {date}", + "agenda_range_end": "Hendelser til {date}", "export_ics": "Eksporter som .ics", "copy_title": "Kopier tittel", "copy_link": "Kopier møtelenken", diff --git a/locales/nl/common.json b/locales/nl/common.json index db2b111c8..04c4fd1ba 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -2776,6 +2776,10 @@ "duplicate": "Dupliceren", "today_header": "Vandaag", "tomorrow_header": "Morgen", + "agenda_show_earlier": "Eerdere afspraken tonen", + "agenda_loading": "Laden…", + "agenda_range_start": "Afspraken vanaf {date}", + "agenda_range_end": "Afspraken tot {date}", "export_ics": "Exporteren als .ics", "copy_title": "Titel kopiëren", "copy_link": "Vergaderlink kopiëren", diff --git a/locales/pl/common.json b/locales/pl/common.json index b08be9a16..6cb849c83 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -2776,6 +2776,10 @@ "duplicate": "Duplikuj", "today_header": "Dzisiaj", "tomorrow_header": "Jutro", + "agenda_show_earlier": "Pokaż wcześniejsze wydarzenia", + "agenda_loading": "Ładowanie…", + "agenda_range_start": "Wydarzenia od {date}", + "agenda_range_end": "Wydarzenia do {date}", "export_ics": "Eksportuj jako .ics", "copy_title": "Kopiuj tytuł", "copy_link": "Kopiuj link do spotkania", diff --git a/locales/pt/common.json b/locales/pt/common.json index 3166c1b64..6a75f9aed 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -2776,6 +2776,10 @@ "duplicate": "Duplicar", "today_header": "Hoje", "tomorrow_header": "Amanhã", + "agenda_show_earlier": "Mostrar eventos anteriores", + "agenda_loading": "A carregar…", + "agenda_range_start": "Eventos a partir de {date}", + "agenda_range_end": "Eventos até {date}", "export_ics": "Exportar como .ics", "copy_title": "Copiar título", "copy_link": "Copiar link da reunião", diff --git a/locales/ro/common.json b/locales/ro/common.json index c9c5bf3f6..8e921ddd3 100644 --- a/locales/ro/common.json +++ b/locales/ro/common.json @@ -2776,6 +2776,10 @@ "duplicate": "Duplicare", "today_header": "Astăzi", "tomorrow_header": "Mâine", + "agenda_show_earlier": "Afișează evenimentele anterioare", + "agenda_loading": "Se încarcă…", + "agenda_range_start": "Evenimente de la {date}", + "agenda_range_end": "Evenimente până la {date}", "export_ics": "Exportați ca fișier .ics", "copy_title": "Copiați titlul", "copy_link": "Copiați linkul întâlnirii", diff --git a/locales/ru/common.json b/locales/ru/common.json index 609981d70..fbae8b096 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -2776,6 +2776,10 @@ "duplicate": "Дублировать", "today_header": "Сегодня", "tomorrow_header": "Завтра", + "agenda_show_earlier": "Показать более ранние события", + "agenda_loading": "Загрузка…", + "agenda_range_start": "События с {date}", + "agenda_range_end": "События до {date}", "export_ics": "Экспорт в .ics", "copy_title": "Скопировать название", "copy_link": "Скопировать ссылку встречи", diff --git a/locales/sk/common.json b/locales/sk/common.json index 8125ec96a..e5c4133bc 100644 --- a/locales/sk/common.json +++ b/locales/sk/common.json @@ -2776,6 +2776,10 @@ "duplicate": "Duplikovať", "today_header": "Dnes", "tomorrow_header": "Zajtra", + "agenda_show_earlier": "Zobraziť skoršie udalosti", + "agenda_loading": "Načítava sa…", + "agenda_range_start": "Udalosti od {date}", + "agenda_range_end": "Udalosti do {date}", "export_ics": "Exportovať ako .ics", "copy_title": "Kopírovať názov", "copy_link": "Kopírovať odkaz na stretnutie", diff --git a/locales/tr/common.json b/locales/tr/common.json index f98eb8c59..f69d139f9 100644 --- a/locales/tr/common.json +++ b/locales/tr/common.json @@ -2776,6 +2776,10 @@ "duplicate": "Çoğalt", "today_header": "Bugün", "tomorrow_header": "Yarın", + "agenda_show_earlier": "Önceki etkinlikleri göster", + "agenda_loading": "Yükleniyor…", + "agenda_range_start": "{date} tarihinden itibaren etkinlikler", + "agenda_range_end": "{date} tarihine kadar etkinlikler", "export_ics": ".ics olarak dışa aktar", "copy_title": "Başlığı kopyala", "copy_link": "Toplantı bağlantısını kopyala", diff --git a/locales/uk/common.json b/locales/uk/common.json index 2b3ddbb39..8bdce4c45 100644 --- a/locales/uk/common.json +++ b/locales/uk/common.json @@ -2776,6 +2776,10 @@ "duplicate": "дублікат", "today_header": "Сьогодні", "tomorrow_header": "завтра", + "agenda_show_earlier": "Показати попередні події", + "agenda_loading": "Завантаження…", + "agenda_range_start": "Події з {date}", + "agenda_range_end": "Події до {date}", "export_ics": "Експортувати як .ics", "copy_title": "Скопіювати назву", "copy_link": "Скопіювати посилання зустрічі", diff --git a/locales/zh-TW/common.json b/locales/zh-TW/common.json index b3e1b3d4a..ba500f628 100644 --- a/locales/zh-TW/common.json +++ b/locales/zh-TW/common.json @@ -2776,6 +2776,10 @@ "duplicate": "建立副本", "today_header": "今天", "tomorrow_header": "明天", + "agenda_show_earlier": "顯示更早的行程", + "agenda_loading": "載入中…", + "agenda_range_start": "顯示 {date} 起的行程", + "agenda_range_end": "顯示至 {date} 的行程", "export_ics": "匯出為 .ics", "copy_title": "複製標題", "copy_link": "複製會議連結", diff --git a/locales/zh/common.json b/locales/zh/common.json index bdffc3464..774d20f20 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -2776,6 +2776,10 @@ "duplicate": "复制", "today_header": "今天", "tomorrow_header": "明天", + "agenda_show_earlier": "显示更早的日程", + "agenda_loading": "加载中…", + "agenda_range_start": "显示 {date} 起的日程", + "agenda_range_end": "显示至 {date} 的日程", "export_ics": "导出为 .ics", "copy_title": "复制标题", "copy_link": "复制会议链接",