feat: standing availability, absences, and session-at-risk warnings (#104) #187
Loading…
Reference in a new issue
No description provided.
Delete branch "feat/104-availability"
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 #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_riskBeat 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.session_at_riskbot 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 → noBOT_CONTRACT_VERSIONbump.SessionResponse.at_risk(computed on the confirmed-session read) drives an "at risk" banner.Frontend
at_risk, and a per-card request was ruled out.)Acceptance criteria
availableat the instant that is Tue 20:00 Tokyo butunavailableat a different-zone "Tue 20:00" that lands outside their window.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 anddowngrade -1 → upgrade headround-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/formatclean.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
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>