blob: 3c9201ef58505928dc81f21f3e65d3fc0b5dc3c7 [file]
/**
* Timezone conversions.
*
* The suite runs with TZ=UTC (see src/tests/setup.ts), so "local" and UTC
* coincide here. To exercise the interesting cases the tests use explicit IANA
* zones, which do not depend on the machine's own setting.
*/
import { describe, expect, it } from "vitest";
import {
LOCAL,
UTC,
browserZone,
describeDisplayZone,
explainDisplayZone,
fromWall,
fromWallInZone,
localOffsetMs,
nowInZone,
offsetLabel,
toWall,
toWallInZone,
zoneAbbreviation,
zoneList,
zoneOffsetMs,
} from "./timezone";
import {
hourLabels,
isKnownZone,
majorZones,
normaliseZone,
resolveZone,
shortZoneLabel,
uniqueZones,
zoneCity,
zoneLabel,
zoneRegion,
} from "./timezone";
const HOUR = 3_600_000;
// 10 March 2026, 12:00 UTC. Still winter time in Europe: EU clocks change on
// the last Sunday of March. The US has already sprung forward by then, so the
// American cases use a January date instead.
const WINTER = new Date("2026-03-10T12:00:00Z");
const DEEP_WINTER = new Date("2026-01-10T12:00:00Z");
// 10 July 2026, 12:00 UTC. Summer time in Europe.
const SUMMER = new Date("2026-07-10T12:00:00Z");
describe("zone offsets", () => {
it("UTC is always zero", () => {
expect(zoneOffsetMs(WINTER, "UTC")).toBe(0);
expect(zoneOffsetMs(SUMMER, "UTC")).toBe(0);
});
it("follows daylight saving", () => {
expect(zoneOffsetMs(WINTER, "Europe/Berlin")).toBe(HOUR);
expect(zoneOffsetMs(SUMMER, "Europe/Berlin")).toBe(2 * HOUR);
});
it("handles zones west of Greenwich", () => {
expect(zoneOffsetMs(DEEP_WINTER, "America/New_York")).toBe(-5 * HOUR);
expect(zoneOffsetMs(SUMMER, "America/New_York")).toBe(-4 * HOUR);
});
it("the EU and the US change their clocks on different dates", () => {
// 10 March 2026: the US is already on summer time, the EU is not.
expect(zoneOffsetMs(WINTER, "America/New_York")).toBe(-4 * HOUR);
expect(zoneOffsetMs(WINTER, "Europe/Berlin")).toBe(HOUR);
});
it("handles half-hour and three-quarter-hour offsets", () => {
expect(zoneOffsetMs(WINTER, "Asia/Kolkata")).toBe(5.5 * HOUR);
expect(zoneOffsetMs(WINTER, "Asia/Kathmandu")).toBe(5.75 * HOUR);
});
it("does not throw on a zone it has never heard of", () => {
expect(zoneOffsetMs(WINTER, "Mars/Olympus")).toBe(0);
});
it("localOffsetMs agrees with the Date it is given", () => {
expect(localOffsetMs(WINTER)).toBe(-WINTER.getTimezoneOffset() * 60_000);
});
});
describe("instants to wall clocks", () => {
it("reads an instant on a Berlin clock", () => {
const wall = toWallInZone(SUMMER, "Europe/Berlin");
expect(wall.getHours()).toBe(14); // 12:00 UTC is 14:00 CEST
expect(wall.getDate()).toBe(10);
});
it("can push an instant onto the previous day", () => {
const wall = toWallInZone(new Date("2026-01-10T02:00:00Z"), "America/New_York");
expect(wall.getDate()).toBe(9);
expect(wall.getHours()).toBe(21);
});
it("can push an instant onto the next day", () => {
const wall = toWallInZone(new Date("2026-03-10T22:00:00Z"), "Asia/Tokyo");
expect(wall.getDate()).toBe(11);
expect(wall.getHours()).toBe(7);
});
it("round-trips", () => {
for (const timeZone of ["UTC", "Europe/Berlin", "America/New_York", "Asia/Kolkata", "Pacific/Auckland"]) {
for (const instant of [WINTER, DEEP_WINTER, SUMMER]) {
const wall = toWallInZone(instant, timeZone);
expect(fromWallInZone(wall, timeZone).getTime()).toBe(instant.getTime());
}
}
});
it("turns a wall clock into the instant it names", () => {
// 15:00 in Berlin in July is 13:00 UTC.
const wall = new Date(2026, 6, 10, 15, 0);
expect(fromWallInZone(wall, "Europe/Berlin").toISOString()).toBe("2026-07-10T13:00:00.000Z");
});
it("the same wall clock is a different instant in a different zone", () => {
const wall = new Date(2026, 6, 10, 15, 0);
const berlin = fromWallInZone(wall, "Europe/Berlin");
const tokyo = fromWallInZone(wall, "Asia/Tokyo");
expect(tokyo.getTime()).toBeLessThan(berlin.getTime());
expect(berlin.getTime() - tokyo.getTime()).toBe(7 * HOUR);
});
});
describe("the display zone", () => {
it("local leaves an instant alone", () => {
expect(toWall(SUMMER, LOCAL).getTime()).toBe(SUMMER.getTime());
expect(fromWall(SUMMER, LOCAL).getTime()).toBe(SUMMER.getTime());
});
it("UTC reads an instant on the UTC clock", () => {
const wall = toWall(SUMMER, UTC);
expect(wall.getUTCHours()).toBe(SUMMER.getUTCHours());
});
it("any IANA zone can be the display zone", () => {
expect(toWall(SUMMER, "Asia/Tokyo").getHours()).toBe(21);
expect(toWall(SUMMER, "America/New_York").getHours()).toBe(8);
});
it("round-trips through every display zone", () => {
for (const zone of [LOCAL, UTC, "Europe/Berlin", "Pacific/Auckland"]) {
expect(fromWall(toWall(SUMMER, zone), zone).getTime()).toBe(SUMMER.getTime());
}
});
it("resolves the local sentinel to a real zone name", () => {
expect(resolveZone(LOCAL)).toBe(browserZone());
expect(resolveZone("Asia/Tokyo")).toBe("Asia/Tokyo");
});
it("nowInZone returns something close to now", () => {
expect(Math.abs(nowInZone(LOCAL).getTime() - Date.now())).toBeLessThan(2000);
});
});
describe("naming a zone so it is obvious why that clock", () => {
it("uses the place the zone is named after", () => {
expect(zoneCity("Europe/Copenhagen")).toBe("Copenhagen");
expect(zoneCity("America/New_York")).toBe("New York");
expect(zoneCity("America/Argentina/Buenos_Aires")).toBe("Buenos Aires");
expect(zoneCity(UTC)).toBe(UTC);
});
it("knows which part of the world a zone is in", () => {
expect(zoneRegion("Europe/Copenhagen")).toBe("Europe");
expect(zoneRegion("Asia/Tokyo")).toBe("Asia");
expect(zoneRegion(UTC)).toBe(UTC);
});
it("spells out the abbreviation and the offset", () => {
expect(zoneLabel("Europe/Berlin", SUMMER)).toBe("Berlin (UTC+02:00)");
expect(zoneLabel(UTC, SUMMER)).toBe(UTC);
});
it("does not repeat the offset when there is no letter abbreviation", () => {
// Intl reports "GMT+5:45" for Kathmandu, which would just echo the offset.
expect(zoneLabel("Asia/Kathmandu", SUMMER)).toBe("Kathmandu (UTC+05:45)");
});
it("says what local resolves to", () => {
const described = describeDisplayZone(LOCAL, SUMMER);
expect(described.startsWith("Local - ")).toBe(true);
expect(described).toContain(zoneCity(browserZone()));
});
it("names a chosen zone without the local prefix", () => {
expect(describeDisplayZone("Asia/Tokyo", SUMMER)).toBe("Tokyo (UTC+09:00)");
});
it("has a compact form for column headings", () => {
expect(shortZoneLabel(UTC, SUMMER)).toBe(UTC);
expect(shortZoneLabel("Asia/Kathmandu", SUMMER)).toBe("Kathmandu");
});
it("explains itself in a sentence", () => {
expect(explainDisplayZone(LOCAL, SUMMER)).toContain("your browser's timezone");
expect(explainDisplayZone("Asia/Tokyo", SUMMER)).toContain("Asia/Tokyo");
});
});
describe("the zones on offer", () => {
it("starts with UTC", () => {
expect(majorZones()[0]).toBe(UTC);
});
it("covers every inhabited continent", () => {
const zones = majorZones();
for (const name of [
"America/New_York",
"Europe/London",
"Africa/Nairobi",
"Asia/Tokyo",
"Australia/Sydney",
"Pacific/Auckland",
]) {
expect(zones).toContain(name);
}
});
it("only lists zones the browser understands", () => {
for (const name of majorZones()) expect(isKnownZone(name)).toBe(true);
});
it("has no duplicates", () => {
const zones = majorZones();
expect(new Set(zones).size).toBe(zones.length);
});
it("rejects things that are not zones", () => {
expect(isKnownZone("Mars/Olympus")).toBe(false);
expect(isKnownZone("")).toBe(false);
expect(isKnownZone(LOCAL)).toBe(false);
});
});
describe("tidying a stored or configured zone", () => {
it("keeps the local sentinel", () => {
expect(normaliseZone("local")).toBe(LOCAL);
expect(normaliseZone("Local")).toBe(LOCAL);
});
it("accepts the old lowercase utc spelling", () => {
// The two-way switch used to store "utc"; an existing preference should
// survive the upgrade rather than silently resetting.
expect(normaliseZone("utc")).toBe(UTC);
});
it("keeps a real IANA name", () => {
expect(normaliseZone("Asia/Tokyo")).toBe("Asia/Tokyo");
});
it("falls back when the value is missing or nonsense", () => {
expect(normaliseZone(null)).toBe(UTC);
expect(normaliseZone("")).toBe(UTC);
expect(normaliseZone("Mars/Olympus")).toBe(UTC);
expect(normaliseZone("Mars/Olympus", LOCAL)).toBe(LOCAL);
});
});
describe("keeping the comparison list sensible", () => {
it("drops duplicates", () => {
expect(uniqueZones(["Asia/Tokyo", "Asia/Tokyo", UTC])).toEqual(["Asia/Tokyo", UTC]);
});
it("drops whichever clock is already the primary one", () => {
expect(uniqueZones([UTC, "Asia/Tokyo"], UTC)).toEqual(["Asia/Tokyo"]);
});
it("treats local and the zone it resolves to as the same clock", () => {
// The suite runs in UTC, so "local" and "UTC" are one and the same here.
expect(uniqueZones([LOCAL], UTC)).toEqual([]);
expect(uniqueZones([UTC, LOCAL])).toEqual([UTC]);
});
it("keeps the order they were added in", () => {
expect(uniqueZones(["Asia/Tokyo", "Europe/Berlin"])).toEqual(["Asia/Tokyo", "Europe/Berlin"]);
});
});
describe("hour labels for a comparison gutter", () => {
const day = new Date(2026, 6, 10);
it("gives one label per hour", () => {
expect(hourLabels(day, UTC, "Asia/Tokyo")).toHaveLength(24);
});
it("shifts by the difference between the two zones", () => {
const labels = hourLabels(day, UTC, "Asia/Tokyo");
expect(labels[0]).toBe("09:00");
expect(labels[9]).toBe("18:00");
// ...and wraps round midnight rather than running past 23:00.
expect(labels[23]).toBe("08:00");
});
it("handles a zone behind the primary one", () => {
expect(hourLabels(day, UTC, "America/New_York")[12]).toBe("08:00");
});
it("handles a half-hour offset", () => {
expect(hourLabels(day, UTC, "Asia/Kolkata")[0]).toBe("05:30");
});
it("is the identity when both zones are the same", () => {
expect(hourLabels(day, UTC, UTC)[13]).toBe("13:00");
});
});
describe("labels", () => {
it("formats offsets", () => {
expect(offsetLabel(0)).toBe("UTC");
expect(offsetLabel(2 * HOUR)).toBe("UTC+02:00");
expect(offsetLabel(-5 * HOUR)).toBe("UTC-05:00");
expect(offsetLabel(5.5 * HOUR)).toBe("UTC+05:30");
expect(offsetLabel(-3.5 * HOUR)).toBe("UTC-03:30");
});
it("names a zone", () => {
expect(zoneAbbreviation(SUMMER, "Europe/Berlin")).toBe("GMT+2");
expect(zoneAbbreviation(SUMMER, "UTC")).toBe("UTC");
});
it("falls back to the zone name it was given", () => {
expect(zoneAbbreviation(SUMMER, "Mars/Olympus")).toBe("Mars/Olympus");
});
it("describes the display zone", () => {
expect(describeDisplayZone(UTC)).toBe(UTC);
expect(describeDisplayZone(LOCAL, SUMMER)).toContain(UTC);
});
it("explains the display zone in words", () => {
expect(explainDisplayZone(UTC)).toContain(UTC);
expect(explainDisplayZone(LOCAL)).toContain("browser");
});
});
describe("the zone list offered in the form", () => {
const zones = zoneList();
it("includes UTC and the browser's own zone", () => {
expect(zones).toContain("UTC");
expect(zones).toContain(browserZone());
});
it("is sorted and free of duplicates", () => {
expect(zones).toEqual([...zones].sort());
expect(new Set(zones).size).toBe(zones.length);
});
it("covers the places people actually are", () => {
for (const name of ["Europe/Berlin", "America/New_York", "Asia/Tokyo"]) {
expect(zones).toContain(name);
}
});
});