Date, Time & Timezone Conventions
Decided on 2026-05-31. This is the source of truth for how Jod represents dates and times — in the database, across the API, and in the frontend. It supersedes the scattered date handling in .ai/instructions.md and any per-domain habit. When this doc and an older note disagree, this doc wins.
Audience: every engineer (backend and frontend) and every AI assistant working in jodapp-api and jodapp-web.
Why this exists
Jod was built for Singapore only. We are now timezone-aware for the Indonesia launch, and any country after. "Correct in Singapore" is no longer the bar. A time is correct only if it is also correct for a user in Jakarta (+07:00).
Almost every date bug comes from one mistake: treating a moment in time and a calendar day as the same thing. They are not. This convention keeps them apart.
The one test every engineer runs
Before you choose a column type or an API format, ask one question about the field:
"In whose timezone is this?"
- There is an answer (the advertiser's, the company's, the outlet's) → it is an instant.
- The question makes no sense (a birthday is the same day everywhere) → it is a civil date.
- It is a clock time that repeats every day with no date attached (a shift premium runs 22:00–06:00) → it is a time of day.
Pick by the test, not by the column name. ads_campaigns.requested_start_date is named "date" but it means "go live at the start of that day in the advertiser's zone" — that has a timezone, so it is an instant.
The three kinds
| Kind | The test | Postgres type | API wire format | Example |
|---|---|---|---|---|
| Instant | "in whose timezone?" has an answer | timestamptz | ISO-8601 with offset | 2026-06-01T00:00:00+08:00 |
| Civil date | the question is nonsense | date | bare YYYY-MM-DD | 2026-06-01 |
| Time of day | a daily clock time, no date | time | HH:MM:SS | 22:00:00 |
Almost every datetime field in Jod is an instant. Civil dates and times of day are the rare, clearly-justified exceptions.
What the API sends (the wire contract)
Same picker value, two users, so the difference is visible:
| The user picks | Singapore (Asia/Singapore, +08) | Indonesia (Asia/Jakarta, +07) |
|---|---|---|
| 1 June 2026 (date picker → instant) | 2026-06-01T00:00:00+08:00 | 2026-06-01T00:00:00+07:00 |
| 1 June 2026, 2:30 PM (date-time picker → instant) | 2026-06-01T14:30:00+08:00 | 2026-06-01T14:30:00+07:00 |
1 June 2026 (true date column) | 2026-06-01 | 2026-06-01 (same — a civil date has no zone) |
| 10:00 PM (daily clock time) | 22:00:00 | 22:00:00 |
Rules for the wire:
- An instant always carries an explicit offset (
+08:00,+07:00). Keep the offset; do not flatten toZ. The offset makes the payload self-describing in logs and lets the backend sanity-check the zone. - The offset comes from the entity's timezone (the company, the advertiser), never the browser's. This is the same
timezonevalue the user sees in the field's helper text. - The offset is computed from the IANA zone name (
Asia/Jakarta), never a hand-typed+07:00. This stays correct the day we serve a daylight-saving zone. - A bare
YYYY-MM-DDon an instant field is forbidden (this was the old bug). Bare date is only correct on a truedatecolumn.
Backend rules (Rails + Postgres)
Postgres note: a
timestamptzdoes not store a zone. It uses the incoming offset to pin the moment, stores it as UTC, and renders it back in the session zone. The offset on the wire exists only so the moment is read correctly.
- The incoming offset is the
+08:00/+07:00written on the value the API receives. Example: the API gets2026-06-01T00:00:00+07:00; the incoming offset is+07:00; Postgres pins the moment to2026-05-31 17:00:00 UTC, stores that, and does not keep the+07:00.
- Choose the column type by the test —
timestamptzfor instants,datefor civil dates,timefor times of day. - Parse instants with
Time.zone.parse(value). It reads the offset and gives you a Rails time in our app zone (UTC). NeverDate.parseon an instant — it silently drops the offset and stores the wrong moment. (On a truedatecolumn the value is a bareYYYY-MM-DDand maps straight to a RubyDate— there is no offset to lose.) - Derive "which day" in the entity's zone, never in UTC —
value.in_time_zone(entity.timezone).to_date. A plain.to_dateruns in UTC and is off by one day around the local midnight boundary. - Store and compute in UTC; convert to a real zone only at the two edges — when a request comes in, and when you show a value. Use
Time.current, neverTime.now.- When a request comes in (this is "input parsing"): the API receives
2026-06-01T00:00:00+07:00, you callTime.zone.parseon it once, and after that you work with the UTC moment everywhere. - When you show a value: convert the stored UTC moment into the entity's zone —
value.in_time_zone(entity.timezone)on the backend, orDateTimeUtils.format(...)on the frontend.
- When a request comes in (this is "input parsing"): the API receives
The assumption you can now rely on: every instant field arrives as a valid ISO-8601 string with an offset. No guessing the zone. That is the whole point — the convention moves the ambiguity out of the backend.
# Instant field
moment = Time.zone.parse(params[:requested_start_date]) # keeps the offset
# => 2026-05-31 17:00:00 UTC (for "2026-06-01T00:00:00+07:00")
day = moment.in_time_zone(campaign.org_company.address_geo_area.timezone).to_date
# => Mon, 01 Jun 2026 (NOT .to_date, which would give 31 May)
Time.zone.parse vs Time.iso8601
Both read the offset and point at the same moment. The difference is the object you get back — which is why we standardize on one:
Time.zone.parse("2026-06-01T00:00:00+07:00")
# => Sun, 31 May 2026 17:00:00 UTC +00:00 (ActiveSupport::TimeWithZone, in our app zone)
Time.iso8601("2026-06-01T00:00:00+07:00")
# => 2026-06-01 00:00:00 +0700 (plain Ruby Time, keeps the literal +07:00)
Use Time.zone.parse. The TimeWithZone it returns is what ActiveRecord and .in_time_zone(...) expect, so every other rule here just works. Time.iso8601 returns a plain Time that sits outside Rails' zone handling — don't use it.
Frontend rules (React)
The date picker is timezone-independent. It holds only the naive wall-clock value the user sees (YYYY-MM-DD, or YYYY-MM-DDTHH:mm for a date-time picker). react-hook-form carries that naive value around untouched ("pass the parcel"). You add the timezone in exactly one place: the form's submit handler.
- In the submit handler, before you send the request, you convert the naive value into the timezone-aware string with
DateTimeUtils.toApiString(value, { timezone }). That is the only place the conversion happens.
- Convert only at submit, through one shared util — never inside the field, never in
onChange. (A native date input cannot display an offset string, so converting early breaks the controlled input.) - Use the entity's timezone — the same value shown in the field's helper text. The hint and the conversion must read from the same
timezoneprop, so they can never disagree. - Filling a form with existing values (an edit form) is the mirror of submit — convert the API instant back to a naive wall-clock in the entity zone for the picker. Use the shared util, never an inline
toLocaleDateString('en-CA', { timeZone: 'Asia/Singapore' })(that hardcodes Singapore and drops the time). - Never use the browser timezone, never hardcode
'Asia/Singapore'/'en-SG', never hand-write an offset.
The shared utils (app/utils/date-time-utils.js)
// READ — API instant -> display string (shipped)
DateTimeUtils.format(apiValue, { timezone, formatString })
// WRITE — naive picker value -> ISO-8601 with offset (to add)
DateTimeUtils.toApiString(wallClock, { timezone })
// dayjs.tz(wallClock, timezone).format('YYYY-MM-DDTHH:mm:ssZ')
// "2026-06-01" + "Asia/Singapore" -> "2026-06-01T00:00:00+08:00"
// LOAD — API instant -> naive picker value for editing (to add)
DateTimeUtils.toFormValue(apiInstant, { timezone, withTime = false })
// dayjs(apiInstant).tz(timezone).format(withTime ? 'YYYY-MM-DDTHH:mm' : 'YYYY-MM-DD')
All three take the entity's IANA timezone and are strict (throw if timezone is missing), like format already is. toApiString and toFormValue do not exist yet — they ship in the date foundation PR, and the B5 form PRs depend on them.
One end-to-end example
An Indonesian advertiser (zone Asia/Jakarta, +07) sets a campaign to start 1 June 2026.
| Step | Where | What happens | Value |
|---|---|---|---|
| 1 | Picker (form state) | user picks the day; helper text shows "Time zone: Asia/Jakarta" | 2026-06-01 |
| 2 | Submit handler | DateTimeUtils.toApiString("2026-06-01", { timezone: "Asia/Jakarta" }) | 2026-06-01T00:00:00+07:00 |
| 3 | API — parse & store | Time.zone.parse(...) → UTC moment, saved to the timestamptz column | 2026-05-31 17:00:00 UTC |
| 4 | Backend — "which day?" | value.in_time_zone("Asia/Jakarta").to_date | 2026-06-01 ✅ |
| 5 | Edit form — read back | DateTimeUtils.toFormValue(value, { timezone: "Asia/Jakarta" }) | 2026-06-01 (lossless) |
A plain .to_date in step 4 (which runs in UTC) would give 2026-05-31 — the off-by-one this convention prevents.
Date pickers and the start-of-day rule
A date picker (no time) that feeds an instant field sends start-of-day in the entity zone (T00:00:00). The frontend stays dumb: it always sends 00:00:00. Do not send 23:59:59 for "end" dates. Inclusive / exclusive interval rules (e.g. "effective_to covers through the end of that day") live in the backend business logic, not in a magic time-of-day baked into the payload.
Cheat sheet
Don't
- ❌ Send a bare
YYYY-MM-DDfor an instant field. - ❌ Send a naive
YYYY-MM-DDTHH:mm:sswith no offset. - ❌ Convert using the browser timezone.
- ❌ Convert inside
DateFieldor inonChange— only in the submit handler. - ❌ Hand-write an offset like
+08:00; hardcode'Asia/Singapore'/'en-SG'. - ❌ Backend:
Date.parsean instant;.to_datein UTC;Time.now.
Do
- ✅ Instant →
timestamptz→ ISO-8601 with offset, taken from the entity's IANA zone. - ✅ Civil date →
date→ bareYYYY-MM-DD. - ✅ Time of day →
time→HH:MM:SS. - ✅ Backend: parse with
Time.zone.parse; derive days within_time_zone(entity.timezone). - ✅ Frontend: convert once, in the submit handler, with the entity's timezone, through the shared util.
Known violations to fix (as of 2026-05-31)
These columns are mistyped today and are being corrected as part of the timezone epic. Do not copy them as examples.
| Column | Now | Should be | Why |
|---|---|---|---|
identities_users.date_of_birth | naive timestamp | date | a birthday has no timezone |
talent_profile_certificates.issue_date | timestamptz | date | a month/year pick (Date.new(y, m, 1)) — civil date |
talent_profile_certificates.expiry_date | timestamptz | date | a month/year pick (last day of month) — civil date |
gig_pay_rates.starts_at / ends_at | time (correct) — but gig.dbml says timestamp | fix the DBML to time | a daily clock window, not an instant |
For contrast, talent_experiences.started_at / ended_at are already date — the correct shape for the same month/year picker.
Where this is enforced
- Backend reviews:
jodapp-api/.ai/skills/review-rails-ali-style(andjodapp-api/.ai/instructions.md). - Frontend reviews:
jodapp-web/.ai/skills/review-react-ali-style. - Anyone — human or AI — adding a datetime field runs the "in whose timezone?" test and follows the table above.