Purpose
A broadcast day is a local calendar day that does not start at local midnight. It runs from a declared cutover hour (the operational default in this KB is 06:00 local) on calendar date D to the same cutover on date D+1. Anything airing between midnight and the cutover is logged under the previous broadcast day's label — a program airing at 01:00 local on 2026-03-09 is reported as part of the 2026-03-08 broadcast day, not 03-09. This convention exists because overnight programming, sports that run past midnight, and ad-log reconciliation all need a single day boundary that does not fall in the middle of prime evening/overnight viewing. It is the calendar analog of choosing where to draw a boundary in geo: someone must declare it, and the declaration must travel with every result.
Source and destination
Source: an instant (UTC epoch) plus an IANA zone.
Destination: a broadcast_day label (a local calendar date and its declared
cutover) and the slot_set of UTC hour-of-week slots that day occupies.
Exactness: policy dependent
policy dependent because the result depends entirely on a parameter the caller must supply — the cutover hour — and because the number of UTC hours a broadcast day spans is not fixed: it varies with the local DST calendar for that specific date. There is no "just compute it" version of this conversion; the cutover is a business rule, not a physical constant, and it must be declared and echoed on every result (requested vs executed), exactly as the geo KB requires the requested and executed geometry to both be visible.
Algorithm
import {
broadcastDayOf,
broadcastDayInterval,
broadcastDayToSlots,
DEFAULT_CUTOVER_HOUR,
} from "@/lib/time/broadcast";
// Which broadcast day does this instant belong to, in this zone?
const day = broadcastDayOf(Date.parse("2026-03-09T05:30:00Z"), "America/New_York", 6);
// -> { broadcastDate: "2026-03-08", cutoverHour: 6, zone: "America/New_York" }
// A 00:30 local airing (before the 06:00 cutover) rolls back to the prior day.
// The true [start, end) UTC interval — calendar-aware, so it honours DST.
const spring = broadcastDayInterval({ broadcastDate: "2026-03-08", cutoverHour: 6, zone: "America/New_York" });
// utcHours: 23 — 2026-03-08 in America/New_York is the spring-forward day (clocks skip 02:00->03:00),
// so the 24 *local* hours from 06:00 to next-day 06:00 span only 23 UTC hours.
const fall = broadcastDayInterval({ broadcastDate: "2026-11-01", cutoverHour: 6, zone: "America/New_York" });
// utcHours: 25 — 2026-11-01 is the fall-back day; the 06:00-to-06:00 local window
// spans 25 UTC hours because 01:00-02:00 local occurs twice.
// The set of UTC hour-of-week slots the broadcast day touches (deduplicated).
const slots = broadcastDayToSlots({ broadcastDate: "2026-03-08", cutoverHour: 6, zone: "America/New_York" });
// slots.length === 23 for the spring day above; wrapsWeek is true only when the
// interval crosses Sun 23:00 UTC -> Mon 00:00 UTC.
An ordinary, non-DST broadcast day produces exactly 24 UTC hours and 24
distinct slots. The DST day is the interop hazard: a spring-forward broadcast
day is 23 UTC hours (23 slots), and a fall-back broadcast day is 25 UTC hours
— but because one UTC hour genuinely recurs, broadcastDayToSlots still
returns a deduplicated slot set (each UTC hour-of-week slot appears once),
so its length is 24, while broadcastDayInterval.utcHours correctly reports
25 elapsed hours. Consumers that need to distinguish "24 distinct slots" from
"25 hours elapsed" must read both fields — collapsing them loses the DST
signal.
Parameters
- cutoverHour
- Local hour (0-23) the broadcast day begins. Default: 6 (06:00 local). Must be declared explicitly for any non-default operation.
- zone
- IANA zone the cutover is evaluated in, e.g. America/New_York.
- broadcastDate
- The local calendar date (YYYY-MM-DD) the broadcast day is labelled by — this is the OUTPUT label, not necessarily the date of the instant.
Outputs
broadcastDayOf returns the broadcast-day label. broadcastDayInterval
returns the true [startMs, endMs) UTC interval and its actual utcHours
(23, 24, or 25). broadcastDayToSlots returns the deduplicated UTC
hour-of-week slot set and a wrapsWeek flag for the rare case where the
interval crosses the Sunday 23:00 UTC to Monday 00:00 UTC boundary.
Units and convention
Instants are epoch milliseconds; zones are IANA identifiers resolved through luxon (the IANA tz database); UTC hour-of-week slots follow the same convention as the rest of this KB — slot 0 = Monday 00:00 UTC.
DST and disambiguation behavior
broadcastDayInterval computes the end of the day with a calendar-aware
"plus one day" operation (luxon .plus({ days: 1 })), which honours DST
rather than adding a fixed 24 hours. This is the correct behavior for a
broadcast day: a "day" here means one full local calendar day at the cutover
hour, whatever its true UTC duration turns out to be. broadcastDayToSlots
then steps hour by hour across that true interval and de-duplicates by UTC
slot, so the returned slot count reflects distinct hour-of-week buckets
touched, not raw elapsed hours.
Quality and provenance
Every broadcast-day result should echo the cutover hour and zone it was
computed with — the requested parameters — alongside the executed
broadcastDate, utcHours, and slot set. A result missing the cutover hour
cannot be audited: a 1am airing logged under the wrong day is nearly always
traceable to an undeclared or silently-changed cutover.
Edge cases
Broadcast-day cutover is not universal: some operations use 05:00, 02:00, or midnight, and sports/overnight feeds often differ from entertainment scheduling — never assume 06:00. Spring-forward gap and fall-back fold are exactly what produce the 23- and 25-hour broadcast days shown above. Half-hour offset zones (India, Sri Lanka, Newfoundland) mean the cutover itself may not land on a UTC hour boundary, so the resulting slot set can start mid-slot. Broadcast calendar month explains how broadcast days roll up into a broadcast month/quarter that does not align to the Gregorian calendar — see Week Systems.
Python parity
from datetime import date, timedelta
from zoneinfo import ZoneInfo
def broadcast_day_of(instant_utc, zone: str, cutover_hour: int = 6) -> date:
local = instant_utc.astimezone(ZoneInfo(zone))
return local.date() - timedelta(days=1) if local.hour < cutover_hour else local.date()
The tested reference implementation is the TypeScript in lib/time/broadcast.ts;
this Python mirrors broadcastDayOf only — computing the true DST-aware
[start, end) interval requires walking zoneinfo transition data the same
way broadcastDayInterval walks luxon's, and is intentionally left to the TS
implementation as the single source of truth.
