feat: scroll the calendar week and day views sideways #759

The week view showed one week and only the arrows moved it, and there
was no day view at all (a "day" default view opened the agenda). With
free scrolling on (the default) both are now one strip of day columns
that scrolls sideways, as in the webmail (15a5497a):

- the week view shows seven columns and snaps to single days; the new
  day view shows one column and moves a day per swipe; the title
  follows the first day in view and the arrows step from it
- reaching either end adds days and loads only their events; days added
  at the start keep the columns on screen in place
- the day headers and the all-day strip follow the sideways scroll on
  the native thread; all-day bars are cut per week (per day in the day
  view) so each part carries its title
- columns are windowed and lay out their events once per change

The day view is offered as a default view again. With free scrolling
off, or in right-to-left layouts, the week view pages as before and the
day view shows a single day.
This commit is contained in:
Linus Rath
2026-09-24 20:25:40 +02:00
parent caf61ece79
commit de3d697aa9
5 changed files with 1128 additions and 28 deletions
@@ -0,0 +1,207 @@
import { describe, it, expect } from 'vitest';
import type { CalendarEvent } from '../../api/types';
import {
buildEventDayIndex,
buildTimedFullDayWeekSegments,
dayKey,
isTimedEventFullDayOnDate,
} from '../calendar-utils';
import {
computeScrollWindow,
freshScrollWindowState,
growScrollWindow,
} from '../calendar-scroll-window';
import {
buildAllDaySegments,
headerColumnRange,
hourAtOffset,
timeGridFocusColumn,
timedFullDaySegments,
windowDays,
} from '../calendar-time-grid';
const opts = { weekStartsOn: 1 as const };
const ev = (id: string, start: string, duration: string, extra: Partial<CalendarEvent> = {}) =>
({ id, title: id, start, duration, ...extra }) as CalendarEvent;
describe('windowDays', () => {
it('has one column per day of the window, midnight to midnight', () => {
const days = windowDays({ start: new Date(2026, 9, 24), end: new Date(2026, 9, 27) });
expect(days.map(dayKey)).toEqual(['2026-10-24', '2026-10-25', '2026-10-26', '2026-10-27']);
// Across the DST change the days stay on local midnight.
expect(days.every((d) => d.getHours() === 0)).toBe(true);
});
it('grows at the start by the added days only', () => {
const state = freshScrollWindowState('day', new Date(2026, 8, 9));
const before = windowDays(computeScrollWindow(state, opts));
const after = windowDays(computeScrollWindow(growScrollWindow(state, 'before'), opts));
expect(after.length - before.length).toBe(30);
expect(after.slice(30).map(dayKey)).toEqual(before.map(dayKey));
});
});
describe('timeGridFocusColumn', () => {
const win = computeScrollWindow(
growScrollWindow(freshScrollWindowState('week', new Date(2026, 8, 9)), 'before'),
opts,
);
const count = windowDays(win).length;
it('aligns the focused week in the week view', () => {
const col = timeGridFocusColumn(win, new Date(2026, 8, 10), 'week', 1, count);
expect(dayKey(windowDays(win)[col])).toBe('2026-09-07');
});
it('aligns the focused day in the day view', () => {
const col = timeGridFocusColumn(win, new Date(2026, 8, 10, 15), 'day', 1, count);
expect(dayKey(windowDays(win)[col])).toBe('2026-09-10');
});
it('stays inside the columns', () => {
expect(timeGridFocusColumn(win, new Date(2020, 0, 1), 'day', 1, count)).toBe(0);
expect(timeGridFocusColumn(win, new Date(2030, 0, 1), 'day', 1, count)).toBe(count - 1);
});
});
describe('headerColumnRange', () => {
it('covers the screen plus two screens either side', () => {
expect(headerColumnRange(20, 7, 100)).toEqual({ from: 6, to: 40 });
expect(headerColumnRange(20, 1, 100)).toEqual({ from: 18, to: 22 });
});
it('is clamped to the columns there are', () => {
expect(headerColumnRange(0, 7, 10)).toEqual({ from: 0, to: 9 });
});
});
describe('timedFullDaySegments', () => {
const week = windowDays({ start: new Date(2026, 8, 7), end: new Date(2026, 8, 13) });
it('finds the whole days a timed event covers', () => {
// Tue 10:00 .. Fri 15:00 covers Wed and Thu in full.
const segments = timedFullDaySegments([ev('trip', '2026-09-08T10:00:00', 'P3DT5H')], week);
expect(segments).toHaveLength(1);
expect(segments[0]).toMatchObject({ startIndex: 2, span: 2, continuesBefore: false, continuesAfter: false });
});
it('marks events that run past the given days', () => {
const segments = timedFullDaySegments([ev('long', '2026-09-01T00:00:00', 'P20D')], week);
expect(segments[0]).toMatchObject({ startIndex: 0, span: 7, continuesBefore: true, continuesAfter: true });
});
it('skips events that fill no whole day and all-day events', () => {
const segments = timedFullDaySegments(
[
ev('meeting', '2026-09-08T10:00:00', 'PT1H'),
ev('allday', '2026-09-08T00:00:00', 'P1D', { showWithoutTime: true }),
],
week,
);
expect(segments).toEqual([]);
});
it('agrees with the per-day version', () => {
const events = [
ev('a', '2026-09-08T10:00:00', 'P3DT5H'),
ev('b', '2026-09-07T00:00:00', 'P1D'),
ev('c', '2026-09-12T00:00:00', 'P4D'),
ev('d', '2026-09-09T08:00:00', 'PT2H'),
];
const pick = (s: { event: CalendarEvent; startIndex: number; span: number; continuesBefore: boolean; continuesAfter: boolean }) =>
`${s.event.id}:${s.startIndex}+${s.span}:${s.continuesBefore}:${s.continuesAfter}`;
expect(timedFullDaySegments(events, week).map(pick).sort()).toEqual(
buildTimedFullDayWeekSegments(events, week).map(pick).sort(),
);
});
});
describe('timedFullDaySegments across DST changes', () => {
// Europe and the US change clocks on these weekends; whichever zone the
// tests run in, the strip must agree with the grid's per-day test.
const spans = [
{ start: new Date(2026, 2, 5), end: new Date(2026, 2, 31) },
{ start: new Date(2026, 9, 20), end: new Date(2026, 10, 5) },
];
const events = [
ev('conf-eu-spring', '2026-03-27T09:00:00', 'P4D'),
ev('trip-us-spring', '2026-03-07T00:00:00', 'P3D'),
ev('trip-eu-fall', '2026-10-24T00:00:00', 'P2D'),
ev('camp-us-fall', '2026-10-30T12:00:00', 'P4DT2H'),
ev('nearly', '2026-10-25T00:00:00', 'PT23H59M59S'),
];
for (const span of spans) {
const days = windowDays(span);
it(`matches the per-day version (${dayKey(span.start)})`, () => {
const key = (s: { event: CalendarEvent; startIndex: number; span: number; continuesBefore: boolean; continuesAfter: boolean }) =>
`${s.event.id}:${s.startIndex}+${s.span}:${s.continuesBefore}:${s.continuesAfter}`;
expect(timedFullDaySegments(events, days).map(key).sort()).toEqual(
buildTimedFullDayWeekSegments(events, days).map(key).sort(),
);
});
it(`puts an event in the strip exactly on the days the grid leaves it out (${dayKey(span.start)})`, () => {
const segments = timedFullDaySegments(events, days);
for (const event of events) {
days.forEach((day, i) => {
const inStrip = segments.some(
(s) => s.event === event && i >= s.startIndex && i < s.startIndex + s.span,
);
expect(inStrip, `${event.id} on ${dayKey(day)}`).toBe(isTimedEventFullDayOnDate(event, day));
});
}
});
}
});
describe('buildAllDaySegments', () => {
const days = windowDays({ start: new Date(2026, 8, 7), end: new Date(2026, 8, 20) });
it('cuts bars at every week so each part carries its title', () => {
const index = buildEventDayIndex([
ev('holiday', '2026-09-10T00:00:00', 'P7D', { showWithoutTime: true }),
]);
const segments = buildAllDaySegments(index, days, 7);
expect(segments.map((s) => [s.startIndex, s.span, s.continuesBefore, s.continuesAfter])).toEqual([
[3, 4, false, true],
[7, 3, true, false],
]);
});
it('cuts per day in the day view', () => {
const index = buildEventDayIndex([
ev('holiday', '2026-09-10T00:00:00', 'P2D', { showWithoutTime: true }),
]);
expect(buildAllDaySegments(index, days, 1).map((s) => s.startIndex)).toEqual([3, 4]);
});
it('includes timed events that fill a day and packs overlaps into rows', () => {
const index = buildEventDayIndex([
ev('a', '2026-09-08T00:00:00', 'P2D', { showWithoutTime: true }),
ev('b', '2026-09-09T00:00:00', 'P1D', { showWithoutTime: true }),
ev('c', '2026-09-08T00:00:00', 'P1D'),
ev('meeting', '2026-09-08T10:00:00', 'PT1H'),
]);
const segments = buildAllDaySegments(index, days, 7);
expect(segments.map((s) => s.event.id).sort()).toEqual(['a', 'b', 'c']);
const rows = Math.max(...segments.map((s) => s.row)) + 1;
expect(rows).toBe(2);
});
it('is empty without all-day events', () => {
const index = buildEventDayIndex([ev('meeting', '2026-09-08T10:00:00', 'PT1H')]);
expect(buildAllDaySegments(index, days, 7)).toEqual([]);
});
});
describe('hourAtOffset', () => {
it('maps a press in a column to its hour', () => {
expect(hourAtOffset(0, 48)).toBe(0);
expect(hourAtOffset(47.9, 48)).toBe(0);
expect(hourAtOffset(48 * 13 + 5, 48)).toBe(13);
expect(hourAtOffset(48 * 30, 48)).toBe(23);
expect(hourAtOffset(-4, 48)).toBe(0);
});
});
+144
View File
@@ -0,0 +1,144 @@
import { addDays, differenceInCalendarDays, startOfDay, startOfWeek } from 'date-fns';
import type { CalendarEvent } from '../api/types';
import {
buildWeekSegmentsRaw,
eventsOnDayFromIndex,
getEventEndDate,
getEventStartDate,
isTimedEventFullDayOnDate,
packWeekSegments,
type CalendarWeekSegment,
type EventDayIndex,
} from './calendar-utils';
import type { DayRange, WeekStartsOn } from './calendar-scroll-window';
/**
* Geometry of the freely scrolling week and day grids (#759, webmail
* calendar-week-view / calendar-day-view): one column per day of the
* window, scrolling sideways and snapping to whole days.
*/
export type TimeGridMode = 'week' | 'day';
/** Every day of the window, in order. */
export function windowDays(window: DayRange): Date[] {
const count = differenceInCalendarDays(window.end, window.start) + 1;
const days: Date[] = [];
for (let i = 0; i < count; i++) days.push(addDays(window.start, i));
return days;
}
/**
* The column navigation aligns with the start of the viewport: the focused
* week's first day in the week view, the focused day in the day view.
*/
export function timeGridFocusColumn(
window: DayRange,
date: Date,
mode: TimeGridMode,
weekStartsOn: WeekStartsOn,
columnCount: number,
): number {
const target = mode === 'week' ? startOfWeek(date, { weekStartsOn }) : startOfDay(date);
const index = differenceInCalendarDays(target, window.start);
return Math.max(0, Math.min(columnCount - 1, index));
}
/**
* The columns whose header cells are drawn: the ones on screen plus two
* screens either side, so a fling doesn't outrun them.
*/
export function headerColumnRange(
firstVisible: number,
perScreen: number,
columnCount: number,
): { from: number; to: number } {
const margin = perScreen * 2;
return {
from: Math.max(0, firstVisible - margin),
to: Math.min(columnCount - 1, firstVisible + perScreen - 1 + margin),
};
}
/**
* Segments for timed events that fill whole days within `days`, which must
* be consecutive. Same result as buildTimedFullDayWeekSegments (the same
* isTimedEventFullDayOnDate test, so the strip and the timed grid agree on
* every day, DST days included), but each event is only tested on the days
* it spans instead of on every day: the scrolling grids hand in months of
* days (#759).
*/
export function timedFullDaySegments(
events: CalendarEvent[],
days: Date[],
): CalendarWeekSegment[] {
if (days.length === 0) return [];
const firstDay = startOfDay(days[0]);
const last = days.length - 1;
const out: CalendarWeekSegment[] = [];
const push = (event: CalendarEvent, from: number, to: number) => {
out.push({
event,
startIndex: from,
span: to - from + 1,
row: -1,
continuesBefore: isTimedEventFullDayOnDate(event, addDays(days[from], -1)),
continuesAfter: isTimedEventFullDayOnDate(event, addDays(days[to], 1)),
});
};
for (const event of events) {
if (event.showWithoutTime) continue;
const from = Math.max(0, differenceInCalendarDays(getEventStartDate(event), firstDay));
const to = Math.min(last, differenceInCalendarDays(getEventEndDate(event), firstDay));
if (!(from <= to)) continue;
let runStart = -1;
for (let i = from; i <= to; i++) {
if (isTimedEventFullDayOnDate(event, days[i])) {
if (runStart < 0) runStart = i;
} else if (runStart >= 0) {
push(event, runStart, i - 1);
runStart = -1;
}
}
if (runStart >= 0) push(event, runStart, to);
}
return out;
}
/**
* The all-day strip over the window: all-day events plus timed events that
* fill whole days, as bars packed into rows. Bars are cut at every
* `chunkSize` days (a week, or a day in the day view) so each part carries
* its title where it is on screen. Only events found in the day index for
* these days are considered.
*/
export function buildAllDaySegments(
index: EventDayIndex,
days: Date[],
chunkSize: number,
): CalendarWeekSegment[] {
const raw: CalendarWeekSegment[] = [];
const size = Math.max(1, Math.floor(chunkSize));
for (let from = 0; from < days.length; from += size) {
const chunk = days.slice(from, from + size);
const seen = new Set<string>();
const allDay: CalendarEvent[] = [];
const timed: CalendarEvent[] = [];
for (const day of chunk) {
for (const event of eventsOnDayFromIndex(index, day)) {
if (seen.has(event.id)) continue;
seen.add(event.id);
(event.showWithoutTime ? allDay : timed).push(event);
}
}
for (const segment of [...buildWeekSegmentsRaw(allDay, chunk), ...timedFullDaySegments(timed, chunk)]) {
raw.push({ ...segment, startIndex: segment.startIndex + from });
}
}
return packWeekSegments(raw);
}
/** The hour a long press at `offsetY` in a day column lands on. */
export function hourAtOffset(offsetY: number, hourHeight: number): number {
return Math.max(0, Math.min(23, Math.floor(offsetY / hourHeight)));
}