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-ddstrings. 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 aDateobject 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.