mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-04 08:15:41 +00:00
feat: infinite scroll for the calendar agenda view
This commit is contained in:
@@ -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']);
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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": "نسخ رابط الاجتماع",
|
||||
|
||||
@@ -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ó",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "کپی لینک جلسه",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "עותק קישור פגישה",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "会議リンクをコピー",
|
||||
|
||||
@@ -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": "회의 링크 복사",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Уулзалтын холбоосыг хуулах",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Скопировать ссылку встречи",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Скопіювати посилання зустрічі",
|
||||
|
||||
@@ -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": "複製會議連結",
|
||||
|
||||
@@ -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": "复制会议链接",
|
||||
|
||||
Reference in New Issue
Block a user