Timezone Management
show.fm stores every absolute instant in UTC but renders and parses user-entered times in an IANA timezone. This document describes the shared utility, the fallback chain used to pick a timezone for any given context, the data model, and every place the system crosses a timezone boundary.
Source of truth: src/lib/utils/timezone.ts, src/lib/constants/timezones.ts, src/lib/components/TimezoneSelector.svelte.
Why This Exists
HTML <input type="datetime-local"> produces a naive wall-clock string such as "2026-04-13T10:30" with no timezone indicator. Cloudflare Workers run in UTC, so a bare new Date(naiveString) interprets that string as UTC. A user in BST who picks 10:30 would have 10:30Z persisted — one hour off from what they meant.
The shared parseDateTimeLocal(naive, timezone) utility pairs each naive string with the timezone the user was viewing, producing a correct UTC Date. The inverse, formatDateTimeLocal(iso, timezone), round-trips a UTC ISO string back to the same wall-clock value in the chosen timezone.
The Utility
src/lib/utils/timezone.ts exports five functions. None of them depend on any external package — the DST math is done with Intl.DateTimeFormat.formatToParts.
| Function | Signature | Purpose |
|---|---|---|
parseDateTimeLocal | (naive: string, timezone: string) => Date | Parse a naive datetime-local string in an IANA timezone and return a correct UTC Date. Handles DST spring-forward / fall-back by re-checking the offset at the estimated UTC instant. |
formatDateTimeLocal | (iso: string | null | undefined, timezone: string) => string | Convert a UTC ISO string into a YYYY-MM-DDTHH:mm value for a datetime-local input, displayed in the given timezone. Returns "" on nullish/invalid input. |
formatDateOnly | (date: Date, timezone: string) => string | Format a Date as a YYYY-MM-DD calendar day in the given timezone. Replaces .toISOString().split('T')[0], which leaks the UTC calendar day and is off-by-one near midnight for non-UTC users. |
resolveDisplayTimezone | (options) => string | Resolve the best timezone for display from a chain of candidate sources. See Fallback Chain. |
detectBrowserTimezone | () => string | Read Intl.DateTimeFormat().resolvedOptions().timeZone. Client-only helper; falls back to "UTC" on any error. |
DST Algorithm
parseDateTimeLocal treats the naive string as if it were UTC to compute a first-guess offset, applies that offset, then re-checks the offset of the resulting instant. If the offset changes (the first guess landed inside a DST transition), the function recomputes with the corrected offset. The test suite exercises this across Europe/London BST→GMT, America/New_York EST↔EDT, and extreme offsets (Pacific/Kiritimati UTC+14, Pacific/Pago_Pago UTC-11).
Tests
src/lib/utils/__tests__/timezone.test.ts covers:
- Multiple IANA timezones, including DST boundaries and extreme offsets
- Round-trip invariant:
formatDateTimeLocal(parseDateTimeLocal(str, tz).toISOString(), tz) === stracross 7 timezones × 4 test values formatDateOnlycorrectness at day boundaries (e.g. 23:00 PDT resolves to the local day, not the UTC day)- Null / undefined / empty-string / invalid-date inputs
Fallback Chain
resolveDisplayTimezone is the canonical way to pick a timezone for any render. It returns the first non-empty value from:
explicitTimezone— a per-action override (e.g. the booking's owntimezone)userTimezone— the viewing user'suser_profiles.timezonepodcastTimezone— the podcast'sdefault_timezonefallback— typically the browser-detected timezone or a caller-supplied default"UTC"
Empty strings are treated as null so a missing column never accidentally wins.
Where Each Source Is Loaded
| Source | Loaded In | Exposed As |
|---|---|---|
| User timezone | src/routes/(app)/+layout.server.ts — selected on user_profiles | data.user.timezone |
| Podcast timezone | src/routes/(app)/+layout.server.ts — selected on podcasts | data.currentPodcast.defaultTimezone |
| Booking timezone | Captured at booking creation and stored in bookings.timezone | Passed explicitly to email templates and automation context |
| Browser timezone | Client-only via detectBrowserTimezone() | Used in signup and on public booking pages where no user profile exists |
Guest Portal
The (guest) and (book) route groups do not have a Supabase user, so the fallback chain skips userTimezone and relies on bookings.timezone (when an existing booking is in context) or the browser-detected value.
Data Model
Three columns store an IANA timezone. All of them default to 'UTC' so missing data never produces a parse error.
| Table | Column | Type | Default | Purpose |
|---|---|---|---|---|
user_profiles | timezone | TEXT | 'UTC' | The viewing user's preferred timezone. Used by notification preferences (quiet hours, digest delivery time), as a fallback for display in the (app) routes, and as the default for any hidden timezone form field. |
podcasts | default_timezone | TEXT | 'UTC' | The editorial default for a show. Used for episode recording / publish dates when no booking context exists, and by the automation executor when formatting episode-only magic tags. |
bookings | timezone | TEXT | Required at insert | The guest's selected timezone for this booking. Immutable. Always preferred for booking-related emails, Google Calendar events, and automation magic tags. |
The three columns were introduced in supabase/migrations/20251230153534_initial_schema.sql. The signup trigger was later updated so the user's timezone is populated from day one (see Signup Flow).
Signup Flow
The browser detects the user's timezone at signup and passes it through Supabase Auth metadata. A Postgres trigger reads it back when creating the user_profiles row.
Client: src/routes/(auth)/signup/+page.svelte detects the browser timezone via detectBrowserTimezone() and submits it as a hidden form field.
Server action: src/routes/(auth)/signup/+page.server.ts reads the field and passes it to supabase.auth.signUp({ options: { data: { timezone } } }).
Trigger: supabase/migrations/20260416174314_handle_new_user_timezone.sql updates handle_new_user() to read NEW.raw_user_meta_data->>'timezone', falling back to 'UTC'. The trigger retains SECURITY DEFINER and SET search_path = public — see the supabase/CLAUDE.md rule on RLS helper functions.
Write Paths
Every server action that parses a naive datetime-local string submits a hidden timezone form field and calls parseDateTimeLocal(). Never call new Date(naiveString) on user-entered datetime input.
| Action | File | Notes |
|---|---|---|
| Episode scheduling auto-save | src/routes/(app)/p/[slug]/e/[episodeSlug]/+page.server.ts — updateScheduling | Parses recording_date and publish_date. |
| Episode schedule dialog | src/routes/(app)/p/[slug]/e/[episodeSlug]/+page.server.ts — schedule | Parses before calling validateStatusTransition, so the validator receives a UTC ISO string. |
| New episode creation | src/routes/(app)/p/[slug]/e/new/+page.server.ts — default action | Same pattern as scheduling auto-save. |
| Publish handoff | src/routes/(app)/p/[slug]/e/[episodeSlug]/publish-handoff/+page.server.ts — confirmPublished, confirmScheduled | Parses the handoff timestamp against the submitting user's timezone. |
| Signup | src/routes/(auth)/signup/+page.server.ts | Forwards timezone to Supabase Auth metadata; does not parse a datetime-local. |
The client-side forms that feed these actions include a hidden <input type="hidden" name="timezone" value={timezone} />, where timezone comes from the fallback chain (user → podcast → browser).
Read / Display Paths
Every user-visible date is rendered with either formatDateTimeLocal() (for input values) or Intl.DateTimeFormat({ timeZone }) (for prose-style rendering). Direct toLocaleDateString() / toLocaleString() calls without a timeZone option are forbidden for user-facing dates.
Grouped by area:
- Episode views —
src/routes/(app)/p/[slug]/e/[episodeSlug]/+page.svelterenders recording and publish dates viaIntl.DateTimeFormatusing the podcast timezone, with the user timezone as an override when the viewer is outside the podcast's timezone. - Bookings — The guest booking page (
src/routes/(book)/...) renders slot labels in the guest-selected timezone; the email templates take thetimezonefield from the booking row. - Availability API —
src/api/routes/availability/index.tsusesformatDateOnly()to compute calendar-day boundaries for minimum-notice and maximum-advance windows. Availability rules are evaluated against the guest's timezone (passed as a query parameter). - Notifications UI —
src/lib/notifications/ui-utils.tsexportsformatNotificationAbsoluteTime(createdAt, timezone)usingIntl.DateTimeFormat.formatDistanceToNowfromdate-fnsis still used for relative labels ("2 hours ago"), which are timezone-agnostic. - Search filters —
src/lib/search/search-store.svelte.tsrenders date-range labels in the user's timezone so "today"/"yesterday" match the viewer's calendar day. - Email templates — See Emails below.
- Automation magic tags — See Automation Magic Tags below.
Emails
Every transactional template that renders an absolute date takes a timezone parameter. The convention is timezone?: string with 'UTC' as the fallback on the Intl.DateTimeFormat call.
| Template | File | Timezone field |
|---|---|---|
| Booking request | src/lib/email/templates/booking-request.ts | timezone from the booking row |
| Booking confirmed | src/lib/email/templates/booking-confirmed.ts | timezone from the booking row |
| Booking declined | src/lib/email/templates/booking-declined.ts | timezone from the booking row |
| Host notification | src/lib/email/templates/host-notification.ts | Host's user_profiles.timezone |
| Team invitation | src/lib/email/templates/team-invitation.ts | Recipient's user_profiles.timezone (when known); used for expiry date |
| Network invitation | src/lib/email/templates/network-invitation.ts | Recipient's user_profiles.timezone (when known); used for expiry date |
| Show notes available | src/lib/email/templates/show-notes-available.ts | Recipient's user_profiles.timezone when authenticated; falls back to booking / podcast timezone for guests |
When a recipient has no profile timezone (e.g. an email invite sent to an unknown address), the caller passes 'UTC' and the template renders accordingly. Absolute instants in bookings.start_time / bookings.end_time are unambiguous regardless of what timezone is chosen for display.
Automation Magic Tags
The automation executor (workers/automation-executor) renders tags such as {recording_date}, {recording_time}, {recording_datetime}, {recording_timezone}, and {publish_date} via Intl.DateTimeFormat.
src/lib/automation/tag-parser.ts builds the tag context with this precedence:
- If a
bookingis in scope, usebooking.timezonefor all booking-derived tags. - Otherwise, use
podcast.default_timezonefor episode-derived tags. - Otherwise, fall back to
'UTC'.
This mirrors the fallback chain in the app — booking timezone is always more specific than podcast timezone.
{recording_timezone} renders a short label (e.g. "ET") via a small lookup; unknown zones fall through to the raw IANA value.
Workers
| Worker | Timezone role |
|---|---|
automation-executor | Reads podcasts.default_timezone and bookings.timezone when building MagicTagContext. All date tag rendering happens here. See workers/automation-executor/src/index.ts. |
automation-scheduler | Schedules future executions at UTC instants. The app pre-computes the UTC target before enqueuing; the scheduler itself does no timezone math. |
scheduled-publisher | Publishes episodes at a UTC scheduled_for instant. Timezone conversion happens in the app when the host sets the schedule. |
rss-feed | Emits every feed timestamp as an absolute UTC instant formatted to RFC 822 with the literal GMT suffix via toRFC822() in workers/rss-feed/src/rss/utils.ts. The podcast's default_timezone is not consulted — see RSS Feed Dates. |
Cloudflare cron triggers fire on UTC. Anything that needs a wall-clock offset ("9 AM in the user's timezone") must convert in the app before enqueuing.
RSS Feed Dates
RSS 2.0 requires RFC 822 date format for <lastBuildDate>, channel <pubDate>, and item <pubDate>. The only alphabetic timezone designators RFC 822 / RFC 2822 accept are UT, GMT, and the legacy US zones (EST, EDT, etc.) — UTC is not a valid label and will fail strict feed validators. toRFC822() therefore always renders the instant in UTC and appends the literal string GMT.
Implications:
- Feed timestamps are not influenced by
podcasts.default_timezone,user_profiles.timezone, or any viewer context. Display-side localization is every client's responsibility. - A feed labelled
09:36:52 GMTon a date when the UK is on BST is the same instant as10:36:52 BSTon the host's wall clock. This is correct, not a bug. - Do not "fix" the
GMTsuffix toUTC— that would make the feed non-conformant for Apple Podcasts, Spotify, and Podcast 2.0 validators.
Google Calendar
When creating events via src/lib/google-calendar/, the app sends:
start: { dateTime: booking.start_time, timeZone: booking.timezone }
end: { dateTime: booking.end_time, timeZone: booking.timezone }dateTime is a UTC ISO string (unambiguous). Google Calendar uses the timeZone field for rendering in the recipient's calendar UI — not for interpreting the absolute instant. Passing the booking timezone keeps the event displaying naturally in the guest's calendar.
TimezoneSelector Component
src/lib/components/TimezoneSelector.svelte is a modal picker backed by TIMEZONE_GROUPS from src/lib/constants/timezones.ts. It supports search, displays the current time in each zone, and applies the calling page's brand color to the selected row.
interface Props {
selectedTimezone: string;
onSelect: (tz: string) => void;
onClose: () => void;
brandColor?: string; // defaults to '#3b82f6'
}Used in:
- User settings —
src/routes/(app)/settings/+page.svelte, with a "Detect from browser" helper. - Podcast settings —
src/routes/(app)/p/[slug]/settings/+page.svelte, fordefault_timezone. - Public booking page —
src/routes/(book)/[podcastSlug]/[bookingSlug]/+page.svelte, so guests can override the browser-detected zone before picking a slot.
Checklist for New Code Paths
When adding a feature that reads or writes a date:
- Parsing a datetime-local input? Use
parseDateTimeLocal(naive, timezone)and submit a hiddentimezoneform field. Nevernew Date(naive). - Formatting a UTC ISO string for display? Use
formatDateTimeLocal()(for input values) orIntl.DateTimeFormat({ timeZone })(for prose). Resolve the timezone viaresolveDisplayTimezone(). - Need a calendar-day string (
YYYY-MM-DD)? UseformatDateOnly(date, timezone). Never.toISOString().split('T')[0]. - Sending an email with an absolute date? Take a
timezoneparameter on the template's data interface and pass it toIntl.DateTimeFormat. Default to'UTC'. - Writing a magic tag? Wire the context through
buildMagicTagContext()so booking / podcast timezone precedence is honoured.
Related Documentation
- Booking Flow Guide — End-to-end flow, including how the booking's own
timezoneis captured. - Notification Preferences — Quiet hours and digest scheduling, which consume
user_profiles.timezone. - Google Calendar Service — OAuth, event creation, and the
timeZonefield semantics. - Automation Magic Tags — Tag reference, including date tags and timezone precedence.
- Automation Executor — Where magic tag context is built in the worker.