Files
native/src/lib/calendar-timezone.ts
T
Linus Rath 8af163a6a3 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
2026-09-24 20:40:51 +02:00

248 lines
9.0 KiB
TypeScript

/**
* The IANA time zone the calendar works in.
*
* Timed events must be saved with a `timeZone` so other clients, CalDAV
* syncs and invitees resolve the wall-clock correctly; a floating DTSTART
* shifts whenever the viewer's zone differs from the author's. Mirrors the
* webmail's lib/timezone.ts: the user's override when set (#755), otherwise
* the device zone.
*/
import { useSettingsStore } from '../stores/settings-store';
/** Sentinel for "follow the device" — the default. */
export const AUTO_TIME_ZONE = 'auto';
// Reading the device zone builds an Intl.DateTimeFormat, which is slow on
// Hermes, and task sorting resolves a due per comparison. The zone only
// changes when the user travels or changes it, so a short cache is enough.
const DEVICE_ZONE_TTL_MS = 30_000;
let deviceZoneCache: { zone: string; readAt: number } | null = null;
/** The zone the device reports; `UTC` when detection fails. */
export function getDeviceTimeZone(): string {
const now = Date.now();
if (deviceZoneCache && now - deviceZoneCache.readAt < DEVICE_ZONE_TTL_MS) {
return deviceZoneCache.zone;
}
let zone: string;
try {
zone = Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC';
} catch {
zone = 'UTC';
}
deviceZoneCache = { zone, readAt: now };
return zone;
}
const validityCache = new Map<string, boolean>();
/** True when `Intl` accepts `tz` as a time zone identifier. */
export function isValidTimeZone(tz: unknown): tz is string {
if (typeof tz !== 'string' || !tz) return false;
let valid = validityCache.get(tz);
if (valid === undefined) {
try {
new Intl.DateTimeFormat('en-US', { timeZone: tz });
valid = true;
} catch {
valid = false;
}
validityCache.set(tz, valid);
}
return valid;
}
/**
* Resolve a stored `timeZone` setting to a concrete IANA zone. `auto`, empty
* or unknown values (a zone synced from a client with a newer tz database)
* fall back to the device zone instead of throwing later inside Intl.
*/
export function resolveTimeZone(
setting: string | null | undefined,
deviceTimeZone = getDeviceTimeZone(),
): string {
if (!setting || setting === AUTO_TIME_ZONE) return deviceTimeZone;
return isValidTimeZone(setting) ? setting : deviceTimeZone;
}
/**
* The zone the calendar shows and edits times in, new timed events are
* saved in and JMAP queries are interpreted in.
*/
export function getEffectiveTimeZone(): string {
let setting: string | null | undefined;
try {
setting = useSettingsStore.getState().calendarTimeZone;
} catch {
setting = undefined;
}
return resolveTimeZone(setting);
}
// ─── Display dates ───────────────────────────────────────
// The calendar's grid math, the pickers and date-fns `format` all read the
// *local* getters of a Date. Rather than teaching every view about zones, an
// instant is shifted once at the boundary into a "display date" whose local
// fields read as the wall clock in the calendar's zone, and shifted back
// when a time the user picked has to become a real instant. Both are the
// identity while the calendar zone is the device zone. Port of the webmail's
// lib/timezone.ts (toDisplayDate / fromDisplayDate).
export interface WallClock {
year: number;
/** 1-12 */
month: number;
day: number;
hour: number;
minute: number;
second: number;
}
// Building an Intl.DateTimeFormat is expensive; the calendar converts every
// event it shows, so keep one per zone.
const wallClockFormatters = new Map<string, Intl.DateTimeFormat>();
function wallClockFormatter(timeZone: string): Intl.DateTimeFormat {
let formatter = wallClockFormatters.get(timeZone);
if (!formatter) {
formatter = new Intl.DateTimeFormat('en-US', {
timeZone,
hourCycle: 'h23',
year: 'numeric',
month: 'numeric',
day: 'numeric',
hour: 'numeric',
minute: 'numeric',
second: 'numeric',
});
wallClockFormatters.set(timeZone, formatter);
}
return formatter;
}
/** Wall-clock fields of the instant `date` as seen in `timeZone`. */
export function getWallClock(date: Date, timeZone: string): WallClock {
const map: Record<string, number> = {};
for (const part of wallClockFormatter(timeZone).formatToParts(date)) {
if (part.type !== 'literal') map[part.type] = Number(part.value);
}
return {
year: map.year,
month: map.month,
day: map.day,
// Some engines still emit "24" at midnight despite hourCycle h23.
hour: map.hour === 24 ? 0 : map.hour,
minute: map.minute,
second: map.second,
};
}
/** The instant `date` as a JSCalendar LocalDateTime ("yyyy-MM-ddTHH:mm:ss") in `timeZone`. */
export function formatWallClock(date: Date, timeZone: string): string {
const w = getWallClock(date, timeZone);
const pad = (n: number, len = 2) => String(n).padStart(len, '0');
return `${pad(w.year, 4)}-${pad(w.month)}-${pad(w.day)}T${pad(w.hour)}:${pad(w.minute)}:${pad(w.second)}`;
}
/** UTC offset of `timeZone` at the given instant, in milliseconds (east = positive). */
export function getTimeZoneOffsetMs(date: Date, timeZone: string): number {
const w = getWallClock(date, timeZone);
const asUtc = Date.UTC(w.year, w.month - 1, w.day, w.hour, w.minute, w.second);
// Intl drops sub-second precision; compare against the whole-second instant.
return asUtc - Math.floor(date.getTime() / 1000) * 1000;
}
/** A Date whose *local* fields equal the given wall clock (years < 100 included). */
function localDateFromWallClock(w: WallClock, milliseconds: number): Date {
const d = new Date(0);
d.setFullYear(w.year, w.month - 1, w.day);
d.setHours(w.hour, w.minute, w.second, milliseconds);
return d;
}
/**
* Shift an instant into a Date whose local getters read as its wall clock in
* `timeZone`. Not the same instant unless the zones agree: only for local
* field arithmetic and rendering.
*/
export function toZonedDisplayDate(date: Date, timeZone: string): Date {
if (isNaN(date.getTime())) return date;
return localDateFromWallClock(getWallClock(date, timeZone), date.getMilliseconds());
}
/**
* Read the local fields of `wall` as a wall clock in `timeZone` and return
* the real instant. Two offset lookups handle wall clocks next to a DST
* transition.
*/
export function fromZonedDisplayDate(wall: Date, timeZone: string): Date {
if (isNaN(wall.getTime())) return wall;
const asUtc = Date.UTC(
wall.getFullYear(), wall.getMonth(), wall.getDate(),
wall.getHours(), wall.getMinutes(), wall.getSeconds(), wall.getMilliseconds(),
);
const guess = asUtc - getTimeZoneOffsetMs(new Date(asUtc), timeZone);
return new Date(asUtc - getTimeZoneOffsetMs(new Date(guess), timeZone));
}
/**
* The instant of a JSCalendar LocalDateTime ("yyyy-MM-ddTHH:mm:ss") read as
* a wall clock in `timeZone`. Parsed from the digits, so a wall clock that
* does not exist on the device (its own DST gap) still converts right.
* `null` for an unparsable value or zone.
*/
export function localDateTimeToInstant(value: string, timeZone: string): Date | null {
const m = /^(\d{4})-(\d{2})-(\d{2})(?:T(\d{2}):(\d{2})(?::(\d{2}))?)?/.exec(value);
if (!m || !isValidTimeZone(timeZone)) return null;
const asUtc = Date.UTC(
Number(m[1]), Number(m[2]) - 1, Number(m[3]),
Number(m[4] ?? 0), Number(m[5] ?? 0), Number(m[6] ?? 0),
);
const guess = asUtc - getTimeZoneOffsetMs(new Date(asUtc), timeZone);
return new Date(asUtc - getTimeZoneOffsetMs(new Date(guess), timeZone));
}
/** Instant -> display date. The identity unless the calendar zone differs from the device's. */
export function toDisplayDate(date: Date): Date {
const timeZone = getEffectiveTimeZone();
if (timeZone === getDeviceTimeZone()) return date;
return toZonedDisplayDate(date, timeZone);
}
/** Display date (picked in the calendar or an editor) -> real instant. Inverse of `toDisplayDate`. */
export function fromDisplayDate(date: Date): Date {
const timeZone = getEffectiveTimeZone();
if (timeZone === getDeviceTimeZone()) return date;
return fromZonedDisplayDate(date, timeZone);
}
/** "Now" as a display date: what a clock in the calendar's zone reads. */
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 now.getHours() * 60 + now.getMinutes();
}