feat(announcements): user feed, acknowledgements, and What's New panel (PR-2) - #969
Conversation
…ew panel
PR-2 of docs/specs/feature-announcements.md. The durable surface: users can
now see and acknowledge announcements. Nothing interrupts anyone — the banner
(PR-4) and modal (PR-5) are still unbuilt, though the API already returns
their slots so those PRs are pure frontend.
Backend
- `apis/shared/announcements/visibility.py` — the whole filter chain as one
pure function: no DynamoDB, no FastAPI, no clock of its own. The server
computes visibility and the client renders what it is handed (§D5), so the
rules exist in one language instead of drifting across two.
- `GET /announcements` returns the panel list plus the capped banner/modal
slots and an unread count; `POST /announcements/{id}/ack` records seen /
dismissed / acknowledged. Cookie session auth on both, per CLAUDE.md.
- `UserAnnouncement` is deliberately NOT a subset alias of the admin model.
It omits `state`, `targetRoles`, `showToNewUsers` and `createdBy`, so
adding an admin field can never widen the user payload by accident.
**Divergence from the spec's filter chain, on purpose.** §D5 lists the ack
check as step 5, before the caps. D1 and D2 are explicit that dismissing a
loud surface leaves the entry in the panel — so applied literally, step 5
would delete the durable record the design is built around. Eligibility
(steps 1-4) produces the panel; ack suppression applies only when choosing
the banner and the modal.
The ack endpoint 404s (not 403s) on an id this user cannot see: 403 would
confirm that an announcement targeted at another role exists.
Frontend
- `announcements.service.ts` — signal-based, loads on first read from the user
dropdown, which the topnav renders only after the session resolves (so the
request carries the roles the server needs for targeting). Fail-open
dismissal per §D7: a rejected ack still hides the item for the tab and
resolves rather than throwing.
- What's-New panel in the user menu, with the unread dot and count badge. The
count is in the aria-label, not the colour alone. Opening the panel marks
everything seen; the pills snapshot at open so they do not vanish as the
user reads them.
Markdown renders through `message-block`, the app's existing markdown
stylesheet, NOT the `prose` classes: the Tailwind typography plugin is not
installed, so those are inert and preflight strips list markers. Browser
verification caught this — lists rendered without bullets and tables
unstyled, and every unit test passed. Reusing `message-block` is also what
§D10 asks for, since it is what assistant messages use.
Verified end to end against dev data: New / Updated / already-read pills,
the revision bump lapsing suppression, and acks persisting across a reload.
The seeded rows were removed afterwards.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Full backend suite: 7602 passed, 3 skipped, 5 failed — all five failures are pre-existing or load flakes, none related to this change. Detail, since "5 failed" deserves more than an assertion that it's fine: The run took 1:18:23 (normally ~7 min) because it was competing with an Angular build on the same machine. That contention is the direct cause of four of the five.
The auth-sweep one is worth naming precisely, because a failing test with "non-admin" and "403" in the name is exactly the kind you should not wave through when a PR adds routes:
So: pre-existing timing sensitivity in that property test's 200ms deadline, unrelated to announcements. Worth a Everything owned by this change is green:
Also confirmed the branch still merges cleanly into current |
PR-2 of
docs/specs/feature-announcements.md, following #966. This is the durable surface: users can see and acknowledge announcements, and nothing interrupts anyone.The banner (PR-4) and modal (PR-5) are still unbuilt, but
GET /announcementsalready returns their slots — so those PRs are pure frontend work against a contract that exists today.What's here
Backend
visibility.py— the entire filter chain as one pure function. No DynamoDB, no FastAPI, no clock of its own. The server computes visibility and the client renders what it is handed (§D5), so the rules live in one language instead of drifting across two — and they're table-testable without moto.GET /announcements→ panel list + capped banner/modal slots + unread count.POST /announcements/{id}/ack→ 204. Cookie session auth on both, per CLAUDE.md's app_api rule.UserAnnouncementis deliberately not a subset alias of the admin model. It omitsstate,targetRoles,showToNewUsersandcreatedBy, so adding an admin field later can't widen the user payload by accident.Frontend
announcements.service.ts— signal-based, loads on first read from the user dropdown (which the topnav renders only after the session resolves, so the request carries the roles the server needs for targeting).Three things worth a close read
1. I diverged from the spec's filter chain, on purpose. §D5 lists the ack check as step 5, before the caps. But D1 and D2 are explicit that dismissing a loud surface leaves the entry in the panel — so applied literally, step 5 deletes the durable record the whole design is built around. Eligibility (steps 1–4) produces the panel; ack suppression applies only when choosing the banner and the modal. Commented at the call site and pinned by a test.
2. Browser verification caught a bug that every unit test passed. The panel's markdown rendered lists without bullets and tables unstyled. The cause: the
proseclasses copied fromuser-menu-link-modalare inert — the Tailwind typography plugin isn't installed — so Tailwind's preflightlist-style: nonewins. The app's real markdown stylesheet is scoped under.message-blockinstyles.css. Switching to it fixed lists, tables, code and links at once, and it's what §D10 asks for anyway ("styling matches assistant messages").3. Fail-open dismissal (§D7). A rejected ack still hides the item for the tab session and resolves rather than throwing, so no caller has to remember to catch. A user trapped under an undismissable banner by a transient 500 is a worse outcome than one that reappears tomorrow.
localStorageis not used at all — server state is the truth (§D3).Also: the ack endpoint returns 404, not 403, for an id this user can't see. 403 would confirm that an announcement targeted at another role exists.
Verification
Beyond the unit tests, I ran this against real dev data on a local stack (app-api on :8010, SPA on :4300, so nothing touched the running services on :4200/:8000) and confirmed the full loop:
unread_countwas 0 on the next fetch.The three seeded announcements and four ack rows were deleted afterwards — the dev table is back to zero rows. Temporary launch/env config and the dev server's regenerated favicons were all reverted.
Test results
requiresAckfirst, then severity, then oldest), the 404-not-403 path, unread/updated derivation, and the flag-off 404.vi.mockper house convention — service caps, fail-open dismissal, unread clearing without waiting on the server, and the pill snapshot.shared-view.page.spec.ts > should create the component, a load-sensitive timeout — I confirmed it fails on cleandeveloptoo (where this machine produced 9 failures across 5 files), so it is pre-existing and not from this change.npx tsc --noEmit: clean.No infrastructure change in this PR — the table and IAM grant shipped with #966, so this deploys through
backend.yml+frontend-deploy.ymlalone.🤖 Generated with Claude Code