# 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.

URL: https://biloh.com.au/docs/engineering-notes/whose-today-is-it
Category: Engineering notes | Audience: builder | Updated: 2026-07-30

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:

```ts
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.

```ts
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:

```ts
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

- [Scheduling jobs by the day, not the minute](/docs/concepts/scheduling-jobs-by-the-day) — why the unit is a day at all.
- [Every record needs a visible bucket](/docs/engineering-notes/every-record-needs-a-visible-bucket) — the companion invariant found in the same audit.
- [Dispatching and triaging jobs](/docs/how-to/dispatching-and-triage) — the operator surface these dates drive.
