mirror of
https://github.com/bulwarkmail/native.git
synced 2026-10-31 08:06:36 +00:00
feat: scroll the calendar month view continuously #759
The month view showed one month at a time and only the arrows moved it. With the new "Free scrolling" setting (on by default, the webmail's `calendarFreeScroll`) it is one continuous list of weeks, as in the webmail (15a5497a, ae403b06): - the month under the upper part of the grid is drawn at full strength, names the title and is where the arrows step from; the first day of each month carries the month's name - reaching the top or the bottom adds weeks there and loads only their events; weeks added above keep the rows on screen in place - rows have a fixed height and snap to whole weeks, so the list stays windowed and navigation lands exactly on the month Turning the setting off brings back the paged month and a fixed 30-day agenda. The week view still pages.
This commit is contained in:
@@ -0,0 +1,265 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import {
|
||||||
|
FlatList,
|
||||||
|
View,
|
||||||
|
type ListRenderItemInfo,
|
||||||
|
type NativeScrollEvent,
|
||||||
|
type NativeSyntheticEvent,
|
||||||
|
} from 'react-native';
|
||||||
|
import type { Calendar, CalendarEvent } from '../../api/types';
|
||||||
|
import {
|
||||||
|
buildEventDayIndex,
|
||||||
|
dayKey,
|
||||||
|
type EventDayIndex,
|
||||||
|
type TimeFormat,
|
||||||
|
} from '../../lib/calendar-utils';
|
||||||
|
import type { CalendarFocus, DayRange } from '../../lib/calendar-scroll-window';
|
||||||
|
import {
|
||||||
|
dayIndexIn,
|
||||||
|
monthFocusRow,
|
||||||
|
monthKeyOf,
|
||||||
|
monthMask,
|
||||||
|
sampledRow,
|
||||||
|
weekDays,
|
||||||
|
windowWeekStarts,
|
||||||
|
} from '../../lib/calendar-month-scroll';
|
||||||
|
import { useCalendarLocale } from '../../lib/calendar-locale';
|
||||||
|
import {
|
||||||
|
MONTH_ROW_HEIGHT,
|
||||||
|
MONTH_ROW_HEIGHT_CHIPS,
|
||||||
|
MonthWeekRow,
|
||||||
|
MonthWeekdayHeader,
|
||||||
|
useMonthStyles,
|
||||||
|
} from './MonthView';
|
||||||
|
|
||||||
|
type WeekStart = 0 | 1 | 6;
|
||||||
|
|
||||||
|
/** Rows the month viewport shows, as one month grid used to. */
|
||||||
|
const VISIBLE_ROWS = 6;
|
||||||
|
/** Navigation closer than this many rows scrolls smoothly; further jumps. */
|
||||||
|
const SMOOTH_SCROLL_ROWS = 12;
|
||||||
|
|
||||||
|
interface MonthScrollViewProps {
|
||||||
|
/** The day the user navigated to; its month's first week is scrolled to the top. */
|
||||||
|
focus: CalendarFocus;
|
||||||
|
/** The loaded window: whole weeks, one row each. */
|
||||||
|
window: DayRange;
|
||||||
|
selectedDate: Date;
|
||||||
|
events: CalendarEvent[];
|
||||||
|
calendars: Calendar[];
|
||||||
|
eventsByDay?: EventDayIndex;
|
||||||
|
weekStartsOn?: WeekStart;
|
||||||
|
showWeekNumbers?: boolean;
|
||||||
|
showTimeInMonthView?: boolean;
|
||||||
|
timeFormat?: TimeFormat;
|
||||||
|
/** Widen the window at the top; omitted once the limit is reached. */
|
||||||
|
onExtendStart?: () => void;
|
||||||
|
/** Widen the window at the bottom; omitted once the limit is reached. */
|
||||||
|
onExtendEnd?: () => void;
|
||||||
|
/** Reports a day of the month in view whenever that month changes. */
|
||||||
|
onVisibleDateChange?: (date: Date) => void;
|
||||||
|
onSelectDate: (date: Date) => void;
|
||||||
|
onLongPressDate?: (date: Date) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Freely scrolling month (#759, webmail 15a5497a): the window's weeks as one
|
||||||
|
* continuous list of fixed-height rows. The month under the upper part of
|
||||||
|
* the viewport is the one in focus: its days are drawn at full strength,
|
||||||
|
* the title follows it, and the first of every month carries its name.
|
||||||
|
* Reaching either end widens the window; rows added above keep what is on
|
||||||
|
* screen in place. The screen remounts the list (keyed by the window) when
|
||||||
|
* navigation starts a fresh window.
|
||||||
|
*/
|
||||||
|
function MonthScrollViewInner({
|
||||||
|
focus,
|
||||||
|
window,
|
||||||
|
selectedDate,
|
||||||
|
events,
|
||||||
|
calendars,
|
||||||
|
eventsByDay,
|
||||||
|
weekStartsOn = 0,
|
||||||
|
showWeekNumbers = false,
|
||||||
|
showTimeInMonthView = false,
|
||||||
|
timeFormat,
|
||||||
|
onExtendStart,
|
||||||
|
onExtendEnd,
|
||||||
|
onVisibleDateChange,
|
||||||
|
onSelectDate,
|
||||||
|
onLongPressDate,
|
||||||
|
}: MonthScrollViewProps) {
|
||||||
|
const styles = useMonthStyles();
|
||||||
|
const { locale } = useCalendarLocale();
|
||||||
|
const index = React.useMemo(
|
||||||
|
() => eventsByDay ?? buildEventDayIndex(events),
|
||||||
|
[eventsByDay, events],
|
||||||
|
);
|
||||||
|
const opts = React.useMemo(() => ({ weekStartsOn }), [weekStartsOn]);
|
||||||
|
|
||||||
|
// One row per week of the window. A week keeps its array across window
|
||||||
|
// changes, so growing the window doesn't re-render the rows already there.
|
||||||
|
const weekCacheRef = React.useRef(new Map<string, Date[]>());
|
||||||
|
const weeks = React.useMemo(() => {
|
||||||
|
const previous = weekCacheRef.current;
|
||||||
|
const next = new Map<string, Date[]>();
|
||||||
|
const out = windowWeekStarts(window).map((start) => {
|
||||||
|
const key = dayKey(start);
|
||||||
|
const days = previous.get(key) ?? weekDays(start);
|
||||||
|
next.set(key, days);
|
||||||
|
return days;
|
||||||
|
});
|
||||||
|
weekCacheRef.current = next;
|
||||||
|
return out;
|
||||||
|
}, [window]);
|
||||||
|
const weeksRef = React.useRef(weeks);
|
||||||
|
weeksRef.current = weeks;
|
||||||
|
const rowHeight = showTimeInMonthView ? MONTH_ROW_HEIGHT_CHIPS : MONTH_ROW_HEIGHT;
|
||||||
|
const viewportHeight = rowHeight * VISIBLE_ROWS;
|
||||||
|
|
||||||
|
// The month in focus: set by navigation, then by what scrolls under the
|
||||||
|
// sample line.
|
||||||
|
const [activeMonth, setActiveMonth] = React.useState(() => monthKeyOf(focus.date));
|
||||||
|
const activeMonthRef = React.useRef(activeMonth);
|
||||||
|
const onVisibleRef = React.useRef(onVisibleDateChange);
|
||||||
|
onVisibleRef.current = onVisibleDateChange;
|
||||||
|
const offsetRef = React.useRef(0);
|
||||||
|
// Row at the top of the viewport; a change of row height (the "show time"
|
||||||
|
// setting) remounts the list there.
|
||||||
|
const topRowRef = React.useRef<number | null>(null);
|
||||||
|
|
||||||
|
const handleScroll = React.useCallback(
|
||||||
|
(e: NativeSyntheticEvent<NativeScrollEvent>) => {
|
||||||
|
const offset = e.nativeEvent.contentOffset.y;
|
||||||
|
offsetRef.current = offset;
|
||||||
|
topRowRef.current = Math.round(offset / rowHeight);
|
||||||
|
const list = weeksRef.current;
|
||||||
|
const row = sampledRow(offset, viewportHeight, rowHeight, list.length);
|
||||||
|
const days = list[row];
|
||||||
|
if (!days) return;
|
||||||
|
// Mid-week decides which month a row belongs to.
|
||||||
|
const mid = days[3];
|
||||||
|
const key = monthKeyOf(mid);
|
||||||
|
if (key === activeMonthRef.current) return;
|
||||||
|
activeMonthRef.current = key;
|
||||||
|
setActiveMonth(key);
|
||||||
|
onVisibleRef.current?.(mid);
|
||||||
|
},
|
||||||
|
[viewportHeight, rowHeight],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Navigation inside the window scrolls the focused month's first week to
|
||||||
|
// the top. The list mounts there already, so its own nonce needs nothing.
|
||||||
|
const listRef = React.useRef<FlatList<Date[]>>(null);
|
||||||
|
const handledNonceRef = React.useRef(focus.nonce);
|
||||||
|
const initialRef = React.useRef<{ rowHeight: number; row: number } | null>(null);
|
||||||
|
if (initialRef.current === null || initialRef.current.rowHeight !== rowHeight) {
|
||||||
|
initialRef.current = {
|
||||||
|
rowHeight,
|
||||||
|
row: topRowRef.current ?? monthFocusRow(window, focus.date, weeks.length, opts),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (handledNonceRef.current === focus.nonce) return;
|
||||||
|
handledNonceRef.current = focus.nonce;
|
||||||
|
const row = monthFocusRow(window, focus.date, weeks.length, opts);
|
||||||
|
const currentRow = Math.round(offsetRef.current / rowHeight);
|
||||||
|
const key = monthKeyOf(focus.date);
|
||||||
|
activeMonthRef.current = key;
|
||||||
|
setActiveMonth(key);
|
||||||
|
listRef.current?.scrollToIndex({
|
||||||
|
index: row,
|
||||||
|
animated: Math.abs(row - currentRow) <= SMOOTH_SCROLL_ROWS,
|
||||||
|
});
|
||||||
|
}, [focus, window, weeks.length, opts, rowHeight]);
|
||||||
|
|
||||||
|
const selectedKey = dayKey(selectedDate);
|
||||||
|
const todayKey = dayKey(new Date());
|
||||||
|
|
||||||
|
const renderItem = React.useCallback(
|
||||||
|
({ item: days }: ListRenderItemInfo<Date[]>) => {
|
||||||
|
return (
|
||||||
|
<MonthWeekRow
|
||||||
|
days={days}
|
||||||
|
activeMask={monthMask(days, activeMonth)}
|
||||||
|
selectedIndex={dayIndexIn(days, selectedDate)}
|
||||||
|
todayIndex={dayIndexIn(days, new Date())}
|
||||||
|
index={index}
|
||||||
|
calendars={calendars}
|
||||||
|
weekStartsOn={weekStartsOn}
|
||||||
|
showWeekNumbers={showWeekNumbers}
|
||||||
|
showTimeInMonthView={showTimeInMonthView}
|
||||||
|
timeFormat={timeFormat}
|
||||||
|
labelMonths
|
||||||
|
height={rowHeight}
|
||||||
|
locale={locale}
|
||||||
|
styles={styles}
|
||||||
|
onSelectDate={onSelectDate}
|
||||||
|
onLongPressDate={onLongPressDate}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[
|
||||||
|
activeMonth,
|
||||||
|
selectedDate,
|
||||||
|
index,
|
||||||
|
calendars,
|
||||||
|
weekStartsOn,
|
||||||
|
showWeekNumbers,
|
||||||
|
showTimeInMonthView,
|
||||||
|
timeFormat,
|
||||||
|
rowHeight,
|
||||||
|
locale,
|
||||||
|
styles,
|
||||||
|
onSelectDate,
|
||||||
|
onLongPressDate,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
const getItemLayout = React.useCallback(
|
||||||
|
(_: ArrayLike<Date[]> | null | undefined, i: number) => ({
|
||||||
|
length: rowHeight,
|
||||||
|
offset: rowHeight * i,
|
||||||
|
index: i,
|
||||||
|
}),
|
||||||
|
[rowHeight],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.grid}>
|
||||||
|
<MonthWeekdayHeader weekStartsOn={weekStartsOn} showWeekNumbers={showWeekNumbers} styles={styles} />
|
||||||
|
<FlatList
|
||||||
|
key={rowHeight}
|
||||||
|
ref={listRef}
|
||||||
|
style={{ height: viewportHeight }}
|
||||||
|
data={weeks}
|
||||||
|
keyExtractor={weekKey}
|
||||||
|
renderItem={renderItem}
|
||||||
|
extraData={`${activeMonth}|${selectedKey}|${todayKey}`}
|
||||||
|
getItemLayout={getItemLayout}
|
||||||
|
initialScrollIndex={Math.min(initialRef.current.row, weeks.length - 1)}
|
||||||
|
// Weeks added above keep the rows on screen where they are.
|
||||||
|
maintainVisibleContentPosition={{ minIndexForVisible: 0 }}
|
||||||
|
// Half the grid: the window opens with a month of room either side,
|
||||||
|
// so this doesn't fire while the list settles on the focus.
|
||||||
|
onStartReached={onExtendStart}
|
||||||
|
onStartReachedThreshold={0.5}
|
||||||
|
onEndReached={onExtendEnd}
|
||||||
|
onEndReachedThreshold={0.5}
|
||||||
|
onScroll={handleScroll}
|
||||||
|
scrollEventThrottle={32}
|
||||||
|
snapToInterval={rowHeight}
|
||||||
|
decelerationRate="fast"
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
initialNumToRender={VISIBLE_ROWS + 2}
|
||||||
|
maxToRenderPerBatch={VISIBLE_ROWS}
|
||||||
|
windowSize={5}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MonthScrollView = React.memo(MonthScrollViewInner);
|
||||||
|
|
||||||
|
function weekKey(days: Date[]): string {
|
||||||
|
return dayKey(days[0]);
|
||||||
|
}
|
||||||
@@ -7,11 +7,9 @@ import {
|
|||||||
format,
|
format,
|
||||||
getISOWeek,
|
getISOWeek,
|
||||||
getWeek,
|
getWeek,
|
||||||
isSameDay,
|
|
||||||
isSameMonth,
|
|
||||||
isToday,
|
|
||||||
startOfMonth,
|
startOfMonth,
|
||||||
startOfWeek,
|
startOfWeek,
|
||||||
|
type Locale,
|
||||||
} from 'date-fns';
|
} from 'date-fns';
|
||||||
import type { Calendar, CalendarEvent } from '../../api/types';
|
import type { Calendar, CalendarEvent } from '../../api/types';
|
||||||
import { componentSizes, radius, spacing, typography, type ThemePalette } from '../../theme/tokens';
|
import { componentSizes, radius, spacing, typography, type ThemePalette } from '../../theme/tokens';
|
||||||
@@ -25,10 +23,16 @@ import {
|
|||||||
type EventDayIndex,
|
type EventDayIndex,
|
||||||
type TimeFormat,
|
type TimeFormat,
|
||||||
} from '../../lib/calendar-utils';
|
} from '../../lib/calendar-utils';
|
||||||
|
import { dayIndexIn, monthKeyOf, monthMask } from '../../lib/calendar-month-scroll';
|
||||||
import { useCalendarLocale } from '../../lib/calendar-locale';
|
import { useCalendarLocale } from '../../lib/calendar-locale';
|
||||||
|
|
||||||
type WeekStart = 0 | 1 | 6;
|
type WeekStart = 0 | 1 | 6;
|
||||||
|
|
||||||
|
// Height of a week row with dots, and with event chips (#666). The freely
|
||||||
|
// scrolling month uses them as fixed row heights.
|
||||||
|
export const MONTH_ROW_HEIGHT = 54;
|
||||||
|
export const MONTH_ROW_HEIGHT_CHIPS = 74;
|
||||||
|
|
||||||
interface MonthViewProps {
|
interface MonthViewProps {
|
||||||
currentDate: Date;
|
currentDate: Date;
|
||||||
selectedDate: Date;
|
selectedDate: Date;
|
||||||
@@ -52,6 +56,175 @@ export function weekNumberFor(date: Date, weekStartsOn: WeekStart): number {
|
|||||||
return getWeek(date, { weekStartsOn });
|
return getWeek(date, { weekStartsOn });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type MonthStyles = ReturnType<typeof makeStyles>;
|
||||||
|
|
||||||
|
export function useMonthStyles(): MonthStyles {
|
||||||
|
const c = useColors();
|
||||||
|
return React.useMemo(() => makeStyles(c), [c]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MonthWeekdayHeader({
|
||||||
|
weekStartsOn,
|
||||||
|
showWeekNumbers,
|
||||||
|
styles,
|
||||||
|
}: {
|
||||||
|
weekStartsOn: WeekStart;
|
||||||
|
showWeekNumbers: boolean;
|
||||||
|
styles: MonthStyles;
|
||||||
|
}) {
|
||||||
|
const { locale } = useCalendarLocale();
|
||||||
|
const labels = React.useMemo(() => {
|
||||||
|
const start = startOfWeek(new Date(), { weekStartsOn });
|
||||||
|
return Array.from({ length: 7 }, (_, i) => format(addDays(start, i), 'EEEEE', { locale }));
|
||||||
|
}, [weekStartsOn, locale]);
|
||||||
|
return (
|
||||||
|
<View style={styles.weekdayRow}>
|
||||||
|
{showWeekNumbers && <Text style={styles.weekNumberLabel}>#</Text>}
|
||||||
|
{labels.map((wd, i) => (
|
||||||
|
<Text key={i} style={styles.weekdayLabel}>{wd}</Text>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MonthWeekRowProps {
|
||||||
|
days: Date[];
|
||||||
|
/** Bit i set: day i belongs to the month in focus (others are muted). */
|
||||||
|
activeMask: number;
|
||||||
|
/** Index of the selected / today's day in this row, or -1. */
|
||||||
|
selectedIndex: number;
|
||||||
|
todayIndex: number;
|
||||||
|
index: EventDayIndex;
|
||||||
|
calendars: Calendar[];
|
||||||
|
weekStartsOn: WeekStart;
|
||||||
|
showWeekNumbers: boolean;
|
||||||
|
showTimeInMonthView: boolean;
|
||||||
|
timeFormat?: TimeFormat;
|
||||||
|
/** Mark the first day of a month with the month's name (continuous scrolling). */
|
||||||
|
labelMonths?: boolean;
|
||||||
|
/** Fixed row height (continuous scrolling); natural height otherwise. */
|
||||||
|
height?: number;
|
||||||
|
locale: Locale;
|
||||||
|
styles: MonthStyles;
|
||||||
|
onSelectDate: (date: Date) => void;
|
||||||
|
onLongPressDate?: (date: Date) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function MonthWeekRowInner({
|
||||||
|
days,
|
||||||
|
activeMask,
|
||||||
|
selectedIndex,
|
||||||
|
todayIndex,
|
||||||
|
index,
|
||||||
|
calendars,
|
||||||
|
weekStartsOn,
|
||||||
|
showWeekNumbers,
|
||||||
|
showTimeInMonthView,
|
||||||
|
timeFormat,
|
||||||
|
labelMonths = false,
|
||||||
|
height,
|
||||||
|
locale,
|
||||||
|
styles,
|
||||||
|
onSelectDate,
|
||||||
|
onLongPressDate,
|
||||||
|
}: MonthWeekRowProps) {
|
||||||
|
return (
|
||||||
|
<View style={[styles.weekRow, height !== undefined && { height, overflow: 'hidden' }]}>
|
||||||
|
{showWeekNumbers && (
|
||||||
|
<Text style={styles.weekNumberCell}>{weekNumberFor(days[0], weekStartsOn)}</Text>
|
||||||
|
)}
|
||||||
|
{days.map((d, i) => {
|
||||||
|
const sameMonth = (activeMask & (1 << i)) !== 0;
|
||||||
|
const today = i === todayIndex;
|
||||||
|
const selected = i === selectedIndex;
|
||||||
|
const dayEvents = eventsOnDayFromIndex(index, d);
|
||||||
|
const MAX_DOTS = dayEvents.length > 3 ? 2 : 3;
|
||||||
|
const MAX_CHIPS = dayEvents.length > 2 ? 1 : 2;
|
||||||
|
const maxVisible = showTimeInMonthView ? MAX_CHIPS : MAX_DOTS;
|
||||||
|
const visible = dayEvents.slice(0, maxVisible);
|
||||||
|
const overflow = Math.max(0, dayEvents.length - maxVisible);
|
||||||
|
const monthLabel = labelMonths && d.getDate() === 1 ? format(d, 'MMM', { locale }) : null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
key={i}
|
||||||
|
style={[styles.dayCell, showTimeInMonthView && styles.dayCellTall]}
|
||||||
|
onPress={() => onSelectDate(d)}
|
||||||
|
onLongPress={onLongPressDate ? () => onLongPressDate(d) : undefined}
|
||||||
|
>
|
||||||
|
<View style={[
|
||||||
|
styles.dayNumber,
|
||||||
|
today && styles.todayCircle,
|
||||||
|
selected && !today && styles.selectedCircle,
|
||||||
|
]}>
|
||||||
|
{monthLabel && (
|
||||||
|
<Text
|
||||||
|
numberOfLines={1}
|
||||||
|
style={[
|
||||||
|
styles.monthLabel,
|
||||||
|
!sameMonth && styles.dayTextMuted,
|
||||||
|
today && styles.todayText,
|
||||||
|
selected && !today && styles.selectedText,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{monthLabel}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
<Text style={[
|
||||||
|
styles.dayText,
|
||||||
|
monthLabel !== null && styles.dayTextUnderLabel,
|
||||||
|
!sameMonth && styles.dayTextMuted,
|
||||||
|
today && styles.todayText,
|
||||||
|
selected && !today && styles.selectedText,
|
||||||
|
]}>
|
||||||
|
{format(d, 'd')}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
{dayEvents.length > 0 && !showTimeInMonthView && (
|
||||||
|
<View style={styles.dotsRow}>
|
||||||
|
{visible.map((event, idx) => (
|
||||||
|
<View
|
||||||
|
key={`${event.id}-${idx}`}
|
||||||
|
style={[styles.dot, { backgroundColor: getEventColor(event, calendars) }]}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{overflow > 0 && (
|
||||||
|
<Text style={styles.overflowText}>+{overflow}</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
{dayEvents.length > 0 && showTimeInMonthView && (
|
||||||
|
<View style={styles.chipsCol}>
|
||||||
|
{visible.map((event, idx) => {
|
||||||
|
const color = getEventColor(event, calendars);
|
||||||
|
const cancelled = event.status === 'cancelled';
|
||||||
|
return (
|
||||||
|
<View key={`${event.id}-${idx}`} style={[styles.chip, { backgroundColor: color }]}>
|
||||||
|
<Text
|
||||||
|
style={[styles.chipText, cancelled && styles.chipTextCancelled]}
|
||||||
|
numberOfLines={1}
|
||||||
|
>
|
||||||
|
{event.showWithoutTime
|
||||||
|
? (event.title || '')
|
||||||
|
: `${format(getEventStartDate(event), timePattern(timeFormat), { locale })} ${event.title || ''}`}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{overflow > 0 && (
|
||||||
|
<Text style={styles.overflowText}>+{overflow}</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MonthWeekRow = React.memo(MonthWeekRowInner);
|
||||||
|
|
||||||
function MonthViewInner({
|
function MonthViewInner({
|
||||||
currentDate,
|
currentDate,
|
||||||
selectedDate,
|
selectedDate,
|
||||||
@@ -65,130 +238,50 @@ function MonthViewInner({
|
|||||||
onSelectDate,
|
onSelectDate,
|
||||||
onLongPressDate,
|
onLongPressDate,
|
||||||
}: MonthViewProps) {
|
}: MonthViewProps) {
|
||||||
const c = useColors();
|
const styles = useMonthStyles();
|
||||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
|
||||||
const { locale } = useCalendarLocale();
|
const { locale } = useCalendarLocale();
|
||||||
const weekdayLabels = React.useMemo(() => {
|
|
||||||
const start = startOfWeek(new Date(), { weekStartsOn });
|
|
||||||
return Array.from({ length: 7 }, (_, i) => format(addDays(start, i), 'EEEEE', { locale }));
|
|
||||||
}, [weekStartsOn, locale]);
|
|
||||||
const index = React.useMemo(
|
const index = React.useMemo(
|
||||||
() => eventsByDay ?? buildEventDayIndex(events),
|
() => eventsByDay ?? buildEventDayIndex(events),
|
||||||
[eventsByDay, events],
|
[eventsByDay, events],
|
||||||
);
|
);
|
||||||
const days = React.useMemo(() => {
|
|
||||||
const monthStart = startOfMonth(currentDate);
|
// The month grid as rows of 7 days.
|
||||||
const monthEnd = endOfMonth(currentDate);
|
const rows = React.useMemo(() => {
|
||||||
const calStart = startOfWeek(monthStart, { weekStartsOn });
|
const calStart = startOfWeek(startOfMonth(currentDate), { weekStartsOn });
|
||||||
const calEnd = endOfWeek(monthEnd, { weekStartsOn });
|
const calEnd = endOfWeek(endOfMonth(currentDate), { weekStartsOn });
|
||||||
const result: Date[] = [];
|
const out: Date[][] = [];
|
||||||
let day = calStart;
|
let day = calStart;
|
||||||
while (day <= calEnd) {
|
while (day <= calEnd) {
|
||||||
result.push(day);
|
out.push(Array.from({ length: 7 }, (_, i) => addDays(day, i)));
|
||||||
day = addDays(day, 1);
|
day = addDays(day, 7);
|
||||||
}
|
}
|
||||||
return result;
|
|
||||||
}, [currentDate, weekStartsOn]);
|
|
||||||
|
|
||||||
// Group days into rows of 7 so we can prepend a week-number column.
|
|
||||||
const rows = React.useMemo(() => {
|
|
||||||
const out: Date[][] = [];
|
|
||||||
for (let i = 0; i < days.length; i += 7) out.push(days.slice(i, i + 7));
|
|
||||||
return out;
|
return out;
|
||||||
}, [days]);
|
}, [currentDate, weekStartsOn]);
|
||||||
|
const activeMonth = monthKeyOf(currentDate);
|
||||||
const renderDay = (d: Date, key: React.Key, flexLayout = false) => {
|
const today = new Date();
|
||||||
const sameMonth = isSameMonth(d, currentDate);
|
|
||||||
const today = isToday(d);
|
|
||||||
const selected = isSameDay(d, selectedDate);
|
|
||||||
const dayEvents = eventsOnDayFromIndex(index, d);
|
|
||||||
const MAX_DOTS = dayEvents.length > 3 ? 2 : 3;
|
|
||||||
const MAX_CHIPS = dayEvents.length > 2 ? 1 : 2;
|
|
||||||
const maxVisible = showTimeInMonthView ? MAX_CHIPS : MAX_DOTS;
|
|
||||||
const visible = dayEvents.slice(0, maxVisible);
|
|
||||||
const overflow = Math.max(0, dayEvents.length - maxVisible);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Pressable
|
|
||||||
key={key}
|
|
||||||
style={[styles.dayCell, flexLayout && styles.dayCellFlex, showTimeInMonthView && styles.dayCellTall]}
|
|
||||||
onPress={() => onSelectDate(d)}
|
|
||||||
onLongPress={onLongPressDate ? () => onLongPressDate(d) : undefined}
|
|
||||||
>
|
|
||||||
<View style={[
|
|
||||||
styles.dayNumber,
|
|
||||||
today && styles.todayCircle,
|
|
||||||
selected && !today && styles.selectedCircle,
|
|
||||||
]}>
|
|
||||||
<Text style={[
|
|
||||||
styles.dayText,
|
|
||||||
!sameMonth && styles.dayTextMuted,
|
|
||||||
today && styles.todayText,
|
|
||||||
selected && !today && styles.selectedText,
|
|
||||||
]}>
|
|
||||||
{format(d, 'd')}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
{dayEvents.length > 0 && !showTimeInMonthView && (
|
|
||||||
<View style={styles.dotsRow}>
|
|
||||||
{visible.map((event, idx) => (
|
|
||||||
<View
|
|
||||||
key={`${event.id}-${idx}`}
|
|
||||||
style={[styles.dot, { backgroundColor: getEventColor(event, calendars) }]}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
{overflow > 0 && (
|
|
||||||
<Text style={styles.overflowText}>+{overflow}</Text>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
{dayEvents.length > 0 && showTimeInMonthView && (
|
|
||||||
<View style={styles.chipsCol}>
|
|
||||||
{visible.map((event, idx) => {
|
|
||||||
const color = getEventColor(event, calendars);
|
|
||||||
const cancelled = event.status === 'cancelled';
|
|
||||||
return (
|
|
||||||
<View key={`${event.id}-${idx}`} style={[styles.chip, { backgroundColor: color }]}>
|
|
||||||
<Text
|
|
||||||
style={[styles.chipText, cancelled && styles.chipTextCancelled]}
|
|
||||||
numberOfLines={1}
|
|
||||||
>
|
|
||||||
{event.showWithoutTime
|
|
||||||
? (event.title || '')
|
|
||||||
: `${format(getEventStartDate(event), timePattern(timeFormat), { locale })} ${event.title || ''}`}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
{overflow > 0 && (
|
|
||||||
<Text style={styles.overflowText}>+{overflow}</Text>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
</Pressable>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.grid}>
|
<View style={styles.grid}>
|
||||||
<View style={styles.weekdayRow}>
|
<MonthWeekdayHeader weekStartsOn={weekStartsOn} showWeekNumbers={showWeekNumbers} styles={styles} />
|
||||||
{showWeekNumbers && <Text style={styles.weekNumberLabel}>#</Text>}
|
{rows.map((days) => (
|
||||||
{weekdayLabels.map((wd, i) => (
|
<MonthWeekRow
|
||||||
<Text key={i} style={styles.weekdayLabel}>{wd}</Text>
|
key={days[0].toISOString()}
|
||||||
))}
|
days={days}
|
||||||
</View>
|
activeMask={monthMask(days, activeMonth)}
|
||||||
{showWeekNumbers ? (
|
selectedIndex={dayIndexIn(days, selectedDate)}
|
||||||
rows.map((row, rowIdx) => (
|
todayIndex={dayIndexIn(days, today)}
|
||||||
<View key={rowIdx} style={styles.weekRow}>
|
index={index}
|
||||||
<Text style={styles.weekNumberCell}>{weekNumberFor(row[0], weekStartsOn)}</Text>
|
calendars={calendars}
|
||||||
{row.map((d, i) => renderDay(d, `${rowIdx}-${i}`, true))}
|
weekStartsOn={weekStartsOn}
|
||||||
</View>
|
showWeekNumbers={showWeekNumbers}
|
||||||
))
|
showTimeInMonthView={showTimeInMonthView}
|
||||||
) : (
|
timeFormat={timeFormat}
|
||||||
<View style={styles.daysGrid}>
|
locale={locale}
|
||||||
{days.map((d, i) => renderDay(d, i))}
|
styles={styles}
|
||||||
</View>
|
onSelectDate={onSelectDate}
|
||||||
)}
|
onLongPressDate={onLongPressDate}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -205,7 +298,6 @@ function makeStyles(c: ThemePalette) {
|
|||||||
...typography.small,
|
...typography.small,
|
||||||
color: c.textMuted,
|
color: c.textMuted,
|
||||||
},
|
},
|
||||||
daysGrid: { flexDirection: 'row', flexWrap: 'wrap' },
|
|
||||||
weekRow: { flexDirection: 'row', alignItems: 'flex-start' },
|
weekRow: { flexDirection: 'row', alignItems: 'flex-start' },
|
||||||
weekNumberLabel: {
|
weekNumberLabel: {
|
||||||
width: 24,
|
width: 24,
|
||||||
@@ -220,9 +312,8 @@ function makeStyles(c: ThemePalette) {
|
|||||||
color: c.textMuted,
|
color: c.textMuted,
|
||||||
paddingVertical: 4,
|
paddingVertical: 4,
|
||||||
},
|
},
|
||||||
dayCell: { width: '14.28%', alignItems: 'center', paddingVertical: 4 },
|
dayCell: { flex: 1, alignItems: 'center', paddingVertical: 4 },
|
||||||
dayCellFlex: { width: undefined, flex: 1 },
|
dayCellTall: { minHeight: MONTH_ROW_HEIGHT_CHIPS, paddingHorizontal: 1 },
|
||||||
dayCellTall: { minHeight: 74, paddingHorizontal: 1 },
|
|
||||||
dayNumber: {
|
dayNumber: {
|
||||||
width: 36,
|
width: 36,
|
||||||
height: 36,
|
height: 36,
|
||||||
@@ -231,6 +322,9 @@ function makeStyles(c: ThemePalette) {
|
|||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
},
|
},
|
||||||
dayText: { ...typography.body, color: c.text },
|
dayText: { ...typography.body, color: c.text },
|
||||||
|
// Day 1 under its month's name: both fit the 36px circle.
|
||||||
|
dayTextUnderLabel: { lineHeight: 16 },
|
||||||
|
monthLabel: { fontSize: 8, lineHeight: 10, fontWeight: '600', color: c.textSecondary, textTransform: 'uppercase' },
|
||||||
dayTextMuted: { color: c.textMuted },
|
dayTextMuted: { color: c.textMuted },
|
||||||
todayCircle: { backgroundColor: c.primary },
|
todayCircle: { backgroundColor: c.primary },
|
||||||
todayText: { color: c.textInverse, fontWeight: '700' },
|
todayText: { color: c.textInverse, fontWeight: '700' },
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ export function CalendarSettings() {
|
|||||||
const timeZone = useSettingsStore((s) => s.calendarTimeZone);
|
const timeZone = useSettingsStore((s) => s.calendarTimeZone);
|
||||||
const showTimeInMonth = useSettingsStore((s) => s.calendarShowTimeInMonth);
|
const showTimeInMonth = useSettingsStore((s) => s.calendarShowTimeInMonth);
|
||||||
const showWeekNumbers = useSettingsStore((s) => s.calendarShowWeekNumbers);
|
const showWeekNumbers = useSettingsStore((s) => s.calendarShowWeekNumbers);
|
||||||
|
const freeScroll = useSettingsStore((s) => s.calendarFreeScroll);
|
||||||
const birthdayCal = useSettingsStore((s) => s.showBirthdayCalendar);
|
const birthdayCal = useSettingsStore((s) => s.showBirthdayCalendar);
|
||||||
const tasksEnabled = useSettingsStore((s) => s.enableCalendarTasks);
|
const tasksEnabled = useSettingsStore((s) => s.enableCalendarTasks);
|
||||||
const showTasksOnCal = useSettingsStore((s) => s.showTasksOnCalendar);
|
const showTasksOnCal = useSettingsStore((s) => s.showTasksOnCalendar);
|
||||||
@@ -140,6 +141,19 @@ export function CalendarSettings() {
|
|||||||
/>
|
/>
|
||||||
</SettingItem>
|
</SettingItem>
|
||||||
|
|
||||||
|
<SettingItem
|
||||||
|
label={t('calendar.settings.calendar_free_scroll', 'Free scrolling')}
|
||||||
|
description={t(
|
||||||
|
'calendar.settings.calendar_free_scroll_desc',
|
||||||
|
'Scroll continuously through months, weeks and days instead of one period at a time',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<ToggleSwitch
|
||||||
|
checked={freeScroll}
|
||||||
|
onChange={(v) => update('calendarFreeScroll', v)}
|
||||||
|
/>
|
||||||
|
</SettingItem>
|
||||||
|
|
||||||
<SettingItem
|
<SettingItem
|
||||||
label={t('calendar.settings.show_birthday_calendar', 'Contact birthday calendar')}
|
label={t('calendar.settings.show_birthday_calendar', 'Contact birthday calendar')}
|
||||||
description={t(
|
description={t(
|
||||||
|
|||||||
@@ -0,0 +1,117 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { dayKey } from '../calendar-utils';
|
||||||
|
import {
|
||||||
|
computeScrollWindow,
|
||||||
|
freshScrollWindowState,
|
||||||
|
growScrollWindow,
|
||||||
|
} from '../calendar-scroll-window';
|
||||||
|
import {
|
||||||
|
dayIndexIn,
|
||||||
|
monthFocusRow,
|
||||||
|
monthKeyOf,
|
||||||
|
monthMask,
|
||||||
|
sampledRow,
|
||||||
|
weekDays,
|
||||||
|
windowWeekStarts,
|
||||||
|
} from '../calendar-month-scroll';
|
||||||
|
|
||||||
|
const opts = { weekStartsOn: 1 as const };
|
||||||
|
|
||||||
|
describe('windowWeekStarts', () => {
|
||||||
|
it('has one row per week of the window, each starting on the first weekday', () => {
|
||||||
|
const win = computeScrollWindow(freshScrollWindowState('month', new Date(2026, 8, 9)), opts);
|
||||||
|
const weeks = windowWeekStarts(win);
|
||||||
|
// 2026-07-27 .. 2026-11-08 is 15 weeks.
|
||||||
|
expect(weeks).toHaveLength(15);
|
||||||
|
expect(dayKey(weeks[0])).toBe('2026-07-27');
|
||||||
|
expect(dayKey(weeks[14])).toBe('2026-11-02');
|
||||||
|
expect(weeks.every((w) => w.getDay() === 1)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps whole days across the DST change', () => {
|
||||||
|
const win = { start: new Date(2026, 9, 19), end: new Date(2026, 10, 1) };
|
||||||
|
const weeks = windowWeekStarts(win);
|
||||||
|
expect(weeks.map(dayKey)).toEqual(['2026-10-19', '2026-10-26']);
|
||||||
|
expect(weeks[1].getHours()).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('adds rows above when the window grows into the past', () => {
|
||||||
|
const state = freshScrollWindowState('month', new Date(2026, 8, 9));
|
||||||
|
const before = windowWeekStarts(computeScrollWindow(state, opts));
|
||||||
|
const after = windowWeekStarts(computeScrollWindow(growScrollWindow(state, 'before'), opts));
|
||||||
|
const added = after.length - before.length;
|
||||||
|
expect(added).toBeGreaterThanOrEqual(4);
|
||||||
|
// The rows that were there keep their dates, shifted down by `added`.
|
||||||
|
expect(after.slice(added).map(dayKey)).toEqual(before.map(dayKey));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('weekDays', () => {
|
||||||
|
it('lists the seven days of a week', () => {
|
||||||
|
expect(weekDays(new Date(2026, 8, 28)).map(dayKey)).toEqual([
|
||||||
|
'2026-09-28', '2026-09-29', '2026-09-30', '2026-10-01', '2026-10-02', '2026-10-03', '2026-10-04',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('monthFocusRow', () => {
|
||||||
|
const win = computeScrollWindow(
|
||||||
|
growScrollWindow(freshScrollWindowState('month', new Date(2026, 8, 9)), 'before'),
|
||||||
|
opts,
|
||||||
|
);
|
||||||
|
const count = windowWeekStarts(win).length;
|
||||||
|
|
||||||
|
it('is the row of the week that starts the month grid', () => {
|
||||||
|
const row = monthFocusRow(win, new Date(2026, 9, 20), count, opts);
|
||||||
|
// October 2026's grid starts on Monday 2026-09-28.
|
||||||
|
expect(dayKey(windowWeekStarts(win)[row])).toBe('2026-09-28');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stays inside the list', () => {
|
||||||
|
expect(monthFocusRow(win, new Date(2020, 0, 1), count, opts)).toBe(0);
|
||||||
|
expect(monthFocusRow(win, new Date(2030, 0, 1), count, opts)).toBe(count - 1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('sampledRow', () => {
|
||||||
|
it('picks the row under 40% of the viewport', () => {
|
||||||
|
// 6 rows of 54px: the sample line is 129.6px below the top.
|
||||||
|
expect(sampledRow(0, 324, 54, 20)).toBe(2);
|
||||||
|
expect(sampledRow(54 * 5, 324, 54, 20)).toBe(7);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clamps to the rows there are', () => {
|
||||||
|
expect(sampledRow(-50, 324, 54, 20)).toBe(1);
|
||||||
|
expect(sampledRow(100000, 324, 54, 20)).toBe(19);
|
||||||
|
expect(sampledRow(0, 324, 54, 0)).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('monthMask / monthKeyOf', () => {
|
||||||
|
it('marks the days of the month in focus', () => {
|
||||||
|
const days = weekDays(new Date(2026, 8, 28)); // Sep 28 .. Oct 4
|
||||||
|
expect(monthMask(days, monthKeyOf(new Date(2026, 8, 1)))).toBe(0b0000111);
|
||||||
|
expect(monthMask(days, monthKeyOf(new Date(2026, 9, 1)))).toBe(0b1111000);
|
||||||
|
expect(monthMask(days, monthKeyOf(new Date(2026, 10, 1)))).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tells months of different years apart', () => {
|
||||||
|
expect(monthKeyOf(new Date(2026, 0, 1))).not.toBe(monthKeyOf(new Date(2027, 0, 1)));
|
||||||
|
expect(monthKeyOf(new Date(2026, 11, 31)) + 1).toBe(monthKeyOf(new Date(2027, 0, 1)));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('dayIndexIn', () => {
|
||||||
|
const days = weekDays(new Date(2026, 8, 28));
|
||||||
|
|
||||||
|
it('finds a day in the row regardless of the time', () => {
|
||||||
|
expect(dayIndexIn(days, new Date(2026, 9, 1, 23, 30))).toBe(3);
|
||||||
|
expect(dayIndexIn(days, new Date(2026, 8, 28))).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is -1 outside the row', () => {
|
||||||
|
expect(dayIndexIn(days, new Date(2026, 9, 5))).toBe(-1);
|
||||||
|
expect(dayIndexIn(days, new Date(2026, 8, 27))).toBe(-1);
|
||||||
|
expect(dayIndexIn([], new Date())).toBe(-1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -47,9 +47,10 @@ describe('baseRange', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('computeScrollWindow', () => {
|
describe('computeScrollWindow', () => {
|
||||||
it('starts as the base range plus the initial days after it, snapped to whole weeks', () => {
|
it('starts as the base range plus a step either side, snapped to whole weeks', () => {
|
||||||
const win = computeScrollWindow(freshScrollWindowState('month', new Date(2026, 8, 9)), opts);
|
const win = computeScrollWindow(freshScrollWindowState('month', new Date(2026, 8, 9)), opts);
|
||||||
expect(local(win.start)).toBe('2026-08-31');
|
// 2026-08-31 - 30 days = 2026-08-01 (Saturday) -> start of that week
|
||||||
|
expect(local(win.start)).toBe('2026-07-27');
|
||||||
// 2026-10-04 + 30 days = 2026-11-03 (Tuesday) -> end of that week
|
// 2026-10-04 + 30 days = 2026-11-03 (Tuesday) -> end of that week
|
||||||
expect(local(win.end)).toBe('2026-11-08');
|
expect(local(win.end)).toBe('2026-11-08');
|
||||||
expect(win.canExtendStart).toBe(true);
|
expect(win.canExtendStart).toBe(true);
|
||||||
@@ -76,15 +77,15 @@ describe('computeScrollWindow', () => {
|
|||||||
|
|
||||||
it('does not snap the day view to weeks', () => {
|
it('does not snap the day view to weeks', () => {
|
||||||
const win = computeScrollWindow(freshScrollWindowState('day', new Date(2026, 8, 9)), opts);
|
const win = computeScrollWindow(freshScrollWindowState('day', new Date(2026, 8, 9)), opts);
|
||||||
expect(local(win.start)).toBe('2026-09-09');
|
expect(local(win.start)).toBe('2026-08-10');
|
||||||
expect(local(win.end)).toBe('2026-10-09');
|
expect(local(win.end)).toBe('2026-10-09');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('snaps a grown week window to whole weeks', () => {
|
it('snaps a grown week window to whole weeks', () => {
|
||||||
const state = growScrollWindow(freshScrollWindowState('week', new Date(2026, 8, 9)), 'before');
|
const state = growScrollWindow(freshScrollWindowState('week', new Date(2026, 8, 9)), 'before');
|
||||||
const win = computeScrollWindow(state, opts);
|
const win = computeScrollWindow(state, opts);
|
||||||
// 2026-09-07 - 30 days = 2026-08-08 (Saturday) -> Monday 2026-08-03
|
// 2026-09-07 - 60 days = 2026-07-09 (Thursday) -> Monday 2026-07-06
|
||||||
expect(local(win.start)).toBe('2026-08-03');
|
expect(local(win.start)).toBe('2026-07-06');
|
||||||
expect(win.start.getDay()).toBe(1);
|
expect(win.start.getDay()).toBe(1);
|
||||||
expect(win.end.getDay()).toBe(0);
|
expect(win.end.getDay()).toBe(0);
|
||||||
});
|
});
|
||||||
@@ -137,8 +138,8 @@ describe('normalizeScrollWindowState / scrollWindowContains', () => {
|
|||||||
const win = computeScrollWindow(freshScrollWindowState('month', new Date(2026, 8, 9)), opts);
|
const win = computeScrollWindow(freshScrollWindowState('month', new Date(2026, 8, 9)), opts);
|
||||||
expect(scrollWindowContains(win, 'month', new Date(2026, 9, 15), opts)).toBe(true); // October grid ends Nov 1
|
expect(scrollWindowContains(win, 'month', new Date(2026, 9, 15), opts)).toBe(true); // October grid ends Nov 1
|
||||||
expect(scrollWindowContains(win, 'month', new Date(2026, 10, 15), opts)).toBe(false); // November grid ends Dec 6
|
expect(scrollWindowContains(win, 'month', new Date(2026, 10, 15), opts)).toBe(false); // November grid ends Dec 6
|
||||||
expect(scrollWindowContains(win, 'week', new Date(2026, 7, 31), opts)).toBe(true);
|
expect(scrollWindowContains(win, 'week', new Date(2026, 6, 27), opts)).toBe(true); // window starts Jul 27
|
||||||
expect(scrollWindowContains(win, 'week', new Date(2026, 7, 30), opts)).toBe(false);
|
expect(scrollWindowContains(win, 'week', new Date(2026, 6, 26), opts)).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('keys the anchor by calendar day', () => {
|
it('keys the anchor by calendar day', () => {
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import { addDays, differenceInCalendarDays } from 'date-fns';
|
||||||
|
import { baseRange, type DayRange, type ScrollWindowOptions } from './calendar-scroll-window';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Geometry of the freely scrolling month view (#759, webmail
|
||||||
|
* calendar-month-view): one fixed-height row per week of the window.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Fraction of the viewport height at which the "current month" is sampled. */
|
||||||
|
export const VISIBLE_MONTH_SAMPLE = 0.4;
|
||||||
|
|
||||||
|
/** The first day of every week in the window (which is made of whole weeks). */
|
||||||
|
export function windowWeekStarts(window: DayRange): Date[] {
|
||||||
|
const count = Math.floor(differenceInCalendarDays(window.end, window.start) / 7) + 1;
|
||||||
|
const weeks: Date[] = [];
|
||||||
|
for (let i = 0; i < count; i++) weeks.push(addDays(window.start, i * 7));
|
||||||
|
return weeks;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The seven days of the week starting at `weekStart`. */
|
||||||
|
export function weekDays(weekStart: Date): Date[] {
|
||||||
|
return Array.from({ length: 7 }, (_, i) => addDays(weekStart, i));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Row of the week that starts `date`'s month grid: where navigation puts the top. */
|
||||||
|
export function monthFocusRow(
|
||||||
|
window: DayRange,
|
||||||
|
date: Date,
|
||||||
|
rowCount: number,
|
||||||
|
opts: ScrollWindowOptions,
|
||||||
|
): number {
|
||||||
|
const gridStart = baseRange('month', date, opts).start;
|
||||||
|
const row = Math.floor(differenceInCalendarDays(gridStart, window.start) / 7);
|
||||||
|
return Math.max(0, Math.min(rowCount - 1, row));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Row under the sample line for a scroll offset. */
|
||||||
|
export function sampledRow(
|
||||||
|
offset: number,
|
||||||
|
viewportHeight: number,
|
||||||
|
rowHeight: number,
|
||||||
|
rowCount: number,
|
||||||
|
fraction = VISIBLE_MONTH_SAMPLE,
|
||||||
|
): number {
|
||||||
|
if (rowCount <= 0 || rowHeight <= 0) return 0;
|
||||||
|
const row = Math.floor((offset + viewportHeight * fraction) / rowHeight);
|
||||||
|
return Math.max(0, Math.min(rowCount - 1, row));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Year and month as one comparable number. */
|
||||||
|
export function monthKeyOf(date: Date): number {
|
||||||
|
return date.getFullYear() * 12 + date.getMonth();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bit i is set when day i of the week belongs to the month `monthKey`. Rows
|
||||||
|
* compare this instead of the month itself, so moving to another month only
|
||||||
|
* re-renders the rows whose shading changes.
|
||||||
|
*/
|
||||||
|
export function monthMask(days: Date[], monthKey: number): number {
|
||||||
|
let mask = 0;
|
||||||
|
days.forEach((day, i) => {
|
||||||
|
if (monthKeyOf(day) === monthKey) mask |= 1 << i;
|
||||||
|
});
|
||||||
|
return mask;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Index of `date` in `days` (same calendar day), or -1. */
|
||||||
|
export function dayIndexIn(days: Date[], date: Date): number {
|
||||||
|
if (days.length === 0) return -1;
|
||||||
|
const diff = differenceInCalendarDays(date, days[0]);
|
||||||
|
return diff >= 0 && diff < days.length ? diff : -1;
|
||||||
|
}
|
||||||
@@ -71,13 +71,26 @@ const INITIAL_AFTER: Record<ScrollViewMode, number> = {
|
|||||||
day: SCROLL_WINDOW_STEP,
|
day: SCROLL_WINDOW_STEP,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Days loaded before the base range at first. The grids open with a step
|
||||||
|
* of room above / to the left, so the list isn't mounted right at its start
|
||||||
|
* edge (where rows would be prepended while it is still settling on the
|
||||||
|
* focus). The agenda starts at its anchor and loads the past on request.
|
||||||
|
*/
|
||||||
|
const INITIAL_BEFORE: Record<ScrollViewMode, number> = {
|
||||||
|
month: SCROLL_WINDOW_STEP,
|
||||||
|
agenda: 0,
|
||||||
|
week: SCROLL_WINDOW_STEP,
|
||||||
|
day: SCROLL_WINDOW_STEP,
|
||||||
|
};
|
||||||
|
|
||||||
export function parseDayKey(key: string): Date {
|
export function parseDayKey(key: string): Date {
|
||||||
const [y, m, d] = key.split('-').map(Number);
|
const [y, m, d] = key.split('-').map(Number);
|
||||||
return new Date(y, m - 1, d);
|
return new Date(y, m - 1, d);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function freshScrollWindowState(mode: ScrollViewMode, anchor: Date): ScrollWindowState {
|
export function freshScrollWindowState(mode: ScrollViewMode, anchor: Date): ScrollWindowState {
|
||||||
return { mode, anchorKey: dayKey(anchor), before: 0, after: INITIAL_AFTER[mode] };
|
return { mode, anchorKey: dayKey(anchor), before: INITIAL_BEFORE[mode], after: INITIAL_AFTER[mode] };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ import { Button } from '../components';
|
|||||||
import { useCalendarStore } from '../stores/calendar-store';
|
import { useCalendarStore } from '../stores/calendar-store';
|
||||||
import { useSettingsStore } from '../stores/settings-store';
|
import { useSettingsStore } from '../stores/settings-store';
|
||||||
import { MonthView } from '../components/calendar/MonthView';
|
import { MonthView } from '../components/calendar/MonthView';
|
||||||
|
import { MonthScrollView } from '../components/calendar/MonthScrollView';
|
||||||
import { WeekView } from '../components/calendar/WeekView';
|
import { WeekView } from '../components/calendar/WeekView';
|
||||||
import { AgendaView } from '../components/calendar/AgendaView';
|
import { AgendaView } from '../components/calendar/AgendaView';
|
||||||
import { EventCard } from '../components/calendar/EventCard';
|
import { EventCard } from '../components/calendar/EventCard';
|
||||||
@@ -154,6 +155,7 @@ export default function CalendarScreen() {
|
|||||||
const showTasksOnCalendar = useSettingsStore((s) => s.showTasksOnCalendar);
|
const showTasksOnCalendar = useSettingsStore((s) => s.showTasksOnCalendar);
|
||||||
const calendarFirstDayOfWeek = useSettingsStore((s) => s.calendarFirstDayOfWeek);
|
const calendarFirstDayOfWeek = useSettingsStore((s) => s.calendarFirstDayOfWeek);
|
||||||
const calendarShowWeekNumbers = useSettingsStore((s) => s.calendarShowWeekNumbers);
|
const calendarShowWeekNumbers = useSettingsStore((s) => s.calendarShowWeekNumbers);
|
||||||
|
const calendarFreeScroll = useSettingsStore((s) => s.calendarFreeScroll);
|
||||||
const calendarTimeFormat = useSettingsStore((s) => s.calendarTimeFormat);
|
const calendarTimeFormat = useSettingsStore((s) => s.calendarTimeFormat);
|
||||||
const showBirthdayCalendar = useSettingsStore((s) => s.showBirthdayCalendar);
|
const showBirthdayCalendar = useSettingsStore((s) => s.showBirthdayCalendar);
|
||||||
const enableCalendarTasks = useSettingsStore((s) => s.enableCalendarTasks);
|
const enableCalendarTasks = useSettingsStore((s) => s.enableCalendarTasks);
|
||||||
@@ -172,10 +174,12 @@ export default function CalendarScreen() {
|
|||||||
const [viewMode, setViewMode] = React.useState<ViewMode>(initialViewMode);
|
const [viewMode, setViewMode] = React.useState<ViewMode>(initialViewMode);
|
||||||
|
|
||||||
// Scroll window (#759, webmail calendar-app): every view shows a window of
|
// Scroll window (#759, webmail calendar-app): every view shows a window of
|
||||||
// days around the day the user navigated to (the "focus"). The agenda
|
// days around the day the user navigated to (the "focus"). With free
|
||||||
// scrolls freely: reaching an edge widens that side and only the new days
|
// scrolling (the default) the agenda and the month scroll continuously:
|
||||||
// are fetched. Month and week show exactly one period. Navigation moves the
|
// reaching an edge widens that side and only the new days are fetched.
|
||||||
// focus and, when that leaves the window, starts a fresh window there.
|
// Otherwise, and in the week view, a view shows exactly one period.
|
||||||
|
// Navigation moves the focus and, when that leaves the window, starts a
|
||||||
|
// fresh window there.
|
||||||
const windowOptions = React.useMemo<ScrollWindowOptions>(
|
const windowOptions = React.useMemo<ScrollWindowOptions>(
|
||||||
() => ({ weekStartsOn: calendarFirstDayOfWeek }),
|
() => ({ weekStartsOn: calendarFirstDayOfWeek }),
|
||||||
[calendarFirstDayOfWeek],
|
[calendarFirstDayOfWeek],
|
||||||
@@ -186,7 +190,7 @@ export default function CalendarScreen() {
|
|||||||
const [windowState, setWindowState] = React.useState<ScrollWindowState>(
|
const [windowState, setWindowState] = React.useState<ScrollWindowState>(
|
||||||
() => freshScrollWindowState(initialViewMode, new Date()),
|
() => freshScrollWindowState(initialViewMode, new Date()),
|
||||||
);
|
);
|
||||||
const freeScroll = viewMode === 'agenda';
|
const freeScroll = calendarFreeScroll && (viewMode === 'agenda' || viewMode === 'month');
|
||||||
const focusKey = dayKey(focus.date);
|
const focusKey = dayKey(focus.date);
|
||||||
const activeWindowState = React.useMemo(
|
const activeWindowState = React.useMemo(
|
||||||
() =>
|
() =>
|
||||||
@@ -403,20 +407,16 @@ export default function CalendarScreen() {
|
|||||||
}, [syncDueSubscriptions]);
|
}, [syncDueSubscriptions]);
|
||||||
|
|
||||||
// Load the window's events: one fetch at a time, and only the days not
|
// Load the window's events: one fetch at a time, and only the days not
|
||||||
// loaded yet (calendar-store extendRange). `loadingEdge` is the side an
|
// loaded yet (calendar-store extendRange). `loadingEdge` is the side the
|
||||||
// extension asked for, until the wider window is in.
|
// last extension asked for, until the wider window is in.
|
||||||
const [rangeLoading, setRangeLoading] = React.useState(false);
|
const [rangeLoading, setRangeLoading] = React.useState(false);
|
||||||
const [loadingEdge, setLoadingEdge] = React.useState<'start' | 'end' | null>(null);
|
const [loadingEdge, setLoadingEdge] = React.useState<'start' | 'end' | null>(null);
|
||||||
const loadingEdgeRef = React.useRef<'start' | 'end' | null>(null);
|
|
||||||
const [rangeLoader] = React.useState(() =>
|
const [rangeLoader] = React.useState(() =>
|
||||||
createRangeLoader(
|
createRangeLoader(
|
||||||
(range) => useCalendarStore.getState().extendRange(range.after, range.before),
|
(range) => useCalendarStore.getState().extendRange(range.after, range.before),
|
||||||
(busy) => {
|
(busy) => {
|
||||||
setRangeLoading(busy);
|
setRangeLoading(busy);
|
||||||
if (!busy) {
|
if (!busy) setLoadingEdge(null);
|
||||||
loadingEdgeRef.current = null;
|
|
||||||
setLoadingEdge(null);
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -440,12 +440,11 @@ export default function CalendarScreen() {
|
|||||||
[viewMode, windowOptions],
|
[viewMode, windowOptions],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// The grids add their rows for the wider window right away (the events
|
||||||
|
// follow); the agenda waits for one extension to load before the next.
|
||||||
const extendWindow = React.useCallback(
|
const extendWindow = React.useCallback(
|
||||||
(side: 'before' | 'after') => {
|
(side: 'before' | 'after') => {
|
||||||
if (loadingEdgeRef.current) return;
|
setLoadingEdge(side === 'before' ? 'start' : 'end');
|
||||||
const edge = side === 'before' ? 'start' : 'end';
|
|
||||||
loadingEdgeRef.current = edge;
|
|
||||||
setLoadingEdge(edge);
|
|
||||||
setWindowState((prev) =>
|
setWindowState((prev) =>
|
||||||
growScrollWindow(normalizeScrollWindowState(prev, viewMode, focus.date), side),
|
growScrollWindow(normalizeScrollWindowState(prev, viewMode, focus.date), side),
|
||||||
);
|
);
|
||||||
@@ -957,7 +956,27 @@ export default function CalendarScreen() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<View style={styles.content}>
|
<View style={styles.content}>
|
||||||
{viewMode === 'month' && (
|
{viewMode === 'month' && freeScroll && (
|
||||||
|
<MonthScrollView
|
||||||
|
key={windowKey}
|
||||||
|
focus={focus}
|
||||||
|
window={scrollWindow}
|
||||||
|
onExtendStart={scrollWindow.canExtendStart ? extendWindowStart : undefined}
|
||||||
|
onExtendEnd={scrollWindow.canExtendEnd ? extendWindowEnd : undefined}
|
||||||
|
onVisibleDateChange={setVisibleDate}
|
||||||
|
selectedDate={selectedDate}
|
||||||
|
events={events}
|
||||||
|
eventsByDay={eventsByDay}
|
||||||
|
calendars={calendars}
|
||||||
|
weekStartsOn={calendarFirstDayOfWeek}
|
||||||
|
showWeekNumbers={calendarShowWeekNumbers}
|
||||||
|
showTimeInMonthView={calendarShowTimeInMonth}
|
||||||
|
timeFormat={calendarTimeFormat}
|
||||||
|
onSelectDate={handleSelectDate}
|
||||||
|
onLongPressDate={openCreate}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{viewMode === 'month' && !freeScroll && (
|
||||||
<MonthView
|
<MonthView
|
||||||
currentDate={focus.date}
|
currentDate={focus.date}
|
||||||
selectedDate={selectedDate}
|
selectedDate={selectedDate}
|
||||||
|
|||||||
@@ -232,6 +232,9 @@ interface PersistedSettings {
|
|||||||
calendarTimeFormat: TimeFormat;
|
calendarTimeFormat: TimeFormat;
|
||||||
calendarShowTimeInMonth: boolean;
|
calendarShowTimeInMonth: boolean;
|
||||||
calendarShowWeekNumbers: boolean;
|
calendarShowWeekNumbers: boolean;
|
||||||
|
// Scroll continuously through months, weeks and days (#759) instead of
|
||||||
|
// one period at a time. Same key as the webmail's setting.
|
||||||
|
calendarFreeScroll: boolean;
|
||||||
calendarHoverPreview: CalendarHoverPreview;
|
calendarHoverPreview: CalendarHoverPreview;
|
||||||
// IANA zone the calendar works in, or 'auto' to follow the device (#755).
|
// IANA zone the calendar works in, or 'auto' to follow the device (#755).
|
||||||
// Same key semantics as the webmail's `timeZone` setting.
|
// Same key semantics as the webmail's `timeZone` setting.
|
||||||
@@ -392,6 +395,7 @@ const DEFAULT_PERSISTED: PersistedSettings = {
|
|||||||
calendarTimeFormat: '24h',
|
calendarTimeFormat: '24h',
|
||||||
calendarShowTimeInMonth: true,
|
calendarShowTimeInMonth: true,
|
||||||
calendarShowWeekNumbers: false,
|
calendarShowWeekNumbers: false,
|
||||||
|
calendarFreeScroll: true,
|
||||||
calendarHoverPreview: 'delay-500ms',
|
calendarHoverPreview: 'delay-500ms',
|
||||||
calendarTimeZone: 'auto',
|
calendarTimeZone: 'auto',
|
||||||
showBirthdayCalendar: false,
|
showBirthdayCalendar: false,
|
||||||
|
|||||||
Reference in New Issue
Block a user