DEV Community

Daniel Pertu
Daniel Pertu

Posted on

Our day boundaries correct for daylight saving by exactly one hour, and one of the 418 time zones moves by thirty minutes

Nakodo counts most of its limits by UTC day, and one of them by Pacific day, because that is the day the YouTube Data API's quota resets on. src/lib/time.ts is 69 lines and holds both ideas, with a comment at the top that is the whole design:

// Durations and day boundaries, in one place. Limits count by UTC day unless
// a source says otherwise (YouTube's quota day is Pacific); billing periods
// are months from the subscription's start.
Enter fullscreen mode Exit fullscreen mode

The UTC half is unremarkable and heavily used: DAY_MS appears at 82 sites, daysAgo() at 11, nextUtcMidnight() at 11, startOfUtcDay() at 10. The zone-aware half has exactly two functions and, today, exactly one caller.

Finding midnight in a zone you do not control

There is no date library here. The only tool is Intl.DateTimeFormat, which can tell you what the wall clock in a zone reads at a given instant:

// Milliseconds since midnight on the wall clock in `timeZone`.
function wallClockMs(timeZone: string, d: Date): number {
  const parts = format(clockFormats, timeZone, { hour: "2-digit", minute: "2-digit", second: "2-digit", hourCycle: "h23" }).formatToParts(d);
  const get = (type: string) => Number(parts.find((p) => p.type === type)?.value ?? 0);
  return ((get("hour") * 60 + get("minute")) * 60 + get("second")) * SECOND_MS + d.getMilliseconds();
}
Enter fullscreen mode Exit fullscreen mode

hourCycle: "h23" is there so that midnight reads as 00 rather than 24, which is what h24 would give you and what a default locale may pick. The milliseconds come from the Date, because formatToParts has no part for them that is reliable to parse. The formatters are memoised in two Maps keyed by zone, since constructing an Intl.DateTimeFormat is the expensive bit.

Dates use the same trick with a locale chosen for its format rather than its country:

f = new Intl.DateTimeFormat("en-CA", { timeZone, ...options });
Enter fullscreen mode Exit fullscreen mode

en-CA with numeric year, month and day gives 2026-10-30, which sorts and compares as a string. That is the entire reason it is Canada.

With a wall clock in hand, the start of the day is "now minus however far into the day the wall clock says we are". Except that is wrong twice a year:

// Midnight that started today in `timeZone`. On a day the clocks change, the
// wall clock is an hour off the time elapsed, which the checks correct.
export function startOfDayIn(timeZone: string, now = new Date()): Date {
  const today = dateIn(timeZone, now);
  let t = now.getTime() - wallClockMs(timeZone, now);
  if (dateIn(timeZone, new Date(t)) !== today) t += HOUR_MS;
  else if (dateIn(timeZone, new Date(t - 1)) === today) t -= HOUR_MS;
  return new Date(t);
}
Enter fullscreen mode Exit fullscreen mode

On a day the clocks go forward, 13 hours of wall clock is 12 hours of elapsed time, so subtracting the wall clock lands in yesterday: the first check notices the date is wrong and adds an hour. On a day the clocks go back, it lands an hour into today, and the giveaway is that the millisecond before it is also today: the second check subtracts an hour. Both corrections are an hour because daylight saving is an hour.

The other function never makes that assumption:

export function nextMidnightIn(timeZone: string, now = new Date()): Date {
  const today = dateIn(timeZone, now);
  let t = new Date(now.getTime() + HOUR_MS);
  while (dateIn(timeZone, t) === today) t = new Date(t.getTime() + HOUR_MS);
  // t is in the new day's first hour: step back to midnight.
  return new Date(t.getTime() - wallClockMs(timeZone, t));
}
Enter fullscreen mode Exit fullscreen mode

It steps forward an hour at a time until the date changes, then subtracts the wall clock at that new instant, which is correct whatever the offset did on the way. Two functions in one file, written on the same afternoon, and only one of them is shift-size agnostic.

How many zones punish the assumption

Measured with Node's own ICU data, over 2026, by sampling each zone's offset on the 15th of each month and looking at the spread:

const zones = Intl.supportedValuesOf("timeZone");
const off = (tz, d) => {
  const p = new Intl.DateTimeFormat("en-US", { timeZone: tz, timeZoneName: "longOffset" })
    .formatToParts(d).find((x) => x.type === "timeZoneName").value;
  const m = p.match(/GMT([+-])(\d{2}):(\d{2})/);
  return m ? (m[1] === "-" ? -1 : 1) * (Number(m[2]) * 60 + Number(m[3])) : 0;
};
const odd = [];
for (const tz of zones) {
  const offs = new Set();
  for (let m = 0; m < 12; m++) offs.add(off(tz, new Date(Date.UTC(2026, m, 15))));
  const a = [...offs].sort((x, y) => x - y);
  if (a.length > 1 && (a.at(-1) - a[0]) % 60 !== 0) odd.push([tz, a.at(-1) - a[0]]);
}
console.log("total zones:", zones.length, "| non-hour DST:", odd.length, odd);
Enter fullscreen mode Exit fullscreen mode
total zones: 418 | non-hour DST: 1 [ [ 'Australia/Lord_Howe', 30 ] ]
Enter fullscreen mode Exit fullscreen mode

One. Lord Howe Island, a few hundred residents in the Tasman Sea, moves its clocks by half an hour. The 45-minute zones people remember (Kathmandu at +05:45, Chatham at +12:45, Eucla at +08:45) are only awkward offsets, not awkward shifts, and awkward offsets cost this code nothing because it never does arithmetic on an offset. Chatham does keep daylight saving, and it keeps it in whole hours.

Running the two functions over that one zone's transition days shows the disagreement:

-- ordinary day --
Europe/London          now 2026-10-30, 12:00:00  start 2026-10-30, 00:00:00  next 2026-10-31, 00:00:00  len 24.00h
Asia/Kathmandu         now 2026-10-30, 17:45:00  start 2026-10-30, 00:00:00  next 2026-10-31, 00:00:00  len 24.00h
-- US spring forward 8 Mar 2026 --
America/New_York       now 2026-03-08, 14:00:00  start 2026-03-08, 00:00:00  next 2026-03-09, 00:00:00  len 23.00h
-- UK clocks back 25 Oct 2026 --
Europe/London          now 2026-10-25, 12:00:00  start 2026-10-25, 00:00:00  next 2026-10-26, 00:00:00  len 25.00h
-- Lord Howe: DST starts 4 Oct 2026, ends 5 Apr 2026 --
Australia/Lord_Howe    now 2026-10-04, 17:00:00  start 2026-10-04, 00:30:00  next 2026-10-05, 00:00:00  len 23.00h
Australia/Lord_Howe    now 2026-04-05, 16:30:00  start 2026-04-04, 23:30:00  next 2026-04-06, 00:00:00  len 25.00h
Australia/Lord_Howe    now 2026-10-05, 17:00:00  start 2026-10-05, 00:00:00  next 2026-10-06, 00:00:00  len 24.00h
Enter fullscreen mode Exit fullscreen mode

Every whole-hour zone is exact, including the 45-minute offsets and both directions of a one-hour change. On Lord Howe's two transition days the correction overshoots by the half hour it did not need: the day is reported as starting at 00:30 in October and at 23:30 the previous evening in April, while nextMidnightIn gets both right. A counter keyed on that "day" would miss its first 30 minutes once a year and double-count 30 minutes once a year. The day after, both functions agree again.

Why this is a curiosity and not an incident

The zone-aware functions have one caller:

// src/lib/youtube/quota.ts
export const pacificDay = (date = new Date()) => dateIn(YOUTUBE_TIME_ZONE, date);

// When today's quota day began: the last Pacific midnight.
export const quotaDayStart = (now = new Date()) => startOfDayIn(YOUTUBE_TIME_ZONE, now);

// Five minutes after the next Pacific midnight, when deferred work can run
// again.
export const nextQuotaReset = (now = new Date()) =>
  new Date(nextMidnightIn(YOUTUBE_TIME_ZONE, now).getTime() + 5 * MINUTE_MS);
Enter fullscreen mode Exit fullscreen mode

YOUTUBE_TIME_ZONE is "America/Los_Angeles", which shifts by a whole hour and has done for every year this code can be asked about. Everything else in the app counts by UTC day, which has no transitions at all. So the bug is latent: real, reproducible in six lines, and currently unreachable.

The test file says the same thing in a different way. There are six tests, 37 lines, and five of them pin the corrections:

test("start of day when the clocks go back", () => {
  // 1 Nov 2026: midnight is PDT (07:00Z), 05:00 PST is 13:00Z, six hours later.
  assert.equal(startOfDayIn(LA, new Date("2026-11-01T13:00:00Z")).toISOString(), "2026-11-01T07:00:00.000Z");
});
Enter fullscreen mode Exit fullscreen mode
$ npx tsx --test src/lib/time.test.ts
ℹ tests 6
ℹ pass 6
ℹ fail 0
Enter fullscreen mode Exit fullscreen mode

Every one of them passes LA. The tests encode the correction and the assumption behind it, which is exactly what tests written beside an implementation tend to do.

What I would change

  • Make startOfDayIn work like nextMidnightIn: find the boundary by probing, not by assuming its size. Something like "step back an hour at a time until the date changes, then add the wall clock at that instant" has no magic number in it.
  • Or state the assumption in the signature. A function named startOfDayIn(timeZone) promises more than a function that only ever gets America/Los_Angeles.
  • SECOND_MS is exported and used nowhere outside this file. The other three duration constants have 82, 26 and 16 call sites between them.

The user-facing end of all this date arithmetic is public: the retention windows it enforces are written out in the privacy policy, the plan limits it counts against are on pricing, and how it works describes the daily rhythm those counters exist to keep.

Top comments (0)