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.
This commit is contained in:
Linus Rath
2026-09-24 20:25:40 +02:00
parent 71522b9b92
commit caf61ece79
9 changed files with 744 additions and 144 deletions
+265
View File
@@ -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<string, Date[]>());
const weeks = React.useMemo(() => {
const previous = weekCacheRef.current;
const next = new Map<string, Date[]>();
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<number | null>(null);
const handleScroll = React.useCallback(
(e: NativeSyntheticEvent<NativeScrollEvent>) => {
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<FlatList<Date[]>>(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<Date[]>) => {
return (
<MonthWeekRow
days={days}
activeMask={monthMask(days, activeMonth)}
selectedIndex={dayIndexIn(days, selectedDate)}
todayIndex={dayIndexIn(days, new Date())}
index={index}
calendars={calendars}
weekStartsOn={weekStartsOn}
showWeekNumbers={showWeekNumbers}
showTimeInMonthView={showTimeInMonthView}
timeFormat={timeFormat}
labelMonths
height={rowHeight}
locale={locale}
styles={styles}
onSelectDate={onSelectDate}
onLongPressDate={onLongPressDate}
/>
);
},
[
activeMonth,
selectedDate,
index,
calendars,
weekStartsOn,
showWeekNumbers,
showTimeInMonthView,
timeFormat,
rowHeight,
locale,
styles,
onSelectDate,
onLongPressDate,
],
);
const getItemLayout = React.useCallback(
(_: ArrayLike<Date[]> | null | undefined, i: number) => ({
length: rowHeight,
offset: rowHeight * i,
index: i,
}),
[rowHeight],
);
return (
<View style={styles.grid}>
<MonthWeekdayHeader weekStartsOn={weekStartsOn} showWeekNumbers={showWeekNumbers} styles={styles} />
<FlatList
key={rowHeight}
ref={listRef}
style={{ height: viewportHeight }}
data={weeks}
keyExtractor={weekKey}
renderItem={renderItem}
extraData={`${activeMonth}|${selectedKey}|${todayKey}`}
getItemLayout={getItemLayout}
initialScrollIndex={Math.min(initialRef.current.row, weeks.length - 1)}
// Weeks added above keep the rows on screen where they are.
maintainVisibleContentPosition={{ minIndexForVisible: 0 }}
// Half the grid: the window opens with a month of room either side,
// so this doesn't fire while the list settles on the focus.
onStartReached={onExtendStart}
onStartReachedThreshold={0.5}
onEndReached={onExtendEnd}
onEndReachedThreshold={0.5}
onScroll={handleScroll}
scrollEventThrottle={32}
snapToInterval={rowHeight}
decelerationRate="fast"
showsVerticalScrollIndicator={false}
initialNumToRender={VISIBLE_ROWS + 2}
maxToRenderPerBatch={VISIBLE_ROWS}
windowSize={5}
/>
</View>
);
}
export const MonthScrollView = React.memo(MonthScrollViewInner);
function weekKey(days: Date[]): string {
return dayKey(days[0]);
}
+213 -119
View File
@@ -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<typeof makeStyles>;
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 (
<View style={styles.weekdayRow}>
{showWeekNumbers && <Text style={styles.weekNumberLabel}>#</Text>}
{labels.map((wd, i) => (
<Text key={i} style={styles.weekdayLabel}>{wd}</Text>
))}
</View>
);
}
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 (
<View style={[styles.weekRow, height !== undefined && { height, overflow: 'hidden' }]}>
{showWeekNumbers && (
<Text style={styles.weekNumberCell}>{weekNumberFor(days[0], weekStartsOn)}</Text>
)}
{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 (
<Pressable
key={i}
style={[styles.dayCell, showTimeInMonthView && styles.dayCellTall]}
onPress={() => onSelectDate(d)}
onLongPress={onLongPressDate ? () => onLongPressDate(d) : undefined}
>
<View style={[
styles.dayNumber,
today && styles.todayCircle,
selected && !today && styles.selectedCircle,
]}>
{monthLabel && (
<Text
numberOfLines={1}
style={[
styles.monthLabel,
!sameMonth && styles.dayTextMuted,
today && styles.todayText,
selected && !today && styles.selectedText,
]}
>
{monthLabel}
</Text>
)}
<Text style={[
styles.dayText,
monthLabel !== null && styles.dayTextUnderLabel,
!sameMonth && styles.dayTextMuted,
today && styles.todayText,
selected && !today && styles.selectedText,
]}>
{format(d, 'd')}
</Text>
</View>
{dayEvents.length > 0 && !showTimeInMonthView && (
<View style={styles.dotsRow}>
{visible.map((event, idx) => (
<View
key={`${event.id}-${idx}`}
style={[styles.dot, { backgroundColor: getEventColor(event, calendars) }]}
/>
))}
{overflow > 0 && (
<Text style={styles.overflowText}>+{overflow}</Text>
)}
</View>
)}
{dayEvents.length > 0 && showTimeInMonthView && (
<View style={styles.chipsCol}>
{visible.map((event, idx) => {
const color = getEventColor(event, calendars);
const cancelled = event.status === 'cancelled';
return (
<View key={`${event.id}-${idx}`} style={[styles.chip, { backgroundColor: color }]}>
<Text
style={[styles.chipText, cancelled && styles.chipTextCancelled]}
numberOfLines={1}
>
{event.showWithoutTime
? (event.title || '')
: `${format(getEventStartDate(event), timePattern(timeFormat), { locale })} ${event.title || ''}`}
</Text>
</View>
);
})}
{overflow > 0 && (
<Text style={styles.overflowText}>+{overflow}</Text>
)}
</View>
)}
</Pressable>
);
})}
</View>
);
}
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 (
<Pressable
key={key}
style={[styles.dayCell, flexLayout && styles.dayCellFlex, showTimeInMonthView && styles.dayCellTall]}
onPress={() => onSelectDate(d)}
onLongPress={onLongPressDate ? () => onLongPressDate(d) : undefined}
>
<View style={[
styles.dayNumber,
today && styles.todayCircle,
selected && !today && styles.selectedCircle,
]}>
<Text style={[
styles.dayText,
!sameMonth && styles.dayTextMuted,
today && styles.todayText,
selected && !today && styles.selectedText,
]}>
{format(d, 'd')}
</Text>
</View>
{dayEvents.length > 0 && !showTimeInMonthView && (
<View style={styles.dotsRow}>
{visible.map((event, idx) => (
<View
key={`${event.id}-${idx}`}
style={[styles.dot, { backgroundColor: getEventColor(event, calendars) }]}
/>
))}
{overflow > 0 && (
<Text style={styles.overflowText}>+{overflow}</Text>
)}
</View>
)}
{dayEvents.length > 0 && showTimeInMonthView && (
<View style={styles.chipsCol}>
{visible.map((event, idx) => {
const color = getEventColor(event, calendars);
const cancelled = event.status === 'cancelled';
return (
<View key={`${event.id}-${idx}`} style={[styles.chip, { backgroundColor: color }]}>
<Text
style={[styles.chipText, cancelled && styles.chipTextCancelled]}
numberOfLines={1}
>
{event.showWithoutTime
? (event.title || '')
: `${format(getEventStartDate(event), timePattern(timeFormat), { locale })} ${event.title || ''}`}
</Text>
</View>
);
})}
{overflow > 0 && (
<Text style={styles.overflowText}>+{overflow}</Text>
)}
</View>
)}
</Pressable>
);
};
}, [currentDate, weekStartsOn]);
const activeMonth = monthKeyOf(currentDate);
const today = new Date();
return (
<View style={styles.grid}>
<View style={styles.weekdayRow}>
{showWeekNumbers && <Text style={styles.weekNumberLabel}>#</Text>}
{weekdayLabels.map((wd, i) => (
<Text key={i} style={styles.weekdayLabel}>{wd}</Text>
))}
</View>
{showWeekNumbers ? (
rows.map((row, rowIdx) => (
<View key={rowIdx} style={styles.weekRow}>
<Text style={styles.weekNumberCell}>{weekNumberFor(row[0], weekStartsOn)}</Text>
{row.map((d, i) => renderDay(d, `${rowIdx}-${i}`, true))}
</View>
))
) : (
<View style={styles.daysGrid}>
{days.map((d, i) => renderDay(d, i))}
</View>
)}
<MonthWeekdayHeader weekStartsOn={weekStartsOn} showWeekNumbers={showWeekNumbers} styles={styles} />
{rows.map((days) => (
<MonthWeekRow
key={days[0].toISOString()}
days={days}
activeMask={monthMask(days, activeMonth)}
selectedIndex={dayIndexIn(days, selectedDate)}
todayIndex={dayIndexIn(days, today)}
index={index}
calendars={calendars}
weekStartsOn={weekStartsOn}
showWeekNumbers={showWeekNumbers}
showTimeInMonthView={showTimeInMonthView}
timeFormat={timeFormat}
locale={locale}
styles={styles}
onSelectDate={onSelectDate}
onLongPressDate={onLongPressDate}
/>
))}
</View>
);
}
@@ -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' },
@@ -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() {
/>
</SettingItem>
<SettingItem
label={t('calendar.settings.calendar_free_scroll', 'Free scrolling')}
description={t(
'calendar.settings.calendar_free_scroll_desc',
'Scroll continuously through months, weeks and days instead of one period at a time',
)}
>
<ToggleSwitch
checked={freeScroll}
onChange={(v) => update('calendarFreeScroll', v)}
/>
</SettingItem>
<SettingItem
label={t('calendar.settings.show_birthday_calendar', 'Contact birthday calendar')}
description={t(
@@ -0,0 +1,117 @@
import { describe, it, expect } from 'vitest';
import { dayKey } from '../calendar-utils';
import {
computeScrollWindow,
freshScrollWindowState,
growScrollWindow,
} from '../calendar-scroll-window';
import {
dayIndexIn,
monthFocusRow,
monthKeyOf,
monthMask,
sampledRow,
weekDays,
windowWeekStarts,
} from '../calendar-month-scroll';
const opts = { weekStartsOn: 1 as const };
describe('windowWeekStarts', () => {
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);
});
});
@@ -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', () => {
+73
View File
@@ -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;
}
+14 -1
View File
@@ -71,13 +71,26 @@ const INITIAL_AFTER: Record<ScrollViewMode, number> = {
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<ScrollViewMode, number> = {
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] };
}
/**
+36 -17
View File
@@ -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<ViewMode>(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<ScrollWindowOptions>(
() => ({ weekStartsOn: calendarFirstDayOfWeek }),
[calendarFirstDayOfWeek],
@@ -186,7 +190,7 @@ export default function CalendarScreen() {
const [windowState, setWindowState] = React.useState<ScrollWindowState>(
() => 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() {
)}
<View style={styles.content}>
{viewMode === 'month' && (
{viewMode === 'month' && freeScroll && (
<MonthScrollView
key={windowKey}
focus={focus}
window={scrollWindow}
onExtendStart={scrollWindow.canExtendStart ? extendWindowStart : undefined}
onExtendEnd={scrollWindow.canExtendEnd ? extendWindowEnd : undefined}
onVisibleDateChange={setVisibleDate}
selectedDate={selectedDate}
events={events}
eventsByDay={eventsByDay}
calendars={calendars}
weekStartsOn={calendarFirstDayOfWeek}
showWeekNumbers={calendarShowWeekNumbers}
showTimeInMonthView={calendarShowTimeInMonth}
timeFormat={calendarTimeFormat}
onSelectDate={handleSelectDate}
onLongPressDate={openCreate}
/>
)}
{viewMode === 'month' && !freeScroll && (
<MonthView
currentDate={focus.date}
selectedDate={selectedDate}
+4
View File
@@ -232,6 +232,9 @@ interface PersistedSettings {
calendarTimeFormat: TimeFormat;
calendarShowTimeInMonth: boolean;
calendarShowWeekNumbers: boolean;
// Scroll continuously through months, weeks and days (#759) instead of
// one period at a time. Same key as the webmail's setting.
calendarFreeScroll: boolean;
calendarHoverPreview: CalendarHoverPreview;
// IANA zone the calendar works in, or 'auto' to follow the device (#755).
// Same key semantics as the webmail's `timeZone` setting.
@@ -392,6 +395,7 @@ const DEFAULT_PERSISTED: PersistedSettings = {
calendarTimeFormat: '24h',
calendarShowTimeInMonth: true,
calendarShowWeekNumbers: false,
calendarFreeScroll: true,
calendarHoverPreview: 'delay-500ms',
calendarTimeZone: 'auto',
showBirthdayCalendar: false,