mirror of
https://github.com/bulwarkmail/native.git
synced 2026-10-31 08:06:36 +00:00
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:
@@ -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<DaySection[]>(
|
||||
() =>
|
||||
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 (
|
||||
<View style={styles.sectionHeader}>
|
||||
<Text style={[styles.sectionTitle, today && styles.sectionTitleToday]}>
|
||||
|
||||
@@ -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<Date[]>) => {
|
||||
@@ -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}
|
||||
|
||||
@@ -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 (
|
||||
<View style={styles.grid}>
|
||||
|
||||
@@ -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<ScrollView>(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<Date>) => (
|
||||
<DayColumn
|
||||
|
||||
@@ -6,7 +6,8 @@ import {
|
||||
ScrollView,
|
||||
Pressable,
|
||||
} 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 { radius, spacing, typography, type ThemePalette } from '../../theme/tokens';
|
||||
import { useColors } from '../../theme/colors';
|
||||
@@ -106,21 +107,18 @@ function WeekViewInner({
|
||||
});
|
||||
}, [weekDays, index]);
|
||||
|
||||
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);
|
||||
}, []);
|
||||
|
||||
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 (
|
||||
<Pressable
|
||||
@@ -244,7 +243,7 @@ function WeekViewInner({
|
||||
<View style={styles.dayCols}>
|
||||
{weekDays.map((day, dayIndex) => {
|
||||
const layouted = layoutsByDay[dayIndex];
|
||||
const todayCol = isToday(day);
|
||||
const todayCol = isDisplayToday(day);
|
||||
return (
|
||||
<View key={day.toISOString()} style={styles.dayCol}>
|
||||
{HOURS.map((h) => (
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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<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.
|
||||
const [visibleDate, setVisibleDate] = React.useState<Date | null>(null);
|
||||
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;
|
||||
// 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 (
|
||||
<SafeAreaView style={styles.container} edges={['top']}>
|
||||
|
||||
Reference in New Issue
Block a user