Scheduling: a move re-asks everyone and carries the series with it; pencilled in → a vote; why it was cancelled (D99, D100, D101, #804, lane 4) #809

Merged
claude-bot merged 5 commits from build/lane4-scheduling into main 2026-09-24 08:20:26 +00:00
Collaborator

Closes #804. Lane 4 (#730), server work plus one dialog.

A move re-asks everyone (D99)

  • Moving a confirmed, in-progress or pencilled-in session clears everyone's yes, maybe or no and keeps attendance. Needs you asks again, and the confirmation notice goes out.
  • A move to the same time is a no-op.
  • A move now keeps the session's length. On main, end_time was left behind.

The series moves with it (#804)

  • The rule. series_service.plan_series_move walks the later sessions. Each must sit on a regular day at least half an interval after the one before; the first that already does ends the walk. It compares in the campaign's wall-clock time, so DST can't shave the boundary.

  • Where the walk stops without moving anything:

    • at a session moved by hand (the same test the "rescheduled" flag uses);
    • at a cancelled, played or in-progress session;
    • at a session that is voting, or pencilled in.

    A detached session is outside the series and is never touched.

  • A moved successor is re-asked and notified, and keeps its number, notes and title.

  • The materialiser doesn't refill vacated slots. A new occurrence must be at least half an interval after the latest session the series already has. That takes one grouped query and no bookkeeping. The hourly re-run is tested, and it fails without the rule.

  • GET /api/sessions/{id}/reschedule-preview?to= (GM only) returns what would move and where the walk would stop, and why. It uses the same plan_move as the real move, and a test checks the two agree. PATCH returns the same ripple after a move.

Pencilled in → a vote (D100), and restarting a vote

  • reopen-for-voting also accepts pencilled-in sessions and open votes. It clears every answer, sends vote_opened, and reschedules the auto-close and reminder.
  • It takes 2–5 times; outside that is a 422 with the drawing's sentence.
  • Adding a time through PATCH proposed_times keeps every vote on times still offered.

Why it was cancelled (D101)

  • The columns: cancel_reason, cancelled_at, cancelled_by_id and uncancelled_at.
  • POST /api/sessions/{id}/cancel {reason?}. DELETE still works, with no reason.
  • Where the reason goes: into the cancellation email, escaped, and onto the bot's cancel event as an additive extra.reason. There is no bot contract bump. Rendering the reason in Discord is lane 10's.
  • Uncancel keeps the history, including the reason.
  • The one UI change: the old session page's cancel now asks "Why, for the people you are telling".

How long it ran

sessions.recorded_seconds, from the bot's existing duration_seconds. A zero from an older bot never overwrites a stored value.

Migration

d7e8f9a0b1c3, chained after c5d6e7f8a9b1 (#808). The single-head pin moved to test_cancel_reason_migration.

Checks, on the tree merged with main (#807, #808; the code conflicts were resolved by hand)

  • Backend, full suite: 4276 passed, 13 skipped. Ruff 0.4.4 is clean.
  • Frontend: 144 files, 2029 tests. Lint: 0 errors, 227 warnings.
  • The migration chain was run for real: f3a4 → c5d6 → d7e8, down one and up again.
  • New tests: test_scheduling_lane4.py (34) and test_cancel_reason_migration.py (7).

Behaviour worth knowing

  • Reopening a confirmed session for a vote now also clears its answers.
  • A pencilled-in move sends no "session is on" notice, because its time isn't fixed; it relies on Needs you.
  • The walk ignores a series' count and until when it picks days. So a count-limited series whose early sessions are pushed on can end up making fewer sessions than its count.

🤖 Generated with Claude Code

Closes #804. Lane 4 (#730), server work plus one dialog. ## A move re-asks everyone (D99) - Moving a confirmed, in-progress or pencilled-in session **clears everyone's yes, maybe or no** and keeps attendance. Needs you asks again, and the confirmation notice goes out. - A move to the same time is a no-op. - A move now keeps the session's length. On main, `end_time` was left behind. ## The series moves with it (#804) - **The rule.** `series_service.plan_series_move` walks the later sessions. Each must sit on a regular day at least half an interval after the one before; the first that already does ends the walk. It compares in the campaign's wall-clock time, so DST can't shave the boundary. - **Where the walk stops without moving anything:** - at a session moved by hand (the same test the "rescheduled" flag uses); - at a cancelled, played or in-progress session; - at a session that is voting, or pencilled in. A detached session is outside the series and is never touched. - **A moved successor** is re-asked and notified, and keeps its number, notes and title. - **The materialiser doesn't refill vacated slots.** A new occurrence must be at least half an interval after the latest session the series already has. That takes one grouped query and no bookkeeping. The hourly re-run is tested, and it fails without the rule. - **`GET /api/sessions/{id}/reschedule-preview?to=`** (GM only) returns what would move and where the walk would stop, and why. It uses the same `plan_move` as the real move, and a test checks the two agree. `PATCH` returns the same `ripple` after a move. ## Pencilled in → a vote (D100), and restarting a vote - `reopen-for-voting` also accepts pencilled-in sessions and open votes. It clears every answer, sends `vote_opened`, and reschedules the auto-close and reminder. - It takes 2–5 times; outside that is a 422 with the drawing's sentence. - Adding a time through `PATCH proposed_times` keeps every vote on times still offered. ## Why it was cancelled (D101) - **The columns:** `cancel_reason`, `cancelled_at`, `cancelled_by_id` and `uncancelled_at`. - **`POST /api/sessions/{id}/cancel {reason?}`.** `DELETE` still works, with no reason. - **Where the reason goes:** into the cancellation email, escaped, and onto the bot's cancel event as an additive `extra.reason`. **There is no bot contract bump.** Rendering the reason in Discord is lane 10's. - **Uncancel keeps the history**, including the reason. - **The one UI change:** the old session page's cancel now asks "Why, for the people you are telling". ## How long it ran `sessions.recorded_seconds`, from the bot's existing `duration_seconds`. A zero from an older bot never overwrites a stored value. ## Migration `d7e8f9a0b1c3`, chained after `c5d6e7f8a9b1` (#808). The single-head pin moved to `test_cancel_reason_migration`. ## Checks, on the tree merged with main (#807, #808; the code conflicts were resolved by hand) - Backend, full suite: **4276 passed, 13 skipped**. Ruff 0.4.4 is clean. - Frontend: 144 files, 2029 tests. Lint: 0 errors, 227 warnings. - The migration chain was run for real: `f3a4 → c5d6 → d7e8`, down one and up again. - New tests: `test_scheduling_lane4.py` (34) and `test_cancel_reason_migration.py` (7). ## Behaviour worth knowing - Reopening a *confirmed* session for a vote now also clears its answers. - A pencilled-in move sends no "session is on" notice, because its time isn't fixed; it relies on Needs you. - The walk ignores a series' `count` and `until` when it picks days. So a count-limited series whose early sessions are pushed on can end up making fewer sessions than its count. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Migration d7e8f9a0b1c3 adds five nullable columns to sessions, as raw
DDL with no backfill: cancel_reason, cancelled_at, cancelled_by_id
(-> users, ON DELETE SET NULL), uncancelled_at and recorded_seconds.
Old cancellations keep NULLs rather than an invented who or when.

The model gains a selectin-loaded `canceller` and a `cancelled_by`
property ({id, name}) that reads only what is loaded, so building a
response never lazy-loads in async.

The one-head pin moves from test_audio_processed_at_migration to the new
migration's test, which runs the real upgrade and downgrade in a scratch
schema.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Moving (D99). A move of a confirmed, in-progress or pencilled-in session
clears every rsvp_status and rsvp_at (attended is kept), keeps the
session's length, and sends the usual confirmation notice, so Needs you
asks again. A move to the same instant is a no-op. A pencilled-in time
changed through proposed_times clears its soft answers too.

The series moves with it (#804). series_service.plan_series_move walks
the later sessions of a series in date order: one already on a pattern
day at least half an interval after the one before ends the walk;
otherwise it moves to the first pattern day that is. It stops, moving
nothing further, at a session moved by hand, cancelled, played or out to
a vote, and says where. Moving earlier never ripples. Occurrence dates
follow the sessions, set in two steps so the unique key never clashes
mid-flush. session_service.plan_move is the one function both the move
and the new GET /sessions/{id}/reschedule-preview call; the PATCH
response carries the same shape as `ripple`.

The hourly materialiser now extends a series only past its latest
placed session, by the walk's own half-interval rule, so the dates a
walk vacated are never refilled. No schema: the series' sessions already
say how far it has got.

Votes (D100, Q-19). reopen-for-voting takes a pencilled-in session and
an open vote as well, clears every answer, resets the nudge, and a wrong
count is a 422 in the drawing's words. PATCH proposed_times keeps the
slots (and votes) it still offers, so adding a time keeps every answer.

Cancelling (D101). POST /sessions/{id}/cancel {reason?} records the
reason, when and by whom; DELETE is the same with no reason. The reason
reaches the cancellation email and, additively, the bot event's extra;
no contract bump. Uncancel keeps them and stamps uncancelled_at.

The bot's audio upload now stores duration_seconds as recorded_seconds
(Q-14).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The session page's Cancel Session used the browser's confirm and DELETE.
It now opens the drawing's dialog — "Everyone who said yes is told, on
Discord and by email if they asked for it…", the field "Why, for the
people you are telling" (placeholder "Three of us are out and I have no
voice"), [Keep it] [Cancel the session] — and calls the new
POST /api/sessions/{id}/cancel with the reason. cancelSession(id, reason)
in api/sessions.js is the one caller's new shape.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Merge remote-tracking branch 'origin/main' into build/lane4-scheduling
All checks were successful
CI / Does this change touch the recording path? (pull_request) Successful in 21s
CI / Synthetic session harness (no GPU, no LLM) (pull_request) Has been skipped
CI / Bot/backend version sync (pull_request) Successful in 26s
CI / Backend lint (ruff) (pull_request) Successful in 1m11s
CI / Feedback site tests (pull_request) Successful in 1m41s
CI / Summarisation accuracy eval harness (stub provider) (pull_request) Successful in 1m54s
CI / Bot tests and audit (pull_request) Successful in 3m4s
CI / Docker image build (pull_request) Successful in 3m10s
CI / Frontend tests, audit, and build (pull_request) Successful in 4m11s
CI / End-to-end journeys (desktop, phone, keyboard-only) (pull_request) Successful in 8m52s
CI / Backend migration, tests, and audit (pull_request) Successful in 17m26s
4842830cfc
# Conflicts:
#	CHANGELOG.md
#	webapp/backend/app/routers/sessions.py
#	webapp/backend/app/services/session_service.py
#	webapp/backend/app/tasks/reminder_tasks.py
#	webapp/backend/tests/test_audio_processed_at_migration.py
Sign in to join this conversation.
No description provided.