Date, Time & Timezone Conventions
Decided on 2026-07-15. This page replaces the 2026-05-31 version (see jod-app/docs#76 for why). It is the reference for how Jod stores dates and times, sends them over the API, and shows them on screen. It supersedes the date handling in jodapp-api’s AGENTS.md and any per-domain habit. When this page and an older note disagree, this page wins.
Audience: every engineer (backend and frontend) and every AI assistant working in jodapp-api and jodapp-web.
Why this page 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 enough. 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 the same thing. This page keeps them apart.
What changed on 2026-07-15
The old version of this page had a different classification test:
"In whose timezone is this?" — if there is an answer, the field is an instant, and the frontend must send ISO-8601 with an offset. A bare
YYYY-MM-DDis forbidden.
That test gives the wrong answer for dates a person picks from a calendar. "Effective from 28 June" is connected to the company's timezone. But it is still a date a person picked. Handling the timezone is the server's job. It is not a reason to call the value an instant.
The old test also split the codebase into two halves. Both halves "worked" for Singapore, so nobody noticed:
| Field | Frontend sent | Who converted |
|---|---|---|
ads campaign requested_start_date | 2026-06-28 (bare) | server |
billing agreement effective_from / effective_to | 2026-06-28 (bare) | server |
billing invoice due_at | 2026-06-28T00:00:00+08:00 | frontend |
billing payment received_at | 2026-06-28T00:00:00+08:00 | frontend |
We rejected the frontend-converts model from first principles in jod-app/jodapp-api#1850 (implementation: jod-app/jodapp-api#1851). The server owns each company's timezone. The server cannot trust every client to do timezone math correctly. The first two rows of the table are the correct model.
The new test, and everything that follows from it:
| Until 2026-07-15 | From 2026-07-15 | |
|---|---|---|
| The classification test | "In whose timezone is this?" | "Did the system create this time, or did a person pick it?" |
A picked date (effective_from) | An instant. The frontend converts it to 2026-06-28T00:00:00+08:00. | A civil date. The frontend sends 2026-06-28. The server converts. |
Bare YYYY-MM-DD on the wire | Forbidden on most fields. | The normal format for every date a person picks. |
| Timezone math | In every client, at form submit (toApiString). | On the server, in one function (DateTimeUtils.parse_civil_date). |
The five rules
- First ask: did the system create this time, or did a person pick it? That decides the kind of value.
- The server does all timezone math. The frontend sends exactly what the user picked.
- API responses include the company's
timezonefield. The frontend uses it to display values only. - Never use the machine's own clock settings.
Date.todayandTime.noware banned. - For future events that have a time of day (gig shifts), save the timezone name next to the time.
The rest of this page explains each rule, with examples.
The convention at a glance
Three pictures that summarize the whole page. Come back here when you need a refresher while implementing.
Which kind is my field? (Rule 1)
A civil date, end to end (Rules 2 and 3)
The most common flow. An ops admin picks 28 June as effective_from for the FairPrice agreement:
The frontend never converts. The backend anchors once, in one function. The value comes back with a timezone field, and the frontend only formats it for display.
An instant, end to end (Rules 3 and 4)
For contrast — no person picks an instant, and no form sends one:
Rule 1 — Did the system create this time, or did a person pick it?
Example. A billing agreement row has two time fields:
created_at— the system wrote this the moment the row was saved. No person chose it.effective_from— an ops admin opened a calendar and picked 28 June for the FairPrice agreement.
These two fields look similar. They are different kinds of value, and they follow different rules. Ask who produced the value. There are four kinds:
The four kinds
| Kind | Who produced it | Postgres type | Frontend sends | Example |
|---|---|---|---|---|
| Instant | the system recorded a moment (created_at, a webhook arriving) | timestamptz | nothing — server creates it | 2026-06-27T16:00:00Z |
| Civil date | a person picked a date from a calendar (effective_from) | date is the goal; timestamptz today (see below) | bare 2026-06-28 | 2026-06-28 |
| Time of day | a person picked a daily clock time (a 22:00 shift premium) | time | 22:00 | 22:00:00 |
| Local datetime | a person picked a date and a time at a place (a future gig shift) | timestamptz plus a timezone column | naive 2026-06-01T14:30 | see Rule 5 |
Definitions, in the same order:
- An instant is one exact point in time. It is the same moment everywhere in the world.
2026-06-27T16:00:00Zis midnight in Singapore and 23:00 in Jakarta, but it is one moment. Postgres stores instants in atimestamptzcolumn — a column type that keeps the moment as UTC. The frontend never sends an instant. The server creates them. - A civil date is a calendar day, like
2026-06-28. It is not a point in time yet, because 28 June starts at a different moment in Singapore than in Jakarta. The goal shape is a Postgresdatecolumn. Today these values live intimestamptzcolumns, and the server anchors them — "anchor" means: fix the date to a moment in a named timezone (28 June, 00:00,Asia/Singapore). The frontend sends the bare date:2026-06-28. - A time of day is a clock time with no date, like
22:00. It repeats every day. Postgrestimecolumn. The frontend sends22:00. - A local datetime is a date and a clock time that mean "this time on the clock at this place" — 1 June, 2:30 PM at the outlet. The frontend sends the naive value
2026-06-01T14:30. "Naive" means: no offset, no timezone attached. The server combines it with the outlet's timezone. Rule 5 covers the storage.
Do not use the old test
Do not decide by asking "does this field have a timezone?". Almost every field is connected to some timezone, so that test turns everything into an instant. "Effective from 28 June" is connected to the company's timezone — and it is still a date a person picked. The timezone is the server's problem (Rule 2), not part of the field's identity.
Classify by who produced the value, not by whether a timezone is connected to it.
Rule 2 — The server does all timezone math
Example. An ops admin creates a billing agreement for FairPrice, effective from 28 June:
- The admin picks 28 June in the date picker.
- The frontend sends
"2026-06-28". Nothing more. No offset, no time, no timezone. - The server looks up FairPrice's timezone:
Asia/Singapore. - The server anchors the date: 28 June, 00:00,
Asia/Singapore— stored as2026-06-27 16:00:00 UTC.
The frontend sends exactly what the user picked. The server turns it into a moment.
One function does the conversion
The server converts every civil date through one function — DateTimeUtils.parse_civil_date in app/shared/date_time_utils.rb (jod-app/jodapp-api#1851):
DateTimeUtils.parse_civil_date(date_string: '2026-06-28', timezone: company_timezone)
# => Sun, 28 Jun 2026 00:00:00 +08:00 (= 2026-06-27 16:00 UTC)
The manager resolves the timezone itself, from the company's geo area, and loads the path in one query:
billing_account = Billing::Account
.includes(org_company: :address_geo_area)
.find(request.billing_account_id)
company_timezone = billing_account.org_company.address_geo_area&.timezone
effective_from = DateTimeUtils.parse_civil_date(
date_string: request.effective_from,
timezone: company_timezone
)
Why the server, not the client
- The company's timezone is our data. It lives on the backend (
org_company.address_geo_area.timezone). The frontend only knows it because we sent it over. - We cannot control every client. Today the client is our web form. Tomorrow it is a script, a mobile app, a partner integration. If the rule is "the client must convert", every client can get it wrong separately. If the server converts, it is correct for all of them at once.
parse_civil_date only parses — day-boundary meaning lives at the call site
parse_civil_date always returns the start of the day (00:00). It never guesses what the field means.
If a field means "until the end of that day", the code that saves the field says so itself, with a comment. Example: effective_to is inclusive — "effective to 4 July" covers all of 4 July:
# effective_to is INCLUSIVE of the entered date — "effective to 4 July" covers
# all of 4 July, so invoices can be issued on the effective_to date itself.
effective_to = DateTimeUtils
.parse_civil_date(date_string: request.effective_to, timezone: company_timezone)
&.end_of_day
Each field's meaning is written where the field is saved — not hidden inside the utility, and not hidden in the payload as a 23:59:59 time.
Rule 3 — Responses include a timezone field; the frontend uses it for display only
Example. The API returns the FairPrice agreement:
{
"effective_from": "2026-06-27T16:00:00Z",
"org_company": { "name": "FairPrice", "timezone": "Asia/Singapore" }
}
Instants go out as UTC strings (2026-06-27T16:00:00Z). The frontend cannot display that value without knowing which timezone to show it in. That is what the timezone field on serializers is for. (This is the A2/B2 tech-debt work — unchanged by this revision.)
The frontend uses timezone to display values only. It never uses it to build a value to send back — that would be doing the server's job (Rule 2).
Why a timezone field, when the frontend could guess from the browser
The browser knows the user's timezone. That is the wrong one. The correct timezone belongs to the entity — the company, the outlet, the job location. Only the backend knows it. The frontend needs the field for:
| The frontend needs to… | Needs the timezone field? |
|---|---|
| show a UTC instant as a local time | yes |
show the timezone name (Asia/Jakarta) next to a time | yes |
| group rows by local day, compute "today" for a company | yes |
| build a value to send to the API | no — never (Rule 2) |
The timezone field is a hint, not a stored column
timezoneis a field the API adds to a response. It is not a column on the entity.- It tells the frontend: "show this entity's times in this timezone."
- The backend chooses it, based on the endpoint. The frontend never chooses it.
- The same database row can carry a different
timezoneon two endpoints:
Where the value comes from
| Layer | Where it lives | Its one job |
|---|---|---|
| Source | geo_areas.timezone | the only place a timezone is stored |
| Resolve | entity.address_geo_area&.timezone | walk from the entity to its geo area |
| Wire | the serializer | put the timezone field on the response |
- The source is never copied into another table.
- The response holds a copy, but the backend rebuilds it on every request, so the copy can never differ from the source.
- The manager loads the path in advance, so there is no N+1 (no extra query per row):
.includes(org_company: :address_geo_area).
The timezone sits on the geo-anchor; other entities embed it
A geo-anchor is an entity that has a location — it owns an address_geo_area. The flat timezone field lives only on geo-anchors:
| Domain | Geo-anchor (has the timezone field) | The timezone shown |
|---|---|---|
| Identities | Identities::User | the user's timezone |
| Org | Org::Company | the company's timezone |
| Org | Org::Outlet | the outlet's timezone |
| Careers | Careers::Job | the job's timezone (employer view) |
Talent::Profile is not in this table — it owns no location. A talent's own dates (work experience, certificates) are civil dates, so they need no timezone. The one exception is the gig-suspension time (gig_suspended_at, gig_suspension_expires_at); if that is ever shown as an absolute time, read the timezone from the talent's person — talent.user.timezone — because Identities::User is the geo-anchor. Never add a flat timezone to Talent::Profile.
Listings::Job is not in this table either. It is a search read-model with no location link of its own. It resolves its timezone its own way — copy the zone onto the listing when the listing is built (at sync time). To be decided — tracked with the Listings work.
An aggregate root is the main entity a screen is about (a company, a campaign, a billing account). An aggregate root with no location of its own does not get a timezone field. It embeds its geo-anchor and reads the timezone from there:
| Domain | Aggregate root | It embeds | The frontend reads |
|---|---|---|---|
| Ads | Ads::Campaign | Org::Company | campaign.org_company.timezone |
| Billing | Billing::Account | Org::Company | account.org_company.timezone |
| Billing | a child, e.g. Agreement | Billing::Account | agreement.billing_account.org_company.timezone |
A child entity (one that belongs to a root) never gets its own timezone either. It shows in its root's timezone. Reason: one timezone per screen. Copying the timezone onto many children makes the copies drift apart on the next change.
The work location's timezone is the official one — on every screen
A time is always shown in the timezone of the place where the work happens, on every endpoint, including the candidate side:
- A gig shift → the outlet's timezone.
- A job, and its application deadlines → the job's own location timezone (
careers_job.address_geo_area.timezone), not the company's. A company in Jakarta (+07) can post a job in Makassar (+08); the job's own location wins.
The worker's own timezone is never the official time. It may appear as a clearly labeled helper — "(that's 8:00 AM your time)" — but it is never stored, and never used to compute pay or decide which day a time falls on. Reason: the worker stands at the work location. Show the time on the clock at that place. (Decided 2026-06-05.)
Note the anchor is the nearest one, and that may be the outlet: a company-level time uses the company's timezone; an outlet-level time (a shift, opening hours) uses the outlet's. One company can have outlets in different timezones — Jakarta (+07) and Makassar (+08). This is why both Org::Company and Org::Outlet are geo-anchors.
Only entities with instants need a timezone
| Kind of field on the entity | Entity needs a timezone field? |
|---|---|
| Instant | yes |
| Civil date (a birthday) | no — the same day everywhere |
| Time of day (a 22:00 shift window) | no — repeats daily, has no date |
A missing timezone is null in the API — never a hardcoded value
- If the entity has no
geo_arealink, the API sendstimezone: null. The frontend shows--and logs a warning. - Do not fall back to
Asia/Singaporein a serializer. It hides the missing link, and it is wrong for every country except Singapore. (The single allowed fallback lives insideDateTimeUtils— see Rule 4.) - In practice a loaded geo-anchor always has a timezone now: every anchor's
address_geo_area_idisNOT NULL, andgeo_areas.timezoneisNOT NULL(since 2026-06-04). Sonullonly appears when an entity has no anchor link at all. Keep thenullhandling, but expect it to be rare.
Show the timezone as its IANA name, not an abbreviation
The IANA name is the full timezone name from the IANA database: Asia/Singapore, Asia/Jakarta. When you show a timezone to a user, show the IANA name. Do not show a three-letter abbreviation:
- Abbreviations are ambiguous —
ISTmeans India, Ireland, or Israel. - In Ruby,
strftime('%Z')returns the offset (+08) for a zone that has no abbreviation (like Singapore), so the label would be inconsistent across zones.
Rule 4 — Never use the machine's own clock settings
Example. This line returns two different answers:
Date.today
# on your laptop (set to Singapore time): 2026-06-28
# on production (set to UTC): 2026-06-27 (before 08:00 SGT)
Date.today and Time.now answer in the timezone of the machine the code runs on. A laptop says Singapore. Production says UTC. The same line, two answers. Both are banned.
| Instead of… | Use… | Which gives you |
|---|---|---|
Time.now | Time.current | the current moment, in UTC |
Date.today | Time.current.in_time_zone(company_timezone).to_date | "today" for that company |
There is exactly one place where a hardcoded 'Asia/Singapore' is allowed: the fallback inside DateTimeUtils (DEFAULT_TIMEZONE), used only until every company has a geo timezone. Nowhere else — not in serializers, not in managers, not in frontend components.
Rule 5 — For future events with a time of day, save the timezone name next to the time
Example. An employer creates a gig shift: 1 June, 2:30 PM, at the Tampines outlet. The shift is created three weeks before 1 June.
That shift is a promise about a clock on a wall in a place: "be at the outlet when its clock shows 2:30 PM." Countries sometimes change their timezone rules. If we saved only the computed moment (2026-06-01T06:30:00Z) and Singapore changed its offset before 1 June, the stored moment would silently point at the wrong local time. Nobody would notice until workers arrive an hour off.
So for future local datetimes we save both:
- the computed moment (
timestamptz), and - the timezone name it was computed from — the
org_outlets.time_zonecolumn planned in jod-app/jodapp-api#1520.
If the timezone rules change, we can recompute the moment from the name. Past events (clock-ins) do not need this — a moment that already happened never moves.
Recurring times repeat on a rule — store the clock, not the moment
A recurring time repeats on a schedule, and each occurrence's real moment depends on the timezone rules on that date:
- Today:
gig_pay_rateshas a daily pay window —starts_at/ends_atstored astime(a clock time, no date). - Soon: the workforce domain will have recurring shifts (every Monday 09:00–17:00 at an outlet).
For a recurring time, store three things. Never store a pre-computed UTC moment for each future occurrence:
| Store | Example |
|---|---|
the clock time (time) | 09:00:00 |
| the rule (which days) | every Monday |
| the timezone anchor (outlet or company) | the outlet's timezone |
Build the real UTC moment only when you need one specific date, using the anchor's timezone on that date. Same reason as above: a rule change would make pre-computed future moments wrong.
Rails stays on UTC — being timezone-aware means naming the zone
It is easy to think "timezone-aware" means Rails should run in the user's timezone. It does not. Rails stays on UTC, and we become timezone-aware by naming the entity's zone every time we cross an edge.
One default timezone, kept on UTC
# config/application.rb
config.time_zone = 'UTC'
When code does not name a zone — Time.current, or value.in_time_zone with no argument — Rails uses this default. So every such call works in UTC. That is on purpose: we store and compute moments in UTC, and turn them into a local time only at the edges.
| What it is | In Jod | |
|---|---|---|
| The default timezone | one global value (config.time_zone), used when no zone is named | stays UTC, always |
| The entity's timezone | the zone from the entity's geo_area (Asia/Jakarta) | named explicitly at each edge |
# bad — relies on the default timezone being the entity's zone
day = agreement.effective_from.to_date
# good — names the entity's zone
day = agreement.effective_from
.in_time_zone(account.org_company.address_geo_area&.timezone)
.to_date
A plain .to_date runs in UTC and is off by one day near the local midnight boundary. Derive "which day" in the entity's zone, never in UTC.
Never set Time.zone = <user zone> for a request
A common pattern in other apps is Time.zone = current_user.timezone in a before_action. That only works when one request has one correct zone. Jod is not that app — the zone belongs to the entity, not the request, and one response can carry several zones at once:
- The same row shows in the company's zone on
/employersand the talent's zone on/candidates. - One company can list a Jakarta outlet (
+07) and a Makassar outlet (+08) in the same payload.
There is no single "request zone" that is correct for the whole response, so there is nothing correct to switch the default to.
There is also a leak problem. Rails stores Time.zone per worker thread, and the web server reuses that thread for the next request:
- Request A (a Jakarta advertiser) sets
Time.zone = 'Asia/Jakarta'. The request ends. - Request B on the same thread never sets a zone — a health check, an endpoint with no
before_action— and readsTime.zone. It gets Jakarta, the previous user's zone.
This is why CurrentRequest keeps the cleanup line resets { Time.zone = nil }. If one specific flow ever needs a different zone for a block of work, use Time.use_zone(zone) { ... } — it restores the previous zone afterward. Never use a raw Time.zone = that depends on a separate cleanup line.
Parsing values that arrive from outside
- A civil date from a form (
"2026-06-28"): only throughDateTimeUtils.parse_civil_date(Rule 2). Do not parse it in the app zone and convert afterwards — the two-stepTime.zone.parse(...).in_time_zone(tz)lands on the previous calendar day for timezones west of UTC. - An instant with an offset from an external system (a webhook payload, a partner API): parse with
Time.zone.parse(value). It reads the offset and returns anActiveSupport::TimeWithZonein our app zone (UTC) — the object ActiveRecord and.in_time_zone(...)expect. NeverDate.parsean instant — it silently drops the offset and stores the wrong moment. Do not useTime.iso8601either; it returns a plain RubyTimethat sits outside Rails' zone handling.
Frontend rules (React)
The date picker is timezone-independent. It holds only the naive value the user sees (2026-06-28, or 2026-06-01T14:30 for a datetime picker). react-hook-form carries that value unchanged, and the submit handler sends it unchanged.
The frontend never converts a picked value into an offset string. It submits exactly what the picker holds.
| The user picks | The form submits |
|---|---|
| a date | 2026-06-28 |
| a daily clock time | 22:00 |
| a date + time at a place | 2026-06-01T14:30 |
The shared utils in app/utils/date-time-utils.js:
| Util | Direction | Status |
|---|---|---|
DateTimeUtils.format(apiValue, { timezone, formatString }) | API instant → display string | use it |
DateTimeUtils.formatCivilDate(civilDate, formatString) | bare civil date → display string (no zone) | use it |
DateTimeUtils.toFormValue(apiValue, { timezone }) | API instant → naive picker value (edit forms) | use it |
DateTimeUtils.toApiString(formValue, { timezone }) | naive picker value → offset string | deprecated — do not use |
formatandtoFormValuetake the entity's IANA timezone from the response'stimezonefield (Rule 3). Both throw iftimezoneis missing.toFormValuestays because civil dates are stored intimestamptzcolumns today (Rule 1): the API returns an instant, and the edit form must show it as the calendar day the person originally picked.toApiStringimplements the rejected frontend-converts model. Do not add new calls. The four existing call sites (billing invoicedue_at; billing paymentreceived_at) are being migrated to bare civil dates.- Never use the browser timezone. Never hardcode
'Asia/Singapore'or'en-SG'in a component. Never hand-write an offset like+08:00.
One end-to-end example
An ops admin sets the FairPrice billing agreement to be effective from 28 June 2026. FairPrice's timezone is Asia/Singapore (+08).
| Step | Where | What happens | Value |
|---|---|---|---|
| 1 | Date picker | admin picks the day | 2026-06-28 |
| 2 | Submit handler | sends the value unchanged | 2026-06-28 |
| 3 | API manager | resolves org_company.address_geo_area&.timezone | Asia/Singapore |
| 4 | API manager | DateTimeUtils.parse_civil_date(date_string:, timezone:) anchors the date | 2026-06-27 16:00:00 UTC |
| 5 | Response | UTC instant plus the company timezone field | 2026-06-27T16:00:00Z |
| 6 | Display | DateTimeUtils.format(value, { timezone }) | 28 Jun 2026 |
| 7 | Edit form | DateTimeUtils.toFormValue(value, { timezone }) | 2026-06-28 |
The value the admin picked comes back unchanged in steps 6 and 7. If any step used UTC instead of the company timezone, the screen would show 27 Jun 2026 — the off-by-one-day error this convention prevents.
Cheat sheet
Do not:
- Convert a picked date in the frontend (
toApiString, browser timezone, hand-written offsets). - Ask "does this field have a timezone?" to classify a field.
- Use
Date.todayorTime.now— anywhere. - Parse a civil date with
Time.zone.parse+.in_time_zone— the two-step conversion shifts the day. Date.parsean instant — it drops the offset.- Put a
23:59:59time in a payload, or makeparse_civil_datereturn end-of-day. - Hardcode
'Asia/Singapore'outside theDateTimeUtilsfallback.
Do:
- Classify by who produced the value: system → instant; person → civil date, time of day, or local datetime.
- Frontend: send exactly what the user picked —
2026-06-28,22:00,2026-06-01T14:30. - Server: convert civil dates through
DateTimeUtils.parse_civil_date(date_string:, timezone:). - Server: state end-of-day meaning at the save site —
parse_civil_date(...)&.end_of_daywith a comment. - Serializers: send instants as UTC plus the geo-anchor's
timezonefield, for display. - Use
Time.current, andTime.current.in_time_zone(company_timezone).to_datefor "today". - Future shifts: save the outlet's timezone name next to the computed time.
Known gaps (as of 2026-07-15)
Do not copy these as examples. Each is queued for correction:
| What | Now | Should be |
|---|---|---|
billing invoice due_at, payment received_at (frontend) | forms convert with toApiString and send offset strings | send bare YYYY-MM-DD; server anchors |
ads campaign requested_start_date (backend) | its own conversion path | convert through DateTimeUtils.parse_civil_date |
| civil-date columns | timestamptz, anchored server-side | date columns (later migration) |
identities_users.date_of_birth | naive timestamp | date — a birthday has no timezone |
talent_profile_certificates.issue_date / expiry_date | timestamptz | date — a month/year pick is a civil date |
gig_pay_rates.starts_at / ends_at | time (correct), but gig.dbml says timestamp | fix the DBML to time |
Gig::TempJob#timezone / Gig::TempJobSlot#timezone | return a hardcoded 'Asia/Singapore' (a deliberate stopgap — jodgig runs only in Singapore today) | resolve through the outlet's geo anchor when gig expands; until then these are the two known exceptions to Rule 4 |
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/.claude/skills/review-rails-ali-style(andjodapp-api/AGENTS.md). - Frontend reviews:
jodapp-web/.claude/skills/review-react-ali-style. - Anyone — human or AI — adding a date or time field starts from Rule 1 and follows the table there.