This is the core conversion of the entire knowledge base: everything else — ISO week keys, broadcast days, dayparts, MMM features — is built on top of the slot this page produces. Get it wrong and every downstream aggregate inherits the error silently.
Purpose
Convert a local wall-clock timestamp, paired with an IANA time zone identifier, into the canonical hour-of-week slot (0–167) it falls in. The conversion is exact arithmetic on a UTC instant, but reaching that instant requires resolving the local time through the IANA time zone database first, and that resolution step is where policy — not just arithmetic — enters.
Source and destination
Source: timestamp (a plain calendar/clock reading — year, month, day,
hour, minute, no offset attached) plus iana_zone (a canonical zone id
such as Asia/Kolkata, never an abbreviation or a bare offset).
Destination: hour_of_week_slot, an integer 0–167.
- exactness
- policy_dependent — exact once a disambiguation policy is declared; not exact without one
- params
- wall (year, month, day, hour, minute), zone (IANA id), disambiguation (earliest | latest | reject)
- outputs
- slot, epochMs, utc (ISO string), wasNonexistent, wasAmbiguous, disambiguationApplied, offsetMinutes
- units
- instants in epoch milliseconds; zones as IANA identifiers; tz engine is luxon
- convention
- slot 0 = Monday 00:00 UTC; weekdayMon0 * 24 + hourUTC
Algorithm
import { localToSlot } from "@/lib/time/slot";
// Ordinary case: no DST transition involved.
const kolkata = localToSlot(
{ year: 2026, month: 1, day: 15, hour: 5, minute: 30 },
"Asia/Kolkata",
"earliest",
);
// kolkata.utc -> "2026-01-15T00:00:00.000Z"
// kolkata.slot -> 72 (2026-01-15 is a Thursday in UTC: weekdayMon0=3,
// hourUTC=0 -> 3*24 + 0 = 72)
// India has held a fixed +5:30 offset since 1945: no gap, no fold.
// Spring-forward gap: 2026-03-08 02:30 in America/New_York never occurs
// (clocks jump 01:59:59 -> 03:00:00). Only one valid instant is adjacent.
const gap = localToSlot(
{ year: 2026, month: 3, day: 8, hour: 2, minute: 30 },
"America/New_York",
"earliest",
);
// gap.utc -> "2026-03-08T07:30:00.000Z"
// gap.wasNonexistent -> true
// gap.disambiguationApplied -> "earliest"
// Fall-back fold: 2026-11-01 01:30 in America/New_York occurs twice, at
// two different UTC offsets an hour apart.
const foldEarliest = localToSlot(
{ year: 2026, month: 11, day: 1, hour: 1, minute: 30 },
"America/New_York",
"earliest",
);
// foldEarliest.utc -> "2026-11-01T05:30:00.000Z" (pre-transition, EDT, -240)
const foldLatest = localToSlot(
{ year: 2026, month: 11, day: 1, hour: 1, minute: 30 },
"America/New_York",
"latest",
);
// foldLatest.utc -> "2026-11-01T06:30:00.000Z" (post-transition, EST, -300)
// Same requested wall time, one UTC hour and typically one slot apart.
// Reject policy: throw rather than silently pick a side.
try {
localToSlot(
{ year: 2026, month: 11, day: 1, hour: 1, minute: 30 },
"America/New_York",
"reject",
);
} catch (e) {
// "Ambiguous local time (fall-back fold): ... occurs twice."
}
The Kolkata example is worth double-checking by hand: 2026-01-15 05:30
in Asia/Kolkata (a fixed UTC+5:30 offset, no DST) resolves to
2026-01-15T00:00:00.000Z. That instant's UTC weekday is Thursday
(weekdayMon0 = 3) at hourUTC = 0, giving slot = 3*24 + 0 = 72. A
05:30 Kolkata reading only lands on slot 0 when the resolved UTC instant's
date happens to be a Monday — the slot depends on the full UTC instant,
not on the local clock reading alone.
DST and disambiguation behavior
The gap and fold are detected structurally, not by a hardcoded transition
calendar: a gap is any wall time the tz database resolves to a different
clock reading than requested (the time was skipped); a fold is any wall
time for which an hour before and an hour after share the identical wall
clock reading at two different UTC offsets. Both are handled by the same
disambiguation parameter — earliest (first/pre-transition
occurrence for a fold; the single valid instant for a gap), latest
(second/post-transition occurrence for a fold; the same single instant
for a gap, since only one exists), or reject (throw for either hazard).
The result always reports wasNonexistent and wasAmbiguous
independently of which policy was applied, so a caller can distinguish
"resolved cleanly" from "resolved by policy" even when both return a
slot.
Zones like Asia/Kolkata (+5:30), Asia/Kathmandu (+5:45), and
Australia/Eucla (+8:45) sit off the whole UTC hour. A single local clock
hour in these zones straddles two UTC hour-of-week slots — see
half-hour-offset-zones — so a local band (a daypart, a broadcast
window) in these zones cannot be assigned a single slot the way a single
instant can; it needs the weighted-slot-set treatment covered in
Resolution and grain and
Daypart to slots. This page's conversion
(a single instant to a single slot) is unaffected: the instant always
lands in exactly one slot regardless of the zone's offset.
Quality and provenance
Every resolution should be reported with both a requested object (the
wall time, zone, and disambiguation policy as given) and an executed
object (the resolved slot, UTC instant, offset, and whether a gap or fold
was encountered) — see
Requested vs. executed time for the
full resolveLocalToCanonical shape. A resolution where
wasNonexistent || wasAmbiguous is true should always be flagged
lossless: false downstream: the requested wall time and the executed
instant do not correspond 1:1, even though a single slot was returned.
Python parity
The tested reference implementation in this KB is the TypeScript above,
backed by luxon. The Python stdlib equivalent uses zoneinfo (3.9+) and
datetime.fold:
from datetime import datetime
from zoneinfo import ZoneInfo
def local_to_utc(year, month, day, hour, minute, zone_name, fold=0):
tz = ZoneInfo(zone_name)
# fold=0 selects the earlier occurrence of an ambiguous (folded) wall
# time; fold=1 selects the later occurrence. A nonexistent (gap) wall
# time is resolved forward by .astimezone() regardless of fold.
wall = datetime(year, month, day, hour, minute, tzinfo=tz, fold=fold)
return wall.astimezone(ZoneInfo("UTC"))
# Fall-back fold, earlier occurrence (EDT, -04:00):
local_to_utc(2026, 11, 1, 1, 30, "America/New_York", fold=0)
# 2026-11-01 05:30:00+00:00
# Fall-back fold, later occurrence (EST, -05:00):
local_to_utc(2026, 11, 1, 1, 30, "America/New_York", fold=1)
# 2026-11-01 06:30:00+00:00
fold only disambiguates a repeated wall time; it has no effect on an
ordinary unambiguous timestamp and does not, by itself, resolve a
spring-forward gap — .astimezone() always normalizes a nonexistent wall
time forward to the next valid instant, matching this KB's earliest
and latest policies for the gap case (they coincide, since only one
valid instant exists).
Edge cases
Naive datetime, no zone is the most
common upstream failure: a stored 2026-03-08 02:30 with no offset and no
accompanying zone column cannot be resolved at all — and that specific
value does not even exist in America/New_York, compounding a missing
zone with a genuine gap. Reject naive timestamps at ingest rather than
assuming UTC or a default zone.
Ambiguous zone abbreviations
("IST" is India, Ireland, or Israel; "CST" is US Central, China, or
Cuba) and bare numeric offsets carry no DST rules at all, so they cannot
drive this conversion — require canonical IANA ids and reject
abbreviations on ingest.
Non-DST region inside a DST country
— Arizona observes no DST while the rest of US Mountain does — means a
country or generic-region label is ambiguous for half the year; resolve
by IANA zone (America/Phoenix vs. America/Denver), never by country.
Tzdb-vintage mismatch means two
systems on different IANA database releases can resolve the identical
input to different instants near a recently changed transition — pin and
record the tzdb version in provenance, and re-resolve affected wall times
after a database bump.
