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 (