CodeOrigin All articles
Software Engineering

Two O’Clock, Twice: A First-Principles Design for Civil-Time Scheduling

CodeOrigin
Two O’Clock, Twice: A First-Principles Design for Civil-Time Scheduling

A reminder set for 9:00 a.m. sounds unambiguous until the user moves from Denver to Boston, or until the clock reaches the first Sunday in November. Schedulers often fail not because the cron expression is wrong, but because the system never decided what kind of time it was storing. Civil time is a set of rules layered over a continuous timeline, and software that ignores the layers will eventually schedule an event at the wrong moment—or twice.

This is a first-principles problem before it is a library problem. CodeOrigin’s approach is to name the concepts precisely, then let the code follow.

Three Nouns Before Any Code

The first noun is the instant. An instant is a point on the timeline, independent of geography. Internally, it is best represented as UTC or as a count from an epoch. "The meeting starts at 14:00 UTC on 10 November 2026" identifies one moment everywhere.

The second noun is the offset. An offset is a relationship between an instant and a local clock, such as UTC−05:00. Offsets are not zones. A zone such as America/New_York has a history of offset changes, including daylight-saving transitions. Storing -05:00 captures one relationship at one moment, not the rule that produced it.

The third noun is the local wall-clock time. This is what a user sees: "1:30 a.m. on November 1." It is incomplete without a zone. Worse, in some zones it is incomplete even with one, because the local reading can occur twice or not at all.

Why Strings and Simple Arithmetic Break

A string like 2026-11-01T01:30:00 looks like a timestamp, but it carries no zone. Adding 24 hours to a local time is also unsafe: the interval between 9:00 a.m. one day and 9:00 a.m. the next may be 23, 24, or 25 hours depending on the zone and date. Treating dates as scalar arithmetic assumes that civil time is uniform. It is not.

A workable rule is to separate storage from recurrence. One-off events should be stored as instants. Recurring events should be stored as a local rule plus an IANA zone identifier, such as "every weekday at 09:00 in America/Chicago." The instant for each occurrence is computed when needed, using the zone rules in force on that date.

Classifying a Local Time

Before resolving a wall-clock reading, classify it. In Python, the zoneinfo module exposes the fold attribute, which distinguishes the first and second occurrence of an ambiguous local time.

from datetime import datetime, timedelta, timezone
from zoneinfo import ZoneInfo

def classify_local(naive, zone):
    tz = ZoneInfo(zone)
    early = naive.replace(tzinfo=tz, fold=0)
    late = naive.replace(tzinfo=tz, fold=1)
    if early.utcoffset() == late.utcoffset():
        return 'unique', early
    roundtrip = early.astimezone(timezone.utc).astimezone(tz).replace(tzinfo=None)
    if roundtrip != naive:
        return 'gap', None
    return 'fold', (early, late)

A gap occurs when the clocks spring forward and a local reading is skipped. A fold occurs when the clocks fall back and a local reading is repeated. On November 1, 2026, for example, New York moves from daylight time to standard time. The reading 1:30 a.m. occurs once at UTC−04:00 and again at UTC−05:00. Both are real instants; the system must choose.

Resolving Gaps and Folds Explicitly

The scheduler should never guess silently. It should accept a policy and record the decision.

def resolve_local(naive, zone, fold_policy='earlier', gap_policy='shift_forward'):
    kind, value = classify_local(naive, zone)
    if kind == 'unique':
        return value
    if kind == 'fold':
        return value[0] if fold_policy == 'earlier' else value[1]
    if gap_policy == 'skip':
        return None
    candidate = naive
    for _ in range(180):
        candidate += timedelta(minutes=1)
        kind2, value2 = classify_local(candidate, zone)
        if kind2 == 'unique':
            return value2
    raise ValueError('no valid local time found')

For recurring schedules, apply this resolver to each occurrence rather than adding fixed intervals. A daily 9:00 a.m. reminder should remain at 9:00 a.m. local time across a transition; a reminder that must fire 24 hours after the previous one should be stored as an instant duration instead. Those are different intents and deserve different data models.

The Interface Must Say Which Hour

Backend correctness is not enough. Users need to see the resolution. If someone selects 1:30 a.m. on a fall-back date, the interface should show both choices—for example, "1:30 a.m. EDT" and "1:30 a.m. EST"—and ask which one is intended. If someone selects a nonexistent time in the spring gap, the interface should state that the time does not exist and offer the next valid time, such as 3:00 a.m. EDT.

The API should return the chosen instant together with metadata: the zone, the offset, and whether the local time was unique, shifted from a gap, or chosen from a fold. That record helps support teams explain why a notification arrived at what looked like the wrong hour.

A Short Checklist

Store one-off events as instants. Store recurrences as local rules plus IANA zone identifiers, not fixed offsets. Classify every user-supplied local time before scheduling it. Make gap and fold policies explicit, testable, and visible in the interface. Use zone names rather than abbreviations, because abbreviations are ambiguous. Finally, add tests around the actual transition dates for the zones you serve.

Civil time is not a nuisance to be patched around; it is the contract between software and the calendar on the wall. Once the contract is explicit, the scheduler stops being a source of surprises and becomes what it should have been all along: a precise answer to the question, "When does this happen?"

All Articles

Related Articles

The Build That Always Compiles the Same: Tracing Nondeterminism to Its Sources

The Build That Always Compiles the Same: Tracing Nondeterminism to Its Sources

When Histories Diverge: Building a Three-Way Merge From the Diff Up

When Histories Diverge: Building a Three-Way Merge From the Diff Up

What Your package.json Is Actually Carrying: An Afternoon Audit for Overgrown JavaScript Trees

What Your package.json Is Actually Carrying: An Afternoon Audit for Overgrown JavaScript Trees