fix: show today and the now-line in the calendar time zone

The calendar placed events by the calendar time-zone setting, but
"today", the now-line and the "Today" button still went by the device
clock. With the setting away from the device zone, the grid could
mark yesterday as today, draw the now-line hours off and jump to the
wrong day.

- the views, the agenda's "Today"/"Tomorrow" headers and today anchor,
  the selected day, the scroll window's start and the "Today" button
  use `displayNow`, `isDisplayToday`, `isDisplayTomorrow` and
  `displayNowMinutes`
- a long press at an hour already hands the editor a calendar-zone
  time, which it converts when it saves
- changing the setting re-buckets the days and reloads the loaded
  range, since the server reads floating times and the range bounds
  in that zone
This commit is contained in:
Linus Rath
2026-09-24 20:40:51 +02:00
parent b7f711050e
commit 8af163a6a3
8 changed files with 143 additions and 36 deletions
+7 -5
View File
@@ -9,7 +9,8 @@ import {
type LayoutChangeEvent, type LayoutChangeEvent,
type ViewToken, type ViewToken,
} from 'react-native'; } from 'react-native';
import { format, isSameDay, isToday, isTomorrow, type Locale } from 'date-fns'; import { format, type Locale } from 'date-fns';
import { displayNow, isDisplayToday, isDisplayTomorrow } from '../../lib/calendar-timezone';
import type { Calendar, CalendarEvent } from '../../api/types'; import type { Calendar, CalendarEvent } from '../../api/types';
import { radius, spacing, typography, type ThemePalette } from '../../theme/tokens'; import { radius, spacing, typography, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors'; import { useColors } from '../../theme/colors';
@@ -59,8 +60,9 @@ function formatDayHeader(
t: (key: string, fallback?: string) => string, t: (key: string, fallback?: string) => string,
locale: Locale, locale: Locale,
): string { ): string {
if (isToday(date)) return t('calendar.events.today_header', 'Today'); // Today and tomorrow on a clock in the calendar's time zone.
if (isTomorrow(date)) return t('calendar.events.tomorrow_header', 'Tomorrow'); if (isDisplayToday(date)) return t('calendar.events.today_header', 'Today');
if (isDisplayTomorrow(date)) return t('calendar.events.tomorrow_header', 'Tomorrow');
return format(date, 'EEEE, MMM d', { locale }); return format(date, 'EEEE, MMM d', { locale });
} }
@@ -101,7 +103,7 @@ export function AgendaView({
const sections = React.useMemo<DaySection[]>( const sections = React.useMemo<DaySection[]>(
() => () =>
buildAgendaDays(index, loaded, new Date()).map((day) => ({ buildAgendaDays(index, loaded, displayNow()).map((day) => ({
...day, ...day,
key: dayKey(day.date), key: dayKey(day.date),
title: formatDayHeader(day.date, t, locale), title: formatDayHeader(day.date, t, locale),
@@ -310,7 +312,7 @@ export function AgendaView({
maxToRenderPerBatch={12} maxToRenderPerBatch={12}
windowSize={11} windowSize={11}
renderSectionHeader={({ section }) => { renderSectionHeader={({ section }) => {
const today = isSameDay(section.date, new Date()); const today = isDisplayToday(section.date);
return ( return (
<View style={styles.sectionHeader}> <View style={styles.sectionHeader}>
<Text style={[styles.sectionTitle, today && styles.sectionTitleToday]}> <Text style={[styles.sectionTitle, today && styles.sectionTitleToday]}>
+4 -2
View File
@@ -24,6 +24,7 @@ import {
windowWeekStarts, windowWeekStarts,
} from '../../lib/calendar-month-scroll'; } from '../../lib/calendar-month-scroll';
import { useCalendarLocale } from '../../lib/calendar-locale'; import { useCalendarLocale } from '../../lib/calendar-locale';
import { displayNow } from '../../lib/calendar-timezone';
import { import {
MONTH_ROW_HEIGHT, MONTH_ROW_HEIGHT,
MONTH_ROW_HEIGHT_CHIPS, MONTH_ROW_HEIGHT_CHIPS,
@@ -173,7 +174,8 @@ function MonthScrollViewInner({
}, [focus, window, weeks.length, opts, rowHeight]); }, [focus, window, weeks.length, opts, rowHeight]);
const selectedKey = dayKey(selectedDate); const selectedKey = dayKey(selectedDate);
const todayKey = dayKey(new Date()); // Today on a clock in the calendar's time zone.
const todayKey = dayKey(displayNow());
const renderItem = React.useCallback( const renderItem = React.useCallback(
({ item: days }: ListRenderItemInfo<Date[]>) => { ({ item: days }: ListRenderItemInfo<Date[]>) => {
@@ -182,7 +184,7 @@ function MonthScrollViewInner({
days={days} days={days}
activeMask={monthMask(days, activeMonth)} activeMask={monthMask(days, activeMonth)}
selectedIndex={dayIndexIn(days, selectedDate)} selectedIndex={dayIndexIn(days, selectedDate)}
todayIndex={dayIndexIn(days, new Date())} todayIndex={dayIndexIn(days, displayNow())}
index={index} index={index}
calendars={calendars} calendars={calendars}
weekStartsOn={weekStartsOn} weekStartsOn={weekStartsOn}
+3 -1
View File
@@ -25,6 +25,7 @@ import {
} from '../../lib/calendar-utils'; } from '../../lib/calendar-utils';
import { dayIndexIn, monthKeyOf, monthMask } from '../../lib/calendar-month-scroll'; import { dayIndexIn, monthKeyOf, monthMask } from '../../lib/calendar-month-scroll';
import { useCalendarLocale } from '../../lib/calendar-locale'; import { useCalendarLocale } from '../../lib/calendar-locale';
import { displayNow } from '../../lib/calendar-timezone';
type WeekStart = 0 | 1 | 6; type WeekStart = 0 | 1 | 6;
@@ -258,7 +259,8 @@ function MonthViewInner({
return out; return out;
}, [currentDate, weekStartsOn]); }, [currentDate, weekStartsOn]);
const activeMonth = monthKeyOf(currentDate); const activeMonth = monthKeyOf(currentDate);
const today = new Date(); // Today on a clock in the calendar's time zone.
const today = displayNow();
return ( return (
<View style={styles.grid}> <View style={styles.grid}>
@@ -32,6 +32,7 @@ import {
type TimedEventLayout, type TimedEventLayout,
} from '../../lib/calendar-utils'; } from '../../lib/calendar-utils';
import type { CalendarFocus, DayRange } from '../../lib/calendar-scroll-window'; import type { CalendarFocus, DayRange } from '../../lib/calendar-scroll-window';
import { displayNow, displayNowMinutes } from '../../lib/calendar-timezone';
import { import {
buildAllDaySegments, buildAllDaySegments,
headerColumnRange, headerColumnRange,
@@ -262,21 +263,18 @@ function TimeGridScrollViewInner({
// Start the hours at the one before now, like the paged week view. // Start the hours at the one before now, like the paged week view.
const vScrollRef = React.useRef<ScrollView>(null); const vScrollRef = React.useRef<ScrollView>(null);
React.useEffect(() => { React.useEffect(() => {
const target = Math.max(0, (new Date().getHours() - 1) * HOUR_HEIGHT); const target = Math.max(0, (displayNow().getHours() - 1) * HOUR_HEIGHT);
const frame = requestAnimationFrame(() => { const frame = requestAnimationFrame(() => {
vScrollRef.current?.scrollTo({ y: target, animated: false }); vScrollRef.current?.scrollTo({ y: target, animated: false });
}); });
return () => cancelAnimationFrame(frame); return () => cancelAnimationFrame(frame);
}, []); }, []);
const [nowMinutes, setNowMinutes] = React.useState(() => { // The now-line and "today" follow a clock in the calendar's time zone.
const now = new Date(); const [nowMinutes, setNowMinutes] = React.useState(displayNowMinutes);
return now.getHours() * 60 + now.getMinutes();
});
React.useEffect(() => { React.useEffect(() => {
const interval = setInterval(() => { const interval = setInterval(() => {
const now = new Date(); setNowMinutes(displayNowMinutes());
setNowMinutes(now.getHours() * 60 + now.getMinutes());
}, 60_000); }, 60_000);
return () => clearInterval(interval); return () => clearInterval(interval);
}, []); }, []);
@@ -284,6 +282,8 @@ function TimeGridScrollViewInner({
const handleLongPressAt = React.useCallback( const handleLongPressAt = React.useCallback(
(day: Date, hour: number) => { (day: Date, hour: number) => {
if (!onCreateAtTime) return; if (!onCreateAtTime) return;
// A display date (that hour in the calendar's zone); the editor turns
// it into the real instant when it saves (eventTimeFieldsToSave).
const date = new Date(day); const date = new Date(day);
date.setHours(hour, 0, 0, 0); date.setHours(hour, 0, 0, 0);
onCreateAtTime(date); onCreateAtTime(date);
@@ -291,7 +291,7 @@ function TimeGridScrollViewInner({
[onCreateAtTime], [onCreateAtTime],
); );
const todayKey = dayKey(new Date()); const todayKey = dayKey(displayNow());
const renderItem = React.useCallback( const renderItem = React.useCallback(
({ item: day }: ListRenderItemInfo<Date>) => ( ({ item: day }: ListRenderItemInfo<Date>) => (
<DayColumn <DayColumn
+9 -10
View File
@@ -6,7 +6,8 @@ import {
ScrollView, ScrollView,
Pressable, Pressable,
} from 'react-native'; } from 'react-native';
import { addDays, format, isSameDay, isToday, startOfWeek } from 'date-fns'; import { addDays, format, isSameDay, startOfWeek } from 'date-fns';
import { displayNow, displayNowMinutes, isDisplayToday } from '../../lib/calendar-timezone';
import type { Calendar, CalendarEvent } from '../../api/types'; import type { Calendar, CalendarEvent } from '../../api/types';
import { radius, spacing, typography, type ThemePalette } from '../../theme/tokens'; import { radius, spacing, typography, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors'; import { useColors } from '../../theme/colors';
@@ -106,21 +107,18 @@ function WeekViewInner({
}); });
}, [weekDays, index]); }, [weekDays, index]);
const [nowMinutes, setNowMinutes] = React.useState(() => { // The now-line and "today" follow a clock in the calendar's time zone.
const now = new Date(); const [nowMinutes, setNowMinutes] = React.useState(displayNowMinutes);
return now.getHours() * 60 + now.getMinutes();
});
React.useEffect(() => { React.useEffect(() => {
const interval = setInterval(() => { const interval = setInterval(() => {
const now = new Date(); setNowMinutes(displayNowMinutes());
setNowMinutes(now.getHours() * 60 + now.getMinutes());
}, 60_000); }, 60_000);
return () => clearInterval(interval); return () => clearInterval(interval);
}, []); }, []);
React.useEffect(() => { React.useEffect(() => {
const target = Math.max(0, (new Date().getHours() - 1) * HOUR_HEIGHT); const target = Math.max(0, (displayNow().getHours() - 1) * HOUR_HEIGHT);
requestAnimationFrame(() => { requestAnimationFrame(() => {
scrollRef.current?.scrollTo({ y: target, animated: false }); scrollRef.current?.scrollTo({ y: target, animated: false });
}); });
@@ -128,13 +126,14 @@ function WeekViewInner({
const handleSlotLongPress = (day: Date, hour: number) => { const handleSlotLongPress = (day: Date, hour: number) => {
if (!onCreateAtTime) return; if (!onCreateAtTime) return;
// A display date; the editor converts it when it saves.
const date = new Date(day); const date = new Date(day);
date.setHours(hour, 0, 0, 0); date.setHours(hour, 0, 0, 0);
onCreateAtTime(date); onCreateAtTime(date);
}; };
const dayHeader = (day: Date) => { const dayHeader = (day: Date) => {
const today = isToday(day); const today = isDisplayToday(day);
const selected = isSameDay(day, selectedDate); const selected = isSameDay(day, selectedDate);
return ( return (
<Pressable <Pressable
@@ -244,7 +243,7 @@ function WeekViewInner({
<View style={styles.dayCols}> <View style={styles.dayCols}>
{weekDays.map((day, dayIndex) => { {weekDays.map((day, dayIndex) => {
const layouted = layoutsByDay[dayIndex]; const layouted = layoutsByDay[dayIndex];
const todayCol = isToday(day); const todayCol = isDisplayToday(day);
return ( return (
<View key={day.toISOString()} style={styles.dayCol}> <View key={day.toISOString()} style={styles.dayCol}>
{HOURS.map((h) => ( {HOURS.map((h) => (
+70
View File
@@ -0,0 +1,70 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
// Device in New York, calendar in Berlin. At 23:30 UTC on 15 January it is
// still the 15th (18:30) on the device but already the 16th (00:30) on the
// calendar's clock.
vi.hoisted(() => {
process.env.TZ = 'America/New_York';
});
import { useSettingsStore } from '../../stores/settings-store';
import {
displayNow,
displayNowMinutes,
isDisplayToday,
isDisplayTomorrow,
} from '../calendar-timezone';
import { buildAgendaDays } from '../calendar-agenda';
import { eventTimeFieldsToSave } from '../event-editor-times';
const NOW = new Date('2026-01-15T23:30:00Z');
beforeEach(() => {
vi.useFakeTimers({ now: NOW });
useSettingsStore.setState({ calendarTimeZone: 'Europe/Berlin' });
});
afterEach(() => {
vi.useRealTimers();
useSettingsStore.setState({ calendarTimeZone: 'auto' });
});
describe('today in the calendar time zone', () => {
it('reads now on a clock in the calendar zone', () => {
const now = displayNow();
expect([now.getDate(), now.getHours(), now.getMinutes()]).toEqual([16, 0, 30]);
expect(displayNowMinutes()).toBe(30);
});
it('marks the calendar zone\'s today and tomorrow, not the device\'s', () => {
expect(isDisplayToday(new Date(2026, 0, 16))).toBe(true);
expect(isDisplayToday(new Date(2026, 0, 15))).toBe(false);
expect(isDisplayTomorrow(new Date(2026, 0, 17))).toBe(true);
expect(isDisplayTomorrow(new Date(2026, 0, 16))).toBe(false);
});
it('follows the device again when the calendar does', () => {
useSettingsStore.setState({ calendarTimeZone: 'auto' });
expect(displayNow().getDate()).toBe(15);
expect(displayNowMinutes()).toBe(18 * 60 + 30);
expect(isDisplayToday(new Date(2026, 0, 15))).toBe(true);
});
it('anchors the agenda on the calendar zone\'s today', () => {
const days = buildAgendaDays(new Map(), { start: new Date(2026, 0, 10), end: new Date(2026, 0, 20) }, displayNow());
expect(days.map((d) => d.date.getDate())).toEqual([16]);
});
it('creates an event from a long press at that hour of the calendar zone', () => {
// What the grids hand the editor for a long press at 09:00 on the 16th.
const start = new Date(2026, 0, 16);
start.setHours(9, 0, 0, 0);
const end = new Date(start.getTime() + 60 * 60_000);
expect(eventTimeFieldsToSave({ allDay: false, start, end })).toEqual({
start: '2026-01-16T09:00:00',
duration: 'PT1H',
timeZone: 'Europe/Berlin',
showWithoutTime: undefined,
});
});
});
+19 -3
View File
@@ -222,10 +222,26 @@ export function displayNow(): Date {
return toDisplayDate(new Date()); return toDisplayDate(new Date());
} }
function sameLocalDay(a: Date, b: Date): boolean {
return a.getFullYear() === b.getFullYear()
&& a.getMonth() === b.getMonth()
&& a.getDate() === b.getDate();
}
/** Is the display date `date` on today's calendar day in the calendar's zone? */ /** Is the display date `date` on today's calendar day in the calendar's zone? */
export function isDisplayToday(date: Date): boolean { export function isDisplayToday(date: Date): boolean {
return sameLocalDay(date, displayNow());
}
/** Is the display date `date` on tomorrow's calendar day in the calendar's zone? */
export function isDisplayTomorrow(date: Date): boolean {
const tomorrow = displayNow();
tomorrow.setDate(tomorrow.getDate() + 1);
return sameLocalDay(date, tomorrow);
}
/** Minutes since midnight on a clock in the calendar's zone: where the grids draw the now-line. */
export function displayNowMinutes(): number {
const now = displayNow(); const now = displayNow();
return date.getFullYear() === now.getFullYear() return now.getHours() * 60 + now.getMinutes();
&& date.getMonth() === now.getMonth()
&& date.getDate() === now.getDate();
} }
+23 -7
View File
@@ -29,7 +29,6 @@ import {
endOfWeek, endOfWeek,
addDays, addDays,
subDays, subDays,
isToday,
addMonths, addMonths,
subMonths, subMonths,
addWeeks, addWeeks,
@@ -37,6 +36,7 @@ import {
type Locale, type Locale,
} from 'date-fns'; } from 'date-fns';
import { useCalendarLocale } from '../lib/calendar-locale'; import { useCalendarLocale } from '../lib/calendar-locale';
import { displayNow, isDisplayToday } from '../lib/calendar-timezone';
import { spacing, radius, typography, type ThemePalette } from '../theme/tokens'; import { spacing, radius, typography, type ThemePalette } from '../theme/tokens';
import { useColors } from '../theme/colors'; import { useColors } from '../theme/colors';
import { Button } from '../components'; import { Button } from '../components';
@@ -172,8 +172,10 @@ export default function CalendarScreen() {
const setSharedCalendarColor = useSettingsStore((s) => s.setSharedCalendarColor); const setSharedCalendarColor = useSettingsStore((s) => s.setSharedCalendarColor);
const removeSharedCalendarColor = useSettingsStore((s) => s.removeSharedCalendarColor); const removeSharedCalendarColor = useSettingsStore((s) => s.removeSharedCalendarColor);
const contacts = useContactsStore((s) => s.contacts); const contacts = useContactsStore((s) => s.contacts);
const calendarTimeZone = useSettingsStore((s) => s.calendarTimeZone);
const [selectedDate, setSelectedDate] = React.useState(() => new Date()); // "Today" is the day on a clock in the calendar's time zone.
const [selectedDate, setSelectedDate] = React.useState(displayNow);
const initialViewMode: ViewMode = const initialViewMode: ViewMode =
calendarDefaultView === 'week' || calendarDefaultView === 'day' || calendarDefaultView === 'agenda' calendarDefaultView === 'week' || calendarDefaultView === 'day' || calendarDefaultView === 'agenda'
? calendarDefaultView ? calendarDefaultView
@@ -190,11 +192,11 @@ export default function CalendarScreen() {
() => ({ weekStartsOn: calendarFirstDayOfWeek }), () => ({ weekStartsOn: calendarFirstDayOfWeek }),
[calendarFirstDayOfWeek], [calendarFirstDayOfWeek],
); );
const [focus, setFocus] = React.useState<CalendarFocus>(() => ({ date: new Date(), nonce: 0 })); const [focus, setFocus] = React.useState<CalendarFocus>(() => ({ date: displayNow(), nonce: 0 }));
// The day the scrolled view shows at its top, while it differs from the focus. // The day the scrolled view shows at its top, while it differs from the focus.
const [visibleDate, setVisibleDate] = React.useState<Date | null>(null); const [visibleDate, setVisibleDate] = React.useState<Date | null>(null);
const [windowState, setWindowState] = React.useState<ScrollWindowState>( const [windowState, setWindowState] = React.useState<ScrollWindowState>(
() => freshScrollWindowState(initialViewMode, new Date()), () => freshScrollWindowState(initialViewMode, displayNow()),
); );
// The sideways-scrolling week and day grids assume a left-to-right strip; // The sideways-scrolling week and day grids assume a left-to-right strip;
// right-to-left layouts keep them paged. // right-to-left layouts keep them paged.
@@ -402,7 +404,21 @@ export default function CalendarScreen() {
}, [allEvents, hiddenCalendarIds]); }, [allEvents, hiddenCalendarIds]);
// Pre-index events by day once. Child views do O(1) map lookups per cell // Pre-index events by day once. Child views do O(1) map lookups per cell
// instead of re-filtering the full event list with parseISO per day. // instead of re-filtering the full event list with parseISO per day.
const eventsByDay = React.useMemo(() => buildEventDayIndex(events), [events]); // The days events fall on depend on the calendar's time zone too.
const eventsByDay = React.useMemo(
() => buildEventDayIndex(events),
// eslint-disable-next-line react-hooks/exhaustive-deps
[events, calendarTimeZone],
);
// A new calendar time zone also changes how the server reads floating
// times and the range bounds: reload what is loaded.
const loadedTimeZoneRef = React.useRef(calendarTimeZone);
React.useEffect(() => {
if (loadedTimeZoneRef.current === calendarTimeZone) return;
loadedTimeZoneRef.current = calendarTimeZone;
void refresh();
}, [calendarTimeZone, refresh]);
React.useEffect(() => { React.useEffect(() => {
void hydrate(); void hydrate();
@@ -492,7 +508,7 @@ export default function CalendarScreen() {
}, [viewMode, visibleDate, focus.date, jumpTo]); }, [viewMode, visibleDate, focus.date, jumpTo]);
const goToday = React.useCallback(() => { const goToday = React.useCallback(() => {
jumpTo(new Date()); jumpTo(displayNow());
}, [jumpTo]); }, [jumpTo]);
// Switching views keeps the period on screen in view. // Switching views keeps the period on screen in view.
@@ -906,7 +922,7 @@ export default function CalendarScreen() {
[t, removeCalendar, reportError], [t, removeCalendar, reportError],
); );
const isSelectedToday = isToday(selectedDate); const isSelectedToday = isDisplayToday(selectedDate);
return ( return (
<SafeAreaView style={styles.container} edges={['top']}> <SafeAreaView style={styles.container} edges={['top']}>