feat(announcements): severity ladder, audience targeting + UI pass - #44
Merged
Conversation
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.all/ws/user/<id>rooms; clients refetch the server-filtered listNo 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_announcementscategory. 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/soundstay 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)
×character;role="alert"for critical.AwardDialogcheckbox idiom.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
test_announcement_targeting.py): severity round-trip/validation; user- and team-targeted read filtering; staff see all;allrows can't carry stray ids; the leak test — a targeted announcement produces no frame on the shared room whileallstill does; snapshot is audience-filtered; recipients-only notifications; critical overrides a muted category; muting announcements leaves tickets alone. Suite 409 passed.role=alert. Suite 123 passed. tsc + eslint clean.Live verification
Demo stack, verified via API and browser:
role="alert"with destructive tokens and "URGENT"; bell read "Notifications (3 unread)" with a3badge; 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 toinfo/alland behave exactly as before.🤖 Generated with Claude Code