Skip to main content

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

KindThe testPostgres typeAPI wire formatExample
Instant"in whose timezone?" has an answertimestamptzISO-8601 with offset2026-06-01T00:00:00+08:00
Civil datethe question is nonsensedatebare YYYY-MM-DD2026-06-01
Time of daya daily clock time, no datetimeHH:MM:SS22: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 picksSingapore (Asia/Singapore, +08)Indonesia (Asia/Jakarta, +07)
1 June 2026 (date picker → instant)2026-06-01T00:00:00+08:002026-06-01T00:00:00+07:00
1 June 2026, 2:30 PM (date-time picker → instant)2026-06-01T14:30:00+08:002026-06-01T14:30:00+07:00
1 June 2026 (true date column)2026-06-012026-06-01 (same — a civil date has no zone)
10:00 PM (daily clock time)22:00:0022:00:00

Rules for the wire:

  • An instant always carries an explicit offset (+08:00, +07:00). Keep the offset; do not flatten to Z. 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 timezone value 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-DD on an instant field is forbidden (this was the old bug). Bare date is only correct on a true date column.

Backend rules (Rails + Postgres)

Postgres note: a timestamptz does 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:00 written on the value the API receives. Example: the API gets 2026-06-01T00:00:00+07:00; the incoming offset is +07:00; Postgres pins the moment to 2026-05-31 17:00:00 UTC, stores that, and does not keep the +07:00.
  1. Choose the column type by the testtimestamptz for instants, date for civil dates, time for times of day.
  2. Parse instants with Time.zone.parse(value). It reads the offset and gives you a Rails time in our app zone (UTC). Never Date.parse on an instant — it silently drops the offset and stores the wrong moment. (On a true date column the value is a bare YYYY-MM-DD and maps straight to a Ruby Date — there is no offset to lose.)
  3. Derive "which day" in the entity's zone, never in UTCvalue.in_time_zone(entity.timezone).to_date. A plain .to_date runs in UTC and is off by one day around the local midnight boundary.
  4. 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, never Time.now.
    • When a request comes in (this is "input parsing"): the API receives 2026-06-01T00:00:00+07:00, you call Time.zone.parse on 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, or DateTimeUtils.format(...) on the frontend.

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.
  1. 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.)
  2. 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 timezone prop, so they can never disagree.
  3. 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).
  4. 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.

StepWhereWhat happensValue
1Picker (form state)user picks the day; helper text shows "Time zone: Asia/Jakarta"2026-06-01
2Submit handlerDateTimeUtils.toApiString("2026-06-01", { timezone: "Asia/Jakarta" })2026-06-01T00:00:00+07:00
3API — parse & storeTime.zone.parse(...) → UTC moment, saved to the timestamptz column2026-05-31 17:00:00 UTC
4Backend — "which day?"value.in_time_zone("Asia/Jakarta").to_date2026-06-01
5Edit form — read backDateTimeUtils.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-DD for an instant field.
  • ❌ Send a naive YYYY-MM-DDTHH:mm:ss with no offset.
  • ❌ Convert using the browser timezone.
  • ❌ Convert inside DateField or in onChange — only in the submit handler.
  • ❌ Hand-write an offset like +08:00; hardcode 'Asia/Singapore' / 'en-SG'.
  • ❌ Backend: Date.parse an instant; .to_date in UTC; Time.now.

Do

  • ✅ Instant → timestamptz → ISO-8601 with offset, taken from the entity's IANA zone.
  • ✅ Civil date → date → bare YYYY-MM-DD.
  • ✅ Time of day → timeHH:MM:SS.
  • ✅ Backend: parse with Time.zone.parse; derive days with in_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.

ColumnNowShould beWhy
identities_users.date_of_birthnaive timestampdatea birthday has no timezone
talent_profile_certificates.issue_datetimestamptzdatea month/year pick (Date.new(y, m, 1)) — civil date
talent_profile_certificates.expiry_datetimestamptzdatea month/year pick (last day of month) — civil date
gig_pay_rates.starts_at / ends_attime (correct) — but gig.dbml says timestampfix the DBML to timea 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 (and jodapp-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.