Skip to content

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.

FunctionSignaturePurpose
parseDateTimeLocal(naive: string, timezone: string) => DateParse 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) => stringConvert 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) => stringFormat 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) => stringResolve the best timezone for display from a chain of candidate sources. See Fallback Chain.
detectBrowserTimezone() => stringRead 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) === str across 7 timezones × 4 test values
  • formatDateOnly correctness 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:

  1. explicitTimezone — a per-action override (e.g. the booking's own timezone)
  2. userTimezone — the viewing user's user_profiles.timezone
  3. podcastTimezone — the podcast's default_timezone
  4. fallback — typically the browser-detected timezone or a caller-supplied default
  5. "UTC"

Empty strings are treated as null so a missing column never accidentally wins.

Where Each Source Is Loaded

SourceLoaded InExposed As
User timezonesrc/routes/(app)/+layout.server.ts — selected on user_profilesdata.user.timezone
Podcast timezonesrc/routes/(app)/+layout.server.ts — selected on podcastsdata.currentPodcast.defaultTimezone
Booking timezoneCaptured at booking creation and stored in bookings.timezonePassed explicitly to email templates and automation context
Browser timezoneClient-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.

TableColumnTypeDefaultPurpose
user_profilestimezoneTEXT'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.
podcastsdefault_timezoneTEXT'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.
bookingstimezoneTEXTRequired at insertThe 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.

ActionFileNotes
Episode scheduling auto-savesrc/routes/(app)/p/[slug]/e/[episodeSlug]/+page.server.tsupdateSchedulingParses recording_date and publish_date.
Episode schedule dialogsrc/routes/(app)/p/[slug]/e/[episodeSlug]/+page.server.tsscheduleParses before calling validateStatusTransition, so the validator receives a UTC ISO string.
New episode creationsrc/routes/(app)/p/[slug]/e/new/+page.server.ts — default actionSame pattern as scheduling auto-save.
Publish handoffsrc/routes/(app)/p/[slug]/e/[episodeSlug]/publish-handoff/+page.server.tsconfirmPublished, confirmScheduledParses the handoff timestamp against the submitting user's timezone.
Signupsrc/routes/(auth)/signup/+page.server.tsForwards 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 viewssrc/routes/(app)/p/[slug]/e/[episodeSlug]/+page.svelte renders recording and publish dates via Intl.DateTimeFormat using 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 the timezone field from the booking row.
  • Availability APIsrc/api/routes/availability/index.ts uses formatDateOnly() 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 UIsrc/lib/notifications/ui-utils.ts exports formatNotificationAbsoluteTime(createdAt, timezone) using Intl.DateTimeFormat. formatDistanceToNow from date-fns is still used for relative labels ("2 hours ago"), which are timezone-agnostic.
  • Search filterssrc/lib/search/search-store.svelte.ts renders 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.

TemplateFileTimezone field
Booking requestsrc/lib/email/templates/booking-request.tstimezone from the booking row
Booking confirmedsrc/lib/email/templates/booking-confirmed.tstimezone from the booking row
Booking declinedsrc/lib/email/templates/booking-declined.tstimezone from the booking row
Host notificationsrc/lib/email/templates/host-notification.tsHost's user_profiles.timezone
Team invitationsrc/lib/email/templates/team-invitation.tsRecipient's user_profiles.timezone (when known); used for expiry date
Network invitationsrc/lib/email/templates/network-invitation.tsRecipient's user_profiles.timezone (when known); used for expiry date
Show notes availablesrc/lib/email/templates/show-notes-available.tsRecipient'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:

  1. If a booking is in scope, use booking.timezone for all booking-derived tags.
  2. Otherwise, use podcast.default_timezone for episode-derived tags.
  3. 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

WorkerTimezone role
automation-executorReads podcasts.default_timezone and bookings.timezone when building MagicTagContext. All date tag rendering happens here. See workers/automation-executor/src/index.ts.
automation-schedulerSchedules future executions at UTC instants. The app pre-computes the UTC target before enqueuing; the scheduler itself does no timezone math.
scheduled-publisherPublishes episodes at a UTC scheduled_for instant. Timezone conversion happens in the app when the host sets the schedule.
rss-feedEmits 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 GMT on a date when the UK is on BST is the same instant as 10:36:52 BST on the host's wall clock. This is correct, not a bug.
  • Do not "fix" the GMT suffix to UTC — 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:

typescript
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.

typescript
interface Props {
	selectedTimezone: string;
	onSelect: (tz: string) => void;
	onClose: () => void;
	brandColor?: string; // defaults to '#3b82f6'
}

Used in:

  • User settingssrc/routes/(app)/settings/+page.svelte, with a "Detect from browser" helper.
  • Podcast settingssrc/routes/(app)/p/[slug]/settings/+page.svelte, for default_timezone.
  • Public booking pagesrc/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:

  1. Parsing a datetime-local input? Use parseDateTimeLocal(naive, timezone) and submit a hidden timezone form field. Never new Date(naive).
  2. Formatting a UTC ISO string for display? Use formatDateTimeLocal() (for input values) or Intl.DateTimeFormat({ timeZone }) (for prose). Resolve the timezone via resolveDisplayTimezone().
  3. Need a calendar-day string (YYYY-MM-DD)? Use formatDateOnly(date, timezone). Never .toISOString().split('T')[0].
  4. Sending an email with an absolute date? Take a timezone parameter on the template's data interface and pass it to Intl.DateTimeFormat. Default to 'UTC'.
  5. Writing a magic tag? Wire the context through buildMagicTagContext() so booking / podcast timezone precedence is honoured.

Internal documentation - Not for public distribution