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;