Interface Design

How Timezone-Safe UIs Work

May 10, 2026

Why storing dates as ISO strings prevents an entire class of bugs, and how to build interfaces that work correctly across every US timezone.

A staffing platform operates across every US timezone. A shift at a facility in New York and a shift at a facility in Los Angeles might both show as “7:00 AM” on their respective calendars, but they’re hours apart in absolute time. Getting this wrong means showing the wrong shift in the wrong column — or worse, double-booking a worker who’s already committed to a shift 3,000 miles away.

Timezone bugs are uniquely insidious because they only manifest at boundary conditions: midnight crossings, DST transitions, facilities near timezone borders. Your test suite passes at 2 PM on a Wednesday in a single timezone. It fails at 11:58 PM on the first Sunday of November when a facility in Arizona (which doesn’t observe DST) interacts with one in California (which does).

The core principle: dates as strings

The schedule solves this with a strict convention: all dates are stored as ISO strings (yyyy-MM-dd), which are timezone-neutral. The conversion to a Date object only happens at the moment of display, using the facility’s timezone from the API.

// Source of truth: timezone-neutral string
const selectedDateString = "2026-03-04"; // yyyy-MM-dd

// Only converted at display time, using facility TZ
const displayDate = zonedTimeToUtc(
  parseISODateString(selectedDateString),
  facility.timezone // "America/New_York"
);

This seems like a small thing, but it prevents an entire class of bugs. Consider what happens with the alternative — storing dates as Date objects:

// DANGEROUS: Date objects carry timezone assumptions
const date = new Date("2026-03-04");
// This is midnight UTC, which is:
// - 7:00 PM on March 3rd in EST
// - 4:00 PM on March 3rd in PST
// You're now showing yesterday's shifts.

A Date object in JavaScript is always UTC internally. When you construct one from a date-only string, it assumes midnight UTC. But midnight UTC is still “yesterday” in every US timezone. If your calendar compares this Date against shift start times, it will show March 3rd’s shifts on March 4th’s calendar.

ISO date strings ("2026-03-04") carry no timezone assumption. They’re just a label. The timezone is applied explicitly, at the last possible moment, using the facility’s configured timezone. This is the only approach that works across a multi-timezone platform.

Where timezone conversion happens

There are exactly three places where timezone conversion occurs:

1. Time slot assignment

When a shift arrives from the API with a UTC start time, we need to determine which column (AM, PM, NOC) it belongs to. This must use the facility’s timezone:

function getTimeSlotFromShift(
  startTimeUtc: string,
  facilityTimezone: string
): TimeSlot {
  const localHour = utcToZonedTime(
    new Date(startTimeUtc),
    facilityTimezone
  ).getHours();

  if (localHour >= 5 && localHour < 12) return "am";
  if (localHour >= 12 && localHour < 18) return "pm";
  return "noc";
}

A shift starting at 12:00 UTC is an AM shift in New York (7:00 AM EST) but a NOC shift in Honolulu (2:00 AM HST). Same UTC time, different time slot — because the slot assignment happens in the facility’s local timezone.

2. “Today” computation

Determining “today” for a facility requires its timezone. A facility in Los Angeles is still on March 3rd when a facility in New York has already moved to March 4th:

const todayDateString = formatInTimeZone(
  new Date(), // current UTC time
  facility.timezone,
  "yyyy-MM-dd"
);

This affects which day cell is highlighted in the calendar, which shifts are “past” vs “future”, and whether a posting is for “today” (which may trigger rush-fee warnings).

3. API query parameters

When fetching shifts for a date range, the API needs to know the timezone to return the correct shifts. A request for “March 4th shifts” must specify which timezone’s March 4th we mean:

const params = {
  dateFilter: {
    start: "2026-03-04",
    end: "2026-03-04",
  },
  tmz: facility.timezone, // "America/New_York"
};

The backend uses this timezone to convert the date range into UTC boundaries for the database query.

DST: the twice-yearly chaos

Daylight Saving Time transitions create two specific problems:

The spring-forward gap. On the second Sunday of March, 2:00 AM becomes 3:00 AM. A NOC shift scheduled from 10 PM to 6 AM on that night is actually only 7 hours long, not 8. The display must show “8 hours” (the scheduled duration) but the payment calculation must use 7 hours (the actual worked time).

The fall-back overlap. On the first Sunday of November, 2:00 AM happens twice. A shift ending at “2:30 AM” is ambiguous — is it the first 2:30 AM or the second? Without explicit UTC storage for exact times, this is unresolvable.

The solution is the same principle at a finer granularity: exact times are stored as UTC timestamps, display times are computed using the facility timezone, and the duration calculation always works in UTC (where there are no gaps or overlaps).

// Shift times stored as UTC
const shift = {
  startTime: "2026-03-09T03:00:00Z", // 10 PM EST Saturday
  endTime: "2026-03-09T11:00:00Z",   // 6 AM EDT Sunday (after spring-forward)
};

// Duration calculation: always in UTC
const durationMs = new Date(shift.endTime) - new Date(shift.startTime);
const durationHours = durationMs / (1000 * 60 * 60); // 8 hours in UTC
// But actual wall-clock hours: 7 (because 2 AM → 3 AM was skipped)

URL state is timezone-neutral

The schedule stores its date state in the URL as an ISO string: ?date=2026-03-04. This string is timezone-neutral by design. When the page loads, the date string is combined with the facility’s timezone to produce the correct local date for display, API queries, and “today” highlighting.

If the URL stored a Date or timestamp instead, opening the same link in a different timezone (say, a remote administrator checking from a different city) would show the wrong day’s shifts. The ISO string guarantees that ?date=2026-03-04 always means March 4th at the facility, regardless of where you’re viewing it from.

The rule

The rule is simple enough to fit in a callout:

Dates are strings. Times are UTC. Timezone is applied at the edge.

Store dates as yyyy-MM-dd strings. Store exact times as UTC timestamps. Apply the facility’s timezone only at the three conversion points: slot assignment, “today” computation, and API queries. Never let a Date object with implicit timezone assumptions flow through your data layer.

This convention eliminates timezone bugs not by being clever about edge cases, but by making it structurally impossible to introduce them. The timezone is never implicit — it’s always an explicit parameter passed at the moment of conversion.

← All chapters