domain/program_calendar — the date-derived program clock¶
backend/src/domain/program_calendar.py (110 lines). The backend mirror
of the frontend's single program-start anchor (issue #386 / PR #384).
Before it, the server gated weeks by prompt-completion counts and stages
by the advancement chain, so it "could 403 a week or stage the calendar
says is open". These helpers compute the same calendar server-side, and
gating call sites combine them with the existing models via max(...) —
"so time can OPEN access but never revoke what advancement already
granted — and never let a client skip past the calendar"
(program_calendar.py:1-13).
elapsed_days(anchor, now, *, tz=None) -> int¶
Whole calendar days between the anchor's local date and now's local
date, counted in tz (UTC when None) and floored at zero for clock
skew (backend/src/domain/program_calendar.py:30-48):
delta = to_user_date(tz, ensure_aware(now)) - to_user_date(tz, ensure_aware(anchor))
return max(0, delta.days)
The result is "the number of midnights crossed in the user's zone —
matching the frontend's local-midnight convention — rather than a UTC
timedelta anchored to the anchor's wall-clock time-of-day"
(program_calendar.py:33-38). Both operands pass through ensure_aware
because SQLite reads anchors back naive (program_calendar.py:43-45).
Shared by this module and domain.reflection_hierarchy's due-date ladder
so the normalization and skew floor live in exactly one place
(program_calendar.py:40-43).
calendar_week(anchor, now=None, *, tz=None) -> int¶
1-based program week, clamped to the curriculum:
elapsed_days // 7 + 1, capped at TOTAL_WEEKS (36)
(program_calendar.py:51-59).
calendar_stage(anchor, now=None, *, tz=None) -> int¶
1-based stage, walking STAGE_DURATIONS_DAYS
((21,)*8 + (42, 42) — see constants) until the elapsed
days fall inside a window; past the end returns TOTAL_STAGES
(program_calendar.py:62-74).
calendar_day_in_stage(anchor, stage_number, now=None, *, tz=None) -> int¶
The 1-based day within stage_number's window — the input to the
course drip. stage_number is clamped to 1..TOTAL_STAGES;
day = elapsed_days - window_start + 1, capped at the stage duration.
Values before the window opens are non-positive (deliberately not
floored). "Independent of advancement — callers combine it with
current_stage so time can only widen access"
(program_calendar.py:77-98).
resolve_program_anchor(progress) -> datetime¶
progress.program_started_at or progress.stage_started_at
(program_calendar.py:101-110): prefers the stored program anchor
(backfilled by migration 18c9d0e1f2a3 from the earliest habit start
date); legacy rows fall back to the per-stage anchor, which is
"conservative (later) for anyone past stage 1, which only makes the time
gate stricter, never looser" (program_calendar.py:104-108).
Worked example¶
Anchor 2026-01-05, tz America/Los_Angeles, now 2026-03-10 (local):
elapsed_days = 64. calendar_week = 64 // 7 + 1 = 10.
calendar_stage: 64 − 21 − 21 − 21 = 1 remaining after three stages →
stage 4. calendar_day_in_stage(anchor, 4) = 64 − 63 + 1 = 2 — day 2 of
stage 4, so with 7 chapters unlocked_chapter_count(7, 21, 2) = 1.
Grounded in adepthood@fbc529d, 2026-07-31.