From 71522b9b920ef99225fbc2e31177872d5c938c62 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Thu, 24 Sep 2026 19:34:48 +0200 Subject: [PATCH] feat: scroll the calendar agenda endlessly instead of a fixed 30 days The agenda listed 30 days from the current date and the arrows paged through it 30 days at a time. It now keeps a window of days that grows as the user scrolls, like the webmail's agenda (60e05c00): - reaching the end loads further ahead, doubling that side up to a year - "Show earlier events" loads the past without moving what is on screen - "Today" and the arrows scroll to their day while it lies inside the window and start a fresh window there otherwise; the title follows the day in view - the "Today" row only anchors the list when today was actually fetched Growing the window fetches only the new days: `extendRange` in the calendar store loads the missing part and merges it into the loaded window (up to about three years) instead of refetching all of it. Navigation now goes through one focus for every view, so the week view's arrows move the grid (it kept showing the selected day's week) and the month arrows carry the selected day along. --- locales/rn/en.json | 4 - src/components/calendar/AgendaView.tsx | 333 ++++++++++++++---- src/components/calendar/WeekView.tsx | 8 +- src/lib/__tests__/calendar-agenda.test.ts | 66 ++++ .../__tests__/calendar-range-cache.test.ts | 217 ++++++++++++ .../__tests__/calendar-scroll-window.test.ts | 224 ++++++++++++ src/lib/calendar-agenda.ts | 41 +++ src/lib/calendar-range-cache.ts | 151 ++++++++ src/lib/calendar-scroll-window.ts | 217 ++++++++++++ src/screens/CalendarScreen.tsx | 226 ++++++++---- .../calendar-store-extend-range.test.ts | 185 ++++++++++ src/stores/calendar-store.ts | 57 +++ 12 files changed, 1599 insertions(+), 130 deletions(-) create mode 100644 src/lib/__tests__/calendar-agenda.test.ts create mode 100644 src/lib/__tests__/calendar-range-cache.test.ts create mode 100644 src/lib/__tests__/calendar-scroll-window.test.ts create mode 100644 src/lib/calendar-agenda.ts create mode 100644 src/lib/calendar-range-cache.ts create mode 100644 src/lib/calendar-scroll-window.ts create mode 100644 src/stores/__tests__/calendar-store-extend-range.test.ts diff --git a/locales/rn/en.json b/locales/rn/en.json index 95e07436..5304b29c 100644 --- a/locales/rn/en.json +++ b/locales/rn/en.json @@ -6,10 +6,6 @@ "not_starred": "Not starred" }, "calendar": { - "agenda": { - "days_clear": "The next {count} days are clear.", - "no_upcoming": "No upcoming events" - }, "alerts": { "unit_minutes_after": "minutes after" }, diff --git a/src/components/calendar/AgendaView.tsx b/src/components/calendar/AgendaView.tsx index 835fef68..bd69d071 100644 --- a/src/components/calendar/AgendaView.tsx +++ b/src/components/calendar/AgendaView.tsx @@ -1,34 +1,57 @@ import React from 'react'; -import { SectionList, StyleSheet, Text, View } from 'react-native'; -import { addDays, format, isSameDay, isToday, isTomorrow, parseISO, startOfDay, type Locale } from 'date-fns'; -import { CalendarDays } from 'lucide-react-native'; +import { + ActivityIndicator, + Pressable, + SectionList, + StyleSheet, + Text, + View, + type LayoutChangeEvent, + type ViewToken, +} from 'react-native'; +import { format, isSameDay, isToday, isTomorrow, type Locale } from 'date-fns'; import type { Calendar, CalendarEvent } from '../../api/types'; -import { spacing, typography, type ThemePalette } from '../../theme/tokens'; +import { radius, spacing, typography, type ThemePalette } from '../../theme/tokens'; import { useColors } from '../../theme/colors'; import { buildEventDayIndex, dayKey, - eventsOnDayFromIndex, type EventDayIndex, type TimeFormat, } from '../../lib/calendar-utils'; +import type { CalendarFocus, DayRange } from '../../lib/calendar-scroll-window'; +import { buildAgendaDays, findAgendaFocusIndex, type AgendaDay } from '../../lib/calendar-agenda'; import { useCalendarLocale } from '../../lib/calendar-locale'; +import { useLocaleStore } from '../../stores/locale-store'; import { EventCard } from './EventCard'; interface AgendaViewProps { - fromDate: Date; - daysAhead?: number; + /** The day the user navigated to; the list shows the first day at or after it. */ + focus: CalendarFocus; events: CalendarEvent[]; calendars: Calendar[]; eventsByDay?: EventDayIndex; + /** The days the agenda covers. */ + window: DayRange; + /** The part of the window whose events are loaded, or null while none is. */ + loaded: DayRange | null; + /** Widen the window into the past; omitted once the limit is reached. */ + onExtendStart?: () => void; + /** Widen the window into the future; omitted once the limit is reached. */ + onExtendEnd?: () => void; + /** The side an extension is loading, if any. */ + loadingEdge?: 'start' | 'end' | null; + /** True while events for the window are being fetched. */ + isLoading?: boolean; + /** Reports the first day in view as the user scrolls. */ + onVisibleDateChange?: (date: Date) => void; timeFormat?: TimeFormat; onSelectEvent?: (event: CalendarEvent) => void; } -interface DaySection { +interface DaySection extends AgendaDay { + key: string; title: string; - date: Date; - data: CalendarEvent[]; } function formatDayHeader( @@ -41,73 +64,251 @@ function formatDayHeader( return format(date, 'EEEE, MMM d', { locale }); } +const VIEWABILITY_CONFIG = { itemVisiblePercentThreshold: 1 }; +const MAX_SCROLL_RETRIES = 3; + +/** + * Infinite agenda (webmail 60e05c00, #759): days with events over a window + * that grows as the user scrolls. Reaching the end loads further ahead, + * "Show earlier events" loads the past without moving what is on screen, + * and navigation ("Today", the arrows) scrolls to its day when it lies in + * the window. A navigation outside it gets a fresh window; the screen + * remounts the list for that. + */ export function AgendaView({ - fromDate, - daysAhead = 30, + focus, events, calendars, eventsByDay, + window, + loaded, + onExtendStart, + onExtendEnd, + loadingEdge = null, + isLoading = false, + onVisibleDateChange, timeFormat, onSelectEvent, }: AgendaViewProps) { const c = useColors(); const styles = React.useMemo(() => makeStyles(c), [c]); const { locale, t } = useCalendarLocale(); + const tp = useLocaleStore((s) => s.t); const index = React.useMemo( () => eventsByDay ?? buildEventDayIndex(events), [eventsByDay, events], ); - const sections = React.useMemo(() => { - // Mirror webmail's agenda (calendar-agenda-view.tsx): build a group for - // every day that actually has events, always include a "Today" anchor, and - // render them sorted ascending. Driving the list off the events (rather than - // a fixed day-counter) guarantees every upcoming loaded event appears - not - // just the ones inside a brittle N-day window from the anchor. - const anchor = startOfDay(fromDate); - const horizonEnd = addDays(anchor, daysAhead); // safety bound (exclusive) + const sections = React.useMemo( + () => + buildAgendaDays(index, loaded, new Date()).map((day) => ({ + ...day, + key: dayKey(day.date), + title: formatDayHeader(day.date, t, locale), + })), + [index, loaded, t, locale], + ); - const days = new Map(); - for (const key of index.keys()) { - const day = parseISO(key); // index keys are local yyyy-MM-dd - if (isNaN(day.getTime())) continue; - if (day < anchor || day >= horizonEnd) continue; - days.set(key, day); + const listRef = React.useRef>(null); + const sectionsRef = React.useRef(sections); + sectionsRef.current = sections; + const focusRef = React.useRef(focus); + focusRef.current = focus; + + // Navigation inside the window scrolls to the first day at or after the + // focus. The list starts out at the focus already (a fresh window begins + // there), so the nonce it mounted with needs no scrolling. + const scrolledNonceRef = React.useRef(focus.nonce); + const retryTimerRef = React.useRef | null>(null); + const retriesRef = React.useRef(0); + const scrollToSection = React.useCallback((sectionIndex: number) => { + if (retryTimerRef.current !== null) clearTimeout(retryTimerRef.current); + retryTimerRef.current = null; + listRef.current?.scrollToLocation({ + sectionIndex, + itemIndex: 0, + viewOffset: 0, + viewPosition: 0, + animated: false, + }); + }, []); + React.useEffect(() => { + if (scrolledNonceRef.current === focus.nonce || !loaded) return; + const target = findAgendaFocusIndex(sections, focus.date); + if (target < 0) { + // Its days are still loading: try again when they arrive. + if (isLoading) return; + scrolledNonceRef.current = focus.nonce; + return; } - // Always anchor "Today" so the view has a stable starting point even when - // the current day has no events (webmail injects the same anchor). - const today = startOfDay(new Date()); - if (today >= anchor && today < horizonEnd) days.set(dayKey(today), today); + scrolledNonceRef.current = focus.nonce; + retriesRef.current = 0; + scrollToSection(target); + }, [focus, sections, loaded, isLoading, scrollToSection]); + React.useEffect(() => () => { + if (retryTimerRef.current !== null) clearTimeout(retryTimerRef.current); + }, []); - return [...days.values()] - .sort((a, b) => a.getTime() - b.getTime()) - .map((day) => ({ - title: formatDayHeader(day, t, locale), - date: day, - // Buckets are pre-sorted by buildEventDayIndex (all-day first, then by - // start, then title), so no extra sort is needed here. - data: eventsOnDayFromIndex(index, day), - })); - }, [fromDate, daysAhead, index, t, locale]); + // Rows between here and the target aren't measured yet: jump to the + // estimate so the list renders around it, then retry. + const handleScrollToIndexFailed = React.useCallback( + (info: { index: number; averageItemLength: number }) => { + listRef.current + ?.getScrollResponder() + ?.scrollTo({ y: info.averageItemLength * info.index, animated: false }); + if (retriesRef.current >= MAX_SCROLL_RETRIES) return; + retriesRef.current += 1; + retryTimerRef.current = setTimeout(() => { + const target = findAgendaFocusIndex(sectionsRef.current, focusRef.current.date); + if (target >= 0) scrollToSection(target); + }, 50); + }, + [scrollToSection], + ); - if (sections.length === 0) { - return ( - - - {t('calendar.agenda.no_upcoming', 'No upcoming events')} - - {t('calendar.agenda.days_clear', 'The next {count} days are clear.').replace('{count}', String(daysAhead))} + // The first day in view drives the header title. The list wants a stable + // callback, so the latest handler is read through a ref. + const visibleHandlerRef = React.useRef(onVisibleDateChange); + visibleHandlerRef.current = onVisibleDateChange; + const lastVisibleKeyRef = React.useRef(null); + const onViewableItemsChanged = React.useRef( + ({ viewableItems }: { viewableItems: ViewToken[] }) => { + const section = viewableItems.find((token) => token.section)?.section as DaySection | undefined; + if (!section || section.key === lastVisibleKeyRef.current) return; + lastVisibleKeyRef.current = section.key; + visibleHandlerRef.current?.(section.date); + }, + ).current; + + // One outstanding extension at a time, and none while the last one hasn't + // landed: an extension that failed (offline) must not keep widening the + // window with nothing in it. The screen clears the loading edge once the + // wider window is in, and asks again for a window it hasn't got. + const sizeRef = React.useRef({ content: 0, viewport: 0 }); + // Content height when an end request had to wait, or null. + const deferredEndRef = React.useRef(null); + const endLanded = !!loaded && loaded.end.getTime() >= window.end.getTime(); + const startLanded = !!loaded && loaded.start.getTime() <= window.start.getTime(); + const requestEnd = React.useCallback(() => { + if (!onExtendEnd) return; + if (isLoading || loadingEdge || !endLanded) { + deferredEndRef.current = sizeRef.current.content; + return; + } + deferredEndRef.current = null; + onExtendEnd(); + }, [onExtendEnd, isLoading, loadingEdge, endLanded]); + const requestStart = React.useCallback(() => { + if (!onExtendStart || isLoading || loadingEdge || !startLanded) return; + onExtendStart(); + }, [onExtendStart, isLoading, loadingEdge, startLanded]); + + // Once a load has finished and laid out: replay an end request that had + // to wait if the list didn't change height meanwhile (it would not ask + // again by itself), and keep filling a list shorter than the screen, which + // can't be scrolled to its end, up to the limit. + const requestEndRef = React.useRef(requestEnd); + requestEndRef.current = requestEnd; + const settled = !isLoading && !loadingEdge && endLanded; + React.useEffect(() => { + if (!settled) return; + const timer = setTimeout(() => { + const { content, viewport } = sizeRef.current; + const deferred = deferredEndRef.current; + deferredEndRef.current = null; + if ((deferred !== null && deferred === content) || (viewport > 0 && content <= viewport)) { + requestEndRef.current(); + } + }, 250); + return () => clearTimeout(timer); + }, [settled]); + const handleLayout = React.useCallback((e: LayoutChangeEvent) => { + sizeRef.current.viewport = e.nativeEvent.layout.height; + }, []); + const handleContentSizeChange = React.useCallback((_w: number, h: number) => { + sizeRef.current.content = h; + }, []); + + const formatRangeDate = (date: Date) => format(date, 'PP', { locale }); + + const header = ( + + {onExtendStart ? ( + [styles.edgeButton, pressed && styles.edgeButtonPressed]} + > + + {loadingEdge === 'start' + ? t('calendar.events.agenda_loading', 'Loading…') + : t('calendar.events.agenda_show_earlier', 'Show earlier events')} + + + ) : ( + + {tp('calendar.events.agenda_range_start', 'Showing events from {date}', { + date: formatRangeDate(window.start), + })} + )} + + ); + + // The footer is taller while more days load, so the list re-checks its end + // once they are in, even when they brought no new rows. + const footer = onExtendEnd ? ( + loadingEdge === 'end' ? ( + + + {t('calendar.events.agenda_loading', 'Loading…')} - ); - } + ) : ( + + ) + ) : ( + + + {tp('calendar.events.agenda_range_end', 'Showing events until {date}', { + date: formatRangeDate(window.end), + })} + + + ); + + const empty = ( + + {isLoading ? ( + + ) : ( + {t('calendar.events.no_events', 'No events')} + )} + + ); return ( - + ref={listRef} sections={sections} - keyExtractor={(item, index) => `${item.id}:${index}`} - contentContainerStyle={styles.listContent} + keyExtractor={(item, i) => `${item.id}:${i}`} stickySectionHeadersEnabled + // Earlier days are inserted above what the user is reading without + // moving it. + maintainVisibleContentPosition={{ minIndexForVisible: 0 }} + ListHeaderComponent={header} + ListFooterComponent={footer} + ListEmptyComponent={empty} + onEndReached={requestEnd} + onEndReachedThreshold={0.5} + onScrollToIndexFailed={handleScrollToIndexFailed} + onViewableItemsChanged={onViewableItemsChanged} + viewabilityConfig={VIEWABILITY_CONFIG} + onLayout={handleLayout} + onContentSizeChange={handleContentSizeChange} + initialNumToRender={12} + maxToRenderPerBatch={12} + windowSize={11} renderSectionHeader={({ section }) => { const today = isSameDay(section.date, new Date()); return ( @@ -115,7 +316,7 @@ export function AgendaView({ {section.title} - {format(section.date, 'MMM d', { locale })} + {format(section.date, 'PP', { locale })} ); }} @@ -137,7 +338,6 @@ export function AgendaView({ function makeStyles(c: ThemePalette) { return StyleSheet.create({ - listContent: { paddingBottom: 80 }, sectionHeader: { flexDirection: 'row', alignItems: 'baseline', @@ -154,14 +354,29 @@ function makeStyles(c: ThemePalette) { itemWrap: { paddingHorizontal: spacing.lg, paddingTop: spacing.sm }, emptyDayWrap: { paddingHorizontal: spacing.lg, paddingVertical: spacing.md }, emptyDayText: { ...typography.caption, color: c.textMuted, textAlign: 'center' }, - empty: { - flex: 1, + emptyWrap: { paddingVertical: spacing.xl, alignItems: 'center' }, + edge: { + paddingHorizontal: spacing.lg, + paddingVertical: spacing.sm, + alignItems: 'center', + }, + edgeButton: { + paddingHorizontal: spacing.md, + paddingVertical: spacing.xs, + borderRadius: radius.md, + }, + edgeButtonPressed: { backgroundColor: c.surfaceHover }, + edgeButtonText: { ...typography.caption, color: c.primary }, + edgeText: { ...typography.caption, color: c.textMuted, textAlign: 'center' }, + footerLoading: { + flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: spacing.sm, - paddingVertical: spacing.xxxl, + paddingTop: spacing.lg, + paddingBottom: 80, }, - emptyTitle: { ...typography.bodyMedium, color: c.textSecondary }, - emptySubtitle: { ...typography.caption, color: c.textMuted }, + // Room below the last day, as the list had before. + footerIdle: { minHeight: 80 }, }); } diff --git a/src/components/calendar/WeekView.tsx b/src/components/calendar/WeekView.tsx index 11f21f01..b538ee89 100644 --- a/src/components/calendar/WeekView.tsx +++ b/src/components/calendar/WeekView.tsx @@ -31,6 +31,8 @@ const ALL_DAY_GAP = 2; const HOURS = Array.from({ length: 24 }, (_, i) => i); interface WeekViewProps { + /** A day of the week to show; defaults to the selected day. */ + weekDate?: Date; selectedDate: Date; events: CalendarEvent[]; calendars: Calendar[]; @@ -43,6 +45,7 @@ interface WeekViewProps { } function WeekViewInner({ + weekDate, selectedDate, events, calendars, @@ -63,10 +66,11 @@ function WeekViewInner({ [eventsByDay, events], ); + const shownDate = weekDate ?? selectedDate; const weekDays = React.useMemo(() => { - const start = startOfWeek(selectedDate, { weekStartsOn }); + const start = startOfWeek(shownDate, { weekStartsOn }); return Array.from({ length: 7 }, (_, i) => addDays(start, i)); - }, [selectedDate, weekStartsOn]); + }, [shownDate, weekStartsOn]); // Multi-day all-day events render as one bar across the days they span. // Timed events that consume the entire day get promoted to this strip too. diff --git a/src/lib/__tests__/calendar-agenda.test.ts b/src/lib/__tests__/calendar-agenda.test.ts new file mode 100644 index 00000000..6629754b --- /dev/null +++ b/src/lib/__tests__/calendar-agenda.test.ts @@ -0,0 +1,66 @@ +import { describe, it, expect } from 'vitest'; +import type { CalendarEvent } from '../../api/types'; +import { buildEventDayIndex, dayKey } from '../calendar-utils'; +import { buildAgendaDays, findAgendaFocusIndex } from '../calendar-agenda'; + +const ev = (id: string, start: string, duration = 'PT1H', extra: Partial = {}) => + ({ id, title: id, start, duration, ...extra }) as CalendarEvent; + +const keys = (days: Array<{ date: Date }>) => days.map((d) => dayKey(d.date)); + +describe('buildAgendaDays', () => { + const index = buildEventDayIndex([ + ev('aug', '2026-08-20T10:00:00'), + ev('sep1', '2026-09-10T09:00:00'), + ev('sep2', '2026-09-10T08:00:00'), + ev('oct', '2026-10-05T10:00:00'), + ev('trip', '2026-09-29T00:00:00', 'P3D', { showWithoutTime: true }), + ev('dec', '2026-12-01T10:00:00'), + ]); + const loaded = { start: new Date(2026, 8, 1), end: new Date(2026, 9, 31) }; + + it('lists the loaded days that have events, in order', () => { + const days = buildAgendaDays(index, loaded, new Date(2026, 8, 10, 12)); + expect(keys(days)).toEqual(['2026-09-10', '2026-09-29', '2026-09-30', '2026-10-01', '2026-10-05']); + // Buckets keep the day index's order (by start time). + expect(days[0].data.map((e) => e.id)).toEqual(['sep2', 'sep1']); + }); + + it('adds an empty "Today" row when today is loaded but has no events', () => { + const days = buildAgendaDays(index, loaded, new Date(2026, 8, 24, 8)); + expect(keys(days)).toContain('2026-09-24'); + const today = days.find((d) => dayKey(d.date) === '2026-09-24'); + expect(today?.data).toEqual([]); + expect(keys(days)).toEqual([...keys(days)].sort()); + }); + + it('leaves out today when it was never fetched', () => { + const days = buildAgendaDays(index, loaded, new Date(2027, 0, 5)); + expect(keys(days)).not.toContain('2027-01-05'); + }); + + it('shows nothing while none of the window is loaded', () => { + expect(buildAgendaDays(index, null, new Date(2026, 8, 24))).toEqual([]); + }); + + it('grows with the loaded range as the window extends', () => { + const wider = { start: new Date(2026, 7, 1), end: new Date(2026, 11, 31) }; + const days = buildAgendaDays(index, wider, new Date(2026, 8, 10)); + expect(keys(days)[0]).toBe('2026-08-20'); + expect(keys(days).at(-1)).toBe('2026-12-01'); + }); +}); + +describe('findAgendaFocusIndex', () => { + const days = [new Date(2026, 8, 10), new Date(2026, 8, 24), new Date(2026, 9, 5)].map((date) => ({ date })); + + it('finds the first day at or after the focus', () => { + expect(findAgendaFocusIndex(days, new Date(2026, 8, 24, 18))).toBe(1); + expect(findAgendaFocusIndex(days, new Date(2026, 8, 25))).toBe(2); + expect(findAgendaFocusIndex(days, new Date(2026, 0, 1))).toBe(0); + }); + + it('is -1 past the last day', () => { + expect(findAgendaFocusIndex(days, new Date(2026, 9, 6))).toBe(-1); + }); +}); diff --git a/src/lib/__tests__/calendar-range-cache.test.ts b/src/lib/__tests__/calendar-range-cache.test.ts new file mode 100644 index 00000000..fecb5437 --- /dev/null +++ b/src/lib/__tests__/calendar-range-cache.test.ts @@ -0,0 +1,217 @@ +import { describe, it, expect, vi } from 'vitest'; +import type { CalendarEvent } from '../../api/types'; +import { + coversRange, + createRangeLoader, + mergeRangeEvents, + mergedSpanLimit, + planRangeLoad, + type EventRange, +} from '../calendar-range-cache'; + +const DAY = 24 * 60 * 60 * 1000; +const YEAR = 366 * DAY; +const r = (after: string, before: string): EventRange => ({ after, before }); + +describe('planRangeLoad', () => { + it('loads afresh when nothing is loaded', () => { + expect(planRangeLoad(null, r('2026-09-01T00:00:00.000Z', '2026-10-01T00:00:00.000Z'), YEAR)).toEqual({ + kind: 'replace', + }); + }); + + it('does nothing when the loaded range covers the request', () => { + const loaded = r('2026-08-01T00:00:00.000Z', '2026-12-01T00:00:00.000Z'); + expect(planRangeLoad(loaded, r('2026-09-01T00:00:00.000Z', '2026-10-01T00:00:00.000Z'), YEAR)).toEqual({ + kind: 'covered', + }); + }); + + it('fetches only the new part when the window grows at the end', () => { + const loaded = r('2026-09-01T00:00:00.000Z', '2026-10-01T00:00:00.000Z'); + const plan = planRangeLoad(loaded, r('2026-09-01T00:00:00.000Z', '2026-11-01T00:00:00.000Z'), YEAR); + expect(plan).toEqual({ + kind: 'extend', + pieces: [r('2026-10-01T00:00:00.000Z', '2026-11-01T00:00:00.000Z')], + union: r('2026-09-01T00:00:00.000Z', '2026-11-01T00:00:00.000Z'), + }); + }); + + it('fetches only the new part when the window grows at the start', () => { + const loaded = r('2026-09-01T00:00:00.000Z', '2026-10-01T00:00:00.000Z'); + const plan = planRangeLoad(loaded, r('2026-08-01T00:00:00.000Z', '2026-09-15T00:00:00.000Z'), YEAR); + expect(plan).toEqual({ + kind: 'extend', + pieces: [r('2026-08-01T00:00:00.000Z', '2026-09-01T00:00:00.000Z')], + union: r('2026-08-01T00:00:00.000Z', '2026-10-01T00:00:00.000Z'), + }); + }); + + it('fetches both sides when the request surrounds the loaded range', () => { + const loaded = r('2026-09-01T00:00:00.000Z', '2026-10-01T00:00:00.000Z'); + const plan = planRangeLoad(loaded, r('2026-08-01T00:00:00.000Z', '2026-11-01T00:00:00.000Z'), YEAR); + expect(plan.kind).toBe('extend'); + if (plan.kind !== 'extend') return; + expect(plan.pieces).toEqual([ + r('2026-08-01T00:00:00.000Z', '2026-09-01T00:00:00.000Z'), + r('2026-10-01T00:00:00.000Z', '2026-11-01T00:00:00.000Z'), + ]); + }); + + it('extends a range that merely touches the loaded one', () => { + const loaded = r('2026-09-01T00:00:00.000Z', '2026-10-01T00:00:00.000Z'); + const plan = planRangeLoad(loaded, r('2026-10-01T00:00:00.000Z', '2026-10-15T00:00:00.000Z'), YEAR); + expect(plan.kind).toBe('extend'); + }); + + it('starts over for a disjoint range instead of claiming the gap', () => { + const loaded = r('2026-09-01T00:00:00.000Z', '2026-10-01T00:00:00.000Z'); + expect(planRangeLoad(loaded, r('2027-03-01T00:00:00.000Z', '2027-04-01T00:00:00.000Z'), YEAR).kind).toBe('replace'); + expect(planRangeLoad(loaded, r('2026-01-01T00:00:00.000Z', '2026-02-01T00:00:00.000Z'), YEAR).kind).toBe('replace'); + }); + + it('starts over when the union would grow past the cap', () => { + const loaded = r('2026-01-01T00:00:00.000Z', '2026-12-01T00:00:00.000Z'); + const plan = planRangeLoad(loaded, r('2026-11-01T00:00:00.000Z', '2027-03-01T00:00:00.000Z'), YEAR); + expect(plan.kind).toBe('replace'); + }); + + it('compares instants, not strings, across time-zone offsets', () => { + const loaded = r('2026-09-01T00:00:00.000Z', '2026-10-01T00:00:00.000Z'); + // 02:00+02:00 is the same instant as the loaded start. + expect(planRangeLoad(loaded, r('2026-09-01T02:00:00+02:00', '2026-09-15T00:00:00.000Z'), YEAR).kind).toBe( + 'covered', + ); + }); +}); + +describe('coversRange', () => { + const loaded = r('2026-09-01T00:00:00.000Z', '2026-10-01T00:00:00.000Z'); + + it('is true only when the whole request is loaded', () => { + expect(coversRange(loaded, r('2026-09-10T00:00:00.000Z', '2026-09-20T00:00:00.000Z'))).toBe(true); + expect(coversRange(loaded, loaded)).toBe(true); + expect(coversRange(loaded, r('2026-09-10T00:00:00.000Z', '2026-10-02T00:00:00.000Z'))).toBe(false); + expect(coversRange(null, loaded)).toBe(false); + }); +}); + +describe('mergedSpanLimit', () => { + const MIN = 183 * DAY; + const MAX = 3 * YEAR; + + it('allows twice the request, within the floor and the ceiling', () => { + expect(mergedSpanLimit(r('2026-01-01T00:00:00.000Z', '2026-11-01T00:00:00.000Z'), MIN, MAX)).toBe(2 * 304 * DAY); + expect(mergedSpanLimit(r('2026-09-01T00:00:00.000Z', '2026-10-01T00:00:00.000Z'), MIN, MAX)).toBe(MIN); + expect(mergedSpanLimit(r('2024-01-01T00:00:00.000Z', '2027-01-01T00:00:00.000Z'), MIN, MAX)).toBe(MAX); + }); + + it('makes a jump away from a large window start over', () => { + // Two years loaded; a fresh 3-month window right after it. + const loaded = r('2025-01-01T00:00:00.000Z', '2027-01-01T00:00:00.000Z'); + const jump = r('2026-12-20T00:00:00.000Z', '2027-03-20T00:00:00.000Z'); + expect(planRangeLoad(loaded, jump, mergedSpanLimit(jump, MIN, MAX)).kind).toBe('replace'); + // Growing that large window itself keeps merging. + const grown = r('2025-01-01T00:00:00.000Z', '2027-02-01T00:00:00.000Z'); + expect(planRangeLoad(loaded, grown, mergedSpanLimit(grown, MIN, MAX)).kind).toBe('extend'); + }); +}); + +describe('mergeRangeEvents', () => { + const ev = (id: string, title = id) => ({ id, title }) as CalendarEvent; + + it('adds the new events and keeps the loaded ones', () => { + const merged = mergeRangeEvents([ev('a'), ev('b')], [ev('c')]); + expect(merged.map((e) => e.id)).toEqual(['a', 'b', 'c']); + }); + + it('keeps one copy of an event that crosses the piece boundary, the fresh one', () => { + const merged = mergeRangeEvents([ev('a', 'old'), ev('b')], [ev('a', 'new'), ev('series:2026-10-01')]); + expect(merged.map((e) => e.id)).toEqual(['a', 'b', 'series:2026-10-01']); + expect(merged[0].title).toBe('new'); + }); + + it('matches server-expanded occurrences by series and recurrence id, not by synthetic id', () => { + const occurrence = (id: string, recurrenceId: string, title = id) => + ({ id, title, baseEventId: 'base1', recurrenceId }) as CalendarEvent; + const merged = mergeRangeEvents( + [occurrence('h1', '2026-09-30T09:00:00', 'old'), occurrence('h2', '2026-09-23T09:00:00')], + [occurrence('k7', '2026-09-30T09:00:00', 'new'), occurrence('k8', '2026-10-07T09:00:00')], + ); + expect(merged.map((e) => e.id)).toEqual(['k7', 'h2', 'k8']); + expect(merged[0].title).toBe('new'); + }); + + it('keeps occurrences of different series or accounts apart', () => { + const a = { id: 'x1', baseEventId: 'base1', recurrenceId: '2026-09-30T09:00:00' } as CalendarEvent; + const b = { id: 'x2', baseEventId: 'base2', recurrenceId: '2026-09-30T09:00:00' } as CalendarEvent; + const c = { id: 'acc:x3', originalId: 'x3', accountId: 'acc', baseEventId: 'base1', recurrenceId: '2026-09-30T09:00:00' } as CalendarEvent; + expect(mergeRangeEvents([a], [b, c])).toHaveLength(3); + }); + + it('returns the loaded array untouched when nothing new arrived', () => { + const existing = [ev('a')]; + expect(mergeRangeEvents(existing, [])).toBe(existing); + }); +}); + +describe('createRangeLoader', () => { + function deferred() { + let resolve!: () => void; + const promise = new Promise((res) => { resolve = res; }); + return { promise, resolve }; + } + + it('runs one load at a time and collapses requests made meanwhile into the latest', async () => { + const gates: Array<{ range: EventRange; resolve: () => void }> = []; + const load = vi.fn((range: EventRange) => { + const d = deferred(); + gates.push({ range, resolve: d.resolve }); + return d.promise; + }); + const busy: boolean[] = []; + const loader = createRangeLoader(load, (b) => busy.push(b)); + + const first = loader.request(r('a1', 'b1')); + loader.request(r('a2', 'b2')); + const last = loader.request(r('a3', 'b3')); + expect(load).toHaveBeenCalledTimes(1); + expect(loader.isBusy()).toBe(true); + + gates[0].resolve(); + await Promise.resolve(); + await Promise.resolve(); + expect(load).toHaveBeenCalledTimes(2); + expect(gates[1].range).toEqual(r('a3', 'b3')); + + gates[1].resolve(); + await Promise.all([first, last]); + expect(load).toHaveBeenCalledTimes(2); + expect(loader.isBusy()).toBe(false); + expect(busy).toEqual([true, false]); + }); + + it('ignores a repeat of the range being loaded, but not a later one', async () => { + const load = vi.fn(async () => undefined); + const loader = createRangeLoader(load); + const first = loader.request(r('a', 'b')); + loader.request(r('a', 'b')); + await first; + expect(load).toHaveBeenCalledTimes(1); + // Once idle, a request runs again (the store skips a covered range). + await loader.request(r('a', 'b')); + expect(load).toHaveBeenCalledTimes(2); + }); + + it('keeps going after a failed load', async () => { + const load = vi.fn(async (range: EventRange) => { + if (range.after === 'bad') throw new Error('offline'); + }); + const loader = createRangeLoader(load); + const p = loader.request(r('bad', 'x')); + loader.request(r('good', 'y')); + await p; + expect(load).toHaveBeenCalledTimes(2); + expect(loader.isBusy()).toBe(false); + }); +}); diff --git a/src/lib/__tests__/calendar-scroll-window.test.ts b/src/lib/__tests__/calendar-scroll-window.test.ts new file mode 100644 index 00000000..0c92321d --- /dev/null +++ b/src/lib/__tests__/calendar-scroll-window.test.ts @@ -0,0 +1,224 @@ +import { describe, it, expect } from 'vitest'; +import { + baseRange, + computeScrollWindow, + fixedScrollWindowState, + freshScrollWindowState, + growScrollWindow, + loadedPartOfWindow, + normalizeScrollWindowState, + scrollWindowContains, + scrollWindowLoadRange, + windowStateForJump, + SCROLL_WINDOW_MAX, + SCROLL_WINDOW_STEP, + type ScrollWindowOptions, +} from '../calendar-scroll-window'; + +// #759: every calendar view keeps one window of days around the focused +// day; edges double, navigation inside the window does not reset it. + +const opts: ScrollWindowOptions = { weekStartsOn: 1 }; +// Local-midnight dates; compare by local fields. +const local = (d: Date) => + `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; + +describe('baseRange', () => { + it('is the day itself, the week, the month grid, or 30 days for the agenda', () => { + const wed = new Date(2026, 8, 9); // Wednesday + expect(local(baseRange('day', wed, opts).start)).toBe('2026-09-09'); + expect(local(baseRange('day', wed, opts).end)).toBe('2026-09-09'); + expect(local(baseRange('week', wed, opts).start)).toBe('2026-09-07'); + expect(local(baseRange('week', wed, opts).end)).toBe('2026-09-13'); + expect(local(baseRange('month', wed, opts).start)).toBe('2026-08-31'); + expect(local(baseRange('month', wed, opts).end)).toBe('2026-10-04'); + expect(local(baseRange('agenda', wed, opts).start)).toBe('2026-09-09'); + expect(local(baseRange('agenda', wed, opts).end)).toBe('2026-10-09'); + }); + + it('follows the first day of the week', () => { + const wed = new Date(2026, 8, 9); + const sunday = { weekStartsOn: 0 as const }; + expect(local(baseRange('week', wed, sunday).start)).toBe('2026-09-06'); + expect(local(baseRange('week', wed, sunday).end)).toBe('2026-09-12'); + const saturday = { weekStartsOn: 6 as const }; + expect(local(baseRange('month', wed, saturday).start)).toBe('2026-08-29'); + }); +}); + +describe('computeScrollWindow', () => { + it('starts as the base range plus the initial days after it, snapped to whole weeks', () => { + const win = computeScrollWindow(freshScrollWindowState('month', new Date(2026, 8, 9)), opts); + expect(local(win.start)).toBe('2026-08-31'); + // 2026-10-04 + 30 days = 2026-11-03 (Tuesday) -> end of that week + expect(local(win.end)).toBe('2026-11-08'); + expect(win.canExtendStart).toBe(true); + expect(win.canExtendEnd).toBe(true); + }); + + it('is exactly one period when free scrolling is off (agenda keeps its 30 days)', () => { + const month = computeScrollWindow(fixedScrollWindowState('month', new Date(2026, 8, 9)), opts); + expect(local(month.start)).toBe('2026-08-31'); + expect(local(month.end)).toBe('2026-10-04'); + const week = computeScrollWindow(fixedScrollWindowState('week', new Date(2026, 8, 9)), opts); + expect([local(week.start), local(week.end)]).toEqual(['2026-09-07', '2026-09-13']); + const day = computeScrollWindow(fixedScrollWindowState('day', new Date(2026, 8, 9)), opts); + expect([local(day.start), local(day.end)]).toEqual(['2026-09-09', '2026-09-09']); + const agenda = computeScrollWindow(fixedScrollWindowState('agenda', new Date(2026, 8, 9)), opts); + expect([local(agenda.start), local(agenda.end)]).toEqual(['2026-09-09', '2026-10-09']); + }); + + it('starts the agenda at the anchor and loads 60 days ahead', () => { + const win = computeScrollWindow(freshScrollWindowState('agenda', new Date(2026, 8, 9)), opts); + expect(local(win.start)).toBe('2026-09-09'); + expect(local(win.end)).toBe('2026-11-08'); + }); + + it('does not snap the day view to weeks', () => { + const win = computeScrollWindow(freshScrollWindowState('day', new Date(2026, 8, 9)), opts); + expect(local(win.start)).toBe('2026-09-09'); + expect(local(win.end)).toBe('2026-10-09'); + }); + + it('snaps a grown week window to whole weeks', () => { + const state = growScrollWindow(freshScrollWindowState('week', new Date(2026, 8, 9)), 'before'); + const win = computeScrollWindow(state, opts); + // 2026-09-07 - 30 days = 2026-08-08 (Saturday) -> Monday 2026-08-03 + expect(local(win.start)).toBe('2026-08-03'); + expect(win.start.getDay()).toBe(1); + expect(win.end.getDay()).toBe(0); + }); + + it('reports the limits once a side reached its maximum', () => { + const state = { ...freshScrollWindowState('week', new Date(2026, 8, 9)), before: SCROLL_WINDOW_MAX.week }; + expect(computeScrollWindow(state, opts).canExtendStart).toBe(false); + expect(computeScrollWindow(state, opts).canExtendEnd).toBe(true); + }); + + it('keeps whole local days across a DST change', () => { + // Europe switches back on 2026-10-25; the window still ends on a midnight. + const win = computeScrollWindow(freshScrollWindowState('agenda', new Date(2026, 9, 1)), opts); + expect(win.end.getHours()).toBe(0); + expect(local(win.end)).toBe('2026-11-30'); + }); +}); + +describe('growScrollWindow', () => { + it('doubles a side from the first step up to the cap and then stays put', () => { + let state = freshScrollWindowState('agenda', new Date(2026, 8, 9)); + const seen: number[] = []; + for (let i = 0; i < 8; i++) { + state = growScrollWindow(state, 'before'); + seen.push(state.before); + } + expect(seen).toEqual([SCROLL_WINDOW_STEP, 60, 120, 240, 365, 365, 365, 365]); + const capped = growScrollWindow(state, 'before'); + expect(capped).toBe(state); + }); + + it('caps the time grids at half a year per side', () => { + let state = freshScrollWindowState('day', new Date(2026, 8, 9)); + for (let i = 0; i < 10; i++) state = growScrollWindow(state, 'after'); + expect(state.after).toBe(180); + expect(computeScrollWindow(state, opts).canExtendEnd).toBe(false); + }); +}); + +describe('normalizeScrollWindowState / scrollWindowContains', () => { + it('starts over when the view mode changed', () => { + const month = freshScrollWindowState('month', new Date(2026, 8, 9)); + expect(normalizeScrollWindowState(month, 'month', new Date(2026, 0, 1))).toBe(month); + const week = normalizeScrollWindowState(month, 'week', new Date(2026, 0, 1)); + expect(week.mode).toBe('week'); + expect(week.anchorKey).toBe('2026-01-01'); + }); + + it('knows whether a navigation target is already loaded', () => { + const win = computeScrollWindow(freshScrollWindowState('month', new Date(2026, 8, 9)), opts); + expect(scrollWindowContains(win, 'month', new Date(2026, 9, 15), opts)).toBe(true); // October grid ends Nov 1 + expect(scrollWindowContains(win, 'month', new Date(2026, 10, 15), opts)).toBe(false); // November grid ends Dec 6 + expect(scrollWindowContains(win, 'week', new Date(2026, 7, 31), opts)).toBe(true); + expect(scrollWindowContains(win, 'week', new Date(2026, 7, 30), opts)).toBe(false); + }); + + it('keys the anchor by calendar day', () => { + expect(freshScrollWindowState('day', new Date(2026, 8, 9, 23, 59)).anchorKey).toBe('2026-09-09'); + }); +}); + +describe('windowStateForJump', () => { + it('keeps a grown window when the target is inside it', () => { + const grown = growScrollWindow(freshScrollWindowState('agenda', new Date(2026, 8, 9)), 'after'); + // Agenda window: Sep 9 .. Sep 9 + 30 + 60 = Dec 8; Oct 1 + 30 days fits. + expect(windowStateForJump(grown, 'agenda', new Date(2026, 9, 1), opts)).toBe(grown); + }); + + it('starts a fresh window at a target outside it', () => { + const state = freshScrollWindowState('agenda', new Date(2026, 8, 9)); + const next = windowStateForJump(state, 'agenda', new Date(2027, 0, 5), opts); + expect(next).toEqual(freshScrollWindowState('agenda', new Date(2027, 0, 5))); + }); + + it('starts a fresh window for another view mode', () => { + const state = freshScrollWindowState('agenda', new Date(2026, 8, 9)); + const next = windowStateForJump(state, 'month', new Date(2026, 8, 20), opts); + expect(next.mode).toBe('month'); + expect(next.anchorKey).toBe('2026-09-20'); + }); + + it('jumps back to today inside the window without resetting it', () => { + const today = new Date(2026, 8, 24); + let state = freshScrollWindowState('agenda', today); + state = growScrollWindow(state, 'before'); + state = growScrollWindow(state, 'after'); + expect(windowStateForJump(state, 'agenda', today, opts)).toBe(state); + }); +}); + +describe('scrollWindowLoadRange', () => { + it('covers the whole days plus a margin, the end exclusive', () => { + const range = scrollWindowLoadRange({ start: new Date(2026, 8, 9), end: new Date(2026, 8, 30) }, 14); + expect(local(range.after)).toBe('2026-08-26'); + expect(range.after.getHours()).toBe(0); + expect(local(range.before)).toBe('2026-10-15'); + expect(range.before.getHours()).toBe(0); + }); +}); + +describe('loadedPartOfWindow', () => { + const window = { start: new Date(2026, 8, 9), end: new Date(2026, 10, 8) }; + + it('is the whole window once the loaded range covers it', () => { + const part = loadedPartOfWindow( + window, + new Date(2026, 7, 26).toISOString(), + new Date(2026, 10, 23).toISOString(), + ); + expect(part && [local(part.start), local(part.end)]).toEqual(['2026-09-09', '2026-11-08']); + }); + + it('stops at the last whole loaded day while the end is still loading', () => { + const part = loadedPartOfWindow( + window, + new Date(2026, 7, 26).toISOString(), + new Date(2026, 9, 1).toISOString(), // exclusive: Sep 30 is the last loaded day + ); + expect(part && [local(part.start), local(part.end)]).toEqual(['2026-09-09', '2026-09-30']); + }); + + it('does not count a partly loaded day', () => { + const part = loadedPartOfWindow( + window, + new Date(2026, 8, 20, 12).toISOString(), + new Date(2026, 9, 1, 12).toISOString(), + ); + expect(part && [local(part.start), local(part.end)]).toEqual(['2026-09-21', '2026-09-30']); + }); + + it('is null when nothing of the window is loaded yet', () => { + expect(loadedPartOfWindow(window, null, null)).toBeNull(); + expect( + loadedPartOfWindow(window, new Date(2025, 0, 1).toISOString(), new Date(2025, 1, 1).toISOString()), + ).toBeNull(); + }); +}); diff --git a/src/lib/calendar-agenda.ts b/src/lib/calendar-agenda.ts new file mode 100644 index 00000000..a8e0a28d --- /dev/null +++ b/src/lib/calendar-agenda.ts @@ -0,0 +1,41 @@ +import type { CalendarEvent } from '../api/types'; +import { dayKey, type EventDayIndex } from './calendar-utils'; +import { parseDayKey, type DayRange } from './calendar-scroll-window'; + +export interface AgendaDay { + date: Date; + data: CalendarEvent[]; +} + +/** + * The agenda's rows: every day inside the loaded range that has events, in + * order, plus an empty "Today" anchor when today lies inside it. Days that + * were never fetched get no row, so the list never claims they are empty + * (webmail's infinite agenda, 60e05c00). + */ +export function buildAgendaDays( + index: EventDayIndex, + loaded: DayRange | null, + now: Date, +): AgendaDay[] { + if (!loaded) return []; + const startKey = dayKey(loaded.start); + const endKey = dayKey(loaded.end); + // yyyy-MM-dd keys sort like the days they name. + const keys: string[] = []; + for (const key of index.keys()) { + if (key >= startKey && key <= endKey) keys.push(key); + } + const todayKey = dayKey(now); + if (todayKey >= startKey && todayKey <= endKey && !index.has(todayKey)) keys.push(todayKey); + keys.sort(); + // Buckets are pre-sorted by buildEventDayIndex (all-day first, then by + // start, then title). + return keys.map((key) => ({ date: parseDayKey(key), data: index.get(key) ?? [] })); +} + +/** The row the agenda scrolls to for a navigation: the first day at or after `focus`. */ +export function findAgendaFocusIndex(days: ReadonlyArray<{ date: Date }>, focus: Date): number { + const key = dayKey(focus); + return days.findIndex((day) => dayKey(day.date) >= key); +} diff --git a/src/lib/calendar-range-cache.ts b/src/lib/calendar-range-cache.ts new file mode 100644 index 00000000..f243a988 --- /dev/null +++ b/src/lib/calendar-range-cache.ts @@ -0,0 +1,151 @@ +import type { CalendarEvent } from '../api/types'; +import { isServerRecurrenceInstance } from './recurrence-instances'; + +/** A loaded events window: ISO instants, `after` inclusive, `before` exclusive. */ +export interface EventRange { + after: string; + before: string; +} + +/** + * How to bring a requested range in on top of what is loaded: + * - `covered`: nothing to fetch. + * - `extend`: fetch only `pieces` (the parts outside the loaded range) and + * merge them in; the loaded range becomes `union`. + * - `replace`: load the requested range afresh — it doesn't touch the + * loaded one, or the union would grow past `maxSpanMs`. + */ +export type RangeLoadPlan = + | { kind: 'covered' } + | { kind: 'extend'; pieces: EventRange[]; union: EventRange } + | { kind: 'replace' }; + +function ms(iso: string): number { + return new Date(iso).getTime(); +} + +export function planRangeLoad( + loaded: EventRange | null | undefined, + requested: EventRange, + maxSpanMs: number, +): RangeLoadPlan { + if (!loaded) return { kind: 'replace' }; + const la = ms(loaded.after); + const lb = ms(loaded.before); + const ra = ms(requested.after); + const rb = ms(requested.before); + if ([la, lb, ra, rb].some((n) => isNaN(n))) return { kind: 'replace' }; + if (la <= ra && lb >= rb) return { kind: 'covered' }; + // Disjoint (a gap between them): a union would claim the gap is loaded. + if (rb < la || ra > lb) return { kind: 'replace' }; + const union: EventRange = { + after: ra < la ? requested.after : loaded.after, + before: rb > lb ? requested.before : loaded.before, + }; + if (ms(union.before) - ms(union.after) > maxSpanMs) return { kind: 'replace' }; + const pieces: EventRange[] = []; + if (ra < la) pieces.push({ after: requested.after, before: loaded.after }); + if (rb > lb) pieces.push({ after: loaded.before, before: requested.before }); + return { kind: 'extend', pieces, union }; +} + +/** + * What makes two loaded events the same one. Server-expanded occurrences + * (lib/recurrence-instances) are matched by base event and recurrence id: + * their synthetic ids are positional, so the same occurrence is not + * guaranteed the same id in two separately queried ranges. + */ +export function rangeEventKey(event: CalendarEvent): string { + if (isServerRecurrenceInstance(event)) { + return `occurrence|${event.accountId ?? ''}|${event.baseEventId}|${event.recurrenceId ?? ''}`; + } + return `id|${event.id}`; +} + +/** + * Merge the events of newly loaded pieces into the loaded ones. An event + * that overlaps a piece boundary (or an occurrence of a series expanded in + * both) arrives twice; the fresh copy wins. + */ +export function mergeRangeEvents( + existing: CalendarEvent[], + incoming: CalendarEvent[], +): CalendarEvent[] { + if (incoming.length === 0) return existing; + const fresh = new Map(); + for (const event of incoming) fresh.set(rangeEventKey(event), event); + const out: CalendarEvent[] = []; + for (const event of existing) { + const key = rangeEventKey(event); + const replacement = fresh.get(key); + if (replacement) { + out.push(replacement); + fresh.delete(key); + } else { + out.push(event); + } + } + for (const event of fresh.values()) out.push(event); + return out; +} + +/** True when `loaded` spans all of `requested` (compared as instants). */ +export function coversRange(loaded: EventRange | null | undefined, requested: EventRange): boolean { + return planRangeLoad(loaded, requested, Number.POSITIVE_INFINITY).kind === 'covered'; +} + +/** + * How large the loaded range may grow by merging when `requested` is asked + * for: twice the request, but at least `minMs` and at most `maxMs`. Growing a + * scrolled window stays cached; jumping away from a large window starts + * over instead of dragging (and refreshing) all of it along. + */ +export function mergedSpanLimit(requested: EventRange, minMs: number, maxMs: number): number { + const span = ms(requested.before) - ms(requested.after); + if (isNaN(span)) return minMs; + return Math.min(maxMs, Math.max(minMs, 2 * span)); +} + +export function sameRange(a: EventRange | null | undefined, b: EventRange | null | undefined): boolean { + return !!a && !!b && a.after === b.after && a.before === b.before; +} + +/** + * Serializes range loads: at most one runs at a time, and requests made + * meanwhile collapse into the latest, which runs once the current load has + * finished. `onBusyChange` reports when the loader starts and goes idle. + */ +export function createRangeLoader( + load: (range: EventRange) => Promise, + onBusyChange?: (busy: boolean) => void, +): { request: (range: EventRange) => Promise; isBusy: () => boolean } { + let wanted: EventRange | null = null; + let running: Promise | null = null; + + const run = async () => { + onBusyChange?.(true); + try { + let last: EventRange | null = null; + while (wanted && !sameRange(wanted, last)) { + last = wanted; + try { + await load(last); + } catch { + // The store reports failures through its error state. + } + } + } finally { + running = null; + onBusyChange?.(false); + } + }; + + return { + request(range) { + wanted = range; + if (!running) running = run(); + return running; + }, + isBusy: () => running !== null, + }; +} diff --git a/src/lib/calendar-scroll-window.ts b/src/lib/calendar-scroll-window.ts new file mode 100644 index 00000000..ffde7251 --- /dev/null +++ b/src/lib/calendar-scroll-window.ts @@ -0,0 +1,217 @@ +import { + addDays, + endOfMonth, + endOfWeek, + startOfDay, + startOfMonth, + startOfWeek, + subDays, +} from 'date-fns'; +import { dayKey } from './calendar-utils'; + +/** + * The calendar views scroll freely (#759; webmail lib/calendar-scroll-window): + * each keeps one window of days around the day the user navigated to. + * Reaching an edge of the rendered range widens that side, and only the new + * part is fetched (calendar-store `extendRange`). A navigation that lands + * inside the window just scrolls; one that leaves it starts a fresh window + * there. + */ + +export type ScrollViewMode = 'month' | 'week' | 'day' | 'agenda'; + +export type WeekStartsOn = 0 | 1 | 2 | 3 | 4 | 5 | 6; + +export interface DayRange { + start: Date; + end: Date; +} + +export interface ScrollWindowState { + mode: ScrollViewMode; + /** yyyy-MM-dd of the day the window was started from. */ + anchorKey: string; + /** Days added before the base range of the anchor. */ + before: number; + /** Days added after the base range of the anchor. */ + after: number; +} + +export interface ScrollWindow extends DayRange { + canExtendStart: boolean; + canExtendEnd: boolean; +} + +/** The day the user navigated to; `nonce` changes on every navigation. */ +export interface CalendarFocus { + date: Date; + nonce: number; +} + +export interface ScrollWindowOptions { + weekStartsOn: WeekStartsOn; +} + +/** First growth step in days; every further step doubles the side. */ +export const SCROLL_WINDOW_STEP = 30; + +/** Furthest a side may grow, in days. Time grids render one column per day. */ +export const SCROLL_WINDOW_MAX: Record = { + month: 365, + agenda: 365, + week: 180, + day: 180, +}; + +/** Days loaded after the base range before the user scrolls anywhere. */ +const INITIAL_AFTER: Record = { + month: SCROLL_WINDOW_STEP, + agenda: SCROLL_WINDOW_STEP, + week: SCROLL_WINDOW_STEP, + day: SCROLL_WINDOW_STEP, +}; + +export function parseDayKey(key: string): Date { + const [y, m, d] = key.split('-').map(Number); + return new Date(y, m - 1, d); +} + +export function freshScrollWindowState(mode: ScrollViewMode, anchor: Date): ScrollWindowState { + return { mode, anchorKey: dayKey(anchor), before: 0, after: INITIAL_AFTER[mode] }; +} + +/** + * The window with free scrolling turned off: exactly the base range (one + * month/week/day, the agenda's 30 days). Never grows; navigation always + * starts over here. + */ +export function fixedScrollWindowState(mode: ScrollViewMode, anchor: Date): ScrollWindowState { + return { mode, anchorKey: dayKey(anchor), before: 0, after: 0 }; +} + +/** The range a view shows for a date when nothing has been scrolled yet. */ +export function baseRange(mode: ScrollViewMode, date: Date, opts: ScrollWindowOptions): DayRange { + const day = startOfDay(date); + switch (mode) { + case 'day': + return { start: day, end: day }; + case 'week': + return { + start: startOfWeek(day, { weekStartsOn: opts.weekStartsOn }), + end: startOfDay(endOfWeek(day, { weekStartsOn: opts.weekStartsOn })), + }; + case 'month': + return { + start: startOfWeek(startOfMonth(day), { weekStartsOn: opts.weekStartsOn }), + end: startOfDay(endOfWeek(endOfMonth(day), { weekStartsOn: opts.weekStartsOn })), + }; + case 'agenda': + return { start: day, end: addDays(day, SCROLL_WINDOW_STEP) }; + } +} + +/** Ensures the state belongs to the view mode; otherwise starts over at the anchor. */ +export function normalizeScrollWindowState( + state: ScrollWindowState, + mode: ScrollViewMode, + anchor: Date, +): ScrollWindowState { + return state.mode === mode ? state : freshScrollWindowState(mode, anchor); +} + +export function computeScrollWindow( + state: ScrollWindowState, + opts: ScrollWindowOptions, +): ScrollWindow { + const anchor = parseDayKey(state.anchorKey); + const base = baseRange(state.mode, anchor, opts); + let start = subDays(base.start, state.before); + let end = addDays(base.end, state.after); + if (state.mode === 'month' || state.mode === 'week') { + // The grids are made of whole weeks. + start = startOfWeek(start, { weekStartsOn: opts.weekStartsOn }); + end = endOfWeek(end, { weekStartsOn: opts.weekStartsOn }); + } + const max = SCROLL_WINDOW_MAX[state.mode]; + return { + start: startOfDay(start), + end: startOfDay(end), + canExtendStart: state.before < max, + canExtendEnd: state.after < max, + }; +} + +export function growScrollWindow( + state: ScrollWindowState, + side: 'before' | 'after', +): ScrollWindowState { + const max = SCROLL_WINDOW_MAX[state.mode]; + const grown = Math.min(max, Math.max(SCROLL_WINDOW_STEP, state[side] * 2)); + return grown === state[side] ? state : { ...state, [side]: grown }; +} + +/** True when the view's base range for `date` is already inside the window. */ +export function scrollWindowContains( + window: DayRange, + mode: ScrollViewMode, + date: Date, + opts: ScrollWindowOptions, +): boolean { + const base = baseRange(mode, date, opts); + return base.start.getTime() >= window.start.getTime() && base.end.getTime() <= window.end.getTime(); +} + +/** + * Where a navigation to `date` leaves the window: unchanged when the target + * is already inside it (the view just scrolls there), otherwise a fresh + * window at the target. + */ +export function windowStateForJump( + state: ScrollWindowState, + mode: ScrollViewMode, + date: Date, + opts: ScrollWindowOptions, +): ScrollWindowState { + const current = normalizeScrollWindowState(state, mode, date); + const loaded = computeScrollWindow(current, opts); + return scrollWindowContains(loaded, mode, date, opts) + ? current + : freshScrollWindowState(mode, date); +} + +/** + * The events range to load for a window: its whole days plus a margin on + * either side, so the next step (an arrow press, the next edge) usually + * finds its events already there. `before` is exclusive. + */ +export function scrollWindowLoadRange( + window: DayRange, + marginDays: number, +): { after: Date; before: Date } { + return { + after: subDays(window.start, marginDays), + before: addDays(window.end, 1 + marginDays), + }; +} + +/** + * The part of the window whose events are loaded, as whole days, or null + * when nothing of it is. `loadedAfter`/`loadedBefore` are the store's + * loaded range (ISO instants, `before` exclusive). + */ +export function loadedPartOfWindow( + window: DayRange, + loadedAfter: string | null | undefined, + loadedBefore: string | null | undefined, +): DayRange | null { + if (!loadedAfter || !loadedBefore) return null; + const after = new Date(loadedAfter); + const before = new Date(loadedBefore); + if (isNaN(after.getTime()) || isNaN(before.getTime())) return null; + // First whole day at or after `after`, last whole day ending by `before`. + const firstDay = startOfDay(after).getTime() === after.getTime() ? after : addDays(startOfDay(after), 1); + const lastDay = subDays(startOfDay(before), 1); + const start = firstDay > window.start ? startOfDay(firstDay) : window.start; + const end = lastDay < window.end ? lastDay : window.end; + return start.getTime() <= end.getTime() ? { start, end } : null; +} diff --git a/src/screens/CalendarScreen.tsx b/src/screens/CalendarScreen.tsx index 6db70c35..e814a1d6 100644 --- a/src/screens/CalendarScreen.tsx +++ b/src/screens/CalendarScreen.tsx @@ -23,8 +23,6 @@ import { } from 'lucide-react-native'; import { format, - startOfMonth, - endOfMonth, startOfWeek, endOfWeek, addDays, @@ -57,9 +55,25 @@ import { ICalImportSheet } from '../components/calendar/ICalImportSheet'; import { ICalSubscriptionSheet } from '../components/calendar/ICalSubscriptionSheet'; import { CalendarEditSheet, type CalendarEditValues } from '../components/calendar/CalendarEditSheet'; import { CalendarShareSheet } from '../components/calendar/CalendarShareSheet'; +import { + computeScrollWindow, + fixedScrollWindowState, + freshScrollWindowState, + growScrollWindow, + loadedPartOfWindow, + normalizeScrollWindowState, + parseDayKey, + scrollWindowLoadRange, + windowStateForJump, + type CalendarFocus, + type ScrollWindowOptions, + type ScrollWindowState, +} from '../lib/calendar-scroll-window'; +import { coversRange, createRangeLoader } from '../lib/calendar-range-cache'; import { applySharedCalendarColors, buildEventDayIndex, + dayKey, eventsOnDayFromIndex, getEventStartDate, getPrimaryCalendarId, @@ -105,32 +119,12 @@ type PendingAction = } | null; -const AGENDA_DAYS = 30; -const RANGE_BUFFER_DAYS = 14; +// Events are loaded this many days past either end of the visible window, so +// the next arrow press or edge usually finds them there already. +const RANGE_MARGIN_DAYS = 14; type WeekStart = 0 | 1 | 6; -function rangeForView( - viewMode: ViewMode, - currentDate: Date, - weekStartsOn: WeekStart, -): { after: Date; before: Date } { - if (viewMode === 'month') { - const start = startOfWeek(startOfMonth(currentDate), { weekStartsOn }); - const end = endOfWeek(endOfMonth(currentDate), { weekStartsOn }); - return { after: addDays(start, -RANGE_BUFFER_DAYS), before: addDays(end, RANGE_BUFFER_DAYS) }; - } - if (viewMode === 'week') { - const start = startOfWeek(currentDate, { weekStartsOn }); - const end = endOfWeek(currentDate, { weekStartsOn }); - return { after: addDays(start, -RANGE_BUFFER_DAYS), before: addDays(end, RANGE_BUFFER_DAYS) }; - } - return { - after: addDays(currentDate, -1), - before: addDays(currentDate, AGENDA_DAYS + RANGE_BUFFER_DAYS), - }; -} - function headerTitle( viewMode: ViewMode, currentDate: Date, @@ -168,16 +162,53 @@ export default function CalendarScreen() { const removeSharedCalendarColor = useSettingsStore((s) => s.removeSharedCalendarColor); const contacts = useContactsStore((s) => s.contacts); - const [currentDate, setCurrentDate] = React.useState(new Date()); - const [selectedDate, setSelectedDate] = React.useState(new Date()); + const [selectedDate, setSelectedDate] = React.useState(() => new Date()); // Day view falls back to Agenda on mobile (we don't have a dedicated day - // grid yet, but the agenda's 30-day view already serves the same purpose). + // grid yet, but the agenda already serves the same purpose). const initialViewMode: ViewMode = calendarDefaultView === 'week' ? 'week' : calendarDefaultView === 'day' || calendarDefaultView === 'agenda' ? 'agenda' : 'month'; const [viewMode, setViewMode] = React.useState(initialViewMode); + // Scroll window (#759, webmail calendar-app): every view shows a window of + // days around the day the user navigated to (the "focus"). The agenda + // scrolls freely: reaching an edge widens that side and only the new days + // are fetched. Month and week show exactly one period. Navigation moves the + // focus and, when that leaves the window, starts a fresh window there. + const windowOptions = React.useMemo( + () => ({ weekStartsOn: calendarFirstDayOfWeek }), + [calendarFirstDayOfWeek], + ); + const [focus, setFocus] = React.useState(() => ({ date: new Date(), nonce: 0 })); + // The day the scrolled view shows at its top, while it differs from the focus. + const [visibleDate, setVisibleDate] = React.useState(null); + const [windowState, setWindowState] = React.useState( + () => freshScrollWindowState(initialViewMode, new Date()), + ); + const freeScroll = viewMode === 'agenda'; + const focusKey = dayKey(focus.date); + const activeWindowState = React.useMemo( + () => + freeScroll + ? normalizeScrollWindowState(windowState, viewMode, parseDayKey(focusKey)) + : fixedScrollWindowState(viewMode, parseDayKey(focusKey)), + [freeScroll, windowState, viewMode, focusKey], + ); + React.useEffect(() => { + if (freeScroll && activeWindowState !== windowState) setWindowState(activeWindowState); + }, [freeScroll, activeWindowState, windowState]); + const scrollWindow = React.useMemo( + () => computeScrollWindow(activeWindowState, windowOptions), + [activeWindowState, windowOptions], + ); + // Changes whenever a fresh window starts; the views remount on it. + const windowKey = `${activeWindowState.mode}:${activeWindowState.anchorKey}`; + const loadRange = React.useMemo(() => { + const { after, before } = scrollWindowLoadRange(scrollWindow, RANGE_MARGIN_DAYS); + return { after: after.toISOString(), before: before.toISOString() }; + }, [scrollWindow]); + const [detailEvent, setDetailEvent] = React.useState(null); const [modalEvent, setModalEvent] = React.useState(null); const [modalDate, setModalDate] = React.useState(undefined); @@ -191,7 +222,6 @@ export default function CalendarScreen() { const hydrate = useCalendarStore((s) => s.hydrate); const fetchCalendarsAction = useCalendarStore((s) => s.fetchCalendars); - const ensureRange = useCalendarStore((s) => s.ensureRange); const refresh = useCalendarStore((s) => s.refresh); const createEvent = useCalendarStore((s) => s.createEvent); const updateEvent = useCalendarStore((s) => s.updateEvent); @@ -230,15 +260,21 @@ export default function CalendarScreen() { const loading = useCalendarStore((s) => s.loading); const error = useCalendarStore((s) => s.error); const storeEvents = useCalendarStore((s) => s.events); + const loadedRange = useCalendarStore((s) => s.loadedRange); + // The days of the window whose events are in (the agenda lists no day it + // hasn't fetched). + const loadedWindow = React.useMemo( + () => loadedPartOfWindow(scrollWindow, loadedRange?.after, loadedRange?.before), + [scrollWindow, loadedRange], + ); // The birthday calendar is a client-side virtual calendar: its events are // generated from contacts for the visible range and merged in alongside the // server calendars. Toggling it on/off is instant (no refetch). const birthdayEvents = React.useMemo(() => { if (!showBirthdayCalendar) return []; - const { after, before } = rangeForView(viewMode, currentDate, calendarFirstDayOfWeek); - return generateBirthdayEvents(contacts, after.toISOString(), before.toISOString()); - }, [showBirthdayCalendar, contacts, viewMode, currentDate, calendarFirstDayOfWeek]); + return generateBirthdayEvents(contacts, loadRange.after, loadRange.before); + }, [showBirthdayCalendar, contacts, loadRange]); // Per-viewer recolor (#345): shared calendars get the viewer's local color // override applied before anything renders. Personal calendars pass through. @@ -366,41 +402,93 @@ export default function CalendarScreen() { return () => sub.remove(); }, [syncDueSubscriptions]); + // Load the window's events: one fetch at a time, and only the days not + // loaded yet (calendar-store extendRange). `loadingEdge` is the side an + // extension asked for, until the wider window is in. + const [rangeLoading, setRangeLoading] = React.useState(false); + const [loadingEdge, setLoadingEdge] = React.useState<'start' | 'end' | null>(null); + const loadingEdgeRef = React.useRef<'start' | 'end' | null>(null); + const [rangeLoader] = React.useState(() => + createRangeLoader( + (range) => useCalendarStore.getState().extendRange(range.after, range.before), + (busy) => { + setRangeLoading(busy); + if (!busy) { + loadingEdgeRef.current = null; + setLoadingEdge(null); + } + }, + ), + ); + // Ask again whenever the loaded range changes without covering the + // window: a refresh that started before an extension landed writes the + // smaller range back. A failed extension is retried by the next refresh + // (pull, push, reconnect) or when calendars arrive after sign-in. A + // covered window costs nothing (extendRange returns at once). React.useEffect(() => { - const { after, before } = rangeForView(viewMode, currentDate, calendarFirstDayOfWeek); - void ensureRange(after.toISOString(), before.toISOString()); - }, [viewMode, currentDate, ensureRange, calendarFirstDayOfWeek]); + if (coversRange(useCalendarStore.getState().loadedRange, loadRange) && !rangeLoader.isBusy()) return; + void rangeLoader.request(loadRange); + }, [rangeLoader, loadRange, loadedRange, storeCalendars]); - const goPrev = React.useCallback(() => { - setCurrentDate((d) => { - if (viewMode === 'month') return subMonths(d, 1); - if (viewMode === 'week') return subWeeks(d, 1); - return addDays(d, -AGENDA_DAYS); - }); - }, [viewMode]); - - const goNext = React.useCallback(() => { - setCurrentDate((d) => { - if (viewMode === 'month') return addMonths(d, 1); - if (viewMode === 'week') return addWeeks(d, 1); - return addDays(d, AGENDA_DAYS); - }); - }, [viewMode]); - - const goToday = React.useCallback(() => { - const today = new Date(); - setCurrentDate(today); - setSelectedDate(today); - }, []); - - const handleSelectDate = React.useCallback( + const jumpTo = React.useCallback( (date: Date) => { setSelectedDate(date); - if (viewMode === 'week') setCurrentDate(date); + setVisibleDate(null); + setFocus((prev) => ({ date, nonce: prev.nonce + 1 })); + setWindowState((prev) => windowStateForJump(prev, viewMode, date, windowOptions)); }, - [viewMode], + [viewMode, windowOptions], ); + const extendWindow = React.useCallback( + (side: 'before' | 'after') => { + if (loadingEdgeRef.current) return; + const edge = side === 'before' ? 'start' : 'end'; + loadingEdgeRef.current = edge; + setLoadingEdge(edge); + setWindowState((prev) => + growScrollWindow(normalizeScrollWindowState(prev, viewMode, focus.date), side), + ); + }, + [viewMode, focus.date], + ); + const extendWindowStart = React.useCallback(() => extendWindow('before'), [extendWindow]); + const extendWindowEnd = React.useCallback(() => extendWindow('after'), [extendWindow]); + + // The arrows step from what is on screen, which may have been scrolled + // away from the focused day. + const goPrev = React.useCallback(() => { + const base = visibleDate ?? focus.date; + jumpTo(viewMode === 'week' ? subWeeks(base, 1) : subMonths(base, 1)); + }, [viewMode, visibleDate, focus.date, jumpTo]); + + const goNext = React.useCallback(() => { + const base = visibleDate ?? focus.date; + jumpTo(viewMode === 'week' ? addWeeks(base, 1) : addMonths(base, 1)); + }, [viewMode, visibleDate, focus.date, jumpTo]); + + const goToday = React.useCallback(() => { + jumpTo(new Date()); + }, [jumpTo]); + + // Switching views keeps the period on screen in view. + const changeViewMode = React.useCallback( + (mode: ViewMode) => { + if (mode === viewMode) return; + const date = visibleDate ?? focus.date; + setViewMode(mode); + setVisibleDate(null); + setFocus((prev) => ({ date, nonce: prev.nonce + 1 })); + setWindowState(freshScrollWindowState(mode, date)); + }, + [viewMode, visibleDate, focus.date], + ); + + // Picking a day only moves the selection; the view stays where it is. + const handleSelectDate = React.useCallback((date: Date) => { + setSelectedDate(date); + }, []); + const openCreate = React.useCallback((date?: Date) => { setModalEvent(null); setModalDate(date); @@ -810,7 +898,7 @@ export default function CalendarScreen() { - {headerTitle(viewMode, currentDate, calendarFirstDayOfWeek, locale)} + {headerTitle(viewMode, visibleDate ?? focus.date, calendarFirstDayOfWeek, locale)} {isSelectedToday @@ -837,7 +925,7 @@ export default function CalendarScreen() { setViewMode(mode)} + onPress={() => changeViewMode(mode)} > @@ -871,7 +959,7 @@ export default function CalendarScreen() { {viewMode === 'month' && ( ({ + getCalendars: vi.fn(), + queryEvents: vi.fn(), + getEvents: vi.fn(), + scanCalendarObjects: vi.fn(), + isCalendarAccessDenied: vi.fn(() => false), + noteCalendarAccessError: vi.fn(() => false), + resetCalendarAccessDenied: vi.fn(), + createEvent: vi.fn(), + updateEvent: vi.fn(), + deleteEvents: vi.fn(), + batchCreateEvents: vi.fn(), + setDefaultCalendar: vi.fn(), + rsvpEvent: vi.fn(), + // No server-side expansion: queryEvents/getEvents answer the range. + supportsSyntheticCalendarIds: vi.fn(async () => false), + queryExpandedEvents: vi.fn(), + hydrateExpandedOccurrences: vi.fn(async (events: unknown[]) => events), + resetSyntheticIdSupport: vi.fn(), +})); + +vi.mock('../../api/jmap-client', () => ({ + jmapClient: { + accountId: 'acc-1', + isConnected: true, + }, +})); + +import * as calendarApi from '../../api/calendar'; +import { useCalendarStore } from '../calendar-store'; + +const mockQueryEvents = calendarApi.queryEvents as ReturnType; +const mockGetEvents = calendarApi.getEvents as ReturnType; +const mockScan = calendarApi.scanCalendarObjects as ReturnType; + +const SEP = '2026-09-01T00:00:00.000Z'; +const OCT = '2026-10-01T00:00:00.000Z'; +const NOV = '2026-11-01T00:00:00.000Z'; + +function event(id: string, start: string) { + return { id, uid: id, title: id, start, duration: 'PT1H', calendarIds: { 'cal-1': true } }; +} + +beforeEach(() => { + vi.clearAllMocks(); + mockScan.mockResolvedValue([]); + useCalendarStore.setState({ + calendars: [{ id: 'cal-1', name: 'Personal' } as never], + events: [], + tasks: [], + taskOnlyCalendarIds: [], + hiddenCalendarIds: [], + loadedRange: null, + loading: false, + error: null, + hydrated: false, + }); +}); + +describe('calendar-store extendRange (#759)', () => { + it('loads the whole range when nothing is loaded yet', async () => { + mockQueryEvents.mockResolvedValue([]); + await useCalendarStore.getState().extendRange(SEP, OCT); + expect(mockQueryEvents).toHaveBeenCalledWith(['cal-1'], SEP, OCT, undefined); + expect(useCalendarStore.getState().loadedRange).toEqual({ after: SEP, before: OCT }); + }); + + it('fetches only the new part and keeps the loaded events', async () => { + useCalendarStore.setState({ + events: [event('a', '2026-09-10T10:00:00') as never], + loadedRange: { after: SEP, before: OCT }, + }); + mockQueryEvents.mockResolvedValue(['b']); + mockGetEvents.mockResolvedValue([event('b', '2026-10-10T10:00:00')]); + + await useCalendarStore.getState().extendRange(SEP, NOV); + + expect(mockQueryEvents).toHaveBeenCalledTimes(1); + expect(mockQueryEvents).toHaveBeenCalledWith(['cal-1'], OCT, NOV, undefined); + const state = useCalendarStore.getState(); + expect(state.events.map((e) => e.id)).toEqual(['a', 'b']); + expect(state.loadedRange).toEqual({ after: SEP, before: NOV }); + expect(state.loading).toBe(false); + }); + + it('keeps one copy of an event that the new part returns again', async () => { + useCalendarStore.setState({ + events: [event('a', '2026-09-30T23:30:00') as never], + loadedRange: { after: SEP, before: OCT }, + }); + mockQueryEvents.mockResolvedValue(['a']); + mockGetEvents.mockResolvedValue([{ ...event('a', '2026-09-30T23:30:00'), title: 'renamed' }]); + + await useCalendarStore.getState().extendRange(SEP, NOV); + + const events = useCalendarStore.getState().events; + expect(events).toHaveLength(1); + expect(events[0].title).toBe('renamed'); + }); + + it('loads the calendars first when a cached window has none yet', async () => { + const mockGetCalendars = calendarApi.getCalendars as ReturnType; + mockGetCalendars.mockResolvedValue([{ id: 'cal-1', name: 'Personal' }]); + useCalendarStore.setState({ calendars: [], loadedRange: { after: SEP, before: OCT } }); + mockQueryEvents.mockResolvedValue([]); + + await useCalendarStore.getState().extendRange(SEP, NOV); + + expect(mockGetCalendars).toHaveBeenCalledTimes(1); + expect(mockQueryEvents).toHaveBeenCalledWith(['cal-1'], OCT, NOV, undefined); + expect(useCalendarStore.getState().loadedRange).toEqual({ after: SEP, before: NOV }); + }); + + it('does not fetch a range that is already loaded', async () => { + useCalendarStore.setState({ loadedRange: { after: SEP, before: NOV } }); + await useCalendarStore.getState().extendRange(OCT, NOV); + expect(mockQueryEvents).not.toHaveBeenCalled(); + }); + + it('starts over for a range far from the loaded one', async () => { + useCalendarStore.setState({ + events: [event('a', '2026-09-10T10:00:00') as never], + loadedRange: { after: SEP, before: OCT }, + }); + mockQueryEvents.mockResolvedValue([]); + const far = { after: '2027-05-01T00:00:00.000Z', before: '2027-06-01T00:00:00.000Z' }; + + await useCalendarStore.getState().extendRange(far.after, far.before); + + expect(mockQueryEvents).toHaveBeenCalledWith(['cal-1'], far.after, far.before, undefined); + expect(useCalendarStore.getState().loadedRange).toEqual(far); + expect(useCalendarStore.getState().events).toEqual([]); + }); + + it('starts over instead of dragging a large window along to a jump next to it', async () => { + const big = { after: '2025-01-01T00:00:00.000Z', before: '2027-01-01T00:00:00.000Z' }; + useCalendarStore.setState({ + events: [event('old', '2025-06-10T10:00:00') as never], + loadedRange: big, + }); + mockQueryEvents.mockResolvedValue([]); + const jump = { after: '2026-12-20T00:00:00.000Z', before: '2027-03-20T00:00:00.000Z' }; + + await useCalendarStore.getState().extendRange(jump.after, jump.before); + + expect(mockQueryEvents).toHaveBeenCalledWith(['cal-1'], jump.after, jump.before, undefined); + expect(useCalendarStore.getState().loadedRange).toEqual(jump); + }); + + it('drops the new part when the window was replaced meanwhile', async () => { + useCalendarStore.setState({ + events: [event('a', '2026-09-10T10:00:00') as never], + loadedRange: { after: SEP, before: OCT }, + }); + const elsewhere = { after: '2027-05-01T00:00:00.000Z', before: '2027-06-01T00:00:00.000Z' }; + mockQueryEvents.mockImplementation(async () => { + useCalendarStore.setState({ events: [], loadedRange: elsewhere }); + return ['b']; + }); + mockGetEvents.mockResolvedValue([event('b', '2026-10-10T10:00:00')]); + + await useCalendarStore.getState().extendRange(SEP, NOV); + + expect(useCalendarStore.getState().loadedRange).toEqual(elsewhere); + expect(useCalendarStore.getState().events).toEqual([]); + expect(useCalendarStore.getState().loading).toBe(false); + }); + + it('reports a failed load and keeps what was loaded', async () => { + useCalendarStore.setState({ + events: [event('a', '2026-09-10T10:00:00') as never], + loadedRange: { after: SEP, before: OCT }, + }); + mockQueryEvents.mockRejectedValue(new Error('offline')); + + await useCalendarStore.getState().extendRange(SEP, NOV); + + const state = useCalendarStore.getState(); + expect(state.error).toBe('offline'); + expect(state.loadedRange).toEqual({ after: SEP, before: OCT }); + expect(state.events.map((e) => e.id)).toEqual(['a']); + }); +}); diff --git a/src/stores/calendar-store.ts b/src/stores/calendar-store.ts index f03833dc..cfe0066d 100644 --- a/src/stores/calendar-store.ts +++ b/src/stores/calendar-store.ts @@ -45,6 +45,13 @@ import { withNewOverrideDetails, } from '../lib/recurrence-instances'; import { findTasksOnlyCalendarIds, isTaskLikeObject } from '../lib/calendar-component-detection'; +import { mergeRangeEvents, mergedSpanLimit, planRangeLoad, sameRange } from '../lib/calendar-range-cache'; + +// extendRange merges up to twice the requested range (at least half a year, +// at most about three years); past that it starts over with the request. +const DAY_MS = 24 * 60 * 60 * 1000; +const MIN_MERGED_RANGE_MS = 183 * DAY_MS; +const MAX_MERGED_RANGE_MS = 3 * 366 * DAY_MS; // Does the event carry attendees the server should notify over iMIP? Used to // decide whether to set sendSchedulingMessages on create/update/delete. @@ -211,6 +218,11 @@ export interface CalendarState { // tasks-only calendars; independent of the visible date range. fetchTasks: () => Promise; ensureRange: (after: string, before: string) => Promise; + // Grow the loaded window to [after, before]: only the parts outside it are + // fetched and merged in (the freely scrolling views, #759). A range that + // doesn't touch the loaded one, or would make it too large, is loaded + // afresh like ensureRange. + extendRange: (after: string, before: string) => Promise; refresh: () => Promise; handleStateChange: (change: StateChange) => Promise; // `options.sendSchedulingMessages` overrides the default (send iMIP when @@ -644,6 +656,51 @@ export const useCalendarStore = create()( if (needTasks) void get().fetchTasks(); }, + extendRange: async (after, before) => { + const loaded = get().loadedRange; + const requested = { after, before }; + const plan = planRangeLoad( + loaded, + requested, + mergedSpanLimit(requested, MIN_MERGED_RANGE_MS, MAX_MERGED_RANGE_MS), + ); + if (plan.kind === 'covered') return; + if (plan.kind === 'replace' || !loaded) { + await get().ensureRange(after, before); + return; + } + if (!jmapClient.isConnected) return; + // Calendars haven't loaded yet (cold start with a cached window): fetch + // them (deduped) first, like ensureRange. + if (get().calendars.length === 0) await get().fetchCalendars(); + const calendars = get().calendars; + const calendarIds = calendars.map((c) => c.id); + if (calendarIds.length === 0) { + set({ loadedRange: plan.union }); + return; + } + set({ loading: true, error: null }); + try { + const incoming: CalendarEvent[] = []; + for (const piece of plan.pieces) { + incoming.push(...(await loadEventsInRange(calendars, calendarIds, piece.after, piece.before))); + } + // A jump elsewhere replaced the window meanwhile: these pieces no + // longer border it. (A refresh of the same window is fine.) + if (!sameRange(get().loadedRange, loaded)) { + set({ loading: false }); + return; + } + set({ + events: mergeRangeEvents(get().events, incoming), + loadedRange: plan.union, + loading: false, + }); + } catch (err) { + set({ loading: false, error: err instanceof Error ? err.message : 'Failed to load events' }); + } + }, + refresh: async () => { const { loadedRange, calendars } = get(); if (!loadedRange) return;