Engineering notesView as Markdown ↗

Whose "today" is it? Timezones in a scheduling engine

`new Date().toISOString().slice(0, 10)` is UTC's today, not your user's. In a scheduling product that single expression decides which jobs look overdue, which day the run sheet opens on, and what the spawner considers "now" — so resolve the day in the tenant's timezone, in one helper, and forbid the UTC form with a structural test.

In a scheduling product, "today" is not a timestamp — it is a calendar date in somebody's timezone, and the somebody is the tenant, not the server. The idiomatic JavaScript one-liner for a date string, new Date().toISOString().slice(0, 10), silently answers UTC's today. Anywhere east of UTC that is wrong for the first hours of the working day; anywhere west, wrong for the last.

The fix is small and worth doing once, properly: one helper that resolves the day in a supplied timezone, an injectable clock so tests can sit on the boundary, and a structural test that forbids the UTC form everywhere else.

What actually breaks

The expression looks harmless in isolation, which is why it spreads. In one audit it had reached six places in a scheduling engine: the board's date window, the run sheet's default day, the assignment board's day, the server-side bucketing that decides what counts as overdue, the recurring-job spawner's window, and the date defaults on the read tools an AI agent calls.

Every one of those is a day-boundary decision, so they all inherited the same error. For a tenant ten hours ahead of UTC the symptom was that until mid-morning the operator's board opened on yesterday, the run sheet offered yesterday's work, and a job scheduled for the actual today was in neither the "today" nor the "overdue" set. Nothing threw. The suite was green — because the tests, like the code, computed their expectations in UTC.

The helper

Resolve the date by formatting in the target zone, not by adding offsets to an epoch:

export function todayInTenantTz(timezone?: string | null, now: Date = new Date()): string {
  const tz = timezone ?? PLATFORM_DEFAULT_TZ;
  try {
    return new Intl.DateTimeFormat("en-CA", {
      timeZone: tz, year: "numeric", month: "2-digit", day: "2-digit",
    }).format(now);              // en-CA yields YYYY-MM-DD
  } catch {
    return now.toISOString().slice(0, 10);   // invalid zone: fall back, never throw
  }
}

Three details that matter more than they look:

  • en-CA is the trick. It formats as YYYY-MM-DD, so you get an ISO calendar date without string surgery on parts.
  • now is a parameter. Without an injectable clock you cannot write the only test that proves the behaviour.
  • It never throws. An unrecognised IANA zone falls back rather than taking down a read path. A missing zone falls back to the platform default; an invalid one is a different case from an absent one, and the tests assert both.

Read paths then take the tenant's zone from its record once and pass it down. On the client, initial state uses the browser's local day — but the server's resolved date stays authoritative for anything that buckets or bills.

The test that makes it real

One assertion carries the whole idea: pin the clock to an instant where UTC and the tenant disagree, and assert the tenant's answer.

const UTC_LATE_EVENING = new Date("2026-07-03T23:30:00Z");
expect(todayInTenantTz("Australia/Brisbane", UTC_LATE_EVENING)).toBe("2026-07-04");
expect(todayInTenantTz("UTC",                UTC_LATE_EVENING)).toBe("2026-07-03");

Then the same fixed instant drives the higher-level tests: a job dated on the tenant's today buckets as today and not as overdue, and a read tool called with no date arguments echoes the tenant's date as its window start. A neat trick for the tool-level tests is to give the fixture tenant a zone that is far from UTC (UTC+14 exists) so the two answers differ for most of the day — the test then earns its keep on nearly every CI run instead of only near midnight.

The lock that stops the regression

A helper is a suggestion until something enforces it. A structural test walks the scheduling source and fails on the UTC-today form, with a single documented exception — the helper itself:

const UTC_TODAY = /new Date\(\)\s*\.\s*toISOString\(\)\s*\.\s*slice\(\s*0/;
// …walk lib/scheduling, components/scheduling, the scheduler tools…
expect(violations).toEqual([]);   // reports file:line for each hit

Writing that lock paid for itself immediately: it failed on three sites the audit had not found by reading — the spawner's window, a recurrence anchor's last-resort fallback, and the rule that resets a missed job to scheduled when it is rebooked into the future. Each was a genuine day-boundary decision hiding in engine code nobody thought of as date logic.

The general shape

  1. A date-in-a-timezone is a formatting problem. Use Intl; never arithmetic on offsets.
  2. Put it in one helper with an injectable clock and non-throwing fallbacks.
  3. Test on the boundary, with a fixed instant where the two answers differ.
  4. Forbid the naive form structurally, and let the lock find the sites you missed.

The wider lesson is about test symmetry: a suite that computes its expectations the same way the code does will confirm a shared mistake. The fix is a fixture that knows something the implementation doesn't — here, a real timezone and a real instant.

Related

Last updated 2026-07-30