From de3d697aa98d2c3eb4ccada3bb97b7d0a5ba4e70 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Thu, 24 Sep 2026 19:59:39 +0200 Subject: [PATCH] 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. --- .../calendar/TimeGridScrollView.tsx | 706 ++++++++++++++++++ src/components/settings/CalendarSettings.tsx | 7 +- src/lib/__tests__/calendar-time-grid.test.ts | 207 +++++ src/lib/calendar-time-grid.ts | 144 ++++ src/screens/CalendarScreen.tsx | 92 ++- 5 files changed, 1128 insertions(+), 28 deletions(-) create mode 100644 src/components/calendar/TimeGridScrollView.tsx create mode 100644 src/lib/__tests__/calendar-time-grid.test.ts create mode 100644 src/lib/calendar-time-grid.ts diff --git a/src/components/calendar/TimeGridScrollView.tsx b/src/components/calendar/TimeGridScrollView.tsx new file mode 100644 index 00000000..e4a3d0d6 --- /dev/null +++ b/src/components/calendar/TimeGridScrollView.tsx @@ -0,0 +1,706 @@ +import React from 'react'; +import { + Animated, + Pressable, + ScrollView, + StyleSheet, + Text, + View, + useWindowDimensions, + type FlatList, + type GestureResponderEvent, + type LayoutChangeEvent, + type ListRenderItemInfo, + type NativeScrollEvent, + type NativeSyntheticEvent, + type ViewToken, +} from 'react-native'; +import { differenceInCalendarDays, format, type Locale } from 'date-fns'; +import type { Calendar, CalendarEvent } from '../../api/types'; +import { radius, spacing, typography, type ThemePalette } from '../../theme/tokens'; +import { useColors } from '../../theme/colors'; +import { useCalendarLocale } from '../../lib/calendar-locale'; +import { + buildEventDayIndex, + dayKey, + eventsOnDayFromIndex, + getEventColor, + isTimedEventFullDayOnDate, + layoutOverlappingEvents, + type CalendarWeekSegment, + type EventDayIndex, + type TimedEventLayout, +} from '../../lib/calendar-utils'; +import type { CalendarFocus, DayRange } from '../../lib/calendar-scroll-window'; +import { + buildAllDaySegments, + headerColumnRange, + hourAtOffset, + timeGridFocusColumn, + windowDays, + type TimeGridMode, +} from '../../lib/calendar-time-grid'; + +const HOUR_HEIGHT = 48; +const GRID_HEIGHT = 24 * HOUR_HEIGHT; +const GUTTER_WIDTH = 44; +const HEADER_HEIGHT = 60; +const ALL_DAY_CHIP_HEIGHT = 18; +const ALL_DAY_GAP = 2; +const HOURS = Array.from({ length: 24 }, (_, i) => i); +const VIEWABILITY_CONFIG = { itemVisiblePercentThreshold: 50 }; +const KEEP_VISIBLE_CONTENT = { minIndexForVisible: 0 }; + +type WeekStart = 0 | 1 | 6; +type TimeFormat = '12h' | '24h'; + +interface TimeGridScrollViewProps { + mode: TimeGridMode; + /** The day the user navigated to; its week (week view) or itself (day view) is scrolled into view. */ + focus: CalendarFocus; + /** The loaded window: one column per day. */ + window: DayRange; + selectedDate: Date; + events: CalendarEvent[]; + calendars: Calendar[]; + eventsByDay?: EventDayIndex; + weekStartsOn?: WeekStart; + timeFormat?: TimeFormat; + /** Widen the window at the start; omitted once the limit is reached. */ + onExtendStart?: () => void; + /** Widen the window at the end; omitted once the limit is reached. */ + onExtendEnd?: () => void; + /** Reports the first day in view as the user scrolls. */ + onVisibleDateChange?: (date: Date) => void; + onSelectDate?: (date: Date) => void; + onSelectEvent?: (event: CalendarEvent) => void; + onCreateAtTime?: (date: Date) => void; +} + +/** + * Freely scrolling week and day grids (#759, webmail 15a5497a): one column + * per day of the window in a horizontal list that snaps to whole days + * (seven on screen in the week view, one in the day view), inside one + * vertical scroller for the hours. The day headers and the all-day strip + * sit above it and follow the sideways scroll on the native thread. + * Reaching either end widens the window; columns added at the start keep + * the ones on screen in place. The screen remounts the grid (keyed by the + * window) when navigation starts a fresh window. + */ +function TimeGridScrollViewInner({ + mode, + focus, + window, + selectedDate, + events, + calendars, + eventsByDay, + weekStartsOn = 0, + timeFormat = '24h', + onExtendStart, + onExtendEnd, + onVisibleDateChange, + onSelectDate, + onSelectEvent, + onCreateAtTime, +}: TimeGridScrollViewProps) { + const c = useColors(); + const styles = React.useMemo(() => makeStyles(c), [c]); + const { locale, t } = useCalendarLocale(); + const index = React.useMemo( + () => eventsByDay ?? buildEventDayIndex(events), + [eventsByDay, events], + ); + + const perScreen = mode === 'week' ? 7 : 1; + const { width: screenWidth } = useWindowDimensions(); + const [rootWidth, setRootWidth] = React.useState(screenWidth); + const listWidth = Math.max(perScreen, rootWidth - GUTTER_WIDTH); + const colWidth = listWidth / perScreen; + const handleRootLayout = React.useCallback((e: LayoutChangeEvent) => { + const width = e.nativeEvent.layout.width; + if (width > 0) setRootWidth(width); + }, []); + + // One column per day of the window. A day keeps its Date across window + // changes so growing the window doesn't re-render the columns already + // there. + const dayCacheRef = React.useRef(new Map()); + const days = React.useMemo(() => { + const previous = dayCacheRef.current; + const next = new Map(); + const out = windowDays(window).map((day) => { + const key = dayKey(day); + const kept = previous.get(key) ?? day; + next.set(key, kept); + return kept; + }); + dayCacheRef.current = next; + return out; + }, [window]); + const daysRef = React.useRef(days); + daysRef.current = days; + + // Timed layouts per day, computed when a column first renders and kept + // until the events change. + // (A new day index means new events: start an empty cache.) + const layoutCache = React.useMemo(() => new Map(), [index]); + const layoutsFor = React.useCallback( + (day: Date) => { + const key = dayKey(day); + let layouts = layoutCache.get(key); + if (!layouts) { + const timed = eventsOnDayFromIndex(index, day).filter( + (e) => !e.showWithoutTime && !isTimedEventFullDayOnDate(e, day), + ); + layouts = layoutOverlappingEvents(timed, day); + layoutCache.set(key, layouts); + } + return layouts; + }, + [index, layoutCache], + ); + + // All-day bars over the whole window (cut per week, or per day in the day + // view), so the strip keeps one height while scrolling. + const allDaySegments = React.useMemo( + () => buildAllDaySegments(index, days, perScreen), + [index, days, perScreen], + ); + const allDayRows = React.useMemo( + () => allDaySegments.reduce((max, s) => Math.max(max, s.row + 1), 0), + [allDaySegments], + ); + const allDayHeight = allDayRows > 0 ? allDayRows * (ALL_DAY_CHIP_HEIGHT + ALL_DAY_GAP) + spacing.xs : 0; + + // The first column in view. Kept as a day so columns added at the start + // don't move it. + const [initialColumn] = React.useState(() => + timeGridFocusColumn(window, focus.date, mode, weekStartsOn, days.length), + ); + const firstDayRef = React.useRef(days[initialColumn] ?? window.start); + const [headerAnchor, setHeaderAnchor] = React.useState(firstDayRef.current); + const headerAnchorRef = React.useRef(headerAnchor); + + const scrollX = React.useRef(new Animated.Value(0)).current; + const headerTranslate = React.useMemo(() => Animated.multiply(scrollX, -1), [scrollX]); + const colWidthRef = React.useRef(colWidth); + colWidthRef.current = colWidth; + // Last sideways offset the list reported. + const offsetXRef = React.useRef(0); + + // Days added at the start shift the header cells right as soon as they + // render; maintainVisibleContentPosition shifts the list by the same + // amount natively. Move the header along now instead of waiting for the + // list's scroll event, which may come a frame later (or, at rest, not + // before the next scroll). + const windowStartRef = React.useRef(window.start); + React.useLayoutEffect(() => { + const added = differenceInCalendarDays(windowStartRef.current, window.start); + windowStartRef.current = window.start; + if (added <= 0) return; + offsetXRef.current += added * colWidthRef.current; + scrollX.setValue(offsetXRef.current); + }, [window.start, scrollX]); + const handleScroll = React.useMemo( + () => + Animated.event([{ nativeEvent: { contentOffset: { x: scrollX } } }], { + useNativeDriver: true, + listener: (e: NativeSyntheticEvent) => { + const list = daysRef.current; + offsetXRef.current = e.nativeEvent.contentOffset.x; + const col = Math.max( + 0, + Math.min(list.length - 1, Math.round(e.nativeEvent.contentOffset.x / colWidthRef.current)), + ); + const day = list[col]; + if (!day) return; + firstDayRef.current = day; + // Redraw the header cells once the view moved a screen away. + const moved = Math.abs(differenceInCalendarDays(day, headerAnchorRef.current)); + if (moved >= perScreen) { + headerAnchorRef.current = day; + setHeaderAnchor(day); + } + }, + }), + [scrollX, perScreen], + ); + + // The day at the start of the viewport drives the title. + const visibleHandlerRef = React.useRef(onVisibleDateChange); + visibleHandlerRef.current = onVisibleDateChange; + const lastVisibleKeyRef = React.useRef(null); + const onViewableItemsChanged = React.useRef(({ viewableItems }: { viewableItems: ViewToken[] }) => { + let first: Date | null = null; + for (const token of viewableItems) { + const day = token.item as Date; + if (!first || day < first) first = day; + } + if (!first) return; + const key = dayKey(first); + if (key === lastVisibleKeyRef.current) return; + lastVisibleKeyRef.current = key; + visibleHandlerRef.current?.(first); + }).current; + + // Navigation inside the window scrolls its week / day to the start. The + // grid mounts there already, so its own nonce needs nothing. + const listRef = React.useRef>(null); + const handledNonceRef = React.useRef(focus.nonce); + React.useEffect(() => { + if (handledNonceRef.current === focus.nonce) return; + handledNonceRef.current = focus.nonce; + const target = timeGridFocusColumn(window, focus.date, mode, weekStartsOn, days.length); + const current = Math.max(0, differenceInCalendarDays(firstDayRef.current, window.start)); + listRef.current?.scrollToIndex({ + index: target, + animated: Math.abs(target - current) <= perScreen * 2, + }); + }, [focus, window, mode, weekStartsOn, days.length, perScreen]); + + // Start the hours at the one before now, like the paged week view. + const vScrollRef = React.useRef(null); + React.useEffect(() => { + const target = Math.max(0, (new Date().getHours() - 1) * HOUR_HEIGHT); + const frame = requestAnimationFrame(() => { + vScrollRef.current?.scrollTo({ y: target, animated: false }); + }); + return () => cancelAnimationFrame(frame); + }, []); + + const [nowMinutes, setNowMinutes] = React.useState(() => { + const now = new Date(); + return now.getHours() * 60 + now.getMinutes(); + }); + React.useEffect(() => { + const interval = setInterval(() => { + const now = new Date(); + setNowMinutes(now.getHours() * 60 + now.getMinutes()); + }, 60_000); + return () => clearInterval(interval); + }, []); + + const handleLongPressAt = React.useCallback( + (day: Date, hour: number) => { + if (!onCreateAtTime) return; + const date = new Date(day); + date.setHours(hour, 0, 0, 0); + onCreateAtTime(date); + }, + [onCreateAtTime], + ); + + const todayKey = dayKey(new Date()); + const renderItem = React.useCallback( + ({ item: day }: ListRenderItemInfo) => ( + + ), + [layoutsFor, colWidth, todayKey, nowMinutes, calendars, timeFormat, styles, onSelectEvent, onCreateAtTime, handleLongPressAt], + ); + + const getItemLayout = React.useCallback( + (_: ArrayLike | null | undefined, i: number) => ({ length: colWidth, offset: colWidth * i, index: i }), + [colWidth], + ); + + // Header cells and all-day bars for the columns around the viewport. + const anchorIndex = Math.max(0, Math.min(days.length - 1, differenceInCalendarDays(headerAnchor, window.start))); + const { from, to } = headerColumnRange(anchorIndex, perScreen, days.length); + const selectedKey = dayKey(selectedDate); + const headerCells: React.ReactNode[] = []; + for (let i = from; i <= to; i++) { + const day = days[i]; + const key = dayKey(day); + headerCells.push( + , + ); + } + const visibleSegments = allDaySegments.filter( + (s) => s.startIndex <= to && s.startIndex + s.span - 1 >= from, + ); + + const threshold = perScreen === 1 ? 3 : 1; + const scrollable = days.length > perScreen || !!onExtendStart || !!onExtendEnd; + // A new column width (rotation, split screen) remounts the list at the + // day that was at the start. + const listKey = Math.round(colWidth * 100); + const initialIndexRef = React.useRef<{ key: number; index: number } | null>(null); + if (initialIndexRef.current === null || initialIndexRef.current.key !== listKey) { + initialIndexRef.current = { + key: listKey, + index: Math.max( + 0, + Math.min(days.length - 1, differenceInCalendarDays(firstDayRef.current, window.start)), + ), + }; + } + const listStyle = React.useMemo(() => ({ width: listWidth, height: GRID_HEIGHT }), [listWidth]); + + return ( + + + + + {allDayHeight > 0 && ( + + + {t('calendar.events.all_day', 'All day')} + + + )} + + + + {headerCells} + {visibleSegments.map((segment) => { + const color = getEventColor(segment.event, calendars); + return ( + onSelectEvent?.(segment.event)} + style={[ + styles.allDayChip, + { + left: segment.startIndex * colWidth + 1, + width: segment.span * colWidth - 2, + top: HEADER_HEIGHT + 2 + segment.row * (ALL_DAY_CHIP_HEIGHT + ALL_DAY_GAP), + backgroundColor: color + '33', + borderLeftColor: color, + borderTopLeftRadius: segment.continuesBefore ? 0 : radius.xs, + borderBottomLeftRadius: segment.continuesBefore ? 0 : radius.xs, + borderTopRightRadius: segment.continuesAfter ? 0 : radius.xs, + borderBottomRightRadius: segment.continuesAfter ? 0 : radius.xs, + }, + ]} + > + + {segment.continuesBefore ? '… ' : ''} + {segment.event.title || t('calendar.events.no_title', '(No title)')} + + + ); + })} + + + + + + + + {HOURS.map((h) => ( + + {h > 0 && ( + + {timeFormat === '12h' + ? `${(h % 12) || 12} ${h < 12 ? 'AM' : 'PM'}` + : `${h.toString().padStart(2, '0')}:00`} + + )} + + ))} + + + + {HOURS.map((h) => ( + + ))} + + + + + + + ); +} + +export const TimeGridScrollView = React.memo(TimeGridScrollViewInner); + +type GridStyles = ReturnType; + +const DayHeaderCell = React.memo(function DayHeaderCell({ + day, + left, + width, + today, + selected, + wide, + locale, + styles, + onPress, +}: { + day: Date; + left: number; + width: number; + today: boolean; + selected: boolean; + wide: boolean; + locale: Locale; + styles: GridStyles; + onPress?: (date: Date) => void; +}) { + return ( + onPress?.(day)}> + + {format(day, wide ? 'EEEE' : 'EEE', { locale })} + + + + {format(day, 'd')} + + + + ); +}); + +const DayColumn = React.memo(function DayColumn({ + day, + layouts, + width, + nowMinutes, + calendars, + timeFormat, + styles, + onSelectEvent, + onLongPressAt, +}: { + day: Date; + layouts: TimedEventLayout[]; + width: number; + /** Minutes since midnight for today's column, -1 for the others. */ + nowMinutes: number; + calendars: Calendar[]; + timeFormat: TimeFormat; + styles: GridStyles; + onSelectEvent?: (event: CalendarEvent) => void; + onLongPressAt?: (day: Date, hour: number) => void; +}) { + const { t } = useCalendarLocale(); + const handleLongPress = React.useCallback( + (e: GestureResponderEvent) => { + onLongPressAt?.(day, hourAtOffset(e.nativeEvent.locationY, HOUR_HEIGHT)); + }, + [onLongPressAt, day], + ); + return ( + + {layouts.map(({ event, column, totalColumns, startMinutes, endMinutes }) => { + const top = (startMinutes / 60) * HOUR_HEIGHT; + const height = Math.max(20, ((endMinutes - startMinutes) / 60) * HOUR_HEIGHT - 1); + const widthPct = 100 / totalColumns; + const color = getEventColor(event, calendars); + return ( + onSelectEvent?.(event)} + style={[ + styles.eventBlock, + { + top, + height, + left: `${column * widthPct}%`, + width: `${widthPct}%`, + backgroundColor: color + '33', + borderLeftColor: color, + }, + ]} + > + + {event.title || t('calendar.events.no_title', '(No title)')} + + {height > 32 && ( + + {minutesToTimeLabel(startMinutes, timeFormat)} + + )} + + ); + })} + {nowMinutes >= 0 && ( + + + + + )} + + ); +}); + +// Event-block start label; honours the 12h/24h setting like the gutter does. +function minutesToTimeLabel(minutes: number, timeFormat: TimeFormat): string { + const h = Math.floor(minutes / 60); + const m = minutes % 60; + const mm = m.toString().padStart(2, '0'); + if (timeFormat === '12h') { + return `${(h % 12) || 12}:${mm} ${h < 12 ? 'AM' : 'PM'}`; + } + return `${h.toString().padStart(2, '0')}:${mm}`; +} + +function makeStyles(c: ThemePalette) { + return StyleSheet.create({ + container: { flex: 1, backgroundColor: c.background }, + headerRow: { + flexDirection: 'row', + borderBottomWidth: 1, + borderBottomColor: c.border, + }, + gutter: { width: GUTTER_WIDTH }, + headerClip: { overflow: 'hidden' }, + headerStrip: { position: 'absolute', left: 0, top: 0, bottom: 0 }, + dayHeaderCell: { + position: 'absolute', + top: 0, + height: HEADER_HEIGHT, + alignItems: 'center', + paddingVertical: spacing.sm, + gap: 4, + }, + dayHeaderWeekday: { + ...typography.small, + color: c.textMuted, + textTransform: 'uppercase', + }, + dayHeaderNumber: { + width: 26, + height: 26, + borderRadius: 13, + alignItems: 'center', + justifyContent: 'center', + }, + dayHeaderNumberToday: { backgroundColor: c.primary }, + dayHeaderNumberSelected: { + backgroundColor: c.primaryBg, + borderWidth: 1, + borderColor: c.primary, + }, + dayHeaderNumberText: { ...typography.bodyMedium, color: c.text }, + dayHeaderNumberTextToday: { color: c.textInverse, fontWeight: '700' }, + dayHeaderNumberTextSelected: { color: c.primary, fontWeight: '600' }, + + allDayLabelWrap: { alignItems: 'flex-end', justifyContent: 'flex-start', paddingRight: 4, paddingTop: 2 }, + allDayLabel: { ...typography.small, color: c.textMuted, textAlign: 'right' }, + allDayChip: { + position: 'absolute', + height: ALL_DAY_CHIP_HEIGHT, + borderLeftWidth: 2, + paddingHorizontal: 4, + justifyContent: 'center', + }, + allDayChipText: { fontSize: 10, color: c.text, fontWeight: '500' }, + + vScroll: { flex: 1 }, + gridRow: { flexDirection: 'row', height: GRID_HEIGHT }, + hourLabelCell: { height: HOUR_HEIGHT, paddingRight: 4, alignItems: 'flex-end' }, + hourLabel: { + ...typography.small, + color: c.textMuted, + transform: [{ translateY: -6 }], + }, + hourLine: { + position: 'absolute', + left: 0, + right: 0, + height: 1, + backgroundColor: c.borderLight, + }, + dayCol: { + height: GRID_HEIGHT, + borderLeftWidth: 1, + borderLeftColor: c.border, + }, + eventBlock: { + position: 'absolute', + borderRadius: radius.xs, + borderLeftWidth: 2, + paddingHorizontal: 3, + paddingVertical: 2, + overflow: 'hidden', + }, + eventBlockTitle: { fontSize: 10, color: c.text, fontWeight: '600' }, + eventBlockTime: { fontSize: 9, color: c.textMuted, marginTop: 1 }, + nowLine: { + position: 'absolute', + left: -3, + right: 0, + flexDirection: 'row', + alignItems: 'center', + }, + nowDot: { + width: 6, + height: 6, + borderRadius: 3, + backgroundColor: c.error, + }, + nowBar: { flex: 1, height: 1, backgroundColor: c.error }, + }); +} diff --git a/src/components/settings/CalendarSettings.tsx b/src/components/settings/CalendarSettings.tsx index c3410421..27217677 100644 --- a/src/components/settings/CalendarSettings.tsx +++ b/src/components/settings/CalendarSettings.tsx @@ -63,19 +63,16 @@ export function CalendarSettings() { ]; }, [deviceZone, timeZone, t]); - // The mobile app has no dedicated day grid ("day" falls back to the - // agenda), so the option isn't offered; a synced "day" value shows as Agenda. - const effectiveView: CalendarView = viewMode === 'day' ? 'agenda' : viewMode; - return (