docs(phase-16): record 16.2 absorption + 16.3 anonId landing
PLAN/COMPLETED mark 16.2 absorbed into 16.1 and 16.3 landed (no migration). Folder CLAUDE.md files reflect anonId now accepted/persisted + the distinct-listener queries.
This commit is contained in:
@@ -44,6 +44,43 @@ Newest entries at the top. Group by phase/wave header (mirroring `PLAN.md` / `CM
|
||||
|
||||
---
|
||||
|
||||
## Phase 16 — Anonymous Play & Share Tracking: Wave 16.3 — Unique-listener `anonId` layer (landed 2026-06-19)
|
||||
|
||||
**Landed:** 2026-06-19 on dev (merge `297805b`). No migration — `anon_id varchar(64)` columns and `IX_play_event_anon_id`/`IX_share_event_anon_id` indexes already shipped in the wave 16.1 migration.
|
||||
|
||||
- **What:** The unique-listener `anonId` seam end-to-end — the "last metric layer" of the Phase 16 substrate. Client mints a first-party `localStorage` GUID on first visit, threads it onto play and share beacon payloads (omitted when null), server accepts and length-clamps it (reject-not-truncate, ≤64 chars), persists it to the reserved nullable `anon_id` columns, and exposes all-time distinct-listener aggregation. The distinct-count capability is in place but not yet surfaced on any read surface (16.5 consumes it). Privacy-notice copy deliberately not authored (Daniel-gated).
|
||||
|
||||
- **Why:** The anonymous unique-listener metric (D5 / D3) is the final substrate wave before the home Plays card can be lit (16.5). It was sequenced last of the metric layers because it is the lowest-priority metric and carries no dependency — the event log captures `anon_id` on the same rows 16.1 already writes; 16.3 simply lights the seam that was reserved but unused.
|
||||
|
||||
- **Shape:**
|
||||
- **Client — `IAnonIdProvider` / `AnonIdProvider`** (`DeepDrftPublic.Client/Services/IAnonIdProvider.cs`, `AnonIdProvider.cs`): `IAnonIdProvider` exposes `string? Current` (synchronous cached read, safe on the unload path) and `ValueTask EnsureLoadedAsync()` (warms the cache from `localStorage` via JS interop — idempotent, best-effort, never throws). `AnonIdProvider` is the production implementation over the `window.DeepDrftAnonId.get` interop call. Degrades to null when `localStorage` is unavailable (private mode / blocked / partitioned iframe) — missing id is the accepted graceful path; over-counting is the direction of error (§3). Scoped (per-session cache); the token itself outlives the session in `localStorage`.
|
||||
- **Client — TypeScript interop** (`DeepDrftPublic/Interop/telemetry/anonid.ts`): mints and reads the `localStorage` GUID. Exposes `window.DeepDrftAnonId.get`. Returns null without throwing when storage is unavailable.
|
||||
- **Client — `BeaconPlayEventSink`** (`DeepDrftPublic.Client/Services/BeaconPlayEventSink.cs`): now injects `IAnonIdProvider`; reads `_anonId.Current` synchronously at emit time and sets `PlayEventDto.AnonId`. Null id produces an anonId-less payload (the field is omitted from the wire JSON entirely via `WhenWritingNull` — the API treats absent and null identically).
|
||||
- **Client — `ShareTracker`** (`DeepDrftPublic.Client/Services/ShareTracker.cs`): now injects `IAnonIdProvider`; reads `_anonId.Current` at share time and sets `ShareEventDto.AnonId`. Same null-omit posture as the play sink.
|
||||
- **API — `EventController`** (`DeepDrftAPI/Controllers/EventController.cs`): `TryNormalizeAnonId` helper on both `POST api/event/play` and `POST api/event/share` — whitespace-only / empty / null collapses to null (valid anonId-less event); a token longer than 64 chars is rejected with `400 Bad Request` rather than truncated (truncation would collide distinct listeners onto one prefix); valid tokens are trimmed and passed through.
|
||||
- **Data — `EventRepository`** (`DeepDrftData/Repositories/EventRepository.cs`): three new distinct-count queries (already in the repository as of 16.3): `CountDistinctListenersAsync()` (site-wide, nulls excluded), `CountDistinctListenersForTrackAsync(trackEntryKey)` (per-track), `CountDistinctListenersForReleaseAsync(releaseId)` (per-release, uses the stamped `release_id` on the play event row — D4 attribution).
|
||||
- **Data — `IEventService` / `EventManager`** (`DeepDrftData/EventManager.cs`): three new members exposing the distinct-count capability: `GetDistinctListenerCount()`, `GetDistinctListenerCountForTrack(trackEntryKey)`, `GetDistinctListenerCountForRelease(releaseId)` — each returns `ResultContainer<int>`. No read surface or card consumes them yet (16.5).
|
||||
- **No migration** — the `anon_id varchar(64)` columns on `play_event` and `share_event` and their covering indexes (`IX_play_event_anon_id`, `IX_share_event_anon_id`) were already created by `20260619155610_AddPlayShareTelemetry` (wave 16.1). Wave 16.3 only wires the client seam and adds the server-side aggregation queries.
|
||||
|
||||
---
|
||||
|
||||
## Phase 16 — Anonymous Play & Share Tracking: Wave 16.2 — Completion-bucket classification + shares (landed by absorption into 16.1)
|
||||
|
||||
**Landed:** absorbed into wave 16.1 (2026-06-19). All §4.1 deliverables shipped inside the foundation wave.
|
||||
|
||||
- **What:** Three-bucket completion classification correct and exhaustive end to end, and share-channel split. Because these were structurally inseparable from the foundation (the tracker, payload, log table, and rollup all required the bucket column set from day one), they landed together with 16.1 rather than as a follow-on wave.
|
||||
|
||||
- **Why:** The §4.2 spec listed bucket classification and share-channel split as wave 16.2 items, but the implementation showed they could not be cleanly deferred — the `play_counter` rollup columns are per-bucket by design (D6), and the share `channel` discriminator is a single non-null column on the `share_event` table. Building the log without them would have required a migration to add them in 16.2 anyway.
|
||||
|
||||
- **Shape:**
|
||||
- **`PlayBucket` enum** (`DeepDrftModels.Enums`): `Partial` (< 30%), `Sampled` (30–80%), `Complete` (> 80%) — exhaustive, non-overlapping. D1 resolved.
|
||||
- **`PlayCounter` rollup columns** (`DeepDrftModels.Entities.PlayCounter`): `PartialCount`, `SampledCount`, `CompleteCount` (each `long`), `TotalPlays` (computed `long` sum). `BumpCounterAsync` in `EventRepository` switches on the bucket to increment the correct column in the same transaction as the event append.
|
||||
- **API-boundary bucket validation** (`EventController`): `Enum.IsDefined(payload.Bucket)` guard — an undefined bucket value returns `400 Bad Request` before the write reaches the repository.
|
||||
- **`ShareChannel` enum** (`DeepDrftModels.Enums`): `Link` / `Embed` on `ShareEvent.Channel`. `ShareTracker` passes the channel through from the `SharePopover` clipboard action; `EventController` validates it is a defined `ShareChannel` value.
|
||||
- **Deferred:** optional `share_count` rollup column on `play_counter` (per-track share count in the rollup table) — not built. Shares are not on the home-card hot path; per-target share reads are speculative wave 16.4 work.
|
||||
|
||||
---
|
||||
|
||||
## Home Hero Stats — Live data wiring (landed 2026-06-18)
|
||||
|
||||
**Landed:** 2026-06-18 on dev (commits `5f0422a` + `8fa330f`, merged `e9e6b60`).
|
||||
|
||||
Reference in New Issue
Block a user