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 <noreply@anthropic.com>
This commit is contained in:
@@ -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<CalendarRow>(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<CalendarRow>(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 (
|
||||
<div className="space-y-6">
|
||||
{/* Renders nothing; reports the browser's zone so the grouping above can
|
||||
use it on the next render. */}
|
||||
<ViewerTimezone current={cookieZone ?? null} />
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.32em] text-admin-accent">Schedule</p>
|
||||
@@ -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({
|
||||
}
|
||||
>
|
||||
<span className="block truncate text-[11px] text-admin-text">{row.title}</span>
|
||||
<span className="block truncate text-[10px] text-admin-muted">{entryCode(row)}</span>
|
||||
<span className="block truncate text-[10px] text-admin-muted">
|
||||
{/* 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 ? (
|
||||
<span className="tabular-nums">{row.local_time}</span>
|
||||
) : null}
|
||||
{row.local_time ? " " : ""}{entryCode(row)}
|
||||
</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
@@ -547,6 +625,17 @@ function Agenda({ byDay, today }: { byDay: Map<string, CalendarRow[]>; today: st
|
||||
>
|
||||
<span className="w-14 shrink-0 font-mono text-xs text-admin-muted">{code}</span>
|
||||
|
||||
{/* 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 ? (
|
||||
<span className="w-11 shrink-0 font-mono text-xs tabular-nums text-admin-muted">
|
||||
{row.local_time}
|
||||
</span>
|
||||
) : (
|
||||
<span className="w-11 shrink-0" aria-hidden="true" />
|
||||
)}
|
||||
|
||||
<Link href={entryHref(row)} className="font-medium text-admin-text hover:text-admin-accent">
|
||||
{row.title}
|
||||
</Link>
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
/**
|
||||
* Tells the server which clock the person reading this is on.
|
||||
*
|
||||
* The calendar groups episodes by the VIEWER's day, and only the browser knows
|
||||
* what that is. The obvious alternative -- convert in the browser at render
|
||||
* time -- means the server cannot group, sort or decide what "today" is, and
|
||||
* means the HTML it sends does not match what React then renders. Handing the
|
||||
* zone over once and letting Postgres do the arithmetic keeps all of that on
|
||||
* one side of the wire.
|
||||
*
|
||||
* A cookie rather than a query parameter so it survives navigation and does not
|
||||
* end up in shared links, where it would export one person's timezone to
|
||||
* whoever they sent the URL to.
|
||||
*
|
||||
* It refreshes exactly once, when the stored zone is wrong or missing. Without
|
||||
* that guard this is an infinite loop: refresh re-renders the page, which
|
||||
* re-runs this effect, which refreshes.
|
||||
*/
|
||||
export function ViewerTimezone({ current }: { current: string | null }) {
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
const zone = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
if (!zone || zone === current) return;
|
||||
|
||||
// Lax rather than Strict: the calendar is reached by ordinary navigation
|
||||
// from elsewhere in the app, and Strict would withhold the cookie on the
|
||||
// first such request and cause one avoidable refresh every time.
|
||||
document.cookie = `ampelos_tz=${encodeURIComponent(zone)}; path=/; max-age=31536000; samesite=lax`;
|
||||
router.refresh();
|
||||
}, [current, router]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
ALTER TABLE "series" ADD COLUMN "airs_time" time;--> statement-breakpoint
|
||||
ALTER TABLE "series" ADD COLUMN "airs_timezone" text;--> statement-breakpoint
|
||||
ALTER TABLE "series" ADD COLUMN "airs_country" text;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -183,6 +183,13 @@
|
||||
"when": 1786967391633,
|
||||
"tag": "0025_fine_venom",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 26,
|
||||
"version": "7",
|
||||
"when": 1786967937046,
|
||||
"tag": "0026_polite_wong",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
integer,
|
||||
boolean,
|
||||
date,
|
||||
time,
|
||||
timestamp,
|
||||
index,
|
||||
uniqueIndex,
|
||||
@@ -45,6 +46,30 @@ export const series = pgTable("series", {
|
||||
// Recomputed on metadata refresh. See PLANNING.md "Currently Airing".
|
||||
isCurrentlyRelevant: boolean("is_currently_relevant"),
|
||||
metadataRefreshedAt: timestamp("metadata_refreshed_at"),
|
||||
|
||||
// WHEN, not just what day. `episodes.air_date` is a DATE and carries no time
|
||||
// at all, which is why the calendar could only ever show a day and why the
|
||||
// fetch queue began hunting an episode up to 29 hours before it existed.
|
||||
//
|
||||
// These come from TVDB, not TMDB: TMDB has no airtime field of any kind.
|
||||
// TVDB's `airsTimeUTC` is documented but empty for every one of the 18 series
|
||||
// currently in the calendar window, so it is ignored -- the usable pair is
|
||||
// `airsTime` (network-LOCAL, "23:00") plus the network's country.
|
||||
//
|
||||
// Stored as local-time-plus-zone rather than as a UTC offset on purpose. An
|
||||
// offset is only correct until the next DST change; a zone stays correct
|
||||
// because the conversion is done against the episode's own date:
|
||||
//
|
||||
// (e.air_date + s.airs_time) at time zone s.airs_timezone
|
||||
//
|
||||
// Null means TVDB had nothing, and the calendar falls back to showing the
|
||||
// date alone -- exactly what it does today.
|
||||
airsTime: time("airs_time"),
|
||||
airsTimezone: text("airs_timezone"),
|
||||
// The raw country TVDB reported ("usa", "gbr", "fra"). Kept so a wrong or
|
||||
// missing zone can be re-derived by fixing the mapping, without refetching
|
||||
// every series from the API.
|
||||
airsCountry: text("airs_country"),
|
||||
});
|
||||
|
||||
export const seasons = pgTable(
|
||||
|
||||
Reference in New Issue
Block a user