diff --git a/src/components/calendar/AgendaView.tsx b/src/components/calendar/AgendaView.tsx index bd69d071..2adc37eb 100644 --- a/src/components/calendar/AgendaView.tsx +++ b/src/components/calendar/AgendaView.tsx @@ -9,7 +9,8 @@ import { type LayoutChangeEvent, type ViewToken, } 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 { radius, spacing, typography, type ThemePalette } from '../../theme/tokens'; import { useColors } from '../../theme/colors'; @@ -59,8 +60,9 @@ function formatDayHeader( t: (key: string, fallback?: string) => string, locale: Locale, ): string { - if (isToday(date)) return t('calendar.events.today_header', 'Today'); - if (isTomorrow(date)) return t('calendar.events.tomorrow_header', 'Tomorrow'); + // Today and tomorrow on a clock in the calendar's time zone. + 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 }); } @@ -101,7 +103,7 @@ export function AgendaView({ const sections = React.useMemo( () => - buildAgendaDays(index, loaded, new Date()).map((day) => ({ + buildAgendaDays(index, loaded, displayNow()).map((day) => ({ ...day, key: dayKey(day.date), title: formatDayHeader(day.date, t, locale), @@ -310,7 +312,7 @@ export function AgendaView({ maxToRenderPerBatch={12} windowSize={11} renderSectionHeader={({ section }) => { - const today = isSameDay(section.date, new Date()); + const today = isDisplayToday(section.date); return ( diff --git a/src/components/calendar/MonthScrollView.tsx b/src/components/calendar/MonthScrollView.tsx index 7454b1ff..5484273f 100644 --- a/src/components/calendar/MonthScrollView.tsx +++ b/src/components/calendar/MonthScrollView.tsx @@ -24,6 +24,7 @@ import { windowWeekStarts, } from '../../lib/calendar-month-scroll'; import { useCalendarLocale } from '../../lib/calendar-locale'; +import { displayNow } from '../../lib/calendar-timezone'; import { MONTH_ROW_HEIGHT, MONTH_ROW_HEIGHT_CHIPS, @@ -173,7 +174,8 @@ function MonthScrollViewInner({ }, [focus, window, weeks.length, opts, rowHeight]); 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( ({ item: days }: ListRenderItemInfo) => { @@ -182,7 +184,7 @@ function MonthScrollViewInner({ days={days} activeMask={monthMask(days, activeMonth)} selectedIndex={dayIndexIn(days, selectedDate)} - todayIndex={dayIndexIn(days, new Date())} + todayIndex={dayIndexIn(days, displayNow())} index={index} calendars={calendars} weekStartsOn={weekStartsOn} diff --git a/src/components/calendar/MonthView.tsx b/src/components/calendar/MonthView.tsx index dbe1d419..41bf16de 100644 --- a/src/components/calendar/MonthView.tsx +++ b/src/components/calendar/MonthView.tsx @@ -25,6 +25,7 @@ import { } from '../../lib/calendar-utils'; import { dayIndexIn, monthKeyOf, monthMask } from '../../lib/calendar-month-scroll'; import { useCalendarLocale } from '../../lib/calendar-locale'; +import { displayNow } from '../../lib/calendar-timezone'; type WeekStart = 0 | 1 | 6; @@ -258,7 +259,8 @@ function MonthViewInner({ return out; }, [currentDate, weekStartsOn]); const activeMonth = monthKeyOf(currentDate); - const today = new Date(); + // Today on a clock in the calendar's time zone. + const today = displayNow(); return ( diff --git a/src/components/calendar/TimeGridScrollView.tsx b/src/components/calendar/TimeGridScrollView.tsx index e4a3d0d6..3b0cbc02 100644 --- a/src/components/calendar/TimeGridScrollView.tsx +++ b/src/components/calendar/TimeGridScrollView.tsx @@ -32,6 +32,7 @@ import { type TimedEventLayout, } from '../../lib/calendar-utils'; import type { CalendarFocus, DayRange } from '../../lib/calendar-scroll-window'; +import { displayNow, displayNowMinutes } from '../../lib/calendar-timezone'; import { buildAllDaySegments, headerColumnRange, @@ -262,21 +263,18 @@ function TimeGridScrollViewInner({ // Start the hours at the one before now, like the paged week view. const vScrollRef = React.useRef(null); React.useEffect(() => { - const target = Math.max(0, (new Date().getHours() - 1) * HOUR_HEIGHT); + const target = Math.max(0, (displayNow().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(); - }); + // The now-line and "today" follow a clock in the calendar's time zone. + const [nowMinutes, setNowMinutes] = React.useState(displayNowMinutes); React.useEffect(() => { const interval = setInterval(() => { - const now = new Date(); - setNowMinutes(now.getHours() * 60 + now.getMinutes()); + setNowMinutes(displayNowMinutes()); }, 60_000); return () => clearInterval(interval); }, []); @@ -284,6 +282,8 @@ function TimeGridScrollViewInner({ const handleLongPressAt = React.useCallback( (day: Date, hour: number) => { 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); date.setHours(hour, 0, 0, 0); onCreateAtTime(date); @@ -291,7 +291,7 @@ function TimeGridScrollViewInner({ [onCreateAtTime], ); - const todayKey = dayKey(new Date()); + const todayKey = dayKey(displayNow()); const renderItem = React.useCallback( ({ item: day }: ListRenderItemInfo) => ( { - const now = new Date(); - return now.getHours() * 60 + now.getMinutes(); - }); + // The now-line and "today" follow a clock in the calendar's time zone. + const [nowMinutes, setNowMinutes] = React.useState(displayNowMinutes); React.useEffect(() => { const interval = setInterval(() => { - const now = new Date(); - setNowMinutes(now.getHours() * 60 + now.getMinutes()); + setNowMinutes(displayNowMinutes()); }, 60_000); return () => clearInterval(interval); }, []); React.useEffect(() => { - const target = Math.max(0, (new Date().getHours() - 1) * HOUR_HEIGHT); + const target = Math.max(0, (displayNow().getHours() - 1) * HOUR_HEIGHT); requestAnimationFrame(() => { scrollRef.current?.scrollTo({ y: target, animated: false }); }); @@ -128,13 +126,14 @@ function WeekViewInner({ const handleSlotLongPress = (day: Date, hour: number) => { if (!onCreateAtTime) return; + // A display date; the editor converts it when it saves. const date = new Date(day); date.setHours(hour, 0, 0, 0); onCreateAtTime(date); }; const dayHeader = (day: Date) => { - const today = isToday(day); + const today = isDisplayToday(day); const selected = isSameDay(day, selectedDate); return ( {weekDays.map((day, dayIndex) => { const layouted = layoutsByDay[dayIndex]; - const todayCol = isToday(day); + const todayCol = isDisplayToday(day); return ( {HOURS.map((h) => ( diff --git a/src/lib/__tests__/calendar-today.test.ts b/src/lib/__tests__/calendar-today.test.ts new file mode 100644 index 00000000..96180965 --- /dev/null +++ b/src/lib/__tests__/calendar-today.test.ts @@ -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, + }); + }); +}); diff --git a/src/lib/calendar-timezone.ts b/src/lib/calendar-timezone.ts index 7bdef412..ca8dc09d 100644 --- a/src/lib/calendar-timezone.ts +++ b/src/lib/calendar-timezone.ts @@ -222,10 +222,26 @@ export function displayNow(): 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? */ 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(); - return date.getFullYear() === now.getFullYear() - && date.getMonth() === now.getMonth() - && date.getDate() === now.getDate(); + return now.getHours() * 60 + now.getMinutes(); } diff --git a/src/screens/CalendarScreen.tsx b/src/screens/CalendarScreen.tsx index 9817d582..2f5142f3 100644 --- a/src/screens/CalendarScreen.tsx +++ b/src/screens/CalendarScreen.tsx @@ -29,7 +29,6 @@ import { endOfWeek, addDays, subDays, - isToday, addMonths, subMonths, addWeeks, @@ -37,6 +36,7 @@ import { type Locale, } from 'date-fns'; import { useCalendarLocale } from '../lib/calendar-locale'; +import { displayNow, isDisplayToday } from '../lib/calendar-timezone'; import { spacing, radius, typography, type ThemePalette } from '../theme/tokens'; import { useColors } from '../theme/colors'; import { Button } from '../components'; @@ -172,8 +172,10 @@ export default function CalendarScreen() { const setSharedCalendarColor = useSettingsStore((s) => s.setSharedCalendarColor); const removeSharedCalendarColor = useSettingsStore((s) => s.removeSharedCalendarColor); 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 = calendarDefaultView === 'week' || calendarDefaultView === 'day' || calendarDefaultView === 'agenda' ? calendarDefaultView @@ -190,11 +192,11 @@ export default function CalendarScreen() { () => ({ weekStartsOn: calendarFirstDayOfWeek }), [calendarFirstDayOfWeek], ); - const [focus, setFocus] = React.useState(() => ({ date: new Date(), nonce: 0 })); + const [focus, setFocus] = React.useState(() => ({ date: displayNow(), nonce: 0 })); // The day the scrolled view shows at its top, while it differs from the focus. const [visibleDate, setVisibleDate] = React.useState(null); const [windowState, setWindowState] = React.useState( - () => freshScrollWindowState(initialViewMode, new Date()), + () => freshScrollWindowState(initialViewMode, displayNow()), ); // The sideways-scrolling week and day grids assume a left-to-right strip; // right-to-left layouts keep them paged. @@ -402,7 +404,21 @@ export default function CalendarScreen() { }, [allEvents, hiddenCalendarIds]); // 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. - 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(() => { void hydrate(); @@ -492,7 +508,7 @@ export default function CalendarScreen() { }, [viewMode, visibleDate, focus.date, jumpTo]); const goToday = React.useCallback(() => { - jumpTo(new Date()); + jumpTo(displayNow()); }, [jumpTo]); // Switching views keeps the period on screen in view. @@ -906,7 +922,7 @@ export default function CalendarScreen() { [t, removeCalendar, reportError], ); - const isSelectedToday = isToday(selectedDate); + const isSelectedToday = isDisplayToday(selectedDate); return (