DEV Community

yureki_lab
yureki_lab

Posted on

How I Fixed 19 Timezone Bugs in My Time-Tracking App With Claude Code

TL;DR

My time-tracking app had been quietly lying to users in every timezone that wasn't mine. I used Claude Code (v2.x) to build a test harness that replays the app across 40 timezones and every DST boundary, found 19 real bugs, and fixed them in about a week. The secret wasn't the fixes — it was making the agent prove each bug with a failing test before it was allowed to touch a single line of date code.

The Problem

I build a small time-management app as a side project. You start a timer, you stop it, and at the end of the week you get a report of where your hours went. Simple, right?

Then the bug reports started showing up, and they all had the same flavor:

  • "My Monday report shows 25 hours."
  • "A session I logged at 11:30 PM shows up on the wrong day."
  • "My weekly streak reset even though I worked every day."
  • "After the clocks changed, every timer I started was off by an hour."

Every single one of these worked perfectly on my machine. Of course they did — I live in a single timezone, I develop in that timezone, and my CI runs in UTC. The app had effectively been tested in exactly two timezones, neither of which had daylight saving time in the configuration I was using.

The interesting constraint: date/time code is the worst possible place to let an AI agent "just fix things." Every fix looks obviously correct in isolation. new Date() → dayjs.utc(), done, ship it. But time bugs are almost never local. Changing how one function interprets a timestamp silently changes the meaning of every timestamp that flows through it. I've watched agents (and, honestly, myself) "fix" a timezone bug by moving it somewhere else.

So the goal wasn't "have Claude fix the timezone bugs." The goal was: build a system where a timezone bug can't be fixed until it's been proven, and can't come back once it's fixed.

How I Solved It

Step 1: Make the clock a dependency, not a global

The first thing I asked Claude Code to do wasn't fixing anything. It was an audit: find every place the code reads the current time or the local timezone directly.

The prompt was roughly:

Search the codebase for every direct read of wall-clock time or
local timezone: Date.now(), new Date() with no args, Intl APIs that
default to the runtime zone, getTimezoneOffset(), and any date
library call that implicitly uses local time. List each with file,
line, and what it's used for. Do not change anything yet.
Enter fullscreen mode Exit fullscreen mode

It came back with 61 call sites. That number alone explained a lot. Sixty-one places where "now" and "here" were silently assumed.

Then I had it introduce a tiny clock abstraction and route everything through it:

// clock.ts
export interface Clock {
  now(): Date;          // an absolute instant
  zone(): string;       // IANA zone of the *user*, not the server
}

export const systemClock = (userZone: string): Clock => ({
  now: () => new Date(),
  zone: () => userZone,
});

export const fixedClock = (iso: string, zone: string): Clock => ({
  now: () => new Date(iso),
  zone: () => zone,
});
Enter fullscreen mode Exit fullscreen mode

This refactor was boring, mechanical, and exactly the kind of thing an agent is great at. It touched 34 files and didn't change behavior. I verified "didn't change behavior" by running the existing test suite before and after — all green, same count.

Step 2: Build a harness that lives in hell

Once the clock was injectable, I could build the thing that actually found the bugs: a test harness that runs the same scenario across a matrix of timezones and instants that are known to be nasty.

// tz-matrix.ts
export const ZONES = [
  "UTC",
  "America/Los_Angeles",
  "America/St_Johns",       // UTC-3:30, half-hour offset
  "Europe/London",
  "Asia/Kolkata",           // UTC+5:30
  "Asia/Kathmandu",         // UTC+5:45 (yes, really)
  "Australia/Lord_Howe",    // 30-minute DST shift
  "Pacific/Kiritimati",     // UTC+14
  "Pacific/Pago_Pago",      // UTC-11
  // ...31 more
];

export const NASTY_INSTANTS = [
  "spring-forward minus 1 minute",
  "spring-forward plus 1 minute",
  "fall-back, first 1:30 AM",
  "fall-back, second 1:30 AM",
  "local midnight minus 1 second",
  "local midnight",
  "Sunday 23:59 (week boundary)",
  "Dec 31 23:59 (year boundary)",
  "Feb 29 (leap day)",
];
Enter fullscreen mode Exit fullscreen mode

The instants are described in local terms on purpose. A helper resolves each one to an absolute UTC instant for each zone using the IANA tz database, so "spring-forward minus 1 minute" means something different in London than in Los Angeles — and something that doesn't exist at all in Kolkata, which is itself a useful test case.

Then every core behavior got a property-style test:

describe.each(ZONES)("weekly report in %s", (zone) => {
  it.each(NASTY_INSTANTS)("total hours are sane at %s", (instant) => {
    const clock = clockAt(instant, zone);
    const sessions = generateSessions(clock, { days: 7 });
    const report = buildWeeklyReport(sessions, clock);

    for (const day of report.days) {
      expect(day.hours).toBeLessThanOrEqual(25); // 25 = fall-back day
      expect(day.hours).toBeGreaterThanOrEqual(0);
    }
    expect(sumHours(report)).toBeCloseTo(sumHours(sessions), 5);
  });
});
Enter fullscreen mode Exit fullscreen mode

Notice the invariants are about meaning, not specific values. "No day has more than 25 hours." "The report's total equals the sum of the sessions." "A session that starts and ends on the same local day appears on exactly one day." I wrote these invariants myself. That part I did not delegate.

40 zones × 9 instants × 12 behaviors = 4,320 test cases. They run in about 14 seconds.

The first run: 312 failures.

Step 3: Cluster before you fix

312 failures is not 312 bugs. My first instinct was to tell Claude "fix the failing tests," and I'm very glad I didn't.

Instead:

Here are 312 failing test cases. Group them by root cause, not by
test name. For each group, write one sentence describing the actual
bug, the smallest failing case, and which function you believe is
responsible. Don't propose fixes yet.
Enter fullscreen mode Exit fullscreen mode

That collapsed the 312 failures into 19 distinct root causes. A few highlights:

  • Day bucketing used server time. Sessions were grouped by toISOString().slice(0, 10) — the UTC date. Anyone east or west of UTC by enough hours got late-night sessions on the wrong day. This one bug was responsible for ~90 failures by itself.
  • Duration computed from local wall-clock fields. (end.getHours() - start.getHours()) instead of subtracting instants. On fall-back night, a 2-hour session became 1 hour. On spring-forward, it became 3.
  • Week start was hardcoded to Sunday in UTC. Users in Asia/Kolkata had their Monday morning counted as last week.
  • Streak logic compared dates as strings in different zones. One side was the user's local date, the other side was UTC. Two days apart in Pacific/Kiritimati looked like the same day.
  • Kathmandu broke the "round to 15 minutes" helper, because it assumed offsets are multiples of 30 minutes. It's a +5:45 zone. My rounding produced timestamps that didn't exist.

Step 4: Prove it, then fix it, one root cause at a time

Here's the workflow rule that made the whole thing safe. For each root cause, Claude had to:

  1. Write one focused regression test that reproduces only that bug, outside the giant matrix.
  2. Show me that test failing, with the actual output.
  3. Propose the fix as a diff, touching only the function identified in the clustering step.
  4. Run the focused test (must pass) and the full matrix (failure count must go down, never up).
flowchart LR
    A[Cluster failures] --> B[Pick one root cause]
    B --> C[Write focused failing test]
    C --> D{Fails for the right reason?}
    D -- No --> C
    D -- Yes --> E[Minimal fix]
    E --> F[Run focused test + full matrix]
    F --> G{Matrix failures decreased<br/>and none new?}
    G -- No --> H[Revert and rethink]
    H --> E
    G -- Yes --> I[Commit]
    I --> B

Step 4's "never up" rule caught the agent three times. The most memorable one: its first fix for the day-bucketing bug converted everything to the user's zone at read time. Correct! Except the streak logic was already doing its own conversion, so streaks were now converted twice. Matrix failures went from 312 down to 220 — and 14 brand-new failures showed up in streak tests that had been passing. Without the full matrix, that would have shipped looking like a clean win.

I also enforced a single rule in the project spec file (CLAUDE.md) that every session reads:

## Time handling rules
- Store and transmit instants as UTC ISO-8601 with a Z suffix. Always.
- Convert to the user's zone only at the presentation edge
  (reports, UI labels, day bucketing).
- Durations are instant subtraction. Never subtract wall-clock fields.
- Never call Date.now() or new Date() directly. Use the injected Clock.
- Any change to date logic must run the full timezone matrix.
Enter fullscreen mode Exit fullscreen mode

That last rule matters more than it looks. Agents follow rules that are written down and checkable far better than rules that are implied by "the codebase style."

The results

Over six working days:

  • 19 root causes fixed, each with its own regression test
  • Matrix went from 312 failures → 0
  • 61 direct clock reads → 0 (enforced by a lint rule Claude also wrote)
  • Bug reports about "wrong day" / "wrong hours" since then: zero, after about a month in production

Total time I personally spent: maybe 10 hours, almost all of it writing invariants and reviewing diffs.

Lessons Learned

1. 💡 Write the invariants yourself. Delegate everything else.

The single highest-leverage thing I did was write a dozen sentences like "no day has more than 25 hours." The agent wrote thousands of lines of test harness and fixes around those sentences, but the sentences were the spec. If I'd let the agent invent the invariants, it would have encoded the current (buggy) behavior as correct. Agents are great at satisfying a definition of "right." They're bad at choosing one.

2. ⚠️ "Fix the failing tests" is a trap with time code

With 312 failures, an agent told to make tests pass will find the fastest path to green — and with date logic, the fastest path is often adjusting an expected value or adding a special case for one zone. Clustering by root cause first turned an unbounded mess into 19 small, reviewable problems.

3. ✅ A test matrix is a ratchet, not just a test

The rule "failure count must go down and no new failures may appear" did more than any code review. It caught three fixes that were locally correct but globally wrong. Any time you let an agent touch something with wide blast radius, give it a ratchet it can't cheat.

4. 🚀 Make the dangerous thing impossible, not discouraged

"Please don't use new Date()" in a prompt works most of the time. A lint rule that fails CI works every time. Once a rule mattered, I turned it into a check — and asked the agent to write the check. It's weirdly good at writing guardrails against its own mistakes.

5. 🌏 Test the zones that make you uncomfortable

My bugs didn't show up in Los Angeles or London. They showed up in Kathmandu (+5:45), Lord Howe (30-minute DST), and Kiritimati (+14). If your test zones are all whole-hour offsets that you've heard of, you're not testing time handling — you're testing your own geography.

What's Next

A few things I'm working on now:

  • Recurring events. "Every weekday at 9 AM" across a DST change is its own special nightmare, and the matrix doesn't cover recurrence rules yet.
  • Running the matrix on every PR from my autonomous coding setup. I run a fully autonomous implementation system that picks up tasks from a queue; date-related changes now automatically trigger the full timezone matrix before anything is merged.
  • Travelling users. Right now the user's zone is a profile setting. People who fly from Tokyo to New York mid-week deserve a report that makes sense, and I haven't figured out what "makes sense" even means there yet.

Wrap-up

Timezone bugs are the perfect example of where AI coding agents shine and where they're most dangerous: tons of mechanical work, and every fix looks right in isolation. The trick is to keep the definition of "correct" in human hands and let the agent grind through everything else — with a ratchet it can't cheat.

If you've got your own timezone horror story (I know you do), drop it in the comments — I'm collecting zones to add to the matrix. 👇

Follow me here on Dev.to for more build logs on running AI coding agents in real projects, and if you haven't tried Claude Code on a gnarly refactor yet, a date/time audit is a great first job to give it.

Top comments (1)

Collapse
 
fable7 profile image
fable7 •

Good one, and the day-bucketing bug is the one that never dies. My version of it: I bucket records into business days defined by a fixed UTC+8 date, so a record created 17:54 UTC on the 17th belongs to the 18th, and 16:00 UTC on the 18th already belongs to the 19th. My first cut subtracted the offset from a naive datetime and was off by one on every record. What worked was making both sides aware - datetime(..., tzinfo=zone).astimezone(utc) - computing the day boundary the same way, and asserting the bucket for a record one second either side of it.

One thing worth adding to your matrix: a "fixed offset business day" is not safe over history. Asia/Shanghai ran at UTC+9 for part of every year from 1986 to 1991, so anything that buckets old records with a hardcoded +8 gets those years wrong. If you ever import CSV data with 1990 timestamps, that is a live bug.

And a zone to add: Pacific/Chatham, +12:45 standard and +13:45 in DST. Kathmandu gives you the 45-minute offset but no DST; Chatham gives you both, which is what breaks a rounding helper that assumes DST offsets are whole hours.