Shipmind Labs

A shift pattern is wall-clock time, a shift is an instant

· 8 min read

A weekly shift pattern and a rostered shift look like one fact in two shapes: nine on weekdays, and nine on the ninth. They are different types. A pattern is a wall-clock time that means nothing until a timezone is named, and a shift is a point on the timeline. A planner that keeps one in the shape of the other is right for most of the year and wrong on the two days the clocks change, which is how a courier ends up at a depot an hour before anybody expects them.

We have built role-specific apps for couriers, warehouse staff and store operations, and the timezone question is never the feature anyone asks for. It is the thing that decides whether the feature survives March. What follows is the set of rules we settled on in shift-planner, our own library for courier shift planning: https://github.com/shipmindlabs/shift-planner

Two constructors that refuse opposite things#

A ShiftPattern carries at, a plain datetime.time, and its constructor rejects one that knows its own zone:

python
from datetime import time, timedelta
from zoneinfo import ZoneInfo

from shift_planner import ShiftPattern

ShiftPattern(
    "anna",
    "depot-north",
    time(9, tzinfo=ZoneInfo("America/Chicago")),
    timedelta(hours=8),
)
# ValueError: at must be a plain time; the plan carries the timezone

A Shift carries start, a datetime, and its constructor rejects one that does not:

python
if self.start.tzinfo is None or self.start.utcoffset() is None:
    raise ValueError("start must be timezone-aware")

Two guards, pointing in opposite directions. The repeating thing is not allowed to know its zone, and the materialised thing is not allowed to be without one. The comment above that second check is why we spend a constructor on it: naive and aware datetimes cannot be compared, so one naive start turns every overlap check in Roster into an opaque TypeError raised far away from the mistake. Job.at and both ends of a CooldownLock get the same treatment. Everything downstream of the pattern (rosters, dispatch, cooldowns) sees nothing but instants, which leaves exactly one place in the library where wall-clock time becomes a moment.

The zone lives on the plan, not on the pattern#

python
from datetime import date, time, timedelta
from zoneinfo import ZoneInfo

from shift_planner import Horizon, Plan, ShiftPattern

weekday_morning = ShiftPattern(
    "anna",
    "depot-north",
    time(9),
    timedelta(hours=8),
    weekdays={0, 1, 2, 3, 4},
)

plan = Plan(
    horizon=Horizon(date(2026, 3, 2), days=14),
    patterns=(weekday_morning,),
    zone=ZoneInfo("America/Chicago"),
    rest=timedelta(hours=8),
)

roster = plan.roster()

The Plan owns the horizon, the holidays, the per-worker limits, the rest rule and the zone. A pattern is then a small reusable statement about the week that you can read in whichever zone the plan names, and two patterns in one plan cannot disagree about when a day begins, because there is one answer to that question per plan. Materialisation happens one day at a time, in ShiftPattern.shift_on:

python
def shift_on(self, day: date, zone: tzinfo = timezone.utc) -> Shift:
    return Shift(
        worker=self.worker,
        location=self.location,
        start=datetime.combine(day, self.at, tzinfo=zone),
        length=self.length,
        capacity=self.capacity,
        window=self.window,
    )

datetime.combine(day, self.at, tzinfo=zone) is the entire conversion. The local date and the local time are put together first, and the offset is resolved afterwards, for that date. The bug this avoids is the first thing we look for in rostering code that already exists: a week unrolled by adding timedelta(days=7) to an instant. That addition keeps the offset it started with, so after a transition every shift in the series is an hour off in local terms. Nine quietly becomes eight, and nobody notices until the first courier is early.

A two-hour shift that is one hour of duty#

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

from shift_planner import Shift

chicago = ZoneInfo("America/Chicago")

night = Shift(
    worker="anna",
    location="depot-north",
    start=datetime(2026, 3, 8, 1, 30, tzinfo=chicago),
    length=timedelta(hours=2),
)

night.end                             # 2026-03-08 03:30:00-05:00
night.end - night.start               # 1:00:00
night.start.astimezone(timezone.utc)  # 2026-03-08 07:30:00+00:00
night.end.astimezone(timezone.utc)    # 2026-03-08 08:30:00+00:00

Shift.end is start + length. Adding a timedelta to an aware datetime is wall-clock arithmetic: the fields move, the tzinfo stays, no offset adjustment is made. So end reads half past three, exactly as a planner would write it on paper. Subtracting two aware datetimes is not wall-clock arithmetic, it is measured on the timeline, so the same shift reports one hour. Both numbers are correct answers to different questions, and the library does not have to pick one, because covers and overlaps compare instants and only instants.

The consequence is worth saying out loud: across the spring gap, a shift planned as two hours is one hour of real duty, and the honest thing is to let it be. Stretching the length to preserve the paper duration would push the end of the shift past the start of the next one and break the roster's overlap rule for a reason no dispatcher could explain. tests/test_dst.py pins this down, so it is a property of the library rather than a surprise. The autumn case falls out of the same arithmetic in the other direction: on the day the clocks go back, a wall-clock span that crosses the repeated hour is three hours of duty.

One more detail the half-open interval handles for free. Half past two does not exist on that Sunday in that zone, and Python resolves it with the offset in force before the transition, which lands on exactly the instant the shift ends. covers is start <= moment < end, so the moment that does not exist is not covered, and a job stamped with it goes to a courier whose shift genuinely contains it.

Day boundaries are local too#

Plan.claims decides which shifts of an existing roster belong to the plan, and it draws day boundaries by converting back: shift.start.astimezone(plan.zone).date(). That matters for the shifts near midnight. A late shift stored as an instant has a different UTC date from its local one, and a plan that compared UTC dates would fail to recognise its own work there. Plan.applied_to keeps every shift the plan does not claim and appends plan.shifts(), so redrawing a plan over a roster that already contains it is a no-op: plan.applied_to(roster) == roster. Idempotent redrawing is what makes it safe to regenerate a published schedule after a holiday is added, and it stays idempotent only while the day a shift belongs to is computed in the plan's zone.

The rest rule finds the clock change for you#

The rest check is shift.start - previous.end < self.rest, a subtraction of two aware datetimes, so it measures real rest and not paper rest. Which means the clock change can refuse a schedule that is fine every other week:

python
from datetime import date, time, timedelta
from zoneinfo import ZoneInfo

from shift_planner import Horizon, Plan, ShiftPattern

plan = Plan(
    horizon=Horizon(date(2026, 3, 7), days=2),
    patterns=(
        ShiftPattern("anna", "depot-north", time(16), timedelta(hours=8), weekdays={5}),
        ShiftPattern("anna", "depot-north", time(8), timedelta(hours=4), weekdays={6}),
    ),
    zone=ZoneInfo("America/Chicago"),
    rest=timedelta(hours=8),
)

plan.roster()  # InsufficientRestError

Sixteen hundred plus eight hours ends at midnight, and eight in the morning is eight hours later on the page but seven hours later in fact. The plan refuses to draw, and the error names both shifts and the rule. This is the behaviour we want. The resolution is a scheduling decision (move the Sunday shift to nine, or shorten the Saturday night) and it belongs to whoever owns the roster, not to a comparison loosened until it stops complaining. A planner that measured rest in wall-clock hours would give that worker seven hours of sleep and report eight.

What it costs to run#

No source file in the library calls datetime.now(). Every check, covers, may_start_at, holds_at, candidates, takes the moment as an argument. The cost is that callers thread a clock through their own code, and the return is that all of the above is a unit test. The days the clocks change are two dates in a test file rather than two mornings a year when dispatch behaves strangely and nobody can reproduce it. Everything is frozen as well, so Roster.add and CooldownBoard.refuse return new objects and a refused draw leaves the published roster untouched.

The ActivityWindow needs no timezone logic at all, and that is probably the best test of whether the split sits in the right place. It is stored as two offsets from the start (by default a quarter of an hour early and an hour late) and resolved against an instant that has already absorbed the zone. Only the pattern-to-shift step has anything to say about zones, and everything after it does arithmetic on the timeline.

What the design does ask of whoever writes the patterns is one decision: which zone a plan is read in. For couriers that is the depot's zone, and it has to be stored with the plan rather than inferred from a server's locale or a browser. That is the one piece of configuration we would not let a default stand in for.

The discipline fits in two sentences. A pattern is wall-clock time and must not carry a zone, and a shift is an instant and must. Honest duty across the gap, idempotent redrawing and a rest rule that cannot be fooled all follow from refusing to let those two types be the same type.

Was this useful?

Building something similar?

or email hello@shipmindlabs.com