mirror of
https://github.com/bulwarkmail/native.git
synced 2026-10-31 16:06:32 +00:00
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:
@@ -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<string, Date>());
|
||||
const days = React.useMemo(() => {
|
||||
const previous = dayCacheRef.current;
|
||||
const next = new Map<string, Date>();
|
||||
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<string, TimedEventLayout[]>(), [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<CalendarWeekSegment[]>(
|
||||
() => 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<Date>(days[initialColumn] ?? window.start);
|
||||
const [headerAnchor, setHeaderAnchor] = React.useState<Date>(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<NativeScrollEvent>) => {
|
||||
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<string | null>(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<FlatList<Date>>(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<ScrollView>(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<Date>) => (
|
||||
<DayColumn
|
||||
day={day}
|
||||
layouts={layoutsFor(day)}
|
||||
width={colWidth}
|
||||
nowMinutes={dayKey(day) === todayKey ? nowMinutes : -1}
|
||||
calendars={calendars}
|
||||
timeFormat={timeFormat}
|
||||
styles={styles}
|
||||
onSelectEvent={onSelectEvent}
|
||||
onLongPressAt={onCreateAtTime ? handleLongPressAt : undefined}
|
||||
/>
|
||||
),
|
||||
[layoutsFor, colWidth, todayKey, nowMinutes, calendars, timeFormat, styles, onSelectEvent, onCreateAtTime, handleLongPressAt],
|
||||
);
|
||||
|
||||
const getItemLayout = React.useCallback(
|
||||
(_: ArrayLike<Date> | 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(
|
||||
<DayHeaderCell
|
||||
key={key}
|
||||
day={day}
|
||||
left={i * colWidth}
|
||||
width={colWidth}
|
||||
today={key === todayKey}
|
||||
selected={key === selectedKey}
|
||||
wide={perScreen === 1}
|
||||
locale={locale}
|
||||
styles={styles}
|
||||
onPress={onSelectDate}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
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 (
|
||||
<View style={styles.container} onLayout={handleRootLayout}>
|
||||
<View style={styles.headerRow}>
|
||||
<View style={styles.gutter}>
|
||||
<View style={{ height: HEADER_HEIGHT }} />
|
||||
{allDayHeight > 0 && (
|
||||
<View style={[styles.allDayLabelWrap, { height: allDayHeight }]}>
|
||||
<Text style={styles.allDayLabel} numberOfLines={2}>
|
||||
{t('calendar.events.all_day', 'All day')}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
<View style={[styles.headerClip, { width: listWidth, height: HEADER_HEIGHT + allDayHeight }]}>
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.headerStrip,
|
||||
{ width: days.length * colWidth, transform: [{ translateX: headerTranslate }] },
|
||||
]}
|
||||
>
|
||||
{headerCells}
|
||||
{visibleSegments.map((segment) => {
|
||||
const color = getEventColor(segment.event, calendars);
|
||||
return (
|
||||
<Pressable
|
||||
key={`${segment.event.id}:${segment.startIndex}:${segment.row}`}
|
||||
onPress={() => 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,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Text style={styles.allDayChipText} numberOfLines={1}>
|
||||
{segment.continuesBefore ? '… ' : ''}
|
||||
{segment.event.title || t('calendar.events.no_title', '(No title)')}
|
||||
</Text>
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
</Animated.View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<ScrollView ref={vScrollRef} style={styles.vScroll} showsVerticalScrollIndicator={false}>
|
||||
<View style={styles.gridRow}>
|
||||
<View style={styles.gutter}>
|
||||
{HOURS.map((h) => (
|
||||
<View key={h} style={styles.hourLabelCell}>
|
||||
{h > 0 && (
|
||||
<Text style={styles.hourLabel}>
|
||||
{timeFormat === '12h'
|
||||
? `${(h % 12) || 12} ${h < 12 ? 'AM' : 'PM'}`
|
||||
: `${h.toString().padStart(2, '0')}:00`}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
<View style={{ width: listWidth, height: GRID_HEIGHT }}>
|
||||
<View style={StyleSheet.absoluteFill} pointerEvents="none">
|
||||
{HOURS.map((h) => (
|
||||
<View key={h} style={[styles.hourLine, { top: (h + 1) * HOUR_HEIGHT - 1 }]} />
|
||||
))}
|
||||
</View>
|
||||
<Animated.FlatList
|
||||
key={listKey}
|
||||
ref={listRef}
|
||||
horizontal
|
||||
data={days}
|
||||
keyExtractor={dayKey}
|
||||
renderItem={renderItem}
|
||||
getItemLayout={getItemLayout}
|
||||
initialScrollIndex={initialIndexRef.current.index}
|
||||
// Days added at the start keep the columns on screen in place.
|
||||
maintainVisibleContentPosition={KEEP_VISIBLE_CONTENT}
|
||||
onStartReached={onExtendStart}
|
||||
onStartReachedThreshold={threshold}
|
||||
onEndReached={onExtendEnd}
|
||||
onEndReachedThreshold={threshold}
|
||||
onScroll={handleScroll}
|
||||
scrollEventThrottle={16}
|
||||
snapToInterval={colWidth}
|
||||
decelerationRate="fast"
|
||||
disableIntervalMomentum={perScreen === 1}
|
||||
scrollEnabled={scrollable}
|
||||
showsHorizontalScrollIndicator={false}
|
||||
onViewableItemsChanged={onViewableItemsChanged}
|
||||
viewabilityConfig={VIEWABILITY_CONFIG}
|
||||
initialNumToRender={perScreen + 2}
|
||||
maxToRenderPerBatch={perScreen + 1}
|
||||
windowSize={perScreen === 1 ? 5 : 3}
|
||||
style={listStyle}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</ScrollView>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
export const TimeGridScrollView = React.memo(TimeGridScrollViewInner);
|
||||
|
||||
type GridStyles = ReturnType<typeof makeStyles>;
|
||||
|
||||
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 (
|
||||
<Pressable style={[styles.dayHeaderCell, { left, width }]} onPress={() => onPress?.(day)}>
|
||||
<Text style={styles.dayHeaderWeekday} numberOfLines={1}>
|
||||
{format(day, wide ? 'EEEE' : 'EEE', { locale })}
|
||||
</Text>
|
||||
<View
|
||||
style={[
|
||||
styles.dayHeaderNumber,
|
||||
today && styles.dayHeaderNumberToday,
|
||||
selected && !today && styles.dayHeaderNumberSelected,
|
||||
]}
|
||||
>
|
||||
<Text
|
||||
style={[
|
||||
styles.dayHeaderNumberText,
|
||||
today && styles.dayHeaderNumberTextToday,
|
||||
selected && !today && styles.dayHeaderNumberTextSelected,
|
||||
]}
|
||||
>
|
||||
{format(day, 'd')}
|
||||
</Text>
|
||||
</View>
|
||||
</Pressable>
|
||||
);
|
||||
});
|
||||
|
||||
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 (
|
||||
<Pressable
|
||||
style={[styles.dayCol, { width }]}
|
||||
onLongPress={onLongPressAt ? handleLongPress : undefined}
|
||||
>
|
||||
{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 (
|
||||
<Pressable
|
||||
key={event.id}
|
||||
onPress={() => onSelectEvent?.(event)}
|
||||
style={[
|
||||
styles.eventBlock,
|
||||
{
|
||||
top,
|
||||
height,
|
||||
left: `${column * widthPct}%`,
|
||||
width: `${widthPct}%`,
|
||||
backgroundColor: color + '33',
|
||||
borderLeftColor: color,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Text style={styles.eventBlockTitle} numberOfLines={1}>
|
||||
{event.title || t('calendar.events.no_title', '(No title)')}
|
||||
</Text>
|
||||
{height > 32 && (
|
||||
<Text style={styles.eventBlockTime} numberOfLines={1}>
|
||||
{minutesToTimeLabel(startMinutes, timeFormat)}
|
||||
</Text>
|
||||
)}
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
{nowMinutes >= 0 && (
|
||||
<View pointerEvents="none" style={[styles.nowLine, { top: (nowMinutes / 60) * HOUR_HEIGHT }]}>
|
||||
<View style={styles.nowDot} />
|
||||
<View style={styles.nowBar} />
|
||||
</View>
|
||||
)}
|
||||
</Pressable>
|
||||
);
|
||||
});
|
||||
|
||||
// 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 },
|
||||
});
|
||||
}
|
||||
@@ -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 (
|
||||
<SettingsSection title={t('calendar.settings.title', 'Calendar')}>
|
||||
<SettingItem label={t('calendar.settings.default_view', 'Default view')}>
|
||||
<Select
|
||||
value={effectiveView}
|
||||
value={viewMode}
|
||||
onChange={(v) => update('calendarDefaultView', v as CalendarView)}
|
||||
options={[
|
||||
{ value: 'month', label: t('calendar.views.month', 'Month') },
|
||||
{ value: 'week', label: t('calendar.views.week', 'Week') },
|
||||
{ value: 'day', label: t('calendar.views.day', 'Day') },
|
||||
{ value: 'agenda', label: t('calendar.views.agenda', 'Agenda') },
|
||||
]}
|
||||
/>
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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)));
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import React from 'react';
|
||||
import {
|
||||
Alert,
|
||||
AppState,
|
||||
I18nManager,
|
||||
View,
|
||||
Text,
|
||||
StyleSheet,
|
||||
@@ -15,6 +16,7 @@ import {
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Plus,
|
||||
Calendar1,
|
||||
CalendarDays,
|
||||
LayoutGrid,
|
||||
List as ListIcon,
|
||||
@@ -26,6 +28,7 @@ import {
|
||||
startOfWeek,
|
||||
endOfWeek,
|
||||
addDays,
|
||||
subDays,
|
||||
isToday,
|
||||
addMonths,
|
||||
subMonths,
|
||||
@@ -42,6 +45,7 @@ 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 { TimeGridScrollView } from '../components/calendar/TimeGridScrollView';
|
||||
import { AgendaView } from '../components/calendar/AgendaView';
|
||||
import { EventCard } from '../components/calendar/EventCard';
|
||||
import { EventDetailSheet } from '../components/calendar/EventDetailSheet';
|
||||
@@ -102,7 +106,7 @@ import { shareEventICS } from '../lib/calendar-ics-export';
|
||||
import * as Clipboard from 'expo-clipboard';
|
||||
import type { Calendar, CalendarEvent, RecurrenceRule } from '../api/types';
|
||||
|
||||
type ViewMode = 'month' | 'week' | 'agenda';
|
||||
type ViewMode = 'month' | 'week' | 'day' | 'agenda';
|
||||
type PendingAction =
|
||||
| {
|
||||
kind: 'edit';
|
||||
@@ -126,17 +130,21 @@ const RANGE_MARGIN_DAYS = 14;
|
||||
|
||||
type WeekStart = 0 | 1 | 6;
|
||||
|
||||
// `firstDay`: the scrolled week grid reports the first column in view; the
|
||||
// title then spans the seven days from there instead of the focused week.
|
||||
function headerTitle(
|
||||
viewMode: ViewMode,
|
||||
currentDate: Date,
|
||||
weekStartsOn: WeekStart,
|
||||
locale: Locale,
|
||||
firstDay?: Date | null,
|
||||
): string {
|
||||
if (viewMode === 'month') return format(currentDate, 'MMMM yyyy', { locale });
|
||||
if (viewMode === 'day') return format(currentDate, 'EEE, MMM d, yyyy', { locale });
|
||||
if (viewMode === 'week') {
|
||||
const start = startOfWeek(currentDate, { weekStartsOn });
|
||||
const end = endOfWeek(currentDate, { weekStartsOn });
|
||||
if (start.getMonth() === end.getMonth()) {
|
||||
const start = firstDay ?? startOfWeek(currentDate, { weekStartsOn });
|
||||
const end = firstDay ? addDays(firstDay, 6) : endOfWeek(currentDate, { weekStartsOn });
|
||||
if (start.getMonth() === end.getMonth() && start.getFullYear() === end.getFullYear()) {
|
||||
return `${format(start, 'MMM d', { locale })} – ${format(end, 'd, yyyy', { locale })}`;
|
||||
}
|
||||
return `${format(start, 'MMM d', { locale })} – ${format(end, 'MMM d, yyyy', { locale })}`;
|
||||
@@ -165,21 +173,18 @@ export default function CalendarScreen() {
|
||||
const contacts = useContactsStore((s) => s.contacts);
|
||||
|
||||
const [selectedDate, setSelectedDate] = React.useState(() => new Date());
|
||||
// Day view falls back to Agenda on mobile (we don't have a dedicated day
|
||||
// grid yet, but the agenda already serves the same purpose).
|
||||
const initialViewMode: ViewMode =
|
||||
calendarDefaultView === 'week' ? 'week'
|
||||
: calendarDefaultView === 'day' || calendarDefaultView === 'agenda' ? 'agenda'
|
||||
: 'month';
|
||||
calendarDefaultView === 'week' || calendarDefaultView === 'day' || calendarDefaultView === 'agenda'
|
||||
? calendarDefaultView
|
||||
: 'month';
|
||||
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"). 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.
|
||||
// scrolling (the default) the views scroll continuously: reaching an edge
|
||||
// widens that side and only the new days are fetched. Otherwise 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],
|
||||
@@ -190,7 +195,10 @@ export default function CalendarScreen() {
|
||||
const [windowState, setWindowState] = React.useState<ScrollWindowState>(
|
||||
() => freshScrollWindowState(initialViewMode, new Date()),
|
||||
);
|
||||
const freeScroll = calendarFreeScroll && (viewMode === 'agenda' || viewMode === 'month');
|
||||
// The sideways-scrolling week and day grids assume a left-to-right strip;
|
||||
// right-to-left layouts keep them paged.
|
||||
const freeScroll =
|
||||
calendarFreeScroll && ((viewMode !== 'week' && viewMode !== 'day') || !I18nManager.isRTL);
|
||||
const focusKey = dayKey(focus.date);
|
||||
const activeWindowState = React.useMemo(
|
||||
() =>
|
||||
@@ -458,12 +466,20 @@ export default function CalendarScreen() {
|
||||
// away from the focused day.
|
||||
const goPrev = React.useCallback(() => {
|
||||
const base = visibleDate ?? focus.date;
|
||||
jumpTo(viewMode === 'week' ? subWeeks(base, 1) : subMonths(base, 1));
|
||||
jumpTo(
|
||||
viewMode === 'week' ? subWeeks(base, 1)
|
||||
: viewMode === 'day' ? subDays(base, 1)
|
||||
: subMonths(base, 1),
|
||||
);
|
||||
}, [viewMode, visibleDate, focus.date, jumpTo]);
|
||||
|
||||
const goNext = React.useCallback(() => {
|
||||
const base = visibleDate ?? focus.date;
|
||||
jumpTo(viewMode === 'week' ? addWeeks(base, 1) : addMonths(base, 1));
|
||||
jumpTo(
|
||||
viewMode === 'week' ? addWeeks(base, 1)
|
||||
: viewMode === 'day' ? addDays(base, 1)
|
||||
: addMonths(base, 1),
|
||||
);
|
||||
}, [viewMode, visibleDate, focus.date, jumpTo]);
|
||||
|
||||
const goToday = React.useCallback(() => {
|
||||
@@ -896,8 +912,15 @@ export default function CalendarScreen() {
|
||||
<Menu size={20} color={c.text} />
|
||||
</Pressable>
|
||||
<View style={styles.headerLeft}>
|
||||
<Text style={styles.headerTitle}>
|
||||
{headerTitle(viewMode, visibleDate ?? focus.date, calendarFirstDayOfWeek, locale)}
|
||||
{/* Four view buttons leave less room: shrink a long title instead of wrapping it. */}
|
||||
<Text style={styles.headerTitle} numberOfLines={1} adjustsFontSizeToFit minimumFontScale={0.75}>
|
||||
{headerTitle(
|
||||
viewMode,
|
||||
visibleDate ?? focus.date,
|
||||
calendarFirstDayOfWeek,
|
||||
locale,
|
||||
viewMode === 'week' && freeScroll ? visibleDate : null,
|
||||
)}
|
||||
</Text>
|
||||
<Text style={styles.headerSubtitle}>
|
||||
{isSelectedToday
|
||||
@@ -916,9 +939,12 @@ export default function CalendarScreen() {
|
||||
</Pressable>
|
||||
)}
|
||||
<View style={styles.viewToggle}>
|
||||
{(['month', 'week', 'agenda'] as ViewMode[]).map((mode) => {
|
||||
{(['month', 'week', 'day', 'agenda'] as ViewMode[]).map((mode) => {
|
||||
const Icon =
|
||||
mode === 'month' ? LayoutGrid : mode === 'week' ? CalendarDays : ListIcon;
|
||||
mode === 'month' ? LayoutGrid
|
||||
: mode === 'week' ? CalendarDays
|
||||
: mode === 'day' ? Calendar1
|
||||
: ListIcon;
|
||||
const active = viewMode === mode;
|
||||
return (
|
||||
<Pressable
|
||||
@@ -991,7 +1017,27 @@ export default function CalendarScreen() {
|
||||
onLongPressDate={openCreate}
|
||||
/>
|
||||
)}
|
||||
{viewMode === 'week' && (
|
||||
{(viewMode === 'day' || (viewMode === 'week' && freeScroll)) && (
|
||||
<TimeGridScrollView
|
||||
key={windowKey}
|
||||
mode={viewMode === 'day' ? 'day' : 'week'}
|
||||
focus={focus}
|
||||
window={scrollWindow}
|
||||
onExtendStart={freeScroll && scrollWindow.canExtendStart ? extendWindowStart : undefined}
|
||||
onExtendEnd={freeScroll && scrollWindow.canExtendEnd ? extendWindowEnd : undefined}
|
||||
onVisibleDateChange={freeScroll ? setVisibleDate : undefined}
|
||||
selectedDate={selectedDate}
|
||||
events={events}
|
||||
eventsByDay={eventsByDay}
|
||||
calendars={calendars}
|
||||
weekStartsOn={calendarFirstDayOfWeek}
|
||||
timeFormat={calendarTimeFormat}
|
||||
onSelectDate={handleSelectDate}
|
||||
onSelectEvent={handleSelectEvent}
|
||||
onCreateAtTime={openCreate}
|
||||
/>
|
||||
)}
|
||||
{viewMode === 'week' && !freeScroll && (
|
||||
<WeekView
|
||||
weekDate={focus.date}
|
||||
selectedDate={selectedDate}
|
||||
@@ -1245,7 +1291,7 @@ function makeStyles(c: ThemePalette) {
|
||||
padding: 2,
|
||||
},
|
||||
viewToggleBtn: {
|
||||
width: 32,
|
||||
width: 30,
|
||||
height: 32,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
|
||||
Reference in New Issue
Block a user