By Ether DataRequest data sample
All sections

Timestamp to Slot

A local timestamp and an IANA zone resolve through the tz database to a UTC instant, with DST gaps and folds handled by a declared policy, then bucket deterministically into an hour-of-week slot 0-167.

stable6 min read
Source time
timestamp, iana_zone
Destination time
hour_of_week_slot
policy_dependent

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.

Half-hour and 45-minute offset zones

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.

Edge cases affecting this page

Spring-forward gap (nonexistent local time)When clocks jump forward, a local wall time never occurs (e.g. 02:30 on 2026-03-08 in America/New_York). Resolving it to a slot requires a declared policy, not a silent guess.Fall-back fold (ambiguous local time)When clocks fall back, a local wall time occurs twice (e.g. 01:30 on 2026-11-01 in America/New_York), once at the pre-transition offset and once at the post-transition offset — two different UTC instants, two different slots.Half-hour offset zonesIndia (+5:30), Sri Lanka (+5:30), central Australia ACST (+9:30), Iran (+3:30), Afghanistan (+4:30), Myanmar (+6:30), and Newfoundland (-3:30) sit a half hour off UTC. A local clock hour therefore straddles TWO UTC hour-of-week slots, so a local band (daypart, broadcast day) never maps to a whole number of slots.Naive datetime with no zoneA stored '2026-03-08 02:30' with no offset and no accompanying zone cannot be resolved to a slot — and, unluckily, that particular value does not even exist in US Eastern. Zone-less local timestamps are the most common warehouse hazard.Ambiguous zone abbreviations and bare offsets'IST' means India, Ireland, OR Israel; 'CST' means US Central, China, OR Cuba; 'EST' is used by the US and Australia. Three-letter abbreviations and bare offsets cannot identify a zone or its DST rules.Timezone-database vintage mismatchThe IANA tz database is released roughly ten times a year for political changes. Two systems on different releases resolve the same (wall time, zone) to different UTC instants near a changed transition — the temporal analog of a boundary-vintage mismatch in geo.Non-DST region inside a DST countryArizona observes no DST while the rest of US Mountain does; Queensland differs from New South Wales. A country name or a 'Mountain Time' label is ambiguous for half the year.
The 168 AxisConceptual modelDST HandlingTime systems & zonesTimezone DatabaseTime systems & zonesRequested vs. Executed TimeConceptual model