Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
172 changes: 172 additions & 0 deletions components/calendar/__tests__/calendar-agenda-view.test.tsx
Original file line number Diff line number Diff line change
@@ -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 (#759): 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 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;
}

type Props = React.ComponentProps<typeof CalendarAgendaView>;

function baseProps(overrides: Partial<Props> = {}): Props {
return {
focus: { date: new Date(2026, 8, 9), nonce: 0 },
windowKey: 'agenda:2026-09-09',
events: [],
calendars,
rangeStart: new Date(2026, 8, 9),
rangeEnd: new Date(2026, 9, 9),
onSelectEvent: vi.fn(),
...overrides,
};
}

function renderView(overrides: Partial<Props> = {}) {
return render(<CalendarAgendaView {...baseProps(overrides)} />);
}

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 onExtendStart = vi.fn();
const { rerender } = renderView({ onExtendStart });

fireEvent.click(screen.getByText('events.agenda_show_earlier'));
expect(onExtendStart).toHaveBeenCalledTimes(1);

// A second request while the first is still outstanding is ignored ...
fireEvent.click(screen.getByText('events.agenda_show_earlier'));
expect(onExtendStart).toHaveBeenCalledTimes(1);

// ... until the fetch has come and gone.
const wider = { rangeStart: new Date(2026, 7, 10), onExtendStart };
rerender(<CalendarAgendaView {...baseProps({ ...wider, isLoading: true })} />);
rerender(<CalendarAgendaView {...baseProps({ ...wider, isLoading: false })} />);
fireEvent.click(screen.getByText('events.agenda_show_earlier'));
expect(onExtendStart).toHaveBeenCalledTimes(2);
});

it('loads earlier days when wheeling up while already at the top', () => {
const onExtendStart = vi.fn();
const { container } = renderView({ onExtendStart });
const scroller = container.firstElementChild as HTMLElement;

fireEvent.wheel(scroller, { deltaY: 40 });
expect(onExtendStart).not.toHaveBeenCalled();

fireEvent.wheel(scroller, { deltaY: -40 });
expect(onExtendStart).toHaveBeenCalledTimes(1);
});

it('shows the window start instead of the button once the past limit is reached', () => {
renderView({ onExtendStart: 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 onExtendEnd = vi.fn();
const { rerender } = renderView({ onExtendEnd });

// 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 {...baseProps({ onExtendEnd, isLoading: true })} />);
rerender(<CalendarAgendaView {...baseProps({ onExtendEnd, isLoading: false })} />);

expect(observed).toContain(screen.getByTestId('agenda-bottom-sentinel'));
observerCallbacks.at(-1)?.([{ isIntersecting: true }]);
expect(onExtendEnd).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.
const wider = { rangeEnd: new Date(2026, 10, 9), onExtendEnd };
rerender(<CalendarAgendaView {...baseProps(wider)} />);
observerCallbacks.at(-1)?.([{ isIntersecting: true }]);
expect(onExtendEnd).toHaveBeenCalledTimes(1);

// Once that fetch has finished, the next sighting extends again.
rerender(<CalendarAgendaView {...baseProps({ ...wider, isLoading: true })} />);
rerender(<CalendarAgendaView {...baseProps({ ...wider, isLoading: false })} />);
observerCallbacks.at(-1)?.([{ isIntersecting: true }]);
expect(onExtendEnd).toHaveBeenCalledTimes(2);
});

it('does not observe the sentinel while a fetch is in flight or at the future limit', () => {
renderView({ onExtendEnd: vi.fn(), isLoading: true });
expect(observerCallbacks).toHaveLength(0);
cleanup();

renderView({ onExtendEnd: 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({
focus: { date: new Date(2026, 0, 1), nonce: 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']);
});
});
122 changes: 93 additions & 29 deletions components/calendar/calendar-agenda-view.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"use client";

import { useMemo, useRef, useEffect, useCallback } from "react";
import { useMemo, useRef, useCallback } from "react";
import { useTranslations } from "next-intl";
import { useDisplayDateFormatter } from "@/hooks/use-display-date-formatter";
import { format, isTomorrow, startOfDay } from "date-fns";
Expand All @@ -10,10 +10,11 @@ import { getEventColor } from "./event-card";
import { getEventDayBounds, getEventEndDate, getEventStartDate, getPrimaryCalendarId } from "@/lib/calendar-utils";
import { displayNow, isDisplayToday } from "@/lib/timezone";
import { getParticipantCount } from "@/lib/calendar-participants";
import { useScrollWindow } from "@/hooks/use-scroll-window";
import type { ScrollWindowViewProps } from "@/lib/calendar-scroll-window";
import type { CalendarEvent, Calendar } from "@/lib/jmap/types";

interface CalendarAgendaViewProps {
selectedDate: Date;
interface CalendarAgendaViewProps extends ScrollWindowViewProps {
events: CalendarEvent[];
calendars: Calendar[];
onSelectEvent: (event: CalendarEvent, anchorRect: DOMRect) => void;
Expand All @@ -30,9 +31,15 @@ interface DayGroup {
}

export function CalendarAgendaView({
selectedDate,
focus,
events,
calendars,
rangeStart,
rangeEnd,
windowKey,
onExtendStart,
onExtendEnd,
isLoading = false,
onSelectEvent,
onHoverEvent,
onHoverLeave,
Expand All @@ -50,8 +57,8 @@ export function CalendarAgendaView({
return map;
}, [calendars]);

const todayRef = useRef<HTMLDivElement>(null);
const scrollContainerRef = useRef<HTMLDivElement>(null);
const bottomSentinelRef = useRef<HTMLDivElement>(null);

const grouped = useMemo(() => {
const sorted = [...events].sort((a, b) =>
Expand Down Expand Up @@ -79,37 +86,54 @@ 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 focus row is the first day at or after the focused day: where the
// list starts out, and where "Today" brings the user back to.
const focusKey = format(focus.date, "yyyy-MM-dd");
const focusIndex = grouped.findIndex((group) => group.dateKey >= focusKey);
const focusRowRef = useRef<HTMLDivElement>(null);
const scrollToFocus = useCallback(() => {
const el = scrollContainerRef.current;
const target = focusRowRef.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]);
const { requestStart, pendingSide } = useScrollWindow({
scrollRef: scrollContainerRef,
axis: "vertical",
isLoading,
windowKey,
focusNonce: focus.nonce,
scrollToFocus,
onExtendStart,
onExtendEnd,
endSentinelRef: bottomSentinelRef,
contentKey: grouped,
anchorSelector: "[data-agenda-day]",
});

useEffect(() => {
// Scroll to today when selectedDate changes to today
if (isDisplayToday(selectedDate)) {
scrollToToday();
}
}, [selectedDate, scrollToToday]);
// 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) requestStart();
}, [requestStart]);

const formatDateHeader = (date: Date): string => {
if (isDisplayToday(date)) return t("events.today_header");
Expand All @@ -124,10 +148,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 && pendingSide === "start";

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">
{onExtendStart ? (
<button
type="button"
onClick={requestStart}
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 === focusIndex ? focusRowRef : 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",
Expand Down Expand Up @@ -219,6 +273,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"
>
{onExtendEnd
? (isLoading && pendingSide === "end" ? t("events.agenda_loading") : " ")
: t("events.agenda_range_end", { date: formatRangeDate(rangeEnd) })}
</div>
</div>
);
}
Loading
Loading