Part 8 of 8 in the series Time in Software, Done Right
You've modeled time correctly on the backend. You've stored it properly in the database. Now you need to handle it in the browser — where users pick dates and times, and where your API sends data back and forth.
JavaScript's Date object has been the source of countless bugs. The Temporal API finally gives us proper types. But even with good types, you still need to think about what your DateTimePicker is actually asking users to select, and how to send that data across the wire.
This article covers the Temporal API, API contract design, and the principles behind DateTimePickers that don't mislead users.
Why JavaScript's Date Is Problematic
The Date object has been with us since 1995. It has... issues:
// Months are 0-indexed (January = 0)
new Date(2026, 5, 5) // June 5th, not May 5th
// Parsing is inconsistent across browsers
new Date("2026-06-05") // Midnight UTC? Midnight local? Depends on browser.
// No timezone support beyond local and UTC
const d = new Date();
d.getTimezoneOffset(); // Minutes offset, but which zone? You don't know.
// Mutable (a constant footgun)
const d = new Date();
d.setMonth(11); // Mutates in place
There's no LocalDate, no LocalDateTime, no ZonedDateTime. Just one type that tries to do everything and does none of it well.
The Temporal API
The Temporal API is the modern replacement for Date. It's currently at Stage 3 — the final candidate stage before standardization — and requires a polyfill in most browsers (e.g., @js-temporal/polyfill). Browser support is coming, but for now, plan on using the polyfill.
Type Mapping
| Concept | Temporal Type | NodaTime Equivalent |
|---|---|---|
| Physical moment | Temporal.Instant |
Instant |
| Calendar date | Temporal.PlainDate |
LocalDate |
| Wall clock time | Temporal.PlainTime |
LocalTime |
| Date + time (no zone) | Temporal.PlainDateTime |
LocalDateTime |
| IANA timezone | Temporal.TimeZone |
DateTimeZone |
| Full context | Temporal.ZonedDateTime |
ZonedDateTime |
The names differ (Plain vs Local), but the concepts are identical. If you've understood NodaTime, you already understand Temporal.
Basic Usage
// Just a date
const date = Temporal.PlainDate.from("2026-06-05");
const date2 = new Temporal.PlainDate(2026, 6, 5); // Months are 1-indexed!
// Just a time
const time = Temporal.PlainTime.from("10:00");
// Date and time (no zone)
const local = Temporal.PlainDateTime.from("2026-06-05T10:00");
// With timezone
const zone = Temporal.TimeZone.from("Europe/Vienna");
const zoned = local.toZonedDateTime(zone);
// Get the instant
const instant = zoned.toInstant();
Converting Between Types
// ZonedDateTime → components
const zoned = Temporal.ZonedDateTime.from("2026-06-05T10:00[Europe/Vienna]");
const local = zoned.toPlainDateTime(); // PlainDateTime
const instant = zoned.toInstant(); // Instant
const tzId = zoned.timeZoneId; // "Europe/Vienna"
// Instant → ZonedDateTime (for display)
const instant = Temporal.Instant.from("2026-06-05T08:00:00Z");
const inVienna = instant.toZonedDateTimeISO("Europe/Vienna");
const inLondon = instant.toZonedDateTimeISO("Europe/London");
DST Handling
Temporal handles DST ambiguities explicitly:
// Time that doesn't exist (spring forward gap)
const local = Temporal.PlainDateTime.from("2026-03-29T02:30");
const zone = Temporal.TimeZone.from("Europe/Vienna");
// Default ("compatible"): shifts forward for gaps, picks earlier occurrence for overlaps
const zoned = local.toZonedDateTime(zone, { disambiguation: "compatible" });
const zoned = local.toZonedDateTime(zone, { disambiguation: "reject" }); // Throws
// Time that exists twice (fall back overlap)
const local = Temporal.PlainDateTime.from("2026-10-25T02:30");
const zoned = local.toZonedDateTime(zone, { disambiguation: "earlier" }); // First occurrence
const zoned = local.toZonedDateTime(zone, { disambiguation: "later" }); // Second occurrence
API Contracts: Sending Time Across the Wire
When your frontend talks to your backend, you need a clear contract for time values. There are several approaches.
Option 1: ISO Strings (Simple)
For instants, use ISO 8601 with Z suffix:
{
"createdAt": "2026-06-05T08:00:00Z"
}
Unambiguous. Both sides parse it the same way.
Option 2: Structured Object (Recommended for User Intent)
For human-scheduled times, send the components:
{
"appointment": {
"localStart": "2026-06-05T10:00:00",
"timeZoneId": "Europe/Vienna"
}
}
The backend receives exactly what the user chose. It can:
- Validate the timezone ID
- Handle DST ambiguities with domain-specific logic
- Compute the instant
- Store all three values
Option 3: ZonedDateTime String
Temporal and some APIs support bracketed timezone notation:
{
"startsAt": "2026-06-05T10:00:00[Europe/Vienna]"
}
This is compact and unambiguous, but not all JSON parsers handle it natively. You'll need custom parsing.
What NOT to Do
// DON'T: Ambiguous local time
{ "startsAt": "2026-06-05T10:00:00" } // What timezone?
// DON'T: Offset without timezone ID
{ "startsAt": "2026-06-05T10:00:00+02:00" } // Which +02:00 zone?
// DON'T: Unix timestamp for user-scheduled events
{ "startsAt": 1780758000 } // Lost the user's intent
TypeScript Interfaces
Define clear types for your API:
// For instants (logs, events, timestamps)
interface AuditEvent {
occurredAt: string; // ISO 8601 with Z: "2026-06-05T08:00:00Z"
}
// For user-scheduled times
interface ScheduledTime {
local: string; // ISO 8601 without offset: "2026-06-05T10:00:00"
timeZoneId: string; // IANA zone: "Europe/Vienna"
}
interface Appointment {
title: string;
start: ScheduledTime;
end: ScheduledTime;
}
// For date-only values
interface Person {
name: string;
dateOfBirth: string; // ISO 8601 date: "1990-03-15"
}
DateTimePicker Design Principles
A DateTimePicker is a UI component that lets users select a date, time, or both. But "picking a time" isn't as simple as it sounds.
Principle 1: Know What You're Asking For
Before building (or choosing) a picker, decide:
| What does the user select? | What do you send to the backend? |
|---|---|
| Just a date |
PlainDate → "2026-06-05"
|
| Just a time |
PlainTime → "10:00"
|
| Date and time |
PlainDateTime → "2026-06-05T10:00"
|
| Date, time, and timezone |
ZonedDateTime → { local, timeZoneId }
|
Most pickers handle the first three. The fourth requires explicit timezone UI.
Principle 2: Timezone Display — When and How
Show the timezone when:
- Users in different timezones use the system
- The selected timezone might differ from the user's local timezone
- The business operates across timezones
Hide the timezone when:
- All users are in the same timezone
- The context is unambiguous (e.g., "your local time")
- Showing it would cause confusion without adding clarity
How to show it:
- Display the current timezone near the picker: "Vienna (UTC+2)"
- Allow changing it only if the user might need a different zone
- Don't default to UTC — default to the user's timezone or the organization's timezone
Principle 3: Handle DST Gaps and Overlaps
When the user picks a time that falls in a DST transition:
Gap (time doesn't exist):
- Option A: Prevent selection (disable those times in the picker)
- Option B: Accept and adjust (shift forward), but inform the user
- Option C: Show a warning and ask the user to choose a different time
Overlap (time exists twice):
- Option A: Ask which one they mean (before DST change or after)
- Option B: Pick one automatically and note it
- Option C: Ignore it (acceptable for many use cases)
The right choice depends on your domain. A medical appointment might need explicit handling; a casual reminder might not.
Principle 4: Don't Lie About What's Stored
If your backend stores local + timeZoneId, your picker should collect exactly that. Don't:
- Show a local picker but send UTC (user sees 10:00, backend gets 08:00)
- Show UTC but let users think it's local
- Convert silently and hope nobody notices
The picker's display should match what gets stored.
Principle 5: Consider the Editing Experience
When users edit an existing time:
- Show them what they originally entered (the local time)
- Don't show a converted UTC value
- If the timezone changed since creation, decide: show original zone or current zone?
Principle 6: Validation Belongs on Both Ends
The frontend picker should:
- Prevent obviously invalid dates (February 30th)
- Warn about DST issues if relevant
- Send well-formed data
The backend should:
- Validate the timezone ID is real
- Handle DST ambiguities according to business rules
- Never trust that the frontend did it right
A Minimal Picker Contract
For a DateTimePicker that collects a scheduled time:
Input (initial value):
interface DateTimePickerValue {
local: string; // "2026-06-05T10:00"
timeZoneId: string; // "Europe/Vienna"
}
Output (on change):
interface DateTimePickerValue {
local: string; // "2026-06-05T14:30"
timeZoneId: string; // "Europe/Vienna"
}
The picker:
- Displays date and time inputs
- Optionally displays or allows changing the timezone
- Emits the combined value on change
The parent component:
- Receives the structured value
- Sends it to the backend as-is
- Doesn't do timezone math
Putting It Together: Frontend to Database
Here's the full flow:
1. User picks a time in a DateTimePicker
UI shows: June 5, 2026 at 10:00 AM (Vienna)
2. Frontend sends to API
POST /appointments
{
"title": "Team Standup",
"start": {
"local": "2026-06-05T10:00:00",
"timeZoneId": "Europe/Vienna"
}
}
3. Backend (NodaTime) processes
var local = LocalDateTime.FromDateTime(DateTime.Parse(dto.Start.Local));
var zone = DateTimeZoneProviders.Tzdb[dto.Start.TimeZoneId];
var instant = local.InZoneLeniently(zone).ToInstant();
// Store all three
var appointment = new Appointment
{
LocalStart = local,
TimeZoneId = dto.Start.TimeZoneId,
InstantUtc = instant
};
4. Database stores
INSERT INTO appointments (local_start, time_zone_id, instant_utc)
VALUES ('2026-06-05 10:00:00', 'Europe/Vienna', '2026-06-05 08:00:00+00');
5. Later: API returns to frontend
{
"title": "Team Standup",
"start": {
"local": "2026-06-05T10:00:00",
"timeZoneId": "Europe/Vienna"
},
"instantUtc": "2026-06-05T08:00:00Z"
}
6. Frontend displays
- To the organizer (Vienna): "June 5 at 10:00 AM"
- To a participant in London: "June 5 at 9:00 AM (10:00 AM Vienna)"
Detecting the User's Timezone
We've covered storing and displaying times with timezones. But how do you know what timezone the user is in?
Browser Detection
const userZone = Intl.DateTimeFormat().resolvedOptions().timeZone;
// "Europe/Vienna", "Europe/London", etc.
This returns an IANA timezone ID — exactly what you need.
Caveats
- VPNs and proxies may cause the browser to report a different zone than the user expects
- Corporate networks sometimes override timezone settings
- User preference might differ from their physical location (e.g., someone living in Vienna but working with a London team)
Best Practice
Use browser detection as a default, but let users confirm or change it:
- Detect the timezone automatically
- Show it clearly in the UI: "Your timezone: Europe/Vienna"
- Let users change it if needed
- Store their preference (per user, not per session)
Don't silently assume the detected timezone is correct. A user in Vienna might be scheduling a meeting for their London office.
Timezone Is Not a Locale (and Not a Language)
Timezone, language, and locale are often treated as one setting — but they are three independent concerns.
-
Language (i18n) controls text:
- "Today" vs "Heute" vs "Aujourd'hui"
-
Locale (l10n) controls formatting:
-
1,000.00vs1.000,00 -
MM/DD/YYYYvsDD.MM.YYYY
-
-
Timezone controls when things happen:
Europe/ViennaAmerica/New_YorkAsia/Tokyo
They often change together — but they are not coupled.
Example
A French-speaking user in New York expects French UI, French date formatting, and New York time. Inferring Europe/Paris from fr is wrong.
DateTimePicker Rule
A DateTimePicker should not assume timezone based on language or locale.
Timezone must come from explicit user choice, browser detection, or application context.
Key Takeaways
-
Temporal API gives JavaScript proper types:
PlainDate,PlainDateTime,ZonedDateTime,Instant - API contracts should be explicit: use ISO strings for instants, structured objects for user intent
- DateTimePickers need to know what they're collecting: date, time, datetime, or datetime + zone
- Show timezones when they matter, hide them when they'd confuse
- Handle DST explicitly — don't let invalid times slip through silently
- Don't lie about what's stored — the picker should match the backend model
- Validate on both ends — trust but verify
This concludes the series "Time in Software, Done Right." You now have a complete mental model for handling time — from concepts to code, from backend to database to frontend.
The next time someone says "just store it as UTC," you'll know when that's right, and when it's a trap.
Top comments (0)