feat: infinite scroll for the calendar agenda view

This commit is contained in:
Linus Rath
2026-09-09 15:19:53 +02:00
parent 4a057b2348
commit 60e05c0041
30 changed files with 474 additions and 34 deletions
@@ -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<React.ComponentProps<typeof CalendarAgendaView>> = {}) {
const props: React.ComponentProps<typeof CalendarAgendaView> = {
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(<CalendarAgendaView {...props} />);
}
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(<CalendarAgendaView selectedDate={new Date(2026, 8, 9)} events={[]} calendars={calendars}
rangeStart={new Date(2026, 7, 10)} rangeEnd={new Date(2026, 9, 9)} onSelectEvent={vi.fn()}
onExtendPast={onExtendPast} isLoading />);
rerender(<CalendarAgendaView selectedDate={new Date(2026, 8, 9)} events={[]} calendars={calendars}
rangeStart={new Date(2026, 7, 10)} rangeEnd={new Date(2026, 9, 9)} onSelectEvent={vi.fn()}
onExtendPast={onExtendPast} isLoading={false} />);
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(<CalendarAgendaView {...base} rangeEnd={new Date(2026, 9, 9)} />);
// 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(<CalendarAgendaView {...base} rangeEnd={new Date(2026, 9, 9)} isLoading />);
rerender(<CalendarAgendaView {...base} rangeEnd={new Date(2026, 9, 9)} isLoading={false} />);
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(<CalendarAgendaView {...base} rangeEnd={new Date(2026, 10, 9)} />);
observerCallbacks.at(-1)?.([{ isIntersecting: true }]);
expect(onExtendFuture).toHaveBeenCalledTimes(1);
// Once that fetch has finished, the next sighting extends again.
rerender(<CalendarAgendaView {...base} rangeEnd={new Date(2026, 10, 9)} isLoading />);
rerender(<CalendarAgendaView {...base} rangeEnd={new Date(2026, 10, 9)} isLoading={false} />);
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']);
});
});
+148 -25
View File
@@ -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<HTMLDivElement>(null);
const scrollContainerRef = useRef<HTMLDivElement>(null);
const bottomSentinelRef = useRef<HTMLDivElement>(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<HTMLDivElement>(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<HTMLDivElement>) => {
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 (
<div className="flex-1 overflow-y-auto" ref={scrollContainerRef}>
{grouped.map((group) => (
<div key={group.dateKey} ref={isDisplayToday(group.date) ? todayRef : undefined}>
<div
className="flex-1 overflow-y-auto [overflow-anchor:none]"
ref={scrollContainerRef}
onWheel={handleWheel}
>
<div className="px-4 py-2 text-center text-xs text-muted-foreground">
{onExtendPast ? (
<button
type="button"
onClick={requestPast}
disabled={isLoading}
className="rounded-md px-2 py-1 hover:bg-muted hover:text-foreground disabled:opacity-60"
>
{loadingPast ? t("events.agenda_loading") : t("events.agenda_show_earlier")}
</button>
) : (
<span>{t("events.agenda_range_start", { date: formatRangeDate(rangeStart) })}</span>
)}
</div>
{grouped.length === 0 && !isLoading && (
<div className="px-4 py-6 text-center text-sm text-muted-foreground">
{t("events.no_events")}
</div>
)}
{grouped.map((group, index) => (
<div key={group.dateKey} ref={index === anchorIndex ? anchorRef : undefined} data-agenda-day={group.dateKey}>
<div className="sticky top-0 bg-muted/80 backdrop-blur-sm px-4 py-2 border-b border-border">
<span className={cn(
"text-sm font-medium",
@@ -219,6 +332,16 @@ export function CalendarAgendaView({
)}
</div>
))}
<div
ref={bottomSentinelRef}
data-testid="agenda-bottom-sentinel"
className="px-4 py-3 text-center text-xs text-muted-foreground"
>
{onExtendFuture
? (isLoading && !loadingPast ? t("events.agenda_loading") : " ")
: t("events.agenda_range_end", { date: formatRangeDate(rangeEnd) })}
</div>
</div>
);
}
+46 -9
View File
@@ -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<CalendarEvent>; 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}
+4
View File
@@ -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": "نسخ رابط الاجتماع",
+4
View File
@@ -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ó",
+4
View File
@@ -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",
+4
View File
@@ -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",
+4
View File
@@ -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",
+4
View File
@@ -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",
+4
View File
@@ -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",
+4
View File
@@ -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": "کپی لینک جلسه",
+4
View File
@@ -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",
+4
View File
@@ -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": "עותק קישור פגישה",
+4
View File
@@ -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",
+4
View File
@@ -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",
+4
View File
@@ -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": "会議リンクをコピー",
+4
View File
@@ -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": "회의 링크 복사",
+4
View File
@@ -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",
+4
View File
@@ -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": "Уулзалтын холбоосыг хуулах",
+4
View File
@@ -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",
+4
View File
@@ -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",
+4
View File
@@ -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",
+4
View File
@@ -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",
+4
View File
@@ -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",
+4
View File
@@ -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": "Скопировать ссылку встречи",
+4
View File
@@ -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",
+4
View File
@@ -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",
+4
View File
@@ -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": "Скопіювати посилання зустрічі",
+4
View File
@@ -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": "複製會議連結",
+4
View File
@@ -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": "复制会议链接",