Open Is Not Available: Modeling Service Hours, DST Boundaries, and Interval Algebra
A Six-Hour Window That Holds Five Hours of Work
Ask a naive hours model how much working time sits between 00:00 and 06:00 local on 8 March 2026 in America/Edmonton, and it will confidently answer six hours. The right answer is five. Here is the REPL transcript that produced it, unedited:
>>> from datetime import datetime, timezone
>>> from zoneinfo import ZoneInfo
>>> yyc = ZoneInfo("America/Edmonton")
>>> a = datetime(2026, 3, 8, 0, 0, tzinfo=yyc)
>>> b = datetime(2026, 3, 8, 6, 0, tzinfo=yyc)
>>> b.replace(tzinfo=None) - a.replace(tzinfo=None)
datetime.timedelta(seconds=21600)
>>> b.astimezone(timezone.utc) - a.astimezone(timezone.utc)
datetime.timedelta(seconds=18000)
Twenty-one thousand six hundred seconds of wall clock. Eighteen thousand seconds of reality. An hour vanished into the spring-forward transition, and a system that promised customers six hours of throughput that morning had five hours of bay time to deliver it with. Nobody typed a wrong number anywhere. The arithmetic was performed on the wrong kind of quantity.
Let me be exact about what this article is, because that matters more than the code. KMJ Tire is a real tire and oil-change operation in Calgary, working in the America/Edmonton zone, with a violent seasonal changeover rush, walk-in traffic, and jobs whose durations vary by an order of magnitude. Those operational constraints are real and they are the reason the problem is interesting. The scheduling system described below is a design exercise. It is a model I reasoned through on paper and exercised in a Python REPL, not live production infrastructure that the business runs today. There is no deployment, no incident, no migration and no rollout behind any of this.
Every figure in this article — durations, bay counts, technician counts, grid sizes, the shape of a working day — is illustrative. I picked numbers that make the edge cases legible. Do not read them as published hours, as staffing levels, or as measured performance. The engineering claims are testable and I have tested them; the business numbers are pedagogy.
With that settled: the thesis is that "when are we open, and can this job fit" decomposes into three separate problems that most codebases fuse into one. There is a representation problem (what you persist), a resolution problem (how layered rules produce a concrete set of instants for a given date), and an algebra problem (how you combine open windows with busy windows and job durations to get a truthful answer). Fusing them is what produces the six-hour window that holds five hours.
Wall Clock Plus Zone Id, Never a Stored Offset
The first decision determines whether the rest of the system is possible. Opening hours are not instants. They are a rule that generates instants when applied to a date. "We open at seven" is a statement about a local clock face, and it stays true across the transition even though the underlying UTC instant moves by an hour twice a year.
So the storage shape is: a local wall-clock time with no offset attached, plus an IANA zone identifier that names the ruleset.
{
"rule_set_id": "hours-v7",
"location_id": "loc-yyc-main",
"iana_zone": "America/Edmonton",
"weekly": [
{ "weekday": "MON", "windows": [["07:30", "17:30"]] },
{ "weekday": "TUE", "windows": [["07:30", "17:30"]] },
{ "weekday": "WED", "windows": [["07:30", "17:30"]] },
{ "weekday": "THU", "windows": [["07:30", "17:30"]] },
{ "weekday": "FRI", "windows": [["07:30", "17:00"]] },
{ "weekday": "SAT", "windows": [["09:00", "14:00"]] },
{ "weekday": "SUN", "windows": [] }
]
}
(Illustrative rule set. The hour values are chosen to exercise the model, not published hours.)
Three properties earn their keep here. The times are strings on a 24-hour clock, so they survive JSON round-trips without a numeric timezone sneaking in. The zone is an identifier, not an offset, so tzdata updates flow through automatically. And the whole document has an id, hours-v7, which is the hook that later lets you explain why a quote issued three weeks ago said what it said.
Note what is absent: no -07:00, no -06:00, no "MST", no utc_offset_minutes: -420. Abbreviations like MST and MDT are display artifacts. They are not unique across the world, they are not stable over time, and no library should be asked to parse them back into a zone.
What Rots When You Persist an Offset
Storing 07:30-07:00 looks harmless in March and becomes wrong in April. The failure is not that the value is malformed; it is that the value was correct and then quietly stopped being correct, with no write to the row.
Walk the decay:
-
Day one. Someone records winter hours in January.
-07:00is accurate. Every query agrees. -
Second Sunday in March. The zone shifts to
-06:00. The stored row still says-07:00. Every generated instant is now an hour late in real terms, so the location appears to open at 08:30 to anyone rendering from the stored offset. - Someone notices. A patch job updates the offsets in spring. Now the November transition breaks them the other way.
-
A rule change lands. A jurisdiction changes its transition dates or abandons the change entirely. With a zone id, you consume a
tzdatarelease and you are done. With stored offsets, you own a data migration and a backfill for every future-dated record you already emitted.
That last point is a live design pressure in Alberta specifically. The province has revisited the twice-yearly change more than once, including a 2021 referendum on permanent daylight time that was defeated. Nothing has changed, and I am not going to pretend otherwise — clocks in Calgary still move. But the possibility of a legislative change is exactly the argument for storing America/Edmonton rather than -07:00. A zone id makes a future policy shift somebody else's release note. A stored offset makes it your outage.
A subtler variant of the same mistake is persisting the instant instead of the rule. Materializing "opens at 2026-11-04T14:30:00Z" for every future date bakes today's tzdata predictions into your database, and those predictions are forecasts of current legislation rather than physical constants. Materialized instants are a cache, and like every cache they need an invalidation story tied to the tzdata version that produced them.
The Local Time That Never Happens
Between 02:00:00 and 02:59:59 local on 8 March 2026, America/Edmonton has no valid local time. The clock jumps from 01:59:59 MST straight to 03:00:00 MDT. Here is the transition at second granularity, printed from UTC so nothing is hidden:
>>> from datetime import timedelta
>>> u = datetime(2026, 3, 8, 8, 59, 59, tzinfo=timezone.utc)
>>> u.astimezone(yyc).isoformat()
'2026-03-08T01:59:59-07:00'
>>> (u + timedelta(seconds=1)).astimezone(yyc).isoformat()
'2026-03-08T03:00:00-06:00'
Now the dangerous part. Python's zoneinfo does not raise when you construct a local time inside that hole. It returns something, and what it returns depends on the fold attribute in a way that surprises nearly everyone the first time:
>>> ghost = datetime(2026, 3, 8, 2, 15, tzinfo=yyc)
>>> ghost.utcoffset(), ghost.tzname()
(datetime.timedelta(days=-1, seconds=61200), 'MST')
>>> ghost.astimezone(timezone.utc).isoformat()
'2026-03-08T09:15:00+00:00'
>>> ghost.astimezone(timezone.utc).astimezone(yyc).isoformat()
'2026-03-08T03:15:00-06:00'
Read that last line again. You constructed 02:15, converted to UTC, converted straight back, and got 03:15. The round-trip is not the identity. With fold=1 the same wall time resolves an hour the other way, to 01:15 local. PEP 495 defines this precisely — in a gap, fold=0 uses the offset in effect before the transition and fold=1 uses the offset after — but "defined" and "what you wanted" are different things, and a silent 60-minute swing in either direction is not a good default for a system that quotes finish times.
Different ecosystems handle the same hole differently, which is worth knowing if your stack is polyglot. Java's java.time shifts a gap time forward by the length of the gap, so 02:15 becomes 03:15 and it is documented. The Temporal proposal in JavaScript makes you choose with a disambiguation option and defaults to 'compatible', which also shifts forward. Python hands you a value and a fold flag and trusts you to have read the PEP. None of these are wrong. They are simply different policies, and if you do not pick one deliberately, you have inherited three.
The Local Time That Happens Twice
The autumn transition is the mirror image and it is worse, because nothing looks anomalous. On 2 November 2025, the local time 01:30 occurred twice in America/Edmonton, sixty minutes apart:
>>> early = datetime(2025, 11, 2, 1, 30, tzinfo=yyc, fold=0)
>>> late = datetime(2025, 11, 2, 1, 30, tzinfo=yyc, fold=1)
>>> early.tzname(), early.astimezone(timezone.utc).isoformat()
('MDT', '2025-11-02T07:30:00+00:00')
>>> late.tzname(), late.astimezone(timezone.utc).isoformat()
('MST', '2025-11-02T08:30:00+00:00')
>>> late - early
datetime.timedelta(seconds=3600)
Both are legitimate instants. Both render identically in a local-time UI. If two rows in a work-order table say 01:30 and one carries fold=0 while the other carries fold=1, they are an hour apart and every naive equality check will insist they are the same moment. If your ORM drops the fold bit on the way to the database — and plenty of serialization paths do, because fold is not part of ISO 8601 — you have silently collapsed two distinct instants into one.
I used a 2025 date deliberately. It is historical, so it is stable in every tzdata release you are likely to have, which makes it a safe fixture. Future transitions are predictions, and pinning a test to a prediction is how a green suite turns red on a routine dependency bump.
Note also that the elapsed-time arithmetic runs the other way in autumn. The same 00:00-to-06:00 local window that shrank to five hours in March expands to seven hours on a fall-back date. A capacity planner that assumes 24-hour days is wrong twice a year in opposite directions, and the autumn error is the friendlier one only because it errs toward having more time than you promised.
Resolving an Edge on Purpose Instead of by Accident
The fix is to stop treating local-to-instant conversion as a cast and start treating it as a function with a policy argument. Classify first, then resolve according to what the value means.
from enum import Enum
from datetime import datetime, timezone
from zoneinfo import ZoneInfo
UTC = timezone.utc
class LocalKind(str, Enum):
UNIQUE = "unique"
AMBIGUOUS = "ambiguous"
NONEXISTENT = "nonexistent"
def local_kind(zone: ZoneInfo, naive: datetime) -> LocalKind:
"""Classify a naive wall-clock time against a zone's transition table."""
early = naive.replace(tzinfo=zone, fold=0)
late = naive.replace(tzinfo=zone, fold=1)
if early.utcoffset() == late.utcoffset():
return LocalKind.UNIQUE
round_trip = early.astimezone(UTC).astimezone(zone).replace(tzinfo=None)
return LocalKind.AMBIGUOUS if round_trip == naive else LocalKind.NONEXISTENT
The classifier leans on one asymmetry: in an overlap, the wall time survives a UTC round-trip; in a gap, it does not. That is a three-line test with no hard-coded transition dates, and it works for any zone the database knows about, including the half-hour and forty-five-minute offsets that break assumptions elsewhere.
Resolution then depends on the role the value plays. An opening edge and a closing edge want opposite treatment during an overlap:
class Edge(str, Enum):
OPENING = "opening"
CLOSING = "closing"
def to_instant(zone: ZoneInfo, naive: datetime, edge: Edge) -> datetime:
kind = local_kind(zone, naive)
if kind is LocalKind.UNIQUE:
return naive.replace(tzinfo=zone).astimezone(UTC)
if kind is LocalKind.AMBIGUOUS:
fold = 0 if edge is Edge.OPENING else 1
return naive.replace(tzinfo=zone, fold=fold).astimezone(UTC)
early = naive.replace(tzinfo=zone, fold=0)
late = naive.replace(tzinfo=zone, fold=1)
shifted = naive + (late.utcoffset() - early.utcoffset())
if local_kind(zone, shifted) is not LocalKind.UNIQUE:
raise ValueError(f"unresolvable wall time {naive} in {zone}")
return shifted.replace(tzinfo=zone).astimezone(UTC)
Openings take the earliest candidate and closings take the latest, which widens the window across an ambiguous hour. That is the correct bias for describing hours — you do not want to tell someone the doors are shut while the doors are open. It is the wrong bias for committing to a finish time, where you want the pessimistic edge. If the same conversion serves both purposes in your codebase, split it. The policy belongs at the boundary between the hours model and whatever is asking.
The gap branch shifts forward by the size of the jump, matching java.time and Temporal's compatible mode. A stricter variant clamps to the transition instant itself, so a 02:15 opening resolves to exactly 03:00 rather than 03:15. Both are defensible; the guard clause exists because a jurisdiction with a two-hour jump would land the shifted value in a second anomaly, and I would rather see an exception than a plausible-looking wrong answer.
Worked Boundary: Concrete Instants for a Seventy-Five Minute Job
Here is the whole failure mode in one worked example, with every instant written out. Suppose an illustrative seasonal drop-off window runs 01:30 to 04:00 local on 8 March 2026, and a job needs 75 minutes of productive time. Naive wall-clock arithmetic says: start 01:30, finish 02:45, comfortably inside the window.
>>> start = datetime(2026, 3, 8, 1, 30, tzinfo=yyc)
>>> start.astimezone(timezone.utc).isoformat()
'2026-03-08T08:30:00+00:00'
>>> naive_end = start + timedelta(minutes=120)
>>> naive_end.isoformat()
'2026-03-08T03:30:00-06:00'
>>> naive_end.astimezone(timezone.utc) - start.astimezone(timezone.utc)
datetime.timedelta(seconds=3600)
Adding two hours of timedelta to an aware datetime moved the wall clock two hours and moved the instant one hour. Python's arithmetic on aware datetimes is wall-clock arithmetic; the offset is re-derived after the addition. So a job the model believed had 120 minutes of runway actually had 60.
The correct sequence is: resolve to an instant, do the arithmetic on the instant, then render back to local for display.
>>> true_end = (start.astimezone(timezone.utc) + timedelta(minutes=120)).astimezone(yyc)
>>> true_end.isoformat()
'2026-03-08T04:30:00-06:00'
Two hours of real work that begins at 01:30 finishes at 04:30 on the clock face, not 03:30. Every customer-visible time in the system is a rendering of an instant, and every calculation is done on instants. Concretely, for this window:
| Quantity | Naive wall-clock answer | Instant-correct answer |
|---|---|---|
| Window 01:30 → 04:00 local | 150 minutes | 90 minutes |
| Latest start for 75 min of work | 02:45 local | 01:45 local (08:45Z) |
| Finish if started 01:30 | 02:45 local | 03:45 local (09:45Z) |
| Finish if started 02:00 | 03:15 local | resolution error — 02:00 does not exist |
The last row is the one that catches people. A 30-minute grid over that window generates 02:00 and 02:30 as candidate start times, and both are wall times that never occur. If your grid generator iterates in local time by adding timedelta(minutes=30) to a naive datetime, it will emit them happily. Generate candidate starts on the instant timeline and render them to local afterwards, and the ghosts simply never appear, because there is no instant that maps to them.
Layered Rules: Weekly Patterns, Overrides, and Statutory Days
Real opening hours are not one table. They are a stack of assertions made by different people at different times with different authority, and the interesting engineering is in how the stack collapses to an answer.
At minimum you need these layers:
- Base weekly pattern. The default cadence. Lowest authority, always present.
- Seasonal pattern. Extended hours through changeover season, effective over a date range. Overrides the base for the dates it covers.
- Recurring intraday closure. A midday break, a shift handover, a weekly stock-count hour. Removes time rather than replacing a day.
- Statutory holiday set. Jurisdiction-scoped, with its own effective range and provenance.
- One-off date override. "Closing at noon that Friday." Applies to a single date.
- Emergency closure. Highest authority. A furnace failure, a water main, a blizzard that keeps staff off Deerfoot. Wins over everything.
Two distinct kinds of layer live in that list, and conflating them is the usual bug. Some layers replace the day's windows outright. Others cut time out of whatever the day already had. A holiday replaces; a lunch break cuts. Modeling both as "a list of windows" and last-write-wins gives you a resolver whose behaviour depends on insertion order.
def span_for(zone: ZoneInfo, day: date, pair: tuple[str, str]) -> Span:
opens, closes = (time.fromisoformat(value) for value in pair)
return Span(
to_instant(zone, datetime.combine(day, opens), Edge.OPENING),
to_instant(zone, datetime.combine(day, closes), Edge.CLOSING),
)
def resolve_day(layers, day, zone):
"""Collapse a layer stack into concrete open spans for one calendar date."""
ranked = sorted(layers, key=lambda layer: -layer["priority"])
base = next(
(l for l in ranked if l["kind"] == "replace" and l["applies"](day)),
None,
)
if base is None:
return []
spans = [span_for(zone, day, pair) for pair in base["windows"]]
cuts = [
span_for(zone, day, pair)
for l in ranked
if l["kind"] == "cut"
and l["priority"] >= base["priority"]
and l["applies"](day)
for pair in l["windows"]
]
return subtract(spans, cuts) if cuts else merge(spans)
That helper assumes both edges land on the same calendar date, which is true for the windows above and false for an overnight window; a window whose closing time is less than its opening time needs the closing edge combined with the following day. Handle it in span_for or forbid it in validation, but do not let it default to a span with end <= start, because the constructor will reject it at a point far from the rule that caused it.
The algorithm has exactly two rules and both are stated in the code: the highest-priority applicable replace layer establishes the day's shape, and every cut layer at or above that priority removes time from it. Cuts below the winning base are suppressed. That is a real semantic choice, not an accident, and the next section shows why it bites.
Precedence Is a Product Decision, Not an Implementation Detail
Run the resolver over an illustrative stack and watch the consequence:
layers = [
{"name": "base weekly", "priority": 10, "kind": "replace",
"applies": lambda d: d.weekday() < 5, "windows": [("08:00", "17:00")]},
{"name": "seasonal", "priority": 20, "kind": "replace",
"applies": lambda d: d.weekday() < 5 and date(2026, 3, 1) <= d <= date(2026, 5, 31),
"windows": [("07:00", "18:00")]},
{"name": "midday closure", "priority": 15, "kind": "cut",
"applies": lambda d: True, "windows": [("12:00", "12:45")]},
{"name": "statutory", "priority": 40, "kind": "replace",
"applies": lambda d: d == date(2026, 4, 3), "windows": []},
]
Resolving three dates produces:
2026-03-10 [('07:00', '18:00')]
2026-04-03 []
2026-06-10 [('08:00', '12:00'), ('12:45', '17:00')]
On 10 March the midday closure disappears. Its priority of 15 sits below the seasonal layer's 20, so the rule suppresses it. In June, with the seasonal layer inactive and the base weekly layer winning at priority 10, the same closure applies normally.
Is that right? It depends entirely on what the business meant. If the midday break is a staffing reality that holds regardless of season, its priority is wrong and it should sit at 25. If the seasonal pattern exists precisely because the location runs straight through the middle of the day during changeover, then priority 15 encodes the intent correctly. There is no neutral default. What the code buys you is that the question becomes answerable by reading two integers instead of tracing insertion order through a merge function.
I would add one more thing to that stack in anything long-lived: every layer carries an author, an effective_from, and a free-text reason. When a resolved day looks wrong six months later, the fastest path to an answer is a resolver that can emit the winning layer's name alongside the spans it produced.
Alberta's Holiday List Belongs in Data, Not in an If-Statement
Statutory holidays are the layer most likely to be hard-coded, and they are the least suitable for it, because the list is a jurisdictional policy artifact rather than a fact about calendars.
Alberta's general-holiday list is not the same as Ontario's or British Columbia's. Alberta has Family Day on the third Monday in February. Heritage Day in August is an optional holiday here rather than a general one, which means different employers treat it differently and a single boolean cannot represent the situation. The 30 September observance is a federal holiday and its provincial treatment differs across the country. Several dates are computed rather than fixed — Good Friday moves with the ecclesiastical calendar, and 3 April 2026 falls on it, which is why the resolver above returned an empty span list for that date.
The design consequences:
- Holidays are rows, keyed by jurisdiction, with an effective date range and a source citation. Not a set literal in a module.
- A holiday row asserts that a date is a holiday, not what the hours are. Whether the location closes fully, opens short, or ignores the date entirely is a separate policy join. Plenty of service businesses work statutory days.
- Computed dates get materialized into rows by a job with a horizon, and the materialization records which algorithm produced it.
- "Observed on" is its own column. When a fixed-date holiday lands on a weekend, the observed day may shift, and payroll, hours and government offices do not always shift it the same way.
None of this is exotic. It is the same discipline you would apply to tax rates: the values change by jurisdiction and over time, so they live in versioned data with provenance, and the code reads them.
Half-Open Intervals and the Boundary Overlap Bug
Every interval in the system is half-open: [start, end). The start instant is included, the end instant is not. This is not stylistic.
Consider two jobs in one bay, the first ending at 10:00 and the second beginning at 10:00. With closed intervals [start, end], both contain the instant 10:00:00.000, so an overlap check reports a conflict on a perfectly legal back-to-back sequence. Loosen the check to fix that, and now genuinely overlapping intervals slip through at the boundary. Teams oscillate between these two bugs for years, usually by sprinkling + 1 second into comparisons, which merely relocates the problem to a finer resolution and makes it depend on the precision of the clock and the column type.
Half-open intervals dissolve it. The overlap predicate becomes a strict two-way inequality with no epsilon anywhere:
@dataclass(frozen=True, order=True)
class Span:
start: datetime
end: datetime
def __post_init__(self) -> None:
if self.start.tzinfo is None or self.end.tzinfo is None:
raise ValueError("Span requires aware datetimes")
if self.end <= self.start:
raise ValueError(f"empty or inverted span: {self.start} .. {self.end}")
@property
def duration(self) -> timedelta:
return self.end - self.start
def overlaps(self, other: "Span") -> bool:
return self.start < other.end and other.start < self.end
def contains(self, moment: datetime) -> bool:
return self.start <= moment < self.end
Two consequences fall out immediately. Adjacent spans do not overlap, so [08:00, 10:00) and [10:00, 11:00) coexist. And the durations of a partition sum exactly to the whole, with no double-counted boundary instants — which is what makes utilization arithmetic add up instead of drifting by a second per split.
The constructor rejects zero-length spans on purpose. A zero-length span is almost always a symptom: a date override that collapsed, a subtraction that consumed a window entirely, a parse that produced 17:00 for both edges. Letting it exist means it propagates into a merge and produces output that is hard to reason about. Rejecting it at the boundary turns a silent data problem into a loud one.
Merging, Subtracting, Intersecting
Three operations cover essentially every availability question. Merge normalizes an unordered pile of spans into a canonical disjoint set. Subtract removes busy time from open time. Intersect finds where two independently-constrained resources are simultaneously free.
def merge(spans: list[Span]) -> list[Span]:
out: list[Span] = []
for span in sorted(spans, key=lambda s: (s.start, s.end)):
if out and span.start <= out[-1].end:
if span.end > out[-1].end:
out[-1] = Span(out[-1].start, span.end)
else:
out.append(span)
return out
def subtract(base: list[Span], cuts: list[Span]) -> list[Span]:
result: list[Span] = []
for span in merge(base):
pieces = [span]
for cut in merge(cuts):
nxt: list[Span] = []
for piece in pieces:
if not piece.overlaps(cut):
nxt.append(piece)
continue
if cut.start > piece.start:
nxt.append(Span(piece.start, cut.start))
if cut.end < piece.end:
nxt.append(Span(cut.end, piece.end))
pieces = nxt
result.extend(pieces)
return result
The <= in merge is deliberate and it is the opposite of the < in overlaps. Adjacent spans do not overlap, but they should coalesce: [08:00, 10:00) and [10:00, 11:00) merge into [08:00, 11:00) because the union is contiguous. Getting these two comparisons backwards produces a system that either reports phantom conflicts or emits a fragmented free list where every 15-minute increment is its own span.
subtract is where the half-open discipline pays off visibly. When a cut lands strictly inside a span you get two pieces and the boundary instants land in exactly one of them. When a cut consumes a span entirely, both if branches fail and the piece vanishes without producing an empty Span that the constructor would reject.
Intersection composes the same primitives:
def intersect(left: list[Span], right: list[Span]) -> list[Span]:
out = []
for a in merge(left):
for b in merge(right):
lo, hi = max(a.start, b.start), min(a.end, b.end)
if lo < hi:
out.append(Span(lo, hi))
return merge(out)
Quadratic, and completely fine at the scale of one location for one day. If you ever run it across a year of half-hour slots for a fleet of locations, a sweep-line over sorted boundaries gets you to linearithmic, but reach for that when a profiler asks you to and not before.
Work Duration Versus Elapsed Duration
A job has at least four durations and they are all different numbers.
Work duration is hands-on time. Buffer is the pull-in, paperwork, torque check and pull-out around it. Calendar duration is how much wall time the job occupies, which exceeds work plus buffer whenever the job pauses. Customer-perceived duration is arrival to keys-back, which includes the wait before anyone touches the vehicle.
A model that stores one number called duration will eventually be asked to be all four, and the resulting bugs hide well because every individual answer looks plausible.
Concretely: a seasonal changeover with a rebalance is a different shape of job from a puncture repair, which is a different shape again from an oil change. The seasonal changeover work involves four wheel positions with torque and pressure steps; a balance adds spin-up time per wheel that scales with how far out the assembly is; a puncture repair has an inspection step that can terminate the job early with a "not repairable" verdict. These are the tire and oil-change services the location performs, and each one has a different variance profile. A scheduler that treats them as interchangeable 60-minute blocks will be wrong in a different direction for each.
The modeling advice is unglamorous: store work_minutes and buffer_minutes as separate fields, derive calendar duration from the resolved open spans at query time, and never persist calendar duration as an attribute of the service type, because it is a function of when the job runs and not of what the job is.
The Last Viable Start Is Not Closing Time
This is the single most common off-by-a-job bug in service scheduling, and it survives in production systems for years because it only manifests near the end of the day.
If the location closes at 17:00 and a job needs 75 minutes of work plus 10 minutes of buffer, the last instant at which that job can start is 15:35. Not 17:00, and not 15:45 either — the buffer counts.
def latest_start(window: Span, work: timedelta, buffer: timedelta):
cutoff = window.end - work - buffer
return cutoff if cutoff >= window.start else None
The None return matters as much as the arithmetic. A window shorter than the job produces no viable start at all, and that is a normal condition rather than an error. A 30-minute gap between two committed jobs is real open time and it is unusable for a 75-minute job. Systems that return the window's start anyway, on the theory that "the location is open then," produce reservations that cannot finish.
Running the primitives over an illustrative day makes it concrete. Take a resolved day of [07:00, 18:00) with a midday cut of [12:00, 12:45), and a job of 75 minutes of work plus 10 minutes of buffer on a 15-minute grid:
free windows : 07:00–12:00, 12:45–18:00
latest start (am) : 10:35
latest start (pm) : 16:35
grid starts : 07:00, 07:15, ... 10:30 | 12:45, 13:00, ... 16:30
The grid truncates below the true cutoff, because 10:35 is not on a 15-minute boundary. That is the correct behaviour for an offered set of start times, but be aware that you are discarding five usable minutes in each window. Whether that matters depends on how tight capacity is; during a changeover rush it might, and a scheduler that offers off-grid start times when the grid would otherwise return nothing is a legitimate refinement.
Jobs That May Pause and Jobs That May Not
Some work can straddle a midday closure. A vehicle sitting on a hoist while staff are away is not necessarily a problem. Other work cannot: a job holding a shared resource, or one with a chemical or thermal step that has to run continuously, has to finish before the pause begins.
Two predicates, therefore, not one. Non-pausable jobs must fit entirely inside a single free span. Pausable jobs may consume time across several spans, and their calendar end is computed by walking productive time forward while skipping pauses:
def finish_by(start: datetime, work: timedelta, pauses: list[Span]) -> datetime:
"""Advance `work` of productive time from `start`, stepping over pauses."""
remaining, cursor = work, start
for pause in merge(pauses):
if pause.end <= cursor:
continue
if pause.start >= cursor + remaining:
break
productive = pause.start - cursor
if productive > timedelta(0):
remaining -= productive
cursor = max(cursor, pause.end)
return cursor + remaining
Started at 11:30 with 75 minutes of work and a [12:00, 12:45) pause, this returns 13:30 — a calendar span of two hours for 75 minutes of work. That gap between 75 and 120 is exactly the number a customer-facing estimate needs and a technician-facing estimate does not.
The pausable flag belongs on the service type, and its default should be False. Wrongly marking a job pausable produces a promise the location cannot keep; wrongly marking it non-pausable costs you some scheduling density. Those are not symmetric costs.
Capacity as Constrained Resources: Bays, Technicians, a Hoist
Here is where "are we open" stops being sufficient. The location can be open, have an empty free window, and still be unable to take the job, because open hours and capacity are different predicates over different objects.
Model each physical or human constraint — every bay, every technician, the hoist — as its own resource with its own busy list:
{
"resources": [
{"id": "bay-1", "kind": "bay", "capabilities": ["changeover", "repair", "oil"]},
{"id": "bay-2", "kind": "bay", "capabilities": ["changeover", "repair"]},
{"id": "hoist-1", "kind": "hoist", "capabilities": ["oil"]},
{"id": "tech-a", "kind": "tech", "capabilities": ["changeover", "repair", "oil"]},
{"id": "tech-b", "kind": "tech", "capabilities": ["changeover"]}
],
"requirements": {
"changeover": {"bay": 1, "tech": 1},
"oil": {"bay": 1, "tech": 1, "hoist": 1}
}
}
(Illustrative resource inventory — invented for the model, not a description of any real floor plan or staffing level.)
Availability for a job type is then the union, over every valid assignment of resources, of the intersection of those resources' free time with the open windows:
def joint_windows(open_spans, busy_by_resource, need):
options = [
[subtract(open_spans, busy_by_resource.get(rid, [])) for rid in ids]
for ids in need.values()
]
found: list[Span] = []
def walk(idx, acc):
if idx == len(options):
found.extend(acc)
return
for choice in options[idx]:
nxt = intersect(acc, choice) if acc else choice
if nxt:
walk(idx + 1, nxt)
walk(0, [])
return merge(found)
With an open day of [07:00, 18:00), bay-1 busy 09:00–11:00, bay-2 busy 07:00–10:00, and tech-a busy 07:00–09:30, the joint availability is [10:00, 18:00). Neither bay alone explains that answer and neither does the technician. It falls out of the combination, which is the whole point.
Three refinements this sketch omits and a production model would want. Resources have their own hours — a part-timer's shift is a smaller window than the day's. Some resources are shared across concurrent jobs rather than exclusive to one. And the assignment search is a matching problem that grows unpleasant with resource count, so past a certain size you stop enumerating and start solving. Even the naive version, though, gets you the crucial distinction: the difference between "open" and "available" is a join, and code that skips the join over-promises.
Real constraints leak in here too, and they are the reason a model like this earns its complexity. Work that happens at a customer's site through a mobile service route consumes a technician without consuming a bay, and adds travel time that belongs in the buffer. Fleet work arrives in batches that need several resources at once and behaves nothing like a single-vehicle reservation. The geographic areas a location serves determine how much travel buffer a mobile job needs. Any of these can break a model that assumed one job means one bay.
Shape of the Availability Response
The API is where all this either stays honest or leaks. The rule I would enforce: the server does the zone math, always, and the client is never asked to reconstruct an instant from a local string.
{
"query": {
"location_id": "loc-yyc-main",
"service_type": "changeover",
"date_local": "2026-03-08",
"rule_set_id": "hours-v7",
"tzdata_version": "2026c"
},
"zone": "America/Edmonton",
"open_spans": [
{"start": "2026-03-08T14:00:00Z", "end": "2026-03-08T20:00:00Z",
"local_start": "07:00", "local_end": "13:00"}
],
"startable": [
{"instant": "2026-03-08T14:00:00Z", "local": "07:00", "offset": "-07:00"},
{"instant": "2026-03-08T14:30:00Z", "local": "07:30", "offset": "-07:00"}
],
"notes": [
{"code": "dst_gap_skipped", "detail": "local 02:00–03:00 does not exist on this date"}
]
}
(Illustrative payload. Values are constructed to show the shape.)
Four properties are doing work. Every time appears as an unambiguous instant and as a pre-rendered local string, so the client displays a string and reasons with an instant, and never converts. The offset field is present for display and debugging but is never the source of truth. The rule_set_id and tzdata_version make the response reproducible. And notes gives the resolver somewhere to explain itself, which is the difference between a support conversation that takes two minutes and one that takes a week.
Return open spans and startable instants as separate fields. They answer different questions — one is "are the doors open," the other is "can this specific job begin here" — and a client that has both can render a sensible UI without re-deriving the second from the first and getting the buffer wrong.
Versioning the Rule Set So an Old Quote Can Be Explained
Hours change. Seasonal patterns turn on and off, statutory rows get corrected, a one-off override gets added and later removed. If the rule set is mutable in place, then every quote you issued before the change becomes unexplainable, because the only way to justify it is to reconstruct rules that no longer exist.
Make the rule set immutable and content-addressed. Changing hours writes a new version and flips a pointer. A quote records the rule_set_id and the tzdata_version it resolved against. Then "why did the system offer 16:45 on that Thursday" becomes a query rather than an argument.
The tzdata version deserves its own field. Resolve a date six months out, let the zone database change that transition, and your stored instant will disagree with a freshly-resolved one without either being a bug. Recording the version turns that from a mystery into a diff — and in containerized deployments it also explains why two replicas built from different base images resolved the same future date differently.
An Edge-Case Table Worth Pinning to the Wall
These are the inputs I would put in a fixture file before writing a line of resolver code.
| Case | Input | Expected behaviour |
|---|---|---|
| Spring gap, opening edge | wall 02:15 on a gap date | shift forward, never silently pick an offset |
| Spring gap, grid generation | 30-min grid across 01:30–04:00 | 02:00 and 02:30 never emitted |
| Fall overlap, opening edge | wall 01:30, ambiguous | earliest instant (fold=0) |
| Fall overlap, closing edge | wall 01:30, ambiguous | latest instant (fold=1) |
| Overnight window | 22:00 → 02:00 next day | one span crossing midnight, not two |
| Zero-length window | 17:00 → 17:00 | rejected at construction |
| Inverted window | 17:00 → 08:00 same day | rejected, or explicitly overnight |
| Cut consumes window | closure covers the whole day | empty free list, not an empty span |
| Adjacent jobs | 10:00 end, 10:00 start | no conflict |
| Job longer than window | 90 min into a 45 min gap | no viable start |
| Holiday plus override | both apply to one date | highest priority wins, deterministically |
| Leap day | 29 Feb on a weekly rule | resolves like any other date |
| Two cuts overlapping | 12:00–12:45 and 12:30–13:00 | merged before subtraction |
Most of these are one assertion each. All of them are cheap to write and expensive to discover in production.
Property Tests Across a Transition
Example-based tests catch the cases you thought of. Properties catch the ones you did not. Four hold for any correct implementation of this model, in any zone.
Round-trip stability. For any instant t, rendering to local and resolving back yields t — except in an overlap, where the resolved value must be one of the two candidates and the choice must match the declared edge policy.
Monotonicity. If t1 < t2 as instants, then resolving both through the same pipeline preserves the ordering. This is the property that catches offset-based comparison bugs, because a naive implementation that compares wall-clock strings will invert the order across a fall-back hour.
Duration conservation. Subtracting a set of cuts from a set of spans and summing the durations of the result equals the total base duration minus the duration of the intersection of base and cuts. Off-by-one boundary handling breaks this immediately.
Idempotence. merge(merge(xs)) == merge(xs), and subtracting the same cut twice equals subtracting it once.
A generator that concentrates on the interesting region is worth more than one that samples a decade uniformly:
from hypothesis import given, strategies as st
TRANSITION_DATES = [date(2025, 11, 2), date(2026, 3, 8), date(2026, 11, 1)]
near_transition = st.builds(
lambda d, m: datetime.combine(d, time(0, 0)) + timedelta(minutes=m),
st.sampled_from(TRANSITION_DATES),
st.integers(min_value=0, max_value=6 * 60),
)
@given(near_transition)
def test_merge_is_idempotent(anchor):
spans = build_day_spans(anchor)
assert merge(merge(spans)) == merge(spans)
Uniform sampling across a year hits a DST boundary in roughly one draw in four thousand at minute granularity. Sampling the six hours around known transitions hits it constantly. Bias the generator toward the cliff.
Golden Files, Pinned tzdata, and Frozen Clocks
Three testing disciplines, each catching a different class of failure.
Golden files. For a fixed rule set and a fixed list of dates, serialize the resolved spans to a checked-in file, so any resolver change that alters output arrives as a reviewable diff. Keep one per zone, and include at least one zone that is not your primary — something on a southern-hemisphere schedule, or with a half-hour offset, catches assumptions that America/Edmonton alone never exercises.
Pinned tzdata. Add the tzdata package as an explicit dependency and pin it, or your suite's correctness depends on whatever zone database the base image shipped, and a routine rebuild changes behaviour with no commit to blame. Assert the version in a test so an unintended bump fails loudly.
Frozen clocks. Every function here should take "now" as a parameter rather than reading it. Once now is an argument, "what does availability return at 16:50 on a Friday in changeover season" is a unit test rather than a stakeout. Where an ambient clock is unavoidable, freeze it at an interesting instant — one inside an ambiguous hour — not at a comfortable Tuesday afternoon.
One more habit: write tests that assert the instant, not the local rendering. A test that asserts '07:00' passes in both the correct and incorrect implementations across a transition. A test that asserts '2026-03-08T14:00:00Z' does not.
Failure Modes, Named
Naming a bug class makes it easier to spot in review. These are the ones I would put on a checklist.
- Offset fossilization. A stored offset that was correct at write time and silently wrong after the next transition.
- Ghost slot emission. Candidate start times generated by adding to naive local datetimes, producing wall times inside a gap.
-
Fold amnesia. Serialization that discards
fold, collapsing two distinct instants during an overlap into one indistinguishable value. -
Wall-clock arithmetic. Adding
timedeltato an aware datetime and treating the result as elapsed time, as in the two-hour addition that advanced the instant by one hour. - Boundary conflict. Closed intervals reporting a conflict between a job ending at 10:00 and one starting at 10:00.
- Closing-time promise. Offering a start time that equals the last open instant minus nothing, so the job cannot finish before the doors shut.
- Layer order dependence. A resolver whose output depends on the order rules were inserted rather than a declared precedence.
- Holiday hard-coding. A jurisdiction's statutory list embedded in code, so a correction requires a deploy.
- Availability conflated with openness. Returning open windows as though they were bookable capacity, ignoring resource constraints entirely.
Every one of these produces answers that look right in a screenshot. That is what makes them expensive.
Where the Abstraction Meets the Bay Floor
The reason this domain rewards careful modeling is that the physical operation it describes is genuinely irregular, and the irregularity is not noise you can average away.
Demand is not uniform. Changeover season concentrates an enormous share of the year's winter tire work into a few weeks, driven by weather that does not consult a calendar. A model whose capacity assumptions are calibrated on a quiet July week will be catastrophically wrong in late October, and it will be wrong in the direction of over-promising. That is the direction that costs trust.
Job durations are not uniform either. A four-wheel changeover on a passenger vehicle and a wheel-off inspection on a light truck consume different amounts of the same resources. Whether a customer runs all-weather tires year-round or swaps twice annually changes how often they appear in the schedule at all. Understanding the basics of what a tire is telling you is what lets a service writer estimate a duration accurately, and an estimate is the input the whole model depends on. Garbage duration estimates produce a beautifully correct interval algebra over meaningless numbers.
Walk-ins break the closed-world assumption outright. Any model that treats the schedule as fully known is describing a system that does not exist, which is why real deployments reserve capacity rather than filling it — and why the online reservation form and the counter queue are two producers writing to one resource pool. Sizing that reservation is a forecasting problem sitting on top of the availability model, and it needs the availability model to be correct before it can be tuned at all.
There is a documentation angle too. A service writer who knows why the system refused a 16:45 start can explain it in one sentence instead of apologizing for software. A technician who can explain a load index or why a commercial job needs different resourcing is doing exactly what the notes field does in the API response: turning a refusal into a reason.
What I Would Build First
If I were starting this model tomorrow, the order would be: Span with half-open semantics and a validating constructor; merge, subtract and intersect with property tests; local_kind and a policy-carrying to_instant; then the layered resolver; then resources; then the API surface. Availability queries come last, because they are a composition of everything below them and they are the hardest thing to debug if any layer beneath is subtly wrong.
The single highest-leverage decision is the first one. Store wall-clock times with an IANA zone identifier, never an offset, and treat local-to-instant conversion as a function that takes a policy argument and can fail. Systems built that way have DST bugs that are annoying. Systems built the other way have DST bugs that are archaeological.
And keep the six-hour window that holds five hours somewhere visible. It is a two-line REPL session that disproves the intuition the entire naive design rests on, and it costs nothing to re-run whenever someone proposes storing an offset "just for the cache."
Top comments (0)