feat: recurring session series with per-occurrence edits (#98) #186
Loading…
Reference in a new issue
No description provided.
Delete branch "feat/98-recurring-series"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Fixes #98. Second of the v3.6.0 (Scheduling) milestone, after #185. Builds on #95's campaign-timezone correctness.
Why
Weekly groups are the core Quest Board user, but every session was created one-off — a GM running "every Tuesday at 19:00" recreated it by hand each week. This adds recurrence rules that materialize into normal sessions.
Design
A
SessionSeriesrecurrence rule; a Celery Beat task materializes it into ordinary confirmed direct-mode sessions on a rolling 4-week window. Because materialized occurrences are just normal sessions, the existing reminder poller and auto-complete logic pick them up with zero special-casing.Data model (migration
a8b9c0d1e2f3,down_revision = e5f6g7h8i9j0)down_revisionis the verified current head (alembic heads) — the CLAUDE.md migration list is ~15 revisions stale.session_series: cadence (weekly | biweekly | monthly_by_weekday),weekday(0=Mon),time_of_day(naive, campaign-zone),start_date, end condition (until_dateXORcountXOR open-ended),default_duration_minutes,title_template,created_by,active.sessionsgainsseries_id(FK SET NULL) +series_occurrence_date, plus a partial unique index on(series_id, series_occurrence_date) WHERE series_id IS NOT NULL— the idempotency key.Correctness (the parts I verified personally against a real Postgres)
alembic upgrade headapplies from base;downgrade -1 && upgrade headcleanly drops+recreates the table, enum type, columns, and partial index. (pytest builds schema viacreate_all(), so it can't catch a broken migration — CI's separatealembic upgrade headgate can, and now does.)INSERT … ON CONFLICT DO NOTHINGon the partial index — running the Beat task twice creates no duplicates (test asserts row counts).time_of_dayis attached to the campaign zone viazoneinfo, then → UTC. "Every Tuesday 19:00" stays 19:00 across the Nov transition while the UTC instant shifts (test asserts the campaign-zone wall-clock on both sides).Edit semantics
series_id/series_occurrence_datebut keeps the session's data.future_unmaterialized(default) changes only the rule;future_allalso recomputes future materialized occurrences that are still unmodified — an individually-rescheduled occurrence is left alone.cancel_futurecancels upcoming occurrences. Past sessions untouched.One improvement over the first draft
sessions.created_byis NOT NULL butseries.created_byis SET NULL on account deletion. Rather than silently stop a group's game when the creator leaves, materialization falls back to a current campaign GM (only skips if the campaign has no GM). Added a test for it.Frontend
src/api/series.jsvia the shared client (#103).time_of_dayis sent as raw campaign-zone wall-clock (no UTC conversion — the backend interprets it in the campaign zone), labelled with the campaign timezone.SeriesManagerpanel (GM-only): list with human summaries + inactive badges, edit time/duration/end-condition with the scope selector, deactivate with cancel-upcoming.Acceptance criteria
Verification
Backend, via the dev-host harness (ephemeral Postgres, mirrors CI):
alembic upgrade head+ round-trip clean; 412 passed (17 new);ruff check/formatclean.Frontend, node:20: lint clean, 140 tests (11 new), build succeeds.
No
/api/bot/*change → noBOT_CONTRACT_VERSIONbump.Deploy note: this PR ships a schema migration; the v3.6.0 prod deploy will run
alembic upgrade head(backup-first). Flagging for release time.🤖 Generated with Claude Code