Timezone Review
Install
Save this file as ~/.claude/skills/timezone-review/SKILL.md, or
.claude/skills/timezone-review/SKILL.md to scope it to one repo. Claude Code
auto-discovers it. Invoke with /timezone-review or by asking "will this break
for users in other timezones?".
Why this exists
Timezone bugs pass every test, because the test machine, the developer, and the CI runner are usually all in UTC or all in the same offset. They appear in production for the subset of users whose local day boundary is not yours, and they appear as complaints that sound impossible: "my streak reset a day early", "the report shows yesterday's number", "my subscription expired at 7pm".
They are also seasonal. Code that is correct in January can break in March when a DST transition creates a day with 23 hours, or a local time that does not exist at all.
The root cause is nearly always the same: a calendar date and a true instant are different types, and the code uses one where it means the other.
Step 1: Separate instants from calendar dates
Go through every date field in the schema and classify it:
INSTANT. A specific moment on the global timeline. created_at,
last_login, paid_at. Correct storage is UTC with an offset
(timestamptz, epoch millis). Rendering is per-viewer.
CALENDAR DATE. A day as humans name it, with no time and no zone.
birth_date, invoice_date, holiday. Correct storage is a date type or a
string. Attaching a time to these is the bug: a birthday stored as
1990-05-04T00:00:00Z becomes May 3rd for everyone west of Greenwich.
LOCAL WALL TIME. A time in a place, where the place matters more than the instant: "the meeting is at 9am in Berlin", "the store opens at 08:00". Correct storage is the wall time plus an IANA zone id, NOT a UTC instant, because if the zone's rules change your stored instant is now the wrong wall time.
rg -n "(date|time|_at|_on|expires|starts|ends|scheduled|birthday|deadline)" \
prisma/schema.prisma migrations db/schema.rb 2>/dev/null
Any field whose class you cannot state is a finding. Any CALENDAR DATE stored as a timestamp is a finding.
Step 2: Find the server's local clock leaking in
The server's own timezone should never influence a result. Look for the calls that let it:
rg -n "new Date\(\)|Date\.now\(\)|datetime\.now\(\)|Time\.now|time\.Now\(\)" -t code
rg -n "getFullYear|getMonth|getDate|getHours|getDay" -t code
rg -n "toDateString|toLocaleDateString|toLocaleString|strftime" -t code
The get* family and toLocaleString without an explicit zone read the
process timezone. On your laptop that is your zone, on the server it is
usually UTC, and in a container it depends on how the image was built. Same code,
three answers.
Flag any place a date is formatted or truncated without an explicit timezone argument.
rg -n "TZ=|process\.env\.TZ|ZoneId|tzset" --hidden -g '!node_modules'
If nothing sets TZ anywhere, note that the behaviour depends on the host, which means it can change without a deploy.
Step 3: Find the day boundaries
Every "start of day" is a decision about whose day it is:
rg -n "startOf|endOf|setHours\(0|midnight|00:00|23:59|beginning_of_day" -t code
For each, answer: whose midnight? The user's, the server's, UTC, or the business's?
The recurring bugs:
- Daily aggregates in UTC shown to local users. A user in UTC+13 sees "today's sales" that ends at 11am their time.
- Streaks and daily limits. Computed in UTC, they break a streak for anyone whose local day straddles the boundary. This is the single most common version of this bug.
- Expiry. "Valid until 2026-08-01" in UTC expires mid-afternoon on July 31st in Auckland.
- Cron and scheduled jobs. A job at "00:00" runs at a different local time half the year unless the scheduler is zone-aware.
Step 4: Check DST and arithmetic
rg -n "\* 24 \* 60 \* 60|86400|\+ 7 \* 24|addDays|addHours|dateAdd" -t code
Adding 86400 seconds is not adding a day. On DST transition days a local day is 23 or 25 hours. Anything that computes "tomorrow" or "next week" by adding fixed seconds is wrong twice a year for zones that observe DST.
Also check:
- Non-existent local times. In a spring-forward zone, 02:30 does not exist on transition day. Code that constructs it gets an error or a silent shift.
- Ambiguous local times. In autumn, 01:30 happens twice. Which one did you store?
- Hardcoded offsets.
UTC+5:30or-05:00written as a constant. Offsets change; zone ids do not. StoreAsia/Kolkata, not+05:30.
rg -n "[+-][0-9]{2}:[0-9]{2}|UTC[+-][0-9]|GMT[+-][0-9]" -t code
Step 5: Check the boundaries where dates cross systems
Dates get corrupted in transit more often than in storage:
- JSON serialization. Does the API emit ISO 8601 with an offset, or a zone-less string a client will parse as local?
- Form input. An HTML
<input type="date">gives a calendar date. Parsing it withnew Date("2026-05-04")yields UTC midnight, which is the previous day in the Americas. - CSV and spreadsheet export. Almost always zone-less, and often reformatted by the spreadsheet on open.
- Third-party APIs. Check what zone the provider documents, not what you assume.
rg -n "new Date\(['\"][0-9]{4}-" -t code
Step 6: Report
[SEVERITY] <what breaks, in user terms>
Where: file:line
Class: INSTANT / CALENDAR DATE / LOCAL WALL TIME
Assumes: <the timezone the code implicitly uses>
Breaks: <which users, and on which days>
Fix: <the specific change>
Severity:
- CRITICAL wrong data for real users today (wrong day's report, early expiry, broken streak).
- HIGH correct now but breaks on a DST transition or for a zone you do not currently have users in.
- MEDIUM relies on the host's TZ setting, so it is correct by luck.
End with the field classification table from Step 1. If the codebase handles time correctly, that table is the evidence, and it is worth keeping in the repo.
Rules
- Never "fix" a timezone bug by adding or subtracting hours. That is how the second bug is created. Fix the type or the explicit zone.
- Never store a local wall time as a UTC instant when the wall time is what the user promised. Zone rules change.
- Do not assume the deploy target's timezone. Verify it or set it explicitly.
- Test any fix against a zone with a half-hour offset and a southern-hemisphere DST zone. If it only works for whole-hour northern offsets, it is not fixed.
From Toolbay. Free to use, modify, and share. Keep this line and others can find it too.