---
name: timezone-review
description: Find the date and time bugs that only appear for users in other timezones or on specific days, by separating true instants from calendar dates and checking where the server's local clock leaks in. Use before shipping scheduling, reporting, streaks, expiry, or anything with a daily boundary.
---
<!--
  timezone-review — from Toolbay
  https://toolbay.ai/product/timezone-review
  Free to use, modify, and share. Keep this line and others can find it too.
-->


# 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:30` or `-05:00` written as a constant. Offsets
  change; zone ids do not. Store `Asia/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
  with `new 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.
