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} + + ) : ( +