From caf61ece792be520b589f52ac4368db01da2f0b4 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Thu, 24 Sep 2026 19:43:31 +0200 Subject: [PATCH] feat: scroll the calendar month view continuously #759 The month view showed one month at a time and only the arrows moved it. With the new "Free scrolling" setting (on by default, the webmail's `calendarFreeScroll`) it is one continuous list of weeks, as in the webmail (15a5497a, ae403b06): - the month under the upper part of the grid is drawn at full strength, names the title and is where the arrows step from; the first day of each month carries the month's name - reaching the top or the bottom adds weeks there and loads only their events; weeks added above keep the rows on screen in place - rows have a fixed height and snap to whole weeks, so the list stays windowed and navigation lands exactly on the month Turning the setting off brings back the paged month and a fixed 30-day agenda. The week view still pages. --- src/components/calendar/MonthScrollView.tsx | 265 ++++++++++++++ src/components/calendar/MonthView.tsx | 332 +++++++++++------- src/components/settings/CalendarSettings.tsx | 14 + .../__tests__/calendar-month-scroll.test.ts | 117 ++++++ .../__tests__/calendar-scroll-window.test.ts | 15 +- src/lib/calendar-month-scroll.ts | 73 ++++ src/lib/calendar-scroll-window.ts | 15 +- src/screens/CalendarScreen.tsx | 53 ++- src/stores/settings-store.ts | 4 + 9 files changed, 744 insertions(+), 144 deletions(-) create mode 100644 src/components/calendar/MonthScrollView.tsx create mode 100644 src/lib/__tests__/calendar-month-scroll.test.ts create mode 100644 src/lib/calendar-month-scroll.ts diff --git a/src/components/calendar/MonthScrollView.tsx b/src/components/calendar/MonthScrollView.tsx new file mode 100644 index 00000000..7454b1ff --- /dev/null +++ b/src/components/calendar/MonthScrollView.tsx @@ -0,0 +1,265 @@ +import React from 'react'; +import { + FlatList, + View, + type ListRenderItemInfo, + type NativeScrollEvent, + type NativeSyntheticEvent, +} from 'react-native'; +import type { Calendar, CalendarEvent } from '../../api/types'; +import { + buildEventDayIndex, + dayKey, + type EventDayIndex, + type TimeFormat, +} from '../../lib/calendar-utils'; +import type { CalendarFocus, DayRange } from '../../lib/calendar-scroll-window'; +import { + dayIndexIn, + monthFocusRow, + monthKeyOf, + monthMask, + sampledRow, + weekDays, + windowWeekStarts, +} from '../../lib/calendar-month-scroll'; +import { useCalendarLocale } from '../../lib/calendar-locale'; +import { + MONTH_ROW_HEIGHT, + MONTH_ROW_HEIGHT_CHIPS, + MonthWeekRow, + MonthWeekdayHeader, + useMonthStyles, +} from './MonthView'; + +type WeekStart = 0 | 1 | 6; + +/** Rows the month viewport shows, as one month grid used to. */ +const VISIBLE_ROWS = 6; +/** Navigation closer than this many rows scrolls smoothly; further jumps. */ +const SMOOTH_SCROLL_ROWS = 12; + +interface MonthScrollViewProps { + /** The day the user navigated to; its month's first week is scrolled to the top. */ + focus: CalendarFocus; + /** The loaded window: whole weeks, one row each. */ + window: DayRange; + selectedDate: Date; + events: CalendarEvent[]; + calendars: Calendar[]; + eventsByDay?: EventDayIndex; + weekStartsOn?: WeekStart; + showWeekNumbers?: boolean; + showTimeInMonthView?: boolean; + timeFormat?: TimeFormat; + /** Widen the window at the top; omitted once the limit is reached. */ + onExtendStart?: () => void; + /** Widen the window at the bottom; omitted once the limit is reached. */ + onExtendEnd?: () => void; + /** Reports a day of the month in view whenever that month changes. */ + onVisibleDateChange?: (date: Date) => void; + onSelectDate: (date: Date) => void; + onLongPressDate?: (date: Date) => void; +} + +/** + * Freely scrolling month (#759, webmail 15a5497a): the window's weeks as one + * continuous list of fixed-height rows. The month under the upper part of + * the viewport is the one in focus: its days are drawn at full strength, + * the title follows it, and the first of every month carries its name. + * Reaching either end widens the window; rows added above keep what is on + * screen in place. The screen remounts the list (keyed by the window) when + * navigation starts a fresh window. + */ +function MonthScrollViewInner({ + focus, + window, + selectedDate, + events, + calendars, + eventsByDay, + weekStartsOn = 0, + showWeekNumbers = false, + showTimeInMonthView = false, + timeFormat, + onExtendStart, + onExtendEnd, + onVisibleDateChange, + onSelectDate, + onLongPressDate, +}: MonthScrollViewProps) { + const styles = useMonthStyles(); + const { locale } = useCalendarLocale(); + const index = React.useMemo( + () => eventsByDay ?? buildEventDayIndex(events), + [eventsByDay, events], + ); + const opts = React.useMemo(() => ({ weekStartsOn }), [weekStartsOn]); + + // One row per week of the window. A week keeps its array across window + // changes, so growing the window doesn't re-render the rows already there. + const weekCacheRef = React.useRef(new Map()); + const weeks = React.useMemo(() => { + const previous = weekCacheRef.current; + const next = new Map(); + const out = windowWeekStarts(window).map((start) => { + const key = dayKey(start); + const days = previous.get(key) ?? weekDays(start); + next.set(key, days); + return days; + }); + weekCacheRef.current = next; + return out; + }, [window]); + const weeksRef = React.useRef(weeks); + weeksRef.current = weeks; + const rowHeight = showTimeInMonthView ? MONTH_ROW_HEIGHT_CHIPS : MONTH_ROW_HEIGHT; + const viewportHeight = rowHeight * VISIBLE_ROWS; + + // The month in focus: set by navigation, then by what scrolls under the + // sample line. + const [activeMonth, setActiveMonth] = React.useState(() => monthKeyOf(focus.date)); + const activeMonthRef = React.useRef(activeMonth); + const onVisibleRef = React.useRef(onVisibleDateChange); + onVisibleRef.current = onVisibleDateChange; + const offsetRef = React.useRef(0); + // Row at the top of the viewport; a change of row height (the "show time" + // setting) remounts the list there. + const topRowRef = React.useRef(null); + + const handleScroll = React.useCallback( + (e: NativeSyntheticEvent) => { + const offset = e.nativeEvent.contentOffset.y; + offsetRef.current = offset; + topRowRef.current = Math.round(offset / rowHeight); + const list = weeksRef.current; + const row = sampledRow(offset, viewportHeight, rowHeight, list.length); + const days = list[row]; + if (!days) return; + // Mid-week decides which month a row belongs to. + const mid = days[3]; + const key = monthKeyOf(mid); + if (key === activeMonthRef.current) return; + activeMonthRef.current = key; + setActiveMonth(key); + onVisibleRef.current?.(mid); + }, + [viewportHeight, rowHeight], + ); + + // Navigation inside the window scrolls the focused month's first week to + // the top. The list mounts there already, so its own nonce needs nothing. + const listRef = React.useRef>(null); + const handledNonceRef = React.useRef(focus.nonce); + const initialRef = React.useRef<{ rowHeight: number; row: number } | null>(null); + if (initialRef.current === null || initialRef.current.rowHeight !== rowHeight) { + initialRef.current = { + rowHeight, + row: topRowRef.current ?? monthFocusRow(window, focus.date, weeks.length, opts), + }; + } + React.useEffect(() => { + if (handledNonceRef.current === focus.nonce) return; + handledNonceRef.current = focus.nonce; + const row = monthFocusRow(window, focus.date, weeks.length, opts); + const currentRow = Math.round(offsetRef.current / rowHeight); + const key = monthKeyOf(focus.date); + activeMonthRef.current = key; + setActiveMonth(key); + listRef.current?.scrollToIndex({ + index: row, + animated: Math.abs(row - currentRow) <= SMOOTH_SCROLL_ROWS, + }); + }, [focus, window, weeks.length, opts, rowHeight]); + + const selectedKey = dayKey(selectedDate); + const todayKey = dayKey(new Date()); + + const renderItem = React.useCallback( + ({ item: days }: ListRenderItemInfo) => { + return ( + + ); + }, + [ + activeMonth, + selectedDate, + index, + calendars, + weekStartsOn, + showWeekNumbers, + showTimeInMonthView, + timeFormat, + rowHeight, + locale, + styles, + onSelectDate, + onLongPressDate, + ], + ); + + const getItemLayout = React.useCallback( + (_: ArrayLike | null | undefined, i: number) => ({ + length: rowHeight, + offset: rowHeight * i, + index: i, + }), + [rowHeight], + ); + + return ( + + + + + ); +} + +export const MonthScrollView = React.memo(MonthScrollViewInner); + +function weekKey(days: Date[]): string { + return dayKey(days[0]); +} diff --git a/src/components/calendar/MonthView.tsx b/src/components/calendar/MonthView.tsx index 25ccc15b..dbe1d419 100644 --- a/src/components/calendar/MonthView.tsx +++ b/src/components/calendar/MonthView.tsx @@ -7,11 +7,9 @@ import { format, getISOWeek, getWeek, - isSameDay, - isSameMonth, - isToday, startOfMonth, startOfWeek, + type Locale, } from 'date-fns'; import type { Calendar, CalendarEvent } from '../../api/types'; import { componentSizes, radius, spacing, typography, type ThemePalette } from '../../theme/tokens'; @@ -25,10 +23,16 @@ import { type EventDayIndex, type TimeFormat, } from '../../lib/calendar-utils'; +import { dayIndexIn, monthKeyOf, monthMask } from '../../lib/calendar-month-scroll'; import { useCalendarLocale } from '../../lib/calendar-locale'; type WeekStart = 0 | 1 | 6; +// Height of a week row with dots, and with event chips (#666). The freely +// scrolling month uses them as fixed row heights. +export const MONTH_ROW_HEIGHT = 54; +export const MONTH_ROW_HEIGHT_CHIPS = 74; + interface MonthViewProps { currentDate: Date; selectedDate: Date; @@ -52,6 +56,175 @@ export function weekNumberFor(date: Date, weekStartsOn: WeekStart): number { return getWeek(date, { weekStartsOn }); } +export type MonthStyles = ReturnType; + +export function useMonthStyles(): MonthStyles { + const c = useColors(); + return React.useMemo(() => makeStyles(c), [c]); +} + +export function MonthWeekdayHeader({ + weekStartsOn, + showWeekNumbers, + styles, +}: { + weekStartsOn: WeekStart; + showWeekNumbers: boolean; + styles: MonthStyles; +}) { + const { locale } = useCalendarLocale(); + const labels = React.useMemo(() => { + const start = startOfWeek(new Date(), { weekStartsOn }); + return Array.from({ length: 7 }, (_, i) => format(addDays(start, i), 'EEEEE', { locale })); + }, [weekStartsOn, locale]); + return ( + + {showWeekNumbers && #} + {labels.map((wd, i) => ( + {wd} + ))} + + ); +} + +export interface MonthWeekRowProps { + days: Date[]; + /** Bit i set: day i belongs to the month in focus (others are muted). */ + activeMask: number; + /** Index of the selected / today's day in this row, or -1. */ + selectedIndex: number; + todayIndex: number; + index: EventDayIndex; + calendars: Calendar[]; + weekStartsOn: WeekStart; + showWeekNumbers: boolean; + showTimeInMonthView: boolean; + timeFormat?: TimeFormat; + /** Mark the first day of a month with the month's name (continuous scrolling). */ + labelMonths?: boolean; + /** Fixed row height (continuous scrolling); natural height otherwise. */ + height?: number; + locale: Locale; + styles: MonthStyles; + onSelectDate: (date: Date) => void; + onLongPressDate?: (date: Date) => void; +} + +function MonthWeekRowInner({ + days, + activeMask, + selectedIndex, + todayIndex, + index, + calendars, + weekStartsOn, + showWeekNumbers, + showTimeInMonthView, + timeFormat, + labelMonths = false, + height, + locale, + styles, + onSelectDate, + onLongPressDate, +}: MonthWeekRowProps) { + return ( + + {showWeekNumbers && ( + {weekNumberFor(days[0], weekStartsOn)} + )} + {days.map((d, i) => { + const sameMonth = (activeMask & (1 << i)) !== 0; + const today = i === todayIndex; + const selected = i === selectedIndex; + const dayEvents = eventsOnDayFromIndex(index, d); + const MAX_DOTS = dayEvents.length > 3 ? 2 : 3; + const MAX_CHIPS = dayEvents.length > 2 ? 1 : 2; + const maxVisible = showTimeInMonthView ? MAX_CHIPS : MAX_DOTS; + const visible = dayEvents.slice(0, maxVisible); + const overflow = Math.max(0, dayEvents.length - maxVisible); + const monthLabel = labelMonths && d.getDate() === 1 ? format(d, 'MMM', { locale }) : null; + + return ( + onSelectDate(d)} + onLongPress={onLongPressDate ? () => onLongPressDate(d) : undefined} + > + + {monthLabel && ( + + {monthLabel} + + )} + + {format(d, 'd')} + + + {dayEvents.length > 0 && !showTimeInMonthView && ( + + {visible.map((event, idx) => ( + + ))} + {overflow > 0 && ( + +{overflow} + )} + + )} + {dayEvents.length > 0 && showTimeInMonthView && ( + + {visible.map((event, idx) => { + const color = getEventColor(event, calendars); + const cancelled = event.status === 'cancelled'; + return ( + + + {event.showWithoutTime + ? (event.title || '') + : `${format(getEventStartDate(event), timePattern(timeFormat), { locale })} ${event.title || ''}`} + + + ); + })} + {overflow > 0 && ( + +{overflow} + )} + + )} + + ); + })} + + ); +} + +export const MonthWeekRow = React.memo(MonthWeekRowInner); + function MonthViewInner({ currentDate, selectedDate, @@ -65,130 +238,50 @@ function MonthViewInner({ onSelectDate, onLongPressDate, }: MonthViewProps) { - const c = useColors(); - const styles = React.useMemo(() => makeStyles(c), [c]); + const styles = useMonthStyles(); const { locale } = useCalendarLocale(); - const weekdayLabels = React.useMemo(() => { - const start = startOfWeek(new Date(), { weekStartsOn }); - return Array.from({ length: 7 }, (_, i) => format(addDays(start, i), 'EEEEE', { locale })); - }, [weekStartsOn, locale]); const index = React.useMemo( () => eventsByDay ?? buildEventDayIndex(events), [eventsByDay, events], ); - const days = React.useMemo(() => { - const monthStart = startOfMonth(currentDate); - const monthEnd = endOfMonth(currentDate); - const calStart = startOfWeek(monthStart, { weekStartsOn }); - const calEnd = endOfWeek(monthEnd, { weekStartsOn }); - const result: Date[] = []; + + // The month grid as rows of 7 days. + const rows = React.useMemo(() => { + const calStart = startOfWeek(startOfMonth(currentDate), { weekStartsOn }); + const calEnd = endOfWeek(endOfMonth(currentDate), { weekStartsOn }); + const out: Date[][] = []; let day = calStart; while (day <= calEnd) { - result.push(day); - day = addDays(day, 1); + out.push(Array.from({ length: 7 }, (_, i) => addDays(day, i))); + day = addDays(day, 7); } - return result; - }, [currentDate, weekStartsOn]); - - // Group days into rows of 7 so we can prepend a week-number column. - const rows = React.useMemo(() => { - const out: Date[][] = []; - for (let i = 0; i < days.length; i += 7) out.push(days.slice(i, i + 7)); return out; - }, [days]); - - const renderDay = (d: Date, key: React.Key, flexLayout = false) => { - const sameMonth = isSameMonth(d, currentDate); - const today = isToday(d); - const selected = isSameDay(d, selectedDate); - const dayEvents = eventsOnDayFromIndex(index, d); - const MAX_DOTS = dayEvents.length > 3 ? 2 : 3; - const MAX_CHIPS = dayEvents.length > 2 ? 1 : 2; - const maxVisible = showTimeInMonthView ? MAX_CHIPS : MAX_DOTS; - const visible = dayEvents.slice(0, maxVisible); - const overflow = Math.max(0, dayEvents.length - maxVisible); - - return ( - onSelectDate(d)} - onLongPress={onLongPressDate ? () => onLongPressDate(d) : undefined} - > - - - {format(d, 'd')} - - - {dayEvents.length > 0 && !showTimeInMonthView && ( - - {visible.map((event, idx) => ( - - ))} - {overflow > 0 && ( - +{overflow} - )} - - )} - {dayEvents.length > 0 && showTimeInMonthView && ( - - {visible.map((event, idx) => { - const color = getEventColor(event, calendars); - const cancelled = event.status === 'cancelled'; - return ( - - - {event.showWithoutTime - ? (event.title || '') - : `${format(getEventStartDate(event), timePattern(timeFormat), { locale })} ${event.title || ''}`} - - - ); - })} - {overflow > 0 && ( - +{overflow} - )} - - )} - - ); - }; + }, [currentDate, weekStartsOn]); + const activeMonth = monthKeyOf(currentDate); + const today = new Date(); return ( - - {showWeekNumbers && #} - {weekdayLabels.map((wd, i) => ( - {wd} - ))} - - {showWeekNumbers ? ( - rows.map((row, rowIdx) => ( - - {weekNumberFor(row[0], weekStartsOn)} - {row.map((d, i) => renderDay(d, `${rowIdx}-${i}`, true))} - - )) - ) : ( - - {days.map((d, i) => renderDay(d, i))} - - )} + + {rows.map((days) => ( + + ))} ); } @@ -205,7 +298,6 @@ function makeStyles(c: ThemePalette) { ...typography.small, color: c.textMuted, }, - daysGrid: { flexDirection: 'row', flexWrap: 'wrap' }, weekRow: { flexDirection: 'row', alignItems: 'flex-start' }, weekNumberLabel: { width: 24, @@ -220,9 +312,8 @@ function makeStyles(c: ThemePalette) { color: c.textMuted, paddingVertical: 4, }, - dayCell: { width: '14.28%', alignItems: 'center', paddingVertical: 4 }, - dayCellFlex: { width: undefined, flex: 1 }, - dayCellTall: { minHeight: 74, paddingHorizontal: 1 }, + dayCell: { flex: 1, alignItems: 'center', paddingVertical: 4 }, + dayCellTall: { minHeight: MONTH_ROW_HEIGHT_CHIPS, paddingHorizontal: 1 }, dayNumber: { width: 36, height: 36, @@ -231,6 +322,9 @@ function makeStyles(c: ThemePalette) { justifyContent: 'center', }, dayText: { ...typography.body, color: c.text }, + // Day 1 under its month's name: both fit the 36px circle. + dayTextUnderLabel: { lineHeight: 16 }, + monthLabel: { fontSize: 8, lineHeight: 10, fontWeight: '600', color: c.textSecondary, textTransform: 'uppercase' }, dayTextMuted: { color: c.textMuted }, todayCircle: { backgroundColor: c.primary }, todayText: { color: c.textInverse, fontWeight: '700' }, diff --git a/src/components/settings/CalendarSettings.tsx b/src/components/settings/CalendarSettings.tsx index aae39689..c3410421 100644 --- a/src/components/settings/CalendarSettings.tsx +++ b/src/components/settings/CalendarSettings.tsx @@ -41,6 +41,7 @@ export function CalendarSettings() { const timeZone = useSettingsStore((s) => s.calendarTimeZone); const showTimeInMonth = useSettingsStore((s) => s.calendarShowTimeInMonth); const showWeekNumbers = useSettingsStore((s) => s.calendarShowWeekNumbers); + const freeScroll = useSettingsStore((s) => s.calendarFreeScroll); const birthdayCal = useSettingsStore((s) => s.showBirthdayCalendar); const tasksEnabled = useSettingsStore((s) => s.enableCalendarTasks); const showTasksOnCal = useSettingsStore((s) => s.showTasksOnCalendar); @@ -140,6 +141,19 @@ export function CalendarSettings() { /> + + update('calendarFreeScroll', v)} + /> + + { + it('has one row per week of the window, each starting on the first weekday', () => { + const win = computeScrollWindow(freshScrollWindowState('month', new Date(2026, 8, 9)), opts); + const weeks = windowWeekStarts(win); + // 2026-07-27 .. 2026-11-08 is 15 weeks. + expect(weeks).toHaveLength(15); + expect(dayKey(weeks[0])).toBe('2026-07-27'); + expect(dayKey(weeks[14])).toBe('2026-11-02'); + expect(weeks.every((w) => w.getDay() === 1)).toBe(true); + }); + + it('keeps whole days across the DST change', () => { + const win = { start: new Date(2026, 9, 19), end: new Date(2026, 10, 1) }; + const weeks = windowWeekStarts(win); + expect(weeks.map(dayKey)).toEqual(['2026-10-19', '2026-10-26']); + expect(weeks[1].getHours()).toBe(0); + }); + + it('adds rows above when the window grows into the past', () => { + const state = freshScrollWindowState('month', new Date(2026, 8, 9)); + const before = windowWeekStarts(computeScrollWindow(state, opts)); + const after = windowWeekStarts(computeScrollWindow(growScrollWindow(state, 'before'), opts)); + const added = after.length - before.length; + expect(added).toBeGreaterThanOrEqual(4); + // The rows that were there keep their dates, shifted down by `added`. + expect(after.slice(added).map(dayKey)).toEqual(before.map(dayKey)); + }); +}); + +describe('weekDays', () => { + it('lists the seven days of a week', () => { + expect(weekDays(new Date(2026, 8, 28)).map(dayKey)).toEqual([ + '2026-09-28', '2026-09-29', '2026-09-30', '2026-10-01', '2026-10-02', '2026-10-03', '2026-10-04', + ]); + }); +}); + +describe('monthFocusRow', () => { + const win = computeScrollWindow( + growScrollWindow(freshScrollWindowState('month', new Date(2026, 8, 9)), 'before'), + opts, + ); + const count = windowWeekStarts(win).length; + + it('is the row of the week that starts the month grid', () => { + const row = monthFocusRow(win, new Date(2026, 9, 20), count, opts); + // October 2026's grid starts on Monday 2026-09-28. + expect(dayKey(windowWeekStarts(win)[row])).toBe('2026-09-28'); + }); + + it('stays inside the list', () => { + expect(monthFocusRow(win, new Date(2020, 0, 1), count, opts)).toBe(0); + expect(monthFocusRow(win, new Date(2030, 0, 1), count, opts)).toBe(count - 1); + }); +}); + +describe('sampledRow', () => { + it('picks the row under 40% of the viewport', () => { + // 6 rows of 54px: the sample line is 129.6px below the top. + expect(sampledRow(0, 324, 54, 20)).toBe(2); + expect(sampledRow(54 * 5, 324, 54, 20)).toBe(7); + }); + + it('clamps to the rows there are', () => { + expect(sampledRow(-50, 324, 54, 20)).toBe(1); + expect(sampledRow(100000, 324, 54, 20)).toBe(19); + expect(sampledRow(0, 324, 54, 0)).toBe(0); + }); +}); + +describe('monthMask / monthKeyOf', () => { + it('marks the days of the month in focus', () => { + const days = weekDays(new Date(2026, 8, 28)); // Sep 28 .. Oct 4 + expect(monthMask(days, monthKeyOf(new Date(2026, 8, 1)))).toBe(0b0000111); + expect(monthMask(days, monthKeyOf(new Date(2026, 9, 1)))).toBe(0b1111000); + expect(monthMask(days, monthKeyOf(new Date(2026, 10, 1)))).toBe(0); + }); + + it('tells months of different years apart', () => { + expect(monthKeyOf(new Date(2026, 0, 1))).not.toBe(monthKeyOf(new Date(2027, 0, 1))); + expect(monthKeyOf(new Date(2026, 11, 31)) + 1).toBe(monthKeyOf(new Date(2027, 0, 1))); + }); +}); + +describe('dayIndexIn', () => { + const days = weekDays(new Date(2026, 8, 28)); + + it('finds a day in the row regardless of the time', () => { + expect(dayIndexIn(days, new Date(2026, 9, 1, 23, 30))).toBe(3); + expect(dayIndexIn(days, new Date(2026, 8, 28))).toBe(0); + }); + + it('is -1 outside the row', () => { + expect(dayIndexIn(days, new Date(2026, 9, 5))).toBe(-1); + expect(dayIndexIn(days, new Date(2026, 8, 27))).toBe(-1); + expect(dayIndexIn([], new Date())).toBe(-1); + }); +}); diff --git a/src/lib/__tests__/calendar-scroll-window.test.ts b/src/lib/__tests__/calendar-scroll-window.test.ts index 0c92321d..67464b56 100644 --- a/src/lib/__tests__/calendar-scroll-window.test.ts +++ b/src/lib/__tests__/calendar-scroll-window.test.ts @@ -47,9 +47,10 @@ describe('baseRange', () => { }); describe('computeScrollWindow', () => { - it('starts as the base range plus the initial days after it, snapped to whole weeks', () => { + it('starts as the base range plus a step either side, snapped to whole weeks', () => { const win = computeScrollWindow(freshScrollWindowState('month', new Date(2026, 8, 9)), opts); - expect(local(win.start)).toBe('2026-08-31'); + // 2026-08-31 - 30 days = 2026-08-01 (Saturday) -> start of that week + expect(local(win.start)).toBe('2026-07-27'); // 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); @@ -76,15 +77,15 @@ describe('computeScrollWindow', () => { 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.start)).toBe('2026-08-10'); 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'); + // 2026-09-07 - 60 days = 2026-07-09 (Thursday) -> Monday 2026-07-06 + expect(local(win.start)).toBe('2026-07-06'); expect(win.start.getDay()).toBe(1); expect(win.end.getDay()).toBe(0); }); @@ -137,8 +138,8 @@ describe('normalizeScrollWindowState / scrollWindowContains', () => { 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); + expect(scrollWindowContains(win, 'week', new Date(2026, 6, 27), opts)).toBe(true); // window starts Jul 27 + expect(scrollWindowContains(win, 'week', new Date(2026, 6, 26), opts)).toBe(false); }); it('keys the anchor by calendar day', () => { diff --git a/src/lib/calendar-month-scroll.ts b/src/lib/calendar-month-scroll.ts new file mode 100644 index 00000000..b5d5b04b --- /dev/null +++ b/src/lib/calendar-month-scroll.ts @@ -0,0 +1,73 @@ +import { addDays, differenceInCalendarDays } from 'date-fns'; +import { baseRange, type DayRange, type ScrollWindowOptions } from './calendar-scroll-window'; + +/** + * Geometry of the freely scrolling month view (#759, webmail + * calendar-month-view): one fixed-height row per week of the window. + */ + +/** Fraction of the viewport height at which the "current month" is sampled. */ +export const VISIBLE_MONTH_SAMPLE = 0.4; + +/** The first day of every week in the window (which is made of whole weeks). */ +export function windowWeekStarts(window: DayRange): Date[] { + const count = Math.floor(differenceInCalendarDays(window.end, window.start) / 7) + 1; + const weeks: Date[] = []; + for (let i = 0; i < count; i++) weeks.push(addDays(window.start, i * 7)); + return weeks; +} + +/** The seven days of the week starting at `weekStart`. */ +export function weekDays(weekStart: Date): Date[] { + return Array.from({ length: 7 }, (_, i) => addDays(weekStart, i)); +} + +/** Row of the week that starts `date`'s month grid: where navigation puts the top. */ +export function monthFocusRow( + window: DayRange, + date: Date, + rowCount: number, + opts: ScrollWindowOptions, +): number { + const gridStart = baseRange('month', date, opts).start; + const row = Math.floor(differenceInCalendarDays(gridStart, window.start) / 7); + return Math.max(0, Math.min(rowCount - 1, row)); +} + +/** Row under the sample line for a scroll offset. */ +export function sampledRow( + offset: number, + viewportHeight: number, + rowHeight: number, + rowCount: number, + fraction = VISIBLE_MONTH_SAMPLE, +): number { + if (rowCount <= 0 || rowHeight <= 0) return 0; + const row = Math.floor((offset + viewportHeight * fraction) / rowHeight); + return Math.max(0, Math.min(rowCount - 1, row)); +} + +/** Year and month as one comparable number. */ +export function monthKeyOf(date: Date): number { + return date.getFullYear() * 12 + date.getMonth(); +} + +/** + * Bit i is set when day i of the week belongs to the month `monthKey`. Rows + * compare this instead of the month itself, so moving to another month only + * re-renders the rows whose shading changes. + */ +export function monthMask(days: Date[], monthKey: number): number { + let mask = 0; + days.forEach((day, i) => { + if (monthKeyOf(day) === monthKey) mask |= 1 << i; + }); + return mask; +} + +/** Index of `date` in `days` (same calendar day), or -1. */ +export function dayIndexIn(days: Date[], date: Date): number { + if (days.length === 0) return -1; + const diff = differenceInCalendarDays(date, days[0]); + return diff >= 0 && diff < days.length ? diff : -1; +} diff --git a/src/lib/calendar-scroll-window.ts b/src/lib/calendar-scroll-window.ts index ffde7251..a8af49de 100644 --- a/src/lib/calendar-scroll-window.ts +++ b/src/lib/calendar-scroll-window.ts @@ -71,13 +71,26 @@ const INITIAL_AFTER: Record = { day: SCROLL_WINDOW_STEP, }; +/** + * Days loaded before the base range at first. The grids open with a step + * of room above / to the left, so the list isn't mounted right at its start + * edge (where rows would be prepended while it is still settling on the + * focus). The agenda starts at its anchor and loads the past on request. + */ +const INITIAL_BEFORE: Record = { + month: SCROLL_WINDOW_STEP, + agenda: 0, + 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] }; + return { mode, anchorKey: dayKey(anchor), before: INITIAL_BEFORE[mode], after: INITIAL_AFTER[mode] }; } /** diff --git a/src/screens/CalendarScreen.tsx b/src/screens/CalendarScreen.tsx index e814a1d6..06ca9b31 100644 --- a/src/screens/CalendarScreen.tsx +++ b/src/screens/CalendarScreen.tsx @@ -40,6 +40,7 @@ import { Button } from '../components'; import { useCalendarStore } from '../stores/calendar-store'; import { useSettingsStore } from '../stores/settings-store'; import { MonthView } from '../components/calendar/MonthView'; +import { MonthScrollView } from '../components/calendar/MonthScrollView'; import { WeekView } from '../components/calendar/WeekView'; import { AgendaView } from '../components/calendar/AgendaView'; import { EventCard } from '../components/calendar/EventCard'; @@ -154,6 +155,7 @@ export default function CalendarScreen() { const showTasksOnCalendar = useSettingsStore((s) => s.showTasksOnCalendar); const calendarFirstDayOfWeek = useSettingsStore((s) => s.calendarFirstDayOfWeek); const calendarShowWeekNumbers = useSettingsStore((s) => s.calendarShowWeekNumbers); + const calendarFreeScroll = useSettingsStore((s) => s.calendarFreeScroll); const calendarTimeFormat = useSettingsStore((s) => s.calendarTimeFormat); const showBirthdayCalendar = useSettingsStore((s) => s.showBirthdayCalendar); const enableCalendarTasks = useSettingsStore((s) => s.enableCalendarTasks); @@ -172,10 +174,12 @@ export default function CalendarScreen() { 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. + // days around the day the user navigated to (the "focus"). With free + // scrolling (the default) the agenda and the month scroll continuously: + // reaching an edge widens that side and only the new days are fetched. + // Otherwise, and in the week view, a view shows 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], @@ -186,7 +190,7 @@ export default function CalendarScreen() { const [windowState, setWindowState] = React.useState( () => freshScrollWindowState(initialViewMode, new Date()), ); - const freeScroll = viewMode === 'agenda'; + const freeScroll = calendarFreeScroll && (viewMode === 'agenda' || viewMode === 'month'); const focusKey = dayKey(focus.date); const activeWindowState = React.useMemo( () => @@ -403,20 +407,16 @@ export default function CalendarScreen() { }, [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. + // loaded yet (calendar-store extendRange). `loadingEdge` is the side the + // last 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); - } + if (!busy) setLoadingEdge(null); }, ), ); @@ -440,12 +440,11 @@ export default function CalendarScreen() { [viewMode, windowOptions], ); + // The grids add their rows for the wider window right away (the events + // follow); the agenda waits for one extension to load before the next. const extendWindow = React.useCallback( (side: 'before' | 'after') => { - if (loadingEdgeRef.current) return; - const edge = side === 'before' ? 'start' : 'end'; - loadingEdgeRef.current = edge; - setLoadingEdge(edge); + setLoadingEdge(side === 'before' ? 'start' : 'end'); setWindowState((prev) => growScrollWindow(normalizeScrollWindowState(prev, viewMode, focus.date), side), ); @@ -957,7 +956,27 @@ export default function CalendarScreen() { )} - {viewMode === 'month' && ( + {viewMode === 'month' && freeScroll && ( + + )} + {viewMode === 'month' && !freeScroll && (