feat(announcements): reach stats — counters, GET /{id}/stats, admin list (PR-6) - #978
Merged
Conversation
…end) `/stats` needs a count of acks across users, which the key shape does not support: acks live under `USER#<id>` partitions, so counting them per announcement means a GSI on `announcementId` or a scan. The spec ranks those second and third and says start with atomic counters on the announcement item (§9). This does. The counters are top-level attributes — `ackCountsR1Seen` and friends — not a nested `ackCounts` map, because DynamoDB's `ADD` only works on top-level attributes and creates a missing one as 0 in the same atomic write. A nested map needs `SET path = if_not_exists(path, :zero) + :one`, which raises ValidationException until the parent exists, so every announcement authored before this shipped would need an init-then-retry branch on the ack hot path. They count users, not clicks. `record_ack` now reads the previous rank via `ReturnValues="UPDATED_OLD"` and bumps only the ranks the write crossed, so `seen` then `dismissed` adds one to each rather than two to `seen`. They are a funnel, not a partition: acknowledged implies dismissed implies seen, so `seen >= dismissed >= acknowledged` holds without ever reading them back. Keyed by revision, because "Show again" (§D4) is a deliberate re-broadcast and rolling its acks into the previous revision's totals would inflate them and make the numbers lie about the version people actually saw. **The bug worth reading twice:** every admin mutation — `update_announcement`, `set_state`, `bump_revision` — is a full `put_item` of the `Announcement` dataclass, so any attribute the model does not carry is destroyed by it. Publishing an announcement, the most common admin action there is, silently zeroed every counter. `Announcement.ack_counts` now carries them through read → write. Four regression tests cover publish, archive, edit, and continued accrual afterwards. `targeted` is answerable only for a `"*"` audience, via a COUNT query on the users table's StatusLoginIndex. That index is projected INCLUDE without `roles`, so a role-filtered count has nothing to evaluate against, and the alternatives are worse than an honest null: replacing a GSI on the users table (CFN reports green well before an index is ACTIVE), or the scan the spec ranks last. Nor is there a membership list to count — roles arrive as JWT claims mapped at login. Null means "not estimated", never zero. Increments are best-effort by design: a second write after the ack is already durable, logged and swallowed on failure. An under-counted stat beats turning a successful acknowledgement into a 500. 18 new tests; full backend suite 2329 passed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Completes PR-6. The admin list now carries a reach line per announcement — "2 seen · 0 dismissed — of ~68 targeted (estimate)" — which is the point of the whole surface: it tells you whether any of this works. Rendered as a funnel, not a partition. "12 seen · 8 dismissed" means 8 of those 12, because the stored rank only ever rises through them (§D2). `acknowledged` appears only where one was actually asked for; on an announcement without `requiresAck` the number is real but meaningless, and showing a third figure that is always equal to the second reads as a bug. Two cases render nothing rather than a zero: - **A draft.** Nothing has been shown, so "0 seen" would read as "nobody engaged" instead of "not sent yet". `hasReach` gates on published/archived, which also keeps the fetch off every row an admin is still writing. - **A role-scoped audience.** `targeted` is null there — the users table's StatusLoginIndex does not project `roles` — and "of ~0" would imply nobody is targeted. It says "audience not estimated" instead. Stats are a second endpoint per announcement, so they load after the list rather than blocking it, and only for rows that have been live. The cache is keyed by **id plus revision**: "Show again" restarts the counters, so an entry from the previous revision would report stale reach for a broadcast that has only just gone out. A failed fetch is dropped from the requested set so the next pass retries, and leaves the row without a reach line rather than blanking the list — the page's actual job is CRUD. The hover text and the "(estimate)" suffix carry the §11 caveat. One more is now documented on the response model: **nothing is backfilled.** The counters are incremented by the ack write path, so acks recorded before this ships are invisible — an existing environment starts every announcement at zero on deploy day even where people have already read and dismissed it. Verified against dev, where four ack rows predate the counters and only the two written since are tallied. 7 new service specs, 8 new page specs; full frontend suite 2486 passed. Co-Authored-By: Claude Opus 5 <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.
Last PR in the feature-announcements epic (spec:
docs/specs/feature-announcements.md). PR-1 through PR-5 shipped the data layer, the user feed, the admin pages, the banner and the modal; this adds the thing that tells you whether any of it works.The admin list now carries a reach line per announcement:
Counting acks without a GSI or a scan
Acks live under
USER#<id>partitions, so counting them per announcement means a GSI onannouncementIdor a scan. The spec ranks those second and third and says start with atomic counters on the announcement item — this does.They are top-level attributes (
ackCountsR1Seen), not a nestedackCountsmap, because DynamoDB'sADDonly works on top-level attributes and creates a missing one as 0 in the same atomic write. A nested map needsSET path = if_not_exists(path, :zero) + :one, which raises ValidationException until the parent exists — so every announcement authored before this shipped would need an init-then-retry branch on the ack hot path.They count users, not clicks:
record_ackreads the previous rank viaReturnValues="UPDATED_OLD"and bumps only the ranks the write crossed, soseenthendismissedadds one to each rather than two toseen. And they are a funnel, not a partition — acknowledged implies dismissed implies seen, soseen >= dismissed >= acknowledgedholds without ever reading them back. Keyed by revision, because "Show again" is a deliberate re-broadcast and folding its acks into the previous totals would make the numbers lie about the version people saw.The bug worth reading twice
Every admin mutation —
update_announcement,set_state,bump_revision— is a fullput_itemof theAnnouncementdataclass, so any attribute the model does not carry is destroyed by it. Publishing an announcement, the most common admin action there is, silently zeroed every counter.Announcement.ack_countsnow carries them through read → write, with four regression tests: publish, archive, edit, and continued accrual afterwards.A test caught this, not the browser — the revision-scoping test failed because
revisehad wiped revision 1's counts.Two honest nulls
targetedis answerable only for a"*"audience, via a COUNT query on the users table'sStatusLoginIndex. That index is projectedINCLUDEwithoutroles, so a role-filtered count has nothing to evaluate against. The alternatives are worse than a null: replacing a GSI on the users table (CFN reports green well before an index is ACTIVE — we have been bitten by that), or the scan the spec ranks last. There is no membership list to count instead, either: roles arrive as JWT claims mapped at login. The UI says "audience not estimated" rather than "of ~0".Caveat that needs saying out loud
Nothing is backfilled. The counters are incremented by the ack write path, so acks recorded before this deploys are invisible. An existing environment starts every announcement at zero on deploy day even where people have already read and dismissed it — the ack rows are intact, only the tallies begin at the deploy. There is no cheap repair (counting existing rows is the scan the design exists to avoid). Read early numbers as "reach since stats shipped". Verified on dev, where four ack rows predate the counters and only the two written since are tallied.
Increments are best-effort by design: a second write after the ack is already durable, logged and swallowed on failure. An under-counted stat beats turning a successful acknowledgement into a 500.
Verification
25 new tests (18 backend, 7 frontend service) plus 8 new page specs. Full backend suite 2329 passed; full frontend suite 2486 passed with no failures at all this run — including
submission-review.page.spec.ts, confirming those earlier failures were the knownisolate: falseflake.Browser-verified against real dev data with a local app-api:
targetedof 68, read from the real users tableTest data created in dev was deleted afterwards; the table is back to just the "Welcome to Dev" announcement.
Remaining in the epic
Only D10 audit logging, still deferred: the spec requires announcement writes to go through the
admin.audittrail, andAuditActionis a closed namespace of role-only actions whose extension touches the non-delegableadmin.auditscope. Worth a decision beforerequiresAckis used for anything policy-shaped — this PR makes acknowledgement counts visible, which makes the missing audit trail on the authoring side more conspicuous, not less.Separately queued: a follow-up to make the banner an overlay so dismissing it does not shift the layout, and to round its corners.
🤖 Generated with Claude Code