TIL: our trial countdown was wrong one day a year, and only in Europe

A date test that passed for months failed on a CI runner in Berlin. What we learned about DST, local midnight and testing code that reads the clock.

{ }$ run
Illustration for “TIL: our trial countdown was wrong one day a year, and only in Europe”.
On this page 4 sections

Short one. A test that had passed for eight months failed on a new CI runner, and the reason was a small enough lesson that I wanted to write it down.

Our app shows trial users a banner: “3 days left in your trial.” The function behind it had a test, the test pinned the clock, and everything was green. Then we added a self-hosted runner in our Berlin office, and the same test failed there every time.

The function

const DAY_MS = 86_400_000;

export function daysLeft(trialEndsAt: Date, now: Date = new Date()): number {
  const start = new Date(now);
  start.setHours(0, 0, 0, 0);
  const end = new Date(trialEndsAt);
  end.setHours(0, 0, 0, 0);
  return Math.floor((end.getTime() - start.getTime()) / DAY_MS);
}

It looks reasonable. Snap both dates to local midnight, subtract, divide by the length of a day.

The problem is that last word. In time zones with daylight saving, a day is not always 24 hours. On the last Sunday of March in Europe, local time jumps from 02:00 to 03:00, so that calendar day is 23 hours long. Between local midnight on 27 March 2026 and local midnight on 30 March 2026, there are three calendar days but only 71 hours. 71 divided by 24 is 2.96, and Math.floor turns that into 2.

So for anyone in Europe whose trial ended in the three days after the spring change, the banner was off by one. On UTC servers and on our laptops in Osaka, where there is no DST, the bug did not exist.

Why our test did not catch it

The test pinned the clock correctly. It just pinned it to a date in June, and it ran in whatever time zone the machine happened to use. Pinning the time is only half of controlling time: the time zone is part of the input too.

Here is the test we have now, using Vitest’s fake timers:

// trial.test.ts: describe, it, expect, vi and afterEach come from vitest

describe('daysLeft', () => {
  afterEach(() => {
    vi.useRealTimers();
  });

  it('counts calendar days across the spring DST change', () => {
    vi.useFakeTimers();
    vi.setSystemTime(new Date('2026-03-27T09:00:00+01:00'));
    expect(daysLeft(new Date('2026-03-30T18:00:00+02:00'))).toBe(3);
  });

  it('counts calendar days across the autumn DST change', () => {
    vi.useFakeTimers();
    vi.setSystemTime(new Date('2026-10-23T09:00:00+02:00'));
    expect(daysLeft(new Date('2026-10-26T18:00:00+01:00'))).toBe(3);
  });
});

The fix to the function was one word: Math.round instead of Math.floor. A local day is always 23, 24 or 25 hours, so rounding the difference always lands on the right whole number of days.

Run the suite in a time zone you do not live in

The more useful change was to CI. Node reads the TZ environment variable at startup, so we now run the date-heavy tests twice:

TZ=UTC npx vitest run src/billing             # what the servers use
TZ=Europe/Berlin npx vitest run src/billing   # DST edges, never the laptop default

The second run takes nine seconds and has since caught one more bug, in a report that grouped events by local day.

What I took away

Three things, in order of how often they will matter:

  • A test that pins the clock but not the time zone is testing one environment, not the function.
  • Arithmetic on local midnights is calendar arithmetic. Count days by rounding, or better, compare calendar dates with a library that understands them.
  • When a test fails on only one machine, the machine is telling you something. Our first instinct was to set TZ=UTC on the new runner and move on. I am glad someone asked why first.
Filed under
Portrait of Hana Sato (illustration)

Written by

Hana Sato

Developer tooling lead · Osaka, Japan

Hana owns CI, the monorepo and the test suite at a mid-sized SaaS team. She writes about fast builds, flaky tests, feature flags and the small internal tools that are cheaper than a vendor.

One email when a new post ships

Roughly twice a month: the post, the code and one link we found useful. Plain text, no tracking pixels.

Unsubscribe with one click. We never share the list.

Read by 6,800 developers

Live demo · Hot Path Notes— View template →