feat: standing availability, absences, and session-at-risk warnings (#104) #187

Merged
claude-bot merged 4 commits from feat/104-availability into main 2026-07-17 20:34:00 +00:00
Contributor

Fixes #104. Third of the v3.6.0 (Scheduling) milestone, after #185 and #186. Builds on #95's timezone correctness.

Why

Voting handled per-session polling, but nothing captured standing facts like "Sarah is out all August" or a group's minimum table size — so GMs found conflicts only after proposing times, and a confirmed session could quietly fall below quorum with no warning.

Design

Availability (timezone-correct — the correctness core, like #95/#98)

Weekly availability and absences are stored in each user's own timezone. To decide if a player is free at a session's UTC instant, that instant is converted into their zone, then its local weekday+time is matched against their weekly windows and its local date against absences. The campaign zone never enters — candidate times are absolute instants. A user with no availability rows is unknown (no constraint), not unavailable.

  • GET /api/campaigns/{id}/availability?times=… → per-member verdicts (available|absent|unavailable|unknown) aligned with the requested times.
  • GET/PUT /api/me/availability (replace semantics), GET/POST/DELETE /api/me/absences.

Session-at-risk

  • campaigns.quorum_min_players (nullable — off by default).
  • poll_session_at_risk Beat task (5 min): likely_attendance = members − |absent ∪ explicit-"no"-voters| (union, so a member who is both counts once; weekly-window softness is deliberately not counted here). At-risk only when quorum is set and attendance is below it.
  • Fires exactly one session_at_risk bot event per session via a sent-marker keyed on (session_id, confirmed_time) — so a reschedule re-arms the warning (new time = new key). Bot: a new handler posts a GM-facing embed. Additive event → no BOT_CONTRACT_VERSION bump.
  • Web: SessionResponse.at_risk (computed on the confirmed-session read) drives an "at risk" banner.

Frontend

  • Profile: weekly-availability editor + absences.
  • Campaign settings: quorum field.
  • Voting grid: per-slot "N unavailable" overlay (the acceptance-critical one) listing who's absent/unavailable at each candidate time; also a lighter per-time hint on the session-create form.
  • SessionDetail: the at-risk banner. (Dashboard badge intentionally skipped — the next-session list payload doesn't carry at_risk, and a per-card request was ruled out.)

Acceptance criteria

  • A recorded absence shows as a conflict on the create form and voting grid for times inside the range.
  • Weekly-availability conflicts render correctly across timezone boundaries — test: a Tokyo user free Tue 18:00–22:00 local is available at the instant that is Tue 20:00 Tokyo but unavailable at a different-zone "Tue 20:00" that lands outside their window.
  • With quorum set and attendance below it, the GM gets exactly one bot notification per session (idempotency test; re-arms on reschedule) and the session is flagged in the web app.
  • Quorum off (default) → no warnings.

Migration & deploy note

Migration b9c0d1e2f3a4 (down_revision = a8b9c0d1e2f3) adds two availability tables, campaigns.quorum_min_players, and the at-risk sent-marker. Verified against a real Postgres: applies from base and downgrade -1 → upgrade head round-trips (tables + column all drop/recreate). Deploying v3.6.0 runs this + #98's migration, backup-first.

Verification

Backend (dev-host harness, mirrors CI): migration round-trips; 421 passed (9 new); ruff check/format clean.
Bot: 187 passed (1 new session_at_risk handler test).
Frontend (node:20): lint clean, 160 tests (20 new), build succeeds.

🤖 Generated with Claude Code

Fixes #104. Third of the v3.6.0 (Scheduling) milestone, after #185 and #186. Builds on #95's timezone correctness. ## Why Voting handled per-session polling, but nothing captured **standing** facts like "Sarah is out all August" or a group's minimum table size — so GMs found conflicts only after proposing times, and a confirmed session could quietly fall below quorum with no warning. ## Design ### Availability (timezone-correct — the correctness core, like #95/#98) Weekly availability and absences are stored **in each user's own timezone**. To decide if a player is free at a session's UTC instant, that instant is converted into *their* zone, then its local weekday+time is matched against their weekly windows and its local date against absences. The campaign zone never enters — candidate times are absolute instants. A user with **no** availability rows is `unknown` (no constraint), not unavailable. - `GET /api/campaigns/{id}/availability?times=…` → per-member verdicts (`available|absent|unavailable|unknown`) aligned with the requested times. - `GET/PUT /api/me/availability` (replace semantics), `GET/POST/DELETE /api/me/absences`. ### Session-at-risk - `campaigns.quorum_min_players` (nullable — **off by default**). - `poll_session_at_risk` Beat task (5 min): `likely_attendance = members − |absent ∪ explicit-"no"-voters|` (union, so a member who is both counts once; weekly-window softness is deliberately *not* counted here). At-risk only when quorum is set and attendance is below it. - Fires **exactly one** `session_at_risk` bot event per session via a sent-marker keyed on `(session_id, confirmed_time)` — so a **reschedule re-arms** the warning (new time = new key). Bot: a new handler posts a GM-facing embed. Additive event → **no `BOT_CONTRACT_VERSION` bump**. - Web: `SessionResponse.at_risk` (computed on the confirmed-session read) drives an "at risk" banner. ### Frontend - Profile: weekly-availability editor + absences. - Campaign settings: quorum field. - **Voting grid**: per-slot "N unavailable" overlay (the acceptance-critical one) listing who's absent/unavailable at each candidate time; also a lighter per-time hint on the session-create form. - SessionDetail: the at-risk banner. (Dashboard badge intentionally skipped — the next-session list payload doesn't carry `at_risk`, and a per-card request was ruled out.) ## Acceptance criteria - [x] A recorded absence shows as a conflict on the create form and voting grid for times inside the range. - [x] Weekly-availability conflicts render correctly across timezone boundaries — test: a Tokyo user free Tue 18:00–22:00 local is `available` at the instant that is Tue 20:00 Tokyo but `unavailable` at a different-zone "Tue 20:00" that lands outside their window. - [x] With quorum set and attendance below it, the GM gets exactly one bot notification per session (idempotency test; re-arms on reschedule) and the session is flagged in the web app. - [x] Quorum off (default) → no warnings. ## Migration & deploy note Migration `b9c0d1e2f3a4` (`down_revision = a8b9c0d1e2f3`) adds two availability tables, `campaigns.quorum_min_players`, and the at-risk sent-marker. **Verified against a real Postgres**: applies from base **and** `downgrade -1 → upgrade head` round-trips (tables + column all drop/recreate). Deploying v3.6.0 runs this + #98's migration, backup-first. ## Verification Backend (dev-host harness, mirrors CI): migration round-trips; **421 passed** (9 new); `ruff check`/`format` clean. Bot: **187 passed** (1 new session_at_risk handler test). Frontend (node:20): lint clean, **160 tests** (20 new), build succeeds. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Voting handled per-session polling, but nothing captured standing facts like
"Sarah is out all August" or a group's minimum table size, so GMs discovered
conflicts only after proposing times and a confirmed session could quietly fall
below quorum with no warning.

Data model (migration b9c0d1e2f3a4, down_revision a8b9c0d1e2f3 — verified head):
- user_weekly_availability (weekday 0=Mon, start/end time) and user_absences
  (date range + note), both stored in the USER's own timezone.
- campaigns.quorum_min_players (nullable; feature is off by default).
- session_at_risk_notified: a sent-marker keyed on (session_id, confirmed_time),
  mirroring session_reminders_sent. Keying on confirmed_time means a reschedule
  is a new key, which re-arms the warning exactly once.

Availability (availability_service):
- Timezone-correct: a candidate/session UTC instant is converted into each
  user's OWN zone, then its local weekday+time is matched against that user's
  weekly windows, and its local date against absences. The campaign zone never
  enters — candidate times are absolute instants. No weekly rows => "unknown"
  (no constraint), not unavailable. Verdict precedence: absent -> unknown/
  available/unavailable.
- GET /api/campaigns/{id}/availability?times=... returns per-member verdicts
  aligned with the requested times, for the session-create form and voting grid.
- GET/PUT /api/me/availability and GET/POST/DELETE /api/me/absences for the
  current user.

Session-at-risk (poll_session_at_risk beat task, 5 min):
- likely_attendance = members - |absent OR explicit-"no"-voters| (union, so a
  member who is both counts once). Weekly-window mismatches are deliberately not
  counted here — only hard signals. at_risk = quorum set AND below it.
- Fires exactly one session_at_risk bot event per (session_id, confirmed_time)
  via the marker; running the task twice never double-notifies; a reschedule
  re-arms once. Follows the existing bot-event routing.
- Bot: new session_at_risk handler in the notifications dispatch map posts a
  GM-facing "session at risk" embed. Additive event_type — no BOT_CONTRACT_VERSION
  bump.

Dashboard flag: SessionResponse gains a computed at_risk bool, populated on the
confirmed-session read path (no extra route).

Verified against a real Postgres via the dev-host harness: migration applies and
downgrade -1 + upgrade head round-trips; backend 421 passed (9 new), bot 187
passed (1 new); ruff check + format clean.

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

- src/api/availability.js: shared-client wrappers for the availability, absence,
  and campaign-availability-grid endpoints.
- Profile: an Availability section — weekly windows editor (weekday + start/end
  time, in the user's own timezone) and absences (date range + note), with
  AbortController-based loads.
- CampaignDetail: a "minimum players (quorum)" field in campaign settings, and a
  per-proposed-time conflict hint on the new-session form (candidate times
  converted to UTC via #95's zonedNaiveToUtcIso before querying).
- VotingGrid: a per-slot "N unavailable" overlay listing members whose verdict
  is absent/unavailable at that slot's time (positional verdict alignment,
  AbortController, refetch on session change).
- SessionDetail: an "at risk" banner on confirmed sessions below quorum
  (session.at_risk), and campaignId threaded into VotingGrid.

Dashboard at-risk badge intentionally skipped: the next-session list payload
carries no at_risk, and a per-card request was ruled out.

Reviewed and verified by the orchestrator: lint clean, 160/160 frontend tests
(20 new), build succeeds.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
docs: changelog for availability + session-at-risk (#104)
Some checks failed
CI / Docker image build (pull_request) Successful in 36s
CI / Backend lint (ruff) (pull_request) Successful in 42s
CI / Backend migration, tests, and audit (pull_request) Failing after 3m14s
CI / Frontend tests, audit, and build (pull_request) Successful in 4m27s
CI / Bot tests and audit (pull_request) Successful in 8m28s
1b17f56fd0
ci: re-trigger (backend job failed with no local repro; suspected flaky pg readiness)
All checks were successful
CI / Frontend tests, audit, and build (pull_request) Successful in 1m42s
CI / Docker image build (pull_request) Successful in 14s
CI / Backend lint (ruff) (pull_request) Successful in 36s
CI / Bot tests and audit (pull_request) Successful in 2m0s
CI / Backend migration, tests, and audit (pull_request) Successful in 3m57s
7058c1b4e9
claude-bot deleted branch feat/104-availability 2026-07-17 20:34:00 +00:00
Sign in to join this conversation.
No description provided.