Brief
A meeting scheduled for 09:00 fires at 10:00. A daily job runs twice one night in November and not at all one night in March. A calendar invite made in January is an hour off by June because a government changed its mind. Every one of these is the same bug — treating time as one type when it is three — and you are going to build the small scheduling core where that bug cannot survive: a parser, a type model, an event store, and a recurrence expander, each with the failure mode turned into a test.
Instructions
RFC 3339 parser — string → (civil components, offset)
Hand-parse the date-time production of RFC 3339 — full-date "T" full-time, e.g.
2026-08-08T10:00:00+02:00 — without your language's datetime parser:
- Accept:
Zand±hh:mmoffsets, optional fractional seconds, lowercaset/z(the grammar allows them). - Validate the calendar: month 01–12, day valid for month and leap year, hour ≤ 23, minute ≤ 59, second ≤ 60 (60 only as a leap second: parse it, then document your policy for it).
- Reject with position and reason: bad separators, out-of-range fields, missing offset.
Then demonstrate the round-trip loss: parse 2026-08-08T10:00:00+02:00, serialize
it back, and show the result pinpoints the same instant but cannot tell you whether the
event lives in Europe/Paris or Africa/Cairo — an offset is a moment's fact, not a
rule set.
The type model — three kinds of time, two kinds of "later"
Build distinct types with no implicit conversion between them:
Instant— a point on the physical timeline (epoch seconds + nanos).LocalDateTime— civil wall-clock components with no zone; it does not identify an instant.ZonedDateTime— aLocalDateTimepaired with an IANA zone ID (Europe/Paris, never a fixed offset, never an abbreviation likeCET).Duration(physical seconds) andPeriod(calendar units: days, months) — with arithmetic that keeps them apart:Instant + Duration,ZonedDateTime + Period.
Conversions run against a zone-rules interface you define and inject (the language's
tzdb may back the production implementation; tests may hand it scripted rules). The core
contract is local + zone → instant:
- Unique (the normal case) — one instant.
- Gap (spring-forward: the local time doesn't exist) — zero candidates. The API must
demand a policy:
RejectorShiftForward(by the gap's length). - Overlap (fall-back: the local time exists twice) — two candidates. The API must
demand a policy:
EarlierorLateroffset.
No conversion path may silently pick a winner; the policy is an explicit argument.
The future meeting — store civil, not UTC
Implement an event store for future civil events ("09:00 in Paris on 2027-06-12"):
- Persist local time + IANA zone; compute the instant on read, through the rules interface.
- The tzdb-update drill: run with rule set A (Paris observes DST), then swap in rule set B (France abolishes DST, stays on winter time year-round) — a real proposal, and the kind of change the tzdb ships several times a year. Your stored meeting must still resolve to 09:00 on the wall in Paris. Store the same event as a baked UTC instant and show it now renders 10:00 — the silent bug your storage rule prevents.
- Past events are the opposite: they are facts, stored as instants (UTC). The store should make both representations available and document which is for what.
The recurrence expander — RRULE across the edges
Implement a subset of RFC 5545 recurrence: FREQ=DAILY and FREQ=WEEKLY (with
BYDAY), at a given local time-of-day in a given zone, expanding to a stream of
instants. Then take it through the fire:
- Spring-forward gap: "daily at 02:30
America/New_York" across 2026-03-08 — 02:30 doesn't exist that night. Expansion applies the gap policy (SkiporShiftForward), and the choice is visible in the output. - Fall-back overlap: the same rule across 2026-11-01 — 01:30 happens twice. The
overlap policy (
Earlier/Later) decides which one fires; the job must fire once. - Period ≠ duration: compute "next occurrence" both ways across a transition —
instant + 24hlands at 03:30 local the next day;civil + 1 daykeeps 02:30. The expander must use calendar arithmetic and a test must pin the difference.
Clock injection & the hostile-zone suite
- All "now" access goes through an injected
Clock; nothing in the core calls the system clock directly. Tests fix the clock. - Acceptance fixtures must run in zones chosen to break assumptions:
Pacific/Kiritimati(UTC+14 — already "tomorrow" relative to UTC),Asia/Kolkata(+05:30 — half-hour offset),America/New_YorkandEurope/Paris(different DST dates from each other: US switches 2026-03-08/11-01, EU 2026-03-29/10-25). - The capstone fixture: expand "daily at 02:30
America/New_York" across all of 2026 — exactly one firing per calendar day under the chosen policies, no drift, no double-fire, no crash on 2026-03-08.
Examples
Conversions the model must get right (rule sets as of tzdb 2026a):
| Input (local + zone) | Kind | Result |
|---|---|---|
2026-06-12 09:00 @ Europe/Paris |
unique | 2026-06-12T07:00:00Z |
2026-03-08 02:30 @ America/New_York |
gap | policy: Reject errors; ShiftForward → 03:30-04:00 |
2026-11-01 01:30 @ America/New_York |
overlap | policy: Earlier → 01:30-04:00; Later → 01:30-05:00 |
2026-06-12 09:00 @ Asia/Kolkata |
unique | 2026-06-12T03:30:00Z (half-hour offset) |
2026-06-12 09:00 @ Pacific/Kiritimati |
unique | 2026-06-11T19:00:00Z (note the date change) |
Constraints
- Do not use the language's datetime parsing or formatting for milestone 1 — the RFC 3339 grammar is yours to implement. The built-in may serve as an oracle in tests.
- The language's tzdb access (zone rules lookup) is allowed as the production implementation behind your rules interface — shipping your own tz compiler is not the exercise. High-level conveniences that hide the gap/overlap decision (a one-call "parse this string in this zone") are not.
- No recurrence library: the RRULE expansion logic is hand-built.
- Every gap/overlap decision in the codebase is an explicit named policy — a silent default anywhere fails the exercise.
Acceptance
- Milestone 1 (parser): valid RFC 3339 strings parse to the right components and offset; invalid ones are rejected with position and reason; the round-trip demo shows offset preserved, zone unrecoverable. Agrees with the language parser on a fixtures file.
- Milestone 2 (types): the conversion table above passes; gap and overlap inputs cannot be converted without a policy argument (a compile error or a required parameter, not a runtime surprise).
- Milestone 3 (future meeting): under the rule-set swap, the civil-stored meeting still resolves to 09:00 wall time and the UTC-baked copy is demonstrably an hour wrong — both asserted by tests.
- Milestone 4 (expander): across 2026-03-08 and 2026-11-01 in
America/New_York, each policy produces exactly the documented firings; the period-vs-duration test pins the 24h-drift difference. - Milestone 5 (suite): the full fixture set is green with an injected clock; the year-long expansion yields exactly one firing per day; no test depends on the machine's zone or wall clock.
Related
- DST-safe scheduler — the exercise note this is the subject of.
- Sources: RFC 3339 (the timestamp grammar), RFC 5545 (RRULE, restated in subset), RFC 9557 (the zone-preserving stretch goal), and the IANA tz database.