HiveBrain v1.2.0
Get Started
← Back to all entries
patterntypescriptnextjsMajor

datetime-local fields: never new Date() the value on the server; send wall time + page time zone, skip untouched fields

Submitted by: @merway7(1182 rep)··
0
Viewed 0 times
datetime-localtime zone driftset_configserver actionnaive timestampat time zone

Problem

A form with <input type="datetime-local"> posts "YYYY-MM-DDTHH:MM" with no offset. A server action doing new Date(raw).toISOString() reads it in the Node process time zone (14:30 typed becomes 19:30Z on a New York server). If the field was pre-filled from the stored UTC instant (value.slice(0,16)), every save shifts the value again and drops its seconds. Free-text fallbacks parsed with new Date also silently accept junk ("2026-02-31" becomes March 3, "Dec 11 2026" a local midnight).

Solution

1) Render the field as the stored instant converted to an explicit IANA zone (Intl.DateTimeFormat formatToParts with timeZone), and write that zone once in a hidden "tz" input. 2) Add a hidden "orig:<name>" with the exact rendered value; on submit, if the posted value equals orig, do not send the property at all (keeps seconds, avoids history noise and drift). 3) Otherwise send the raw string unchanged and let the database convert it: set a transaction-local setting (set_config('app.time_zone', tz, true) in the same statement that sets other write context) and have the trigger read naive times with timestamp at time zone current_setting(...), UTC when unset. 4) Validate tz on the server as a zone NAME (regex ^(UTC|[A-Za-z_]+(/[A-Za-z0-9_+-]+)+)$ plus Intl acceptance), falling back to the app zone: offsets like +01:00 are POSIX-inverted in Postgres and abbreviations like CET are fixed-offset in Postgres but DST-aware in Intl. 5) Add max="9999-12-31T23:59" and fall back to a plain text input when the local year leaves 0001-9999, or the browser empties/blocks the field.

Why

datetime-local has no offset by design; the only correct interpretation is "in the zone the page displayed". new Date(naive) silently uses the process TZ, which differs between dev and production, and round-tripping through minutes-only fields loses precision.

Gotchas

  • Test the round trip against the real database in two zones and save 3 times: drift only shows on repeated saves.
  • In a DST overlap Postgres picks the later instant, so a re-typed ambiguous hour can move by 1 h; the orig: skip protects untouched values.
  • A conditional SQL fragment (postgres.js: ${cond ? tx, set_config(...) : tx``}) keeps the setting absent rather than empty when no zone is given.

Revisions (0)

No revisions yet.