Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion deno.json
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,6 @@
"esbuild": "npm:esbuild@^0.28.0",
"sass": "npm:sass@^1.101.0",
"@bunny.net/edgescript-sdk": "npm:@bunny.net/edgescript-sdk@^0.12.1",
"@internationalized/date": "npm:@internationalized/date@^3.12.0",
"@bunny.net/storage-sdk": "npm:@bunny.net/storage-sdk@^0.3.1",
"@botpoison/browser": "npm:@botpoison/browser@^0.1.30",
"jsqr": "npm:jsqr@^1.4.0",
Expand All @@ -63,6 +62,7 @@
"fflate": "npm:fflate@^0.8.2",
"auto-console-group": "npm:auto-console-group@^1.3.0",
"valibot": "npm:valibot@^1.4.1",
"temporal-polyfill": "npm:temporal-polyfill@^0.3.0",
"@std/assert": "jsr:@std/assert@^1.0.19",
"@std/collections": "jsr:@std/collections@^1.2.0",
"@std/expect": "jsr:@std/expect@^1.0.18",
Expand Down
25 changes: 11 additions & 14 deletions deno.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

9 changes: 2 additions & 7 deletions src/shared/dates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,14 @@
* Date computation for daily listings
*/

import { fromAbsolute } from "@internationalized/date";
import { filter } from "#fp";
import { settings } from "#shared/db/settings.ts";
import {
formatDatetimeInTz,
formatDatetimeShortInTz,
localToUtc,
todayInTz,
utcToZoned,
} from "#shared/timezone.ts";
import {
type Holiday,
Expand Down Expand Up @@ -449,14 +449,9 @@ export const formatTimeAgo = (
* Used by the calendar view to map standard listing dates to calendar days.
*/
export const listingDateToCalendarDate = (utcIso: string): string | null => {
const tz = settings.timezone;
if (!utcIso) return null;
try {
const ms = new Date(utcIso).getTime();
if (Number.isNaN(ms)) return null;
const zoned = fromAbsolute(ms, tz);
const pad = (n: number) => String(n).padStart(2, "0");
return `${zoned.year}-${pad(zoned.month)}-${pad(zoned.day)}`;
return utcToZoned(utcIso, settings.timezone).toPlainDate().toString();
} catch {
return null;
}
Expand Down
77 changes: 60 additions & 17 deletions src/shared/timezone.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,17 @@
/**
* Timezone conversion utilities using @internationalized/date.
* Timezone conversion utilities built on the Temporal API.
*
* Wraps the library to provide simple string-in/string-out functions
* for the rest of the codebase, with correct DST handling and
* explicit disambiguation.
* Temporal is imported from `temporal-polyfill` rather than used as a runtime
* global: the deployed Bunny Edge bundle must run on whichever Deno the edge
* happens to use, and Deno only exposes `Temporal` as a stable global from
* 2.7+. Bundling the polyfill keeps behaviour identical across runtimes (and
* in tests) instead of depending on an API the baseline runtime lacks.
*
* Provides simple string-in/string-out functions for the rest of the
* codebase, with correct DST handling and explicit disambiguation.
*/

import {
fromAbsolute,
today as libToday,
parseDateTime,
toZoned,
} from "@internationalized/date";
import { Temporal } from "temporal-polyfill";
import { formatIsoForPreview } from "#shared/bulk-replace.ts";

/** Default timezone when none is configured */
Expand All @@ -20,14 +20,56 @@ export const DEFAULT_TIMEZONE = "Europe/London";
/** Pad a number to two digits */
const pad2 = (n: number): string => String(n).padStart(2, "0");

/** Convert epoch milliseconds to a ZonedDateTime in the given timezone */
const msToZoned = (ms: number, tz: string): Temporal.ZonedDateTime =>
Temporal.Instant.fromEpochMilliseconds(ms).toZonedDateTimeISO(tz);

/** Parse a UTC ISO string into a ZonedDateTime in the given timezone */
const utcToZoned = (utcIso: string, tz: string) =>
fromAbsolute(new Date(utcIso).getTime(), tz);
export const utcToZoned = (
utcIso: string,
tz: string,
): Temporal.ZonedDateTime => msToZoned(new Date(utcIso).getTime(), tz);

/**
* Get today's date as YYYY-MM-DD in the given timezone.
*
* Reads the clock via `Date.now()` rather than `Temporal.Now` so the helper
* stays controllable under `@std/testing/time`'s `FakeTime`, which patches
* `Date`/timers but not `Temporal.Now`.
*/
export const todayInTz = (tz: string): string =>
msToZoned(Date.now(), tz).toPlainDate().toString();

/**
* Strict datetime-local shape: a calendar date optionally followed by a
* wall-clock time. Deliberately excludes any UTC designator (`Z`), numeric
* offset, or bracketed IANA zone, since the rest of the app interprets these
* values in the configured timezone. `Temporal.PlainDateTime.from` handles
* those suffixes inconsistently — it *rejects* a `Z` but silently *discards* a
* numeric offset or bracketed zone (storing a different instant than written) —
* so the regex rejects all three up front rather than relying on that.
*
* The time fields are range-constrained (`HH` 00–23, `MM`/`SS` 00–59) rather
* than bare `\d{2}`: Temporal rejects an out-of-range hour/minute, but it
* *clamps* a `:60` leap second to `:59` even under `overflow: "reject"`, which
* would silently shift the stored time. The regex rejects it instead. Calendar
* validity (real month/day) is still delegated to Temporal's `overflow`.
*/
const NAIVE_DATETIME =
/^\d{4}-\d{2}-\d{2}(T([01]\d|2[0-3]):[0-5]\d(:[0-5]\d(\.\d+)?)?)?$/;

/**
* Parse a naive datetime-local value into a PlainDateTime, rejecting
* offset/zone-bearing input up front, then delegating real calendar-validity
* checks to Temporal (`overflow: "reject"` catches impossible dates like
* 2026-02-30 rather than silently clamping them).
*/
export const todayInTz = (tz: string): string => libToday(tz).toString();
const parseNaiveDateTime = (value: string): Temporal.PlainDateTime => {
if (!NAIVE_DATETIME.test(value)) {
throw new RangeError(`Non-naive datetime: ${value}`);
}
return Temporal.PlainDateTime.from(value, { overflow: "reject" });
};

/**
* Convert a naive datetime-local value (YYYY-MM-DDTHH:MM) to a UTC ISO string,
Expand All @@ -38,9 +80,10 @@ export const todayInTz = (tz: string): string => libToday(tz).toString();
*/
export const localToUtc = (naive: string, tz: string): string => {
try {
const dt = parseDateTime(naive);
const zoned = toZoned(dt, tz, "compatible");
return zoned.toAbsoluteString();
return parseNaiveDateTime(naive)
.toZonedDateTime(tz, { disambiguation: "compatible" })
.toInstant()
.toString({ fractionalSecondDigits: 3 });
} catch {
throw new Error(`Invalid datetime: ${naive}`);
}
Expand Down Expand Up @@ -107,7 +150,7 @@ export const isValidTimezone = (tz: string): boolean => {
*/
export const isValidDatetime = (value: string): boolean => {
try {
parseDateTime(value);
parseNaiveDateTime(value);
return true;
} catch {
return false;
Expand Down
53 changes: 53 additions & 0 deletions test/lib/timezone.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { expect } from "@std/expect";
import { describe, it as test } from "@std/testing/bdd";
import { FakeTime } from "@std/testing/time";
import {
DEFAULT_TIMEZONE,
formatDatetimeInTz,
Expand Down Expand Up @@ -33,6 +34,18 @@ describe("timezone", () => {
const b = todayInTz("UTC");
expect(a).toBe(b);
});

test("is controllable under FakeTime (reads the fakeable clock)", () => {
// Temporal.Now bypasses FakeTime; todayInTz must derive "today" from
// Date.now() so date-dependent code stays deterministic in frozen-time
// tests (booking windows, holiday cutoffs, calendar/delivery pages).
const time = new FakeTime(new Date("2030-01-15T12:00:00Z"));
try {
expect(todayInTz("Europe/London")).toBe("2030-01-15");
} finally {
time.restore();
}
});
});

describe("localToUtc", () => {
Expand Down Expand Up @@ -84,6 +97,27 @@ describe("timezone", () => {
expect(() => localToUtc("not-a-date", "UTC")).toThrow("Invalid datetime");
});

test("rejects a datetime carrying a numeric offset", () => {
// Input must be naive: an offset would otherwise be silently discarded
// and the wall-clock time reinterpreted in the target timezone, storing
// a different instant than the string implies.
expect(() =>
localToUtc("2026-06-15T14:30+09:00", "Europe/London"),
).toThrow("Invalid datetime");
});

test("rejects a datetime carrying a bracketed IANA zone", () => {
expect(() =>
localToUtc("2026-06-15T14:30[Asia/Tokyo]", "Europe/London"),
).toThrow("Invalid datetime");
});

test("rejects a datetime carrying a UTC designator", () => {
expect(() => localToUtc("2026-06-15T14:30Z", "Europe/London")).toThrow(
"Invalid datetime",
);
});

test("handles DST spring-forward gap with 'compatible' disambiguation", () => {
// 2026-03-29 01:30 Europe/London doesn't exist (clocks skip from 01:00 GMT to 02:00 BST)
// 'compatible' maps to the later (post-transition) interpretation: 02:30 BST = 01:30 UTC
Expand Down Expand Up @@ -253,6 +287,25 @@ describe("timezone", () => {
test("rejects empty string", () => {
expect(isValidDatetime("")).toBe(false);
});

test("rejects an impossible calendar date", () => {
// overflow: "reject" must not clamp 2026-02-30 to a real day.
expect(isValidDatetime("2026-02-30T00:00")).toBe(false);
});

test("rejects a datetime carrying a numeric offset", () => {
expect(isValidDatetime("2026-06-15T14:30+09:00")).toBe(false);
});

test("rejects a datetime carrying a bracketed IANA zone", () => {
expect(isValidDatetime("2026-06-15T14:30[Asia/Tokyo]")).toBe(false);
});

test("rejects a :60 leap second instead of clamping it to :59", () => {
// Temporal clamps :60 to :59 even under overflow:"reject"; the naive
// shape guard rejects it so a crafted value never stores a shifted time.
expect(isValidDatetime("2026-06-15T14:30:60")).toBe(false);
});
});

describe("round-trip: localToUtc -> utcToLocalInput", () => {
Expand Down