blob: fadc70d05b1c2dccd69c2f0f94ae091ecd9ece99 [file]
/**
* Timezone handling.
*
* There are three separate ideas here, and it helps to keep them apart:
*
* - The **event timezone** is what the organiser meant when they typed the
* times in: "15:00 in Berlin". It is stored with the event.
* - The **display timezone** is the clock the reader wants the calendar drawn
* on. A grid has one time axis, so there is exactly one of these.
* - **Comparison timezones** are extra clocks shown alongside, so you can see
* at a glance what 15:00 in Berlin is where you are.
*
* The instant itself is always the same; only the wall clock changes.
*
* Everything below works with "wall dates". A wall date is an ordinary `Date`
* whose *local* getters (getHours, getDate, ...) read as the wall clock in some
* other zone. That means the existing calendar-grid arithmetic keeps working
* unchanged - it just operates on wall dates rather than on instants - and only
* the edges of the app have to convert.
*/
import { formatTime } from "./dates";
/**
* A zone to display in: an IANA name such as "Europe/Berlin", or the sentinel
* "local" meaning whatever the browser's own zone happens to be. The sentinel
* is kept rather than resolved once, so a laptop that moves keeps up.
*/
export type DisplayZone = string;
export const LOCAL: DisplayZone = "local";
export const UTC = "UTC";
export function isLocal(zone: DisplayZone): boolean {
return zone === LOCAL;
}
/** The IANA name a display zone refers to. */
export function resolveZone(zone: DisplayZone): string {
return isLocal(zone) ? browserZone() : zone;
}
/** Offset of the browser's own zone at an instant, in ms east of UTC. */
export function localOffsetMs(instant: Date): number {
return -instant.getTimezoneOffset() * 60_000;
}
const formatters = new Map<string, Intl.DateTimeFormat>();
function partsFormatter(timeZone: string): Intl.DateTimeFormat {
let formatter = formatters.get(timeZone);
if (!formatter) {
formatter = new Intl.DateTimeFormat("en-US", {
timeZone,
hourCycle: "h23",
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
});
formatters.set(timeZone, formatter);
}
return formatter;
}
/**
* Offset of an IANA zone at an instant, in ms east of UTC.
*
* There is no direct API for this, so we ask Intl what the wall clock is in
* that zone and compare it with the wall clock in UTC.
*/
export function zoneOffsetMs(instant: Date, timeZone: string): number {
let parts: Intl.DateTimeFormatPart[];
try {
parts = partsFormatter(timeZone).formatToParts(instant);
} catch {
// An unknown zone should not take the page down with it.
return 0;
}
const value = (type: string) => Number(parts.find((part) => part.type === type)?.value ?? 0);
const asIfUtc = Date.UTC(
value("year"),
value("month") - 1,
value("day"),
value("hour"),
value("minute"),
value("second"),
);
// Compare whole seconds; Intl has no milliseconds to give us.
return asIfUtc - Math.floor(instant.getTime() / 1000) * 1000;
}
/**
* An instant, as a wall date in `timeZone`.
*
* The browser offset used for the conversion depends on the result, so we
* solve for it: one correction is enough except for instants an hour either
* side of a daylight-saving change, where a second settles it.
*/
export function toWallInZone(instant: Date, timeZone: string): Date {
const target = instant.getTime() + zoneOffsetMs(instant, timeZone);
let wall = new Date(target - localOffsetMs(instant));
for (let attempt = 0; attempt < 2; attempt += 1) {
const corrected = new Date(target - localOffsetMs(wall));
if (corrected.getTime() === wall.getTime()) return wall;
wall = corrected;
}
return wall;
}
/** A wall date in `timeZone`, back to the instant it names. */
export function fromWallInZone(wall: Date, timeZone: string): Date {
const target = wall.getTime() + localOffsetMs(wall);
let instant = new Date(target - zoneOffsetMs(wall, timeZone));
for (let attempt = 0; attempt < 2; attempt += 1) {
const corrected = new Date(target - zoneOffsetMs(instant, timeZone));
if (corrected.getTime() === instant.getTime()) return instant;
instant = corrected;
}
return instant;
}
/** An instant, as a wall date in the chosen display zone. */
export function toWall(instant: Date, zone: DisplayZone): Date {
// The browser's own zone needs no conversion, and taking the short cut keeps
// it exact even if Intl does not recognise the name the browser reports.
return isLocal(zone) ? new Date(instant.getTime()) : toWallInZone(instant, zone);
}
/** A wall date in the chosen display zone, back to the instant it names. */
export function fromWall(wall: Date, zone: DisplayZone): Date {
return isLocal(zone) ? new Date(wall.getTime()) : fromWallInZone(wall, zone);
}
/** Right now, as a wall date in the chosen display zone. */
export function nowInZone(zone: DisplayZone): Date {
return toWall(new Date(), zone);
}
/** The browser's own IANA zone name, or UTC if it will not say. */
export function browserZone(): string {
try {
return Intl.DateTimeFormat().resolvedOptions().timeZone || UTC;
} catch {
return UTC;
}
}
/** Formats an offset in milliseconds as "UTC+02:00". */
export function offsetLabel(offsetMs: number): string {
if (offsetMs === 0) return UTC;
const sign = offsetMs < 0 ? "-" : "+";
const total = Math.abs(Math.round(offsetMs / 60_000));
const hours = String(Math.floor(total / 60)).padStart(2, "0");
const minutes = String(total % 60).padStart(2, "0");
return `UTC${sign}${hours}:${minutes}`;
}
/** The short name a zone goes by right now, such as "CEST" or "GMT+5:30". */
export function zoneAbbreviation(instant: Date, timeZone: string): string {
try {
const parts = new Intl.DateTimeFormat("en-US", {
timeZone,
timeZoneName: "short",
}).formatToParts(instant);
return parts.find((part) => part.type === "timeZoneName")?.value ?? timeZone;
} catch {
return timeZone;
}
}
// ---- naming ---------------------------------------------------------------
/**
* The place an IANA zone is named after: "Europe/Copenhagen" is Copenhagen,
* "America/Argentina/Buenos_Aires" is Buenos Aires.
*
* These names are how the tz database identifies a zone, not a claim about
* where the reader is, but they are a good deal more meaningful than "UTC+2"
* on its own.
*/
export function zoneCity(timeZone: string): string {
if (timeZone === UTC) return UTC;
const segments = timeZone.split("/");
return (segments[segments.length - 1] ?? timeZone).replace(/_/g, " ");
}
/** The part of the world a zone is filed under: "Europe", "America". */
export function zoneRegion(timeZone: string): string {
if (timeZone === UTC) return UTC;
return timeZone.split("/")[0] ?? "";
}
/** "Copenhagen (CEST, UTC+02:00)" - the city, and why that clock. */
export function zoneLabel(timeZone: string, at: Date = new Date()): string {
const offset = offsetLabel(zoneOffsetMs(at, timeZone));
const abbreviation = zoneAbbreviation(at, timeZone);
if (timeZone === UTC) return UTC;
// Intl falls back to "GMT+2" when a zone has no letter abbreviation, which
// would just repeat the offset.
if (abbreviation.startsWith("GMT") || abbreviation === offset) {
return `${zoneCity(timeZone)} (${offset})`;
}
return `${zoneCity(timeZone)} (${abbreviation}, ${offset})`;
}
/** How a display zone is named in the UI, spelling out what "local" resolves to. */
export function describeDisplayZone(zone: DisplayZone, at: Date = new Date()): string {
const resolved = resolveZone(zone);
return isLocal(zone) ? `Local - ${zoneLabel(resolved, at)}` : zoneLabel(resolved, at);
}
/** The compact form used where there is no room, such as a column heading. */
export function shortZoneLabel(zone: DisplayZone, at: Date = new Date()): string {
const resolved = resolveZone(zone);
if (resolved === UTC) return UTC;
const abbreviation = zoneAbbreviation(at, resolved);
if (abbreviation.startsWith("GMT")) return zoneCity(resolved);
return abbreviation;
}
/** A longer description, for tooltips and the help page. */
export function explainDisplayZone(zone: DisplayZone, at: Date = new Date()): string {
const resolved = resolveZone(zone);
if (isLocal(zone)) {
return `Times are shown in ${resolved}, your browser's timezone (${zoneLabel(resolved, at)}).`;
}
return `Times are shown in ${resolved} (${zoneLabel(resolved, at)}).`;
}
// ---- the zones on offer ---------------------------------------------------
/**
* The zones the display picker offers, grouped for an <optgroup>.
*
* This is a deliberately short list of well-known zones rather than all ~400
* the tz database knows, because it is a "show me this calendar in Tokyo time"
* control, not a data-entry field. The event form uses the full list, since an
* organiser really might be anywhere.
*/
export const MAJOR_ZONES: { region: string; zones: string[] }[] = [
{ region: "Coordinated", zones: [UTC] },
{
region: "Americas",
zones: [
"Pacific/Honolulu",
"America/Anchorage",
"America/Los_Angeles",
"America/Denver",
"America/Chicago",
"America/New_York",
"America/Halifax",
"America/Bogota",
"America/Sao_Paulo",
"America/Argentina/Buenos_Aires",
],
},
{
region: "Europe and Africa",
zones: [
"Europe/London",
"Europe/Lisbon",
"Europe/Paris",
"Europe/Berlin",
"Europe/Madrid",
"Europe/Rome",
"Europe/Stockholm",
"Europe/Warsaw",
"Europe/Athens",
"Europe/Kyiv",
"Europe/Moscow",
"Africa/Casablanca",
"Africa/Lagos",
"Africa/Cairo",
"Africa/Nairobi",
"Africa/Johannesburg",
],
},
{
region: "Asia",
zones: [
"Asia/Jerusalem",
"Asia/Dubai",
"Asia/Karachi",
"Asia/Kolkata",
"Asia/Kathmandu",
"Asia/Dhaka",
"Asia/Bangkok",
"Asia/Jakarta",
"Asia/Shanghai",
"Asia/Hong_Kong",
"Asia/Singapore",
"Asia/Seoul",
"Asia/Tokyo",
],
},
{
region: "Oceania",
zones: [
"Australia/Perth",
"Australia/Adelaide",
"Australia/Brisbane",
"Australia/Sydney",
"Pacific/Auckland",
],
},
];
/** Every zone in MAJOR_ZONES, flattened. */
export function majorZones(): string[] {
return MAJOR_ZONES.flatMap((group) => group.zones);
}
/** True if the string names a zone this browser can actually work with. */
export function isKnownZone(timeZone: string): boolean {
if (!timeZone || timeZone === LOCAL) return false;
try {
new Intl.DateTimeFormat("en-US", { timeZone });
return true;
} catch {
return false;
}
}
/**
* The zones to offer in the event form.
*
* Modern browsers can list every zone they know. Where they cannot, fall back
* to the major list, with the browser's own zone added so it is always
* selectable.
*/
export function zoneList(): string[] {
const supported = (Intl as { supportedValuesOf?: (key: string) => string[] }).supportedValuesOf;
let names: string[];
try {
names = supported ? supported("timeZone") : majorZones();
} catch {
names = majorZones();
}
const all = new Set<string>([UTC, browserZone(), ...names]);
return [...all].sort();
}
/**
* Tidies a stored or configured display zone into one we can use.
*
* Accepts the "local"/"utc" spellings the two-way switch used to store, so an
* existing preference survives the upgrade.
*/
export function normaliseZone(value: string | null | undefined, fallback: DisplayZone = UTC): DisplayZone {
const text = (value ?? "").trim();
if (!text) return fallback;
if (text.toLowerCase() === LOCAL) return LOCAL;
if (text.toLowerCase() === "utc") return UTC;
return isKnownZone(text) ? text : fallback;
}
/**
* Drops duplicates and anything already shown as the primary zone.
*
* "local" and the IANA name it resolves to are the same clock, so only one of
* them is worth a column.
*/
export function uniqueZones(zones: DisplayZone[], primary?: DisplayZone): DisplayZone[] {
const seen = new Set<string>(primary === undefined ? [] : [resolveZone(primary)]);
const kept: DisplayZone[] = [];
for (const zone of zones) {
const resolved = resolveZone(zone);
if (seen.has(resolved)) continue;
seen.add(resolved);
kept.push(zone);
}
return kept;
}
/**
* The clock reading in `other` for each hour of `day` as measured in `primary`.
*
* This is what fills the extra hour gutters in the week and day views: row 9 of
* the grid is 09:00 in the primary zone, and this says what that same instant
* reads as somewhere else.
*
* A week view shares one gutter across all seven columns, so the caller passes
* a single reference day. The offset between two zones almost never changes
* mid-week; when it does, because one of them shifts for daylight saving, the
* labels are right for the reference day and an hour out for the rest.
*/
export function hourLabels(day: Date, primary: DisplayZone, other: DisplayZone, hours = 24): string[] {
return Array.from({ length: hours }, (_unused, hour) => {
const wall = new Date(day);
wall.setHours(hour, 0, 0, 0);
return formatTime(toWall(fromWall(wall, primary), other));
});
}