From 1752870cee6a0b3e328ec986bb1d8507d5ab7ae6 Mon Sep 17 00:00:00 2001 From: odin Date: Mon, 17 Aug 2026 15:42:31 +0200 Subject: [PATCH] Show the calendar in the viewer's own timezone An episode's air_date is a DATE in the broadcaster's country, so the calendar could only ever file things under the network's day. Last Week Tonight airs 23:00 Sunday in New York, which is 05:00 MONDAY in Stockholm -- the calendar put it on Sunday for everyone, and nothing anywhere showed a time. series.airs_time and series.airs_timezone (0026) carry the network's local airtime and its zone; the agent repository fills them from TVDB, which is the only source that has them. Postgres does the conversion: ((e.air_date + se.airs_time) at time zone se.airs_timezone) at time zone $viewer against the episode's own date, so it stays right across a DST boundary in a way a stored offset would not. THE BROWSER REPORTS ITS ZONE IN A COOKIE rather than the page converting after hydration. Converting client-side would leave the server unable to group, sort or decide what "today" is, and would make it send HTML it then disagrees with. The cookie is validated against Intl before it reaches SQL -- it is passed as a bound parameter either way, but a client-chosen string that ends up inside `at time zone` is worth closing off at the door too. The window is widened a day at each end, because an episode airing late on the last day belongs to the next day for a viewer far enough east, and one on the first day can move back. Anything landing outside the displayed days is simply never looked up. Series with no airtime -- 28 of 517, plus every film -- fall back to the bare date and no time, which is exactly what this page did before. Co-Authored-By: Claude Opus 5 --- src/app/(admin)/admin/calendar/page.tsx | 97 +- .../admin/calendar/viewer-timezone.tsx | 39 + src/db/migrations/0026_polite_wong.sql | 3 + src/db/migrations/meta/0026_snapshot.json | 3024 +++++++++++++++++ src/db/migrations/meta/_journal.json | 9 +- src/db/schema/media.ts | 25 + 6 files changed, 3192 insertions(+), 5 deletions(-) create mode 100644 src/app/(admin)/admin/calendar/viewer-timezone.tsx create mode 100644 src/db/migrations/0026_polite_wong.sql create mode 100644 src/db/migrations/meta/0026_snapshot.json diff --git a/src/app/(admin)/admin/calendar/page.tsx b/src/app/(admin)/admin/calendar/page.tsx index a2d9b59..f94828a 100644 --- a/src/app/(admin)/admin/calendar/page.tsx +++ b/src/app/(admin)/admin/calendar/page.tsx @@ -1,7 +1,9 @@ import { db } from "@/db/client"; import { sql } from "drizzle-orm"; +import { cookies } from "next/headers"; import Link from "next/link"; import { ManualSearch } from "../manual-search"; +import { ViewerTimezone } from "./viewer-timezone"; export const dynamic = "force-dynamic"; @@ -17,6 +19,8 @@ type CalendarRow = { episode_number: number | null; episode_title: string | null; date: string; + /** "HH:MM" in the viewer's zone, or null when the airtime is unknown. */ + local_time: string | null; has_file: boolean; grab_status: string | null; wanted: boolean; @@ -40,6 +44,32 @@ function singleParam(value: string | string[] | undefined) { return Array.isArray(value) ? value[0] : value; } +// The zone used before the browser has told us its own, and the one used if it +// tells us something that is not a real zone. +const DEFAULT_TIMEZONE = "UTC"; + +/** + * A timezone name we are willing to hand to Postgres. + * + * This value arrives in a cookie, which is to say from the client, and it ends + * up inside `at time zone`. It is passed as a bound parameter rather than + * interpolated, so this is not the only thing standing between a cookie and the + * database -- but "the client can choose an arbitrary string that reaches SQL" + * is worth closing off at the door as well. + * + * Intl is the authority rather than a list of our own: it is the same table + * Postgres is being asked to look the name up in, and a list here would drift. + */ +function safeTimezone(value: string | undefined): string { + if (!value) return DEFAULT_TIMEZONE; + try { + new Intl.DateTimeFormat("en-GB", { timeZone: value }); + return value; + } catch { + return DEFAULT_TIMEZONE; + } +} + // --------------------------------------------------------------------------- // Dates // @@ -106,7 +136,7 @@ function monthGridRange(key: string) { * series-wide rule plus an episode override) and more than one grab, and any of * those as a join would silently duplicate calendar entries. */ -function calendarQuery(from: string, to: string) { +function calendarQuery(from: string, to: string, zone: string) { return sql` with in_window as ( select @@ -120,7 +150,28 @@ function calendarQuery(from: string, to: string) { -- for a date column, and its string form ("Wed Jul 30 2026 ...") sorts -- and compares as nonsense against an ISO day -- which quietly labelled -- every past episode as still upcoming. - to_char(e.air_date, 'YYYY-MM-DD') as date, + -- + -- THE DAY IS THE VIEWER'S DAY, not the network's. Last Week Tonight + -- airs 23:00 Sunday in New York, which is 05:00 MONDAY in Stockholm -- + -- so filing it under Sunday would put it on the wrong square for + -- everyone east of the Atlantic. The conversion is done here rather + -- than in the browser so the grouping, the "today" comparison and the + -- ordering all agree, and so the server renders the same HTML it + -- hydrates. + -- + -- Series with no airtime fall back to the bare date, which is exactly + -- what this showed before any of this existed. + to_char( + case + when se.airs_time is not null and se.airs_timezone is not null + then ((e.air_date + se.airs_time) at time zone se.airs_timezone) at time zone ${zone} + else e.air_date::timestamp + end, 'YYYY-MM-DD') as date, + case + when se.airs_time is not null and se.airs_timezone is not null + then to_char(((e.air_date + se.airs_time) at time zone se.airs_timezone) + at time zone ${zone}, 'HH24:MI') + end as local_time, exists ( select 1 from storage_files sf join storage_tiers t on t.id = sf.tier_id @@ -166,6 +217,8 @@ function calendarQuery(from: string, to: string) { null::int, null::text, to_char(m.release_date, 'YYYY-MM-DD'), + -- A film has a release date and no airtime; there is no hour to show. + null::text, exists ( select 1 from storage_files sf join storage_tiers t on t.id = sf.tier_id @@ -286,7 +339,20 @@ export default async function AdminCalendarPage({ searchParams }: PageProps) { to: toYmd(addDays(fromYmd(today), DAYS_AHEAD)), }; - const { rows } = await db.execute(calendarQuery(range.from, range.to)); + // The viewer's own clock, if the browser has had a chance to say. Until then + // UTC, which is the honest answer rather than the server's incidental zone. + const cookieZone = (await cookies()).get("ampelos_tz")?.value; + const zone = safeTimezone(cookieZone); + + // Widened by a day at each end. An episode airing 23:00 on the last day of + // the window belongs to the NEXT day for a viewer far enough east, and one on + // the first day can move back; querying the exact range would drop both. + // Anything that lands outside the displayed days is simply never looked up. + const { rows } = await db.execute(calendarQuery( + toYmd(addDays(fromYmd(range.from), -1)), + toYmd(addDays(fromYmd(range.to), 1)), + zone, + )); // "Monitored" keeps anything we want, already hold, or are fetching. A title // that is present but no longer monitored still belongs on the calendar -- @@ -309,6 +375,9 @@ export default async function AdminCalendarPage({ searchParams }: PageProps) { return (
+ {/* Renders nothing; reports the browser's zone so the grouping above can + use it on the next render. */} +

Schedule

@@ -474,6 +543,7 @@ function MonthGrid({ key={row.kind + row.media_item_id + entryCode(row)} href={entryHref(row)} title={row.title + " " + entryCode(row) + " — " + STATE_LABEL[state] + + (row.local_time ? " at " + row.local_time : "") + (row.episode_title ? "\n" + row.episode_title : "")} className={ "block rounded-sm bg-admin-subpanel px-1.5 py-1 leading-tight hover:bg-[#20363a] " + @@ -481,7 +551,15 @@ function MonthGrid({ } > {row.title} - {entryCode(row)} + + {/* A grid cell has room for one line; the time earns + its place there because it is what decides + whether you can watch it tonight. */} + {row.local_time ? ( + {row.local_time} + ) : null} + {row.local_time ? " " : ""}{entryCode(row)} + ); })} @@ -547,6 +625,17 @@ function Agenda({ byDay, today }: { byDay: Map; today: st > {code} + {/* Already the viewer's own clock -- the conversion happened + in SQL, against the zone the browser reported. Absent for + films and for series TVDB has no airtime for. */} + {row.local_time ? ( + + {row.local_time} + + ) : ( +