Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
1 change: 0 additions & 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 Down
11 changes: 5 additions & 6 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
56 changes: 39 additions & 17 deletions src/shared/timezone.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,10 @@
/**
* Timezone conversion utilities using @internationalized/date.
* Timezone conversion utilities built on the standard 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.
* 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 { formatIsoForPreview } from "#shared/bulk-replace.ts";

/** Default timezone when none is configured */
Expand All @@ -21,13 +14,41 @@ export const DEFAULT_TIMEZONE = "Europe/London";
const pad2 = (n: number): string => String(n).padStart(2, "0");

/** 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 =>
Temporal.Instant.fromEpochMilliseconds(
new Date(utcIso).getTime(),
).toZonedDateTimeISO(tz);

/**
* Get today's date as YYYY-MM-DD in the given timezone.
*/
export const todayInTz = (tz: string): string => libToday(tz).toString();
export const todayInTz = (tz: string): string =>
Temporal.Now.plainDateISO(tz).toString();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Use the fakeable clock for todayInTz

When date-dependent code is exercised under @std/testing/time's FakeTime, this now reads the real wall clock because FakeTime overrides Date/timers while Temporal.Now uses the runtime clock directly. The previous @internationalized/date today() path was backed by Date.now(), so fake-time tests for booking windows, holiday cutoffs, or delivery/calendar pages can now calculate “today” from the actual date instead of the frozen date; derive the Temporal instant from Date.now() before converting to the requested timezone to keep this helper controllable.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed and fixed in 8ba59c1. Verified empirically: under FakeTime frozen at 2030-01-15, Temporal.Now.plainDateISO returned the real date while a Date.now()-derived instant returned the frozen one. todayInTz now derives "today" from Date.now() (via a shared msToZoned helper that utcToZoned also uses), restoring the freezable behaviour the old today() had. Added a FakeTime test that locks it in. server-qr-book/renewals (FakeTime + todayInTz) still pass via the harness.


Generated by Claude Code


/**
* 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 — the rest of the app interprets these values
* in the configured timezone, and `Temporal.PlainDateTime.from` would silently
* *discard* such a suffix rather than reject it, storing a different instant.
*/
const NAIVE_DATETIME = /^\d{4}-\d{2}-\d{2}(T\d{2}:\d{2}(:\d{2}(\.\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).
*/
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 +59,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 +129,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
34 changes: 34 additions & 0 deletions test/lib/timezone.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,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 +274,19 @@ 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);
});
});

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