Skip to content

feat(announcements): reach stats — counters, GET /{id}/stats, admin list (PR-6) - #978

Merged
philmerrell merged 2 commits into
developfrom
feature/announcement-stats
Sep 6, 2026
Merged

feat(announcements): reach stats — counters, GET /{id}/stats, admin list (PR-6)#978
philmerrell merged 2 commits into
developfrom
feature/announcement-stats

Conversation

@philmerrell

Copy link
Copy Markdown
Contributor

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:

Reach 2 seen · 0 dismissed — of ~68 targeted (estimate)

Counting acks without a GSI or a scan

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 — this does.

They are top-level attributes (ackCountsR1Seen), 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 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. And 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" 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 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, with four regression tests: publish, archive, edit, and continued accrual afterwards.

A test caught this, not the browser — the revision-scoping test failed because revise had wiped revision 1's counts.

Two honest nulls

  • 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. 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".
  • A draft renders no reach line at all. "0 seen" reads as "nobody engaged" instead of "not sent yet", and gating on published/archived also keeps the fetch off every row an admin is still writing.

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 known isolate: false flake.

Browser-verified against real dev data with a local app-api:

  • reach line renders on the published announcement with a live targeted of 68, read from the real users table
  • a draft row shows no reach line, and its stats endpoint is never called — one request per page load, for the one row that has been live
  • the hover text carries the cumulative-counts and moving-denominator caveats

Test 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.audit trail, and AuditAction is a closed namespace of role-only actions whose extension touches the non-delegable admin.audit scope. Worth a decision before requiresAck is 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

philmerrell and others added 2 commits September 6, 2026 09:17
…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>
@philmerrell
philmerrell merged commit 69a8fc2 into develop Sep 6, 2026
4 checks passed
@philmerrell
philmerrell deleted the feature/announcement-stats branch September 6, 2026 15:43
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.

1 participant