feat: recurring session series with per-occurrence edits (#98) #186

Merged
claude-bot merged 3 commits from feat/98-recurring-series into main 2026-07-17 17:17:10 +00:00
Contributor

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 SessionSeries recurrence 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_revision is 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_date XOR count XOR open-ended), default_duration_minutes, title_template, created_by, active.
  • sessions gains series_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)

  • Migration round-trips: alembic upgrade head applies from base; downgrade -1 && upgrade head cleanly drops+recreates the table, enum type, columns, and partial index. (pytest builds schema via create_all(), so it can't catch a broken migration — CI's separate alembic upgrade head gate can, and now does.)
  • Idempotent materialization: INSERT … ON CONFLICT DO NOTHING on the partial index — running the Beat task twice creates no duplicates (test asserts row counts).
  • DST-stable: each occurrence's wall-clock time_of_day is attached to the campaign zone via zoneinfo, 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

  • Per-occurrence reschedule/cancel act on the session row only. Detach clears series_id/series_occurrence_date but keeps the session's data.
  • Series edit scope: future_unmaterialized (default) changes only the rule; future_all also recomputes future materialized occurrences that are still unmodified — an individually-rescheduled occurrence is left alone.
  • Deactivate stops materialization; optional cancel_future cancels upcoming occurrences. Past sessions untouched.

One improvement over the first draft

sessions.created_by is NOT NULL but series.created_by is 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.js via the shared client (#103).
  • CampaignDetail: a "Make this recurring" checkbox (direct mode) collecting cadence/weekday/time-of-day/start/duration/end-condition; time_of_day is sent as raw campaign-zone wall-clock (no UTC conversion — the backend interprets it in the campaign zone), labelled with the campaign timezone.
  • SeriesManager panel (GM-only): list with human summaries + inactive badges, edit time/duration/end-condition with the scope selector, deactivate with cancel-upcoming.
  • SessionDetail: "Detach from series" on series-linked sessions.

Acceptance criteria

  • Weekly series materializes the next N occurrences exactly once; rerun creates no duplicates (idempotency test).
  • Cancelling one occurrence leaves the rest intact; detaching removes the link without deleting the session.
  • Series edit applies to the chosen scope only (future-unmaterialized vs also-future-materialized), sparing individually-edited occurrences.
  • Reminders fire for materialized sessions via the existing poller — no reminder-logic changes.
  • DST boundary: a weekly 19:00 campaign-time series keeps 19:00 wall-clock across the transition.

Verification

Backend, via the dev-host harness (ephemeral Postgres, mirrors CI): alembic upgrade head + round-trip clean; 412 passed (17 new); ruff check/format clean.
Frontend, node:20: lint clean, 140 tests (11 new), build succeeds.

No /api/bot/* change → no BOT_CONTRACT_VERSION bump.

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

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 `SessionSeries` recurrence 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_revision` is 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_date` XOR `count` XOR open-ended), `default_duration_minutes`, `title_template`, `created_by`, `active`. - `sessions` gains `series_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) - **Migration round-trips**: `alembic upgrade head` applies from base; `downgrade -1 && upgrade head` cleanly drops+recreates the table, enum type, columns, and partial index. (pytest builds schema via `create_all()`, so it *can't* catch a broken migration — CI's separate `alembic upgrade head` gate can, and now does.) - **Idempotent materialization**: `INSERT … ON CONFLICT DO NOTHING` on the partial index — running the Beat task twice creates no duplicates (test asserts row counts). - **DST-stable**: each occurrence's wall-clock `time_of_day` is attached to the campaign zone via `zoneinfo`, 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 - Per-occurrence reschedule/cancel act on the session row only. **Detach** clears `series_id`/`series_occurrence_date` but keeps the session's data. - Series edit **scope**: `future_unmaterialized` (default) changes only the rule; `future_all` also recomputes future materialized occurrences that are *still unmodified* — an individually-rescheduled occurrence is left alone. - **Deactivate** stops materialization; optional `cancel_future` cancels upcoming occurrences. Past sessions untouched. ### One improvement over the first draft `sessions.created_by` is NOT NULL but `series.created_by` is 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.js` via the shared client (#103). - CampaignDetail: a "Make this recurring" checkbox (direct mode) collecting cadence/weekday/time-of-day/start/duration/end-condition; `time_of_day` is sent as **raw campaign-zone wall-clock** (no UTC conversion — the backend interprets it in the campaign zone), labelled with the campaign timezone. - `SeriesManager` panel (GM-only): list with human summaries + inactive badges, edit time/duration/end-condition with the scope selector, deactivate with cancel-upcoming. - SessionDetail: "Detach from series" on series-linked sessions. ## Acceptance criteria - [x] Weekly series materializes the next N occurrences exactly once; rerun creates no duplicates (idempotency test). - [x] Cancelling one occurrence leaves the rest intact; detaching removes the link without deleting the session. - [x] Series edit applies to the chosen scope only (future-unmaterialized vs also-future-materialized), sparing individually-edited occurrences. - [x] Reminders fire for materialized sessions via the existing poller — no reminder-logic changes. - [x] DST boundary: a weekly 19:00 campaign-time series keeps 19:00 wall-clock across the transition. ## Verification Backend, via the dev-host harness (ephemeral Postgres, mirrors CI): `alembic upgrade head` + round-trip clean; **412 passed** (17 new); `ruff check`/`format` clean. Frontend, node:20: lint clean, **140 tests** (11 new), build succeeds. No `/api/bot/*` change → no `BOT_CONTRACT_VERSION` bump. **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](https://claude.com/claude-code)
Weekly groups are the core Quest Board user, but every session was created
one-off. Add a SessionSeries recurrence rule that a Celery Beat task
materializes into normal confirmed direct-mode sessions on a rolling 4-week
window, so the existing reminder poller picks them up with no special-casing.

Data model (migration a8b9c0d1e2f3, down_revision e5f6g7h8i9j0 — the verified
current head):
- session_series: cadence (weekly | biweekly | monthly_by_weekday), weekday
  (0=Mon), time_of_day (naive, campaign-zone), start_date, end condition
  (until_date XOR count XOR open-ended), default_duration_minutes,
  title_template, created_by, active.
- sessions gains series_id (FK SET NULL) and series_occurrence_date, plus a
  PARTIAL unique index on (series_id, series_occurrence_date) WHERE series_id IS
  NOT NULL. That index is the idempotency key.

Materialization (materialize_session_series, hourly Beat):
- DST-stable: each occurrence's wall-clock time_of_day is attached to the
  campaign zone via zoneinfo, then converted to UTC — "every Tuesday 19:00"
  stays 19:00 across a DST change while the UTC instant shifts.
- Idempotent: INSERT ... ON CONFLICT DO NOTHING on the partial index, so
  overlapping or repeated task runs never double-create.
- created_by: sessions.created_by is NOT NULL but series.created_by is SET NULL
  on account deletion. Rather than silently stop a group's game, fall back to a
  current campaign GM; only skip if the campaign has no GM at all.

Edit semantics (series_service):
- Per-occurrence reschedule/cancel act on the session row only. Detach clears
  series_id/series_occurrence_date, keeping the session's data.
- Series edit scope: future_unmaterialized (default) changes only the rule;
  future_all also recomputes future materialized occurrences that are still
  unmodified (confirmed_time still equals the series-computed time), leaving
  individually-rescheduled ones alone.
- Deactivate stops materialization; optional cancel_future cancels upcoming
  occurrences. Past sessions are never touched.

Endpoints (all /api/*, cookie auth — no bot contract change): create/list series
under a campaign, patch/deactivate a series, detach a session.

Verified on a real Postgres via the dev-host harness: alembic upgrade head
applies from clean AND downgrade -1 + upgrade head round-trips cleanly; full
suite 412 passed (17 new series tests incl. idempotency, DST boundary, count/
until end conditions, edit scopes, detach, GM fallback); ruff check + format
clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Frontend for the recurring-series backend:

- src/api/series.js: shared-client wrappers for the series endpoints.
- CampaignDetail new-session form: a "Make this a recurring series" checkbox
  (direct mode only) swaps the one-off date picker for cadence / weekday /
  time-of-day / start-date / duration / end-condition fields and calls
  createSeries. time_of_day is raw campaign-zone wall-clock (labelled with the
  campaign timezone) — no UTC conversion, since the backend interprets it in the
  campaign zone.
- SeriesManager component (GM-only) in campaign settings: lists series with
  human-readable summaries and inactive badges, edits time/duration/end-condition
  with a future_unmaterialized vs future_all scope selector, and deactivates
  (with an "also cancel upcoming sessions" option).
- SessionDetail: a "Detach from series" GM action on series-linked sessions.

Verified in node:20: lint clean, 140/140 tests (11 new), build succeeds.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
docs: changelog for recurring session series (#98)
All checks were successful
CI / Docker image build (pull_request) Successful in 37s
CI / Backend lint (ruff) (pull_request) Successful in 39s
CI / Frontend tests, audit, and build (pull_request) Successful in 3m2s
CI / Bot tests and audit (pull_request) Successful in 5m39s
CI / Backend migration, tests, and audit (pull_request) Successful in 7m41s
c5fcd60283
claude-bot deleted branch feat/98-recurring-series 2026-07-17 17:17:10 +00:00
Sign in to join this conversation.
No description provided.