Skip to content

feat(announcements): severity ladder, audience targeting + UI pass - #44

Merged
tbcsec merged 1 commit into
mainfrom
feat/announcement-targeting-severity
Jul 26, 2026
Merged

feat(announcements): severity ladder, audience targeting + UI pass#44
tbcsec merged 1 commit into
mainfrom
feat/announcement-targeting-severity

Conversation

@tbcsec

@tbcsec tbcsec commented Jul 25, 2026

Copy link
Copy Markdown
Owner

Fixes #40

What

Announcements gain severity (info / warning / critical) and an audience (whole competition, or chosen teams/users) — plus the gap that made this matter: a bell notification per recipient. The banner auto-dismisses after 30s (#19), so before this an announcement could be missed entirely just by looking away.

The load-bearing constraint

The shared announcements/<competition_id> room fans a frame to every connected member. So a targeted announcement never goes there — broadcasting it would leak the body to the whole competition while merely looking targeted.

Audience Delivery
all Unchanged fast path: shared-room broadcast, no per-user work
targeted Recipients' existing /ws/user/<id> rooms; clients refetch the server-filtered list

No new room type. Audience resolution lives in one module (utils/announcements) used by both read and delivery, so they can't drift, and the join snapshot is filtered the same way — a reconnect can't reveal what the live path withheld. Staff who can post see everything: it's their own sent history.

The one sanctioned preference override

New inapp_announcements category. A critical announcement is delivered even when that category is muted (create_notifications(..., force=True)) — the operator is saying something the competition can't afford to miss. It overrides the in-app mute only: browser/sound stay opt-in, since they need an OS grant and forcing audio on someone is hostile. Documented in §4.4 as a deliberate exception, not a convenience.

UI pass (owner ask)

  • Banner — severity icon + left accent bar; real hierarchy (eyebrow / title / body were crammed onto one line); a padded icon close button instead of the bare × character; role="alert" for critical.
  • Notification centre — the bell badge shows an unread count rather than a bare dot ("3 waiting" reads very differently from "something happened at some point"); each row gets an explicit unread marker instead of only a faint tint; announcement rows carry their severity chip.
  • Composer — severity selector with per-rung help text, plus the audience picker (teams in team mode, competitors in individual mode) reusing the AwardDialog checkbox idiom.
  • Severity presentation is one pure, unit-tested module (lib/announcement-severity) using design tokens only (§9). It owns the dwell time too — that's how "critical never auto-dismisses" is expressed rather than special-cased in the banner.

Tests

  • Backend +10 (test_announcement_targeting.py): severity round-trip/validation; user- and team-targeted read filtering; staff see all; all rows can't carry stray ids; the leak test — a targeted announcement produces no frame on the shared room while all still does; snapshot is audience-filtered; recipients-only notifications; critical overrides a muted category; muting announcements leaves tickets alone. Suite 409 passed.
  • Frontend +7: severity mapping, unknown/missing fallback, token-only assertion, critical-survives-the-timer, and severity labelling/role=alert. Suite 123 passed. tsc + eslint clean.

Live verification

Demo stack, verified via API and browser:

  • Non-recipient's list omits the targeted announcement; recipient and staff see it.
  • A user who muted announcements received only the critical one — the routine broadcast was correctly suppressed.
  • Banner rendered role="alert" with destructive tokens and "URGENT"; bell read "Notifications (3 unread)" with a 3 badge; dropdown showed per-row unread dots and URGENT/IMPORTANT/ANNOUNCEMENT chips; composer offered "Specific competitors" with 40 checkboxes and kept submit disabled until one was picked.

Migration 2d3e4f5a6b7c — existing rows default to info/all and behave exactly as before.

🤖 Generated with Claude Code

Announcements gain a severity (info/warning/critical) and an audience
(whole competition, or chosen teams/users), plus — the gap that made
this matter — a bell notification per recipient. The banner
auto-dismisses after 30s (#19), so before this an announcement could be
missed entirely just by looking away.

Targeting is enforced on read *and* delivery, resolved in one place
(utils/announcements) so the two can't drift. The shared announcements
room fans a frame to every connected member, so a targeted announcement
never goes there — it would leak the body to the whole competition while
merely looking targeted. Targeted ones reach recipients over their
existing /ws/user/<id> rooms instead, and the join snapshot is filtered
the same way so a reconnect can't reveal what the live path withheld.
Staff who can post see everything: it's their own sent history.

New inapp_announcements preference category. A critical announcement is
the one sanctioned override of a category mute (create_notifications
force=), because the operator is saying something the competition can't
afford to miss — in-app only; browser/sound stay opt-in, since they need
an OS grant and forcing audio on someone is hostile.

UI pass over both surfaces. Banner: severity icon and left accent bar,
real hierarchy (eyebrow / title / body were crammed onto one line), and
a padded icon close button instead of a bare "x" character; role=alert
for critical. Severity presentation lives in one pure module
(lib/announcement-severity, unit-tested, tokens only per §9) so banner,
composer and widget can't drift — it also owns the dwell time, which is
how "critical never auto-dismisses" is expressed. Notification centre:
the bell badge shows an unread *count* rather than a bare dot, each row
gets an explicit unread marker instead of only a faint tint, and
announcement rows carry their severity chip. Composer gains the severity
selector (with per-rung help) and the audience picker.

Migration 2d3e4f5a6b7c; existing rows default to info/all and behave
exactly as before. The create_announcement automation action gains
severity (catalog-driven, so the rule builder picks it up); its audience
stays "all" — a rule firing on a competition-wide event is inherently
competition-wide.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@tbcsec
tbcsec merged commit a8351c2 into main Jul 26, 2026
3 checks passed
@tbcsec
tbcsec deleted the feat/announcement-targeting-severity branch July 26, 2026 00:03
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Feature]: expanded announcement settings

1 participant