diff --git a/PLAN.md b/PLAN.md index 47a718f..c213aba 100644 --- a/PLAN.md +++ b/PLAN.md @@ -245,16 +245,17 @@ The phase deferred behind the home-hero **Plays** stat card (`NowPlayingStats.ra **Architectural spine.** Plays are instrumented at **one seam** — the `StreamingAudioPlayerService` playback lifecycle (not the UI, not the HTTP/media-client layer, which fires multiple times per play via seek-beyond-buffer). A small play-session tracker opens on playback-start, advances a high-water position on the existing progress callback, and closes (classifying the §1 completion bucket) on track-switch / stop / organic-end / page-unload. Shares instrument at the **real** share surface (`SharePopover`'s Copy-link / Copy-embed actions — clipboard writes that record nothing today). Events ship **fire-and-forget via `sendBeacon`** to new `POST api/event/{play,share}` endpoints (proxied through `DeepDrftPublic`, same hop as `api/track/*`), land in an **append-only SQL event log + incremental counter rollup** in `DeepDrftData`, and the home card reads the total through the **existing** `GET api/stats/home` / `HomeStatsDto` / `IStatsDataService` path (the same single persistent-state-bridged round-trip the other two cards use). Release attribution is **resolved server-side** from the track→release join (client sends only the track key); release plays are **derived** (sum of their tracks' plays). All SQL — the FileDatabase vault is not involved. -**Completion buckets (agreed thresholds).** `partial` < 30%, `complete` > 80%; the 30–80% middle band is proposed as its own `sampled` bucket so the three are exhaustive and non-overlapping (headline plays = sum of all three) — see spec §1a / decision **D1**. +**Completion buckets (settled).** `partial` < 30%, `sampled` 30–80%, `complete` > 80% — exhaustive, non-overlapping; headline plays = sum of all three (D1 resolved). The engagement floor (D2) drops trivial skips: a listen counts only at **≥3s OR ≥5% of duration, whichever smaller**. -**Sequenced as four waves.** `16.A → 16.B`, `16.A → 16.C`, `16.A → 16.D`. 16.A is the only cold-start wave. +**Sequenced BOTTOM-UP (Daniel directive 2026-06-19): foundation first, the live card LAST.** Reverses the earlier "visible win early" framing — Daniel does not care about the live card until the whole substrate and all metrics are finished. Strict chain: `16.1 → 16.2 → 16.3 → (16.4 optional) → 16.5`. 16.1 is the only cold-start wave; 16.5 (the card) is the capstone built last. -- **16.A — Play & share counters (core).** Player-service play tracker (three-bucket classification + engagement floor), share tracker in `SharePopover`, `sendBeacon` interop + `POST api/event/{play,share}` (rate-limited), `play_event`/`share_event` log + incremental `play_counter` rollup, server-side release resolution. **No `anonId` yet.** Free-floating cold-start wave. -- **16.B — Home Plays-card payoff.** Extend `HomeStatsDto` + `GetHomeStatsAsync` with `TotalPlays` (+ a secondary line, D7); flip the third card from placeholder to live. **Depends on 16.A.** The visible win — sequence immediately after 16.A. -- **16.C — Unique listeners (stretch / "plus").** The anonymity mechanism (recommend a client-minted random first-party `localStorage` id; alt: server-derived salted daily token; fingerprinting rejected — see §3 / **D5**): thread an `anonId` onto event payloads, count distinct server-side, expose per-target and/or as the card's secondary line. **Depends on 16.A. Explicitly lower priority** — defers indefinitely without stranding 16.A/16.B. -- **16.D — Per-target stats surfaces.** `[speculative]` — detail-page play/share/listener display + CMS analytics views (bucket/channel splits, leaderboards). Not in the agreed scope; the event log already supports it. Build when a surface wants it. +- **16.1 — Foundation: capture seam + transport + event log (nothing reads it yet).** Player-service play tracker (high-water mark + engagement floor), share tracker in `SharePopover` (debounced), `sendBeacon` interop + unload handler, `POST api/event/{play,share}` (proxied, rate-limited), append-only `play_event`/`share_event` log + incremental `play_counter` rollup, server-side release resolution + derived release totals. **No `anonId` yet; no card consumption.** Cold-start wave. +- **16.2 — Completion-bucket classification + shares.** Three-bucket classification (D1) correct and exhaustive end to end (tracker → payload → log → per-bucket counter columns); share-channel split (link/embed). **Depends on 16.1.** +- **16.3 — Unique-listener `anonId` layer (lowest-priority metric, D5).** Option A: mint/read client first-party `localStorage` id, thread `anonId` onto payloads (nullable in the log), count distinct server-side (all-time, D3). **The last metric layer** — folded into "everything finished," explicitly last-built of the substrate. **Depends on 16.1; builds on 16.2.** +- **16.4 — Per-target / CMS stats surfaces.** `[speculative]` — `GET api/stats/{track,release}/{key}` + CMS analytics views (bucket/channel splits, leaderboards). Not committed; the event log already supports it. Off the critical path to the card; build before the capstone only if a surface wants it. **Depends on 16.1–16.3.** +- **16.5 — Home Plays-card payoff (CAPSTONE, built LAST).** Extend `HomeStatsDto` + `GetHomeStatsAsync` with `TotalPlays` (+ secondary line = unique listeners, D7); flip `NowPlayingStats`'s third card from placeholder to live through the existing `GET api/stats/home` round-trip. **The final wave — built only once 16.1–16.3 are in place.** -**Open product decisions (D1–D7, spec §10) — unresolved, awaiting Daniel.** Headline is **D5** (unique-listener anonymity mechanism). Others: D1 (middle-band bucket), D2 (engagement floor before a play counts), D3 (unique-listener window), D4 (release plays derived vs. counted), D6 (rollup strategy), D7 (card secondary line). Resolve before 16.A is decomposed; recommendations are carried in the spec. +**Product decisions D1–D7 — RESOLVED (Daniel 2026-06-19, spec §10).** D1 (three-bucket sampled), D2 (engagement floor on, ≥3s/≥5%), D4 (release plays derived server-side), D5 (Option A — client-minted first-party `localStorage` id, metric labelled "listeners," fingerprinting rejected) resolved on explicit pick. D3 (all-time window), D6 (incremental-on-write rollup), D7 (card secondary = unique listeners) resolved-by-default — low-risk to revisit during their wave. Road-not-taken preserved in the spec. **Adjacency to deferred Identity / accounts (the un-phased backlog item above).** This phase is the deliberate **anonymous** answer to "how many plays" — it does **not** need the accounts/identity work and must not be entangled with it. If identity ever lands, per-user listening history is an additive layer above this anonymous substrate, not a replacement. diff --git a/product-notes/phase-16-play-share-tracking.md b/product-notes/phase-16-play-share-tracking.md index 39de94d..064e4fc 100644 --- a/product-notes/phase-16-play-share-tracking.md +++ b/product-notes/phase-16-play-share-tracking.md @@ -1,9 +1,16 @@ # Phase 16 — Anonymous Play & Share Tracking (Design Spec) -Status: **design-draft, open for Daniel review** (decision points in §10 are unresolved). Author: -product-designer. Date: 2026-06-18. **No code has been written by this doc.** This is the phase -deferred behind the home-hero "Plays" stat card, which today renders a static -`XXX / Plays (Coming Soon)` odometer placeholder in `NowPlayingStats.razor`. +Status: **design-complete — decisions D1–D7 resolved by Daniel 2026-06-19.** Author: +product-designer. Drafted 2026-06-18; decisions resolved and phasing re-sequenced 2026-06-19. +**No code has been written by this doc.** This is the phase deferred behind the home-hero "Plays" +stat card, which today renders a static `XXX / Plays (Coming Soon)` odometer placeholder in +`NowPlayingStats.razor`. + +**Phasing note (Daniel directive, 2026-06-19):** the waves run **bottom-up** — foundation first, +metrics stacked on the substrate, the user-visible Plays-card flip is the **capstone (built last)**. +This deliberately **reverses** the earlier "visible win comes early" framing: Daniel does not care +about the live card until everything underneath it is finished, so the whole telemetry substrate and +all metrics (including unique listeners) land before the card lights up. See §6. This spec adds a **privacy-light, anonymous play & share telemetry layer** to the public site: counting plays (bucketed by completion) and shares, tied to individual tracks and releases, with an @@ -65,17 +72,16 @@ track-listen, classified by how far the listener got: - **partial** — playback reached **< 30%** of the track's duration before the session ended (switched track, stopped, navigated away, closed tab). - **complete** — playback reached **> 80%** of duration. -- **middle band (30%–80%)** — see decision **D1** below. **Recommendation: count the middle band as - its own bucket, `sampled`** (a real listen that wasn't a skip and wasn't a finish), so the three - buckets are exhaustive and non-overlapping: `partial` [0, 30%), `sampled` [30%, 80%], `complete` - (80%, 100%]. The headline "Plays" number is the **sum of all three** (every started listen counts - as a play); the buckets are the texture beneath it. +- **middle band (30%–80%)** — **D1 RESOLVED (Daniel 2026-06-19): three-bucket `sampled`.** The + middle band is its own bucket, `sampled` (a real listen that wasn't a skip and wasn't a finish), so + the three buckets are exhaustive and non-overlapping: `partial` [0, 30%), `sampled` [30%, 80%], + `complete` (80%, 100%]. The headline "Plays" number is the **sum of all three** (every started + listen counts as a play); the buckets are the texture beneath it. - Alternative considered: fold the middle into "complete" (threshold becomes "≥30% = a real play, - else partial"). Simpler, two buckets — but it throws away the distinction between "listened to half" - and "listened to the end," which is the most editorially interesting signal for a music collective - ("which mixes do people actually finish?"). Rejected in favour of three buckets, but it's a genuine - Daniel call (**D1**). + *Road not taken:* folding the middle into "complete" (threshold "≥30% = a real play, else partial") + — simpler, two buckets, but it discards the "listened to half" vs. "listened to the end" + distinction, which is the most editorially interesting signal for a music collective ("which mixes + do people actually finish?"). Three buckets chosen for that texture. **What starts a play candidate:** a track's audio actually begins streaming for playback — i.e. `SelectTrackStreaming` reaches the point where `StartStreamingPlayback` succeeds and `IsPlaying` @@ -113,10 +119,13 @@ per session. Cheap, prevents the obvious gaming, and matches how "copied!" alrea ### 1c. Unique listeners (stretch / "plus" — lower priority) -A **unique listener** is an approximate distinct-listener count over a window (all-time, or rolling -30 days — **D3**), tied to a track or release. This is the metric most in tension with the no-PII -constraint, and it is explicitly the **last** thing to build (§6). It is approximate by design — we -are not building identity, we are estimating reach. Mechanism options and recommendation: **§3**. +A **unique listener** is an approximate distinct-listener count, tied to a track or release. **D3 +RESOLVED (Daniel 2026-06-19, by default): all-time window** — not rolling-30-day. All-time fits the +"N listeners reached" framing and the Option-A mechanism (§3). This is the metric most in tension +with the no-PII constraint; it is approximate by design (we estimate reach, we do not build +identity). Mechanism: **§3**. *Low-risk to revisit during its wave (§6) if implementation surfaces a +reason* — a rolling window is an additive aggregation, not a reshape. Built as part of the substrate +(no longer an indefinite tail — see §6). ### 1d. Edge cases (apply to plays unless noted) @@ -137,13 +146,14 @@ are not building identity, we are estimating reach. Mechanism options and recomm v1. (If "complete" on a 4s clip feels too cheap, a minimum-absolute-seconds floor is an additive tweak — flag as a tuning knob, not a v1 requirement.) - **Rapid skips.** Listener clicks through ten tracks in five seconds. Each reaches `< 30%` → - ten `partial` plays. **Recommendation (D2): apply a minimum-engagement floor before a play counts - at all** — e.g. playback must reach **≥ 3 seconds OR ≥ 5% of duration** (whichever is smaller) for - the listen to register as a play. Below the floor it's a *preview/skip*, not a play, and is dropped - entirely. This keeps the headline number honest (a skim through the archive isn't 40 plays) while - still capturing genuine short partial listens. The floor is a single tunable constant. **D2 is a - Daniel call** — the alternative is "every started playback counts, floor = 0," which is simpler and - defensible ("they hit play, it's a play") but inflates the number on a browsing session. + ten `partial` plays. **D2 RESOLVED (Daniel 2026-06-19): apply a minimum-engagement floor.** A + listen registers as a play only once playback reaches **≥ 3 seconds OR ≥ 5% of duration, whichever + is smaller** (so a sub-60s clip floors on the percentage; anything longer floors on the 3-second + wall). Below the floor it is a *preview/skip*, dropped entirely (no event sent). This keeps the + headline number honest — a skim through the archive isn't 40 plays — while still capturing genuine + short partial listens. The floor is a single tunable constant (one place to change if the band + later wants it looser/tighter). *Road not taken:* floor = 0 ("they hit play, it's a play") — + simpler and defensible, but inflates the count on a browsing session. - **Tab close / navigation mid-play.** The play must still be recorded with its high-water bucket. This is the hardest delivery case and drives the beacon recommendation in §2.2. - **Embedded (`FramePlayer`) plays.** A play inside a third-party iframe is a real play and should @@ -236,14 +246,14 @@ wants both (the metric is "tied to individual tracks and releases"). Options: client plumbing; the release dimension is always correct even for plays that started without release context (e.g. StreamNow random track). -**Recommendation: option 2 (resolve server-side).** The track→release join is authoritative and -already in `DeepDrftData`; sending only the track key keeps the client dumb and the payload minimal, -and it means a random-track play still gets correctly attributed to its release without the client -knowing. The client sends what it cheaply knows (track key); the server enriches. This also means -**release play counts are derived** (sum of plays of the release's tracks) rather than separately -counted — which is exactly right for multi-track Cuts and trivially correct for single-track -Session/Mix. (**D4**: confirm release plays = sum-of-track-plays, not a separately-counted "release -was played" event. Recommend derived.) +**RESOLVED: option 2 (resolve server-side).** The track→release join is authoritative and already in +`DeepDrftData`; sending only the track key keeps the client dumb and the payload minimal, and a +random-track play still gets correctly attributed to its release without the client knowing. The +client sends what it cheaply knows (track key); the server enriches. **D4 RESOLVED (Daniel +2026-06-19): release plays are derived** (sum of plays of the release's tracks), not a +separately-counted "release was played" event — exactly right for multi-track Cuts and trivially +correct for single-track Session/Mix. The client sends only the track key; release attribution and +release-total derivation are both server-side. Shares already carry the right target directly (`SharePopover` knows track vs. release), so share attribution needs no resolution step. @@ -273,9 +283,11 @@ not ad-revenue telemetry — the "90s visitor counter vibe" Daniel wants). Measu ## 3. Privacy-light anonymity mechanism (unique listeners) -This is the decision most in tension with "no PII / anonymous only," and the one most wanting Daniel's -eyes (**D5**). The unique-listener metric needs *some* notion of "the same anonymous listener seen -again" without identifying who they are. Three mechanism families, with trade-offs: +**D5 RESOLVED (Daniel 2026-06-19): Option A — client-minted random first-party `localStorage` id, +metric labelled "listeners," fingerprinting (Option C) rejected.** Rationale and the road not taken +are preserved below — the three mechanism families and their trade-offs are kept as the record of +why A was chosen over B and C. The unique-listener metric needs *some* notion of "the same anonymous +listener seen again" without identifying who they are. ### Option A — Anonymous client-minted id (random GUID in localStorage) @@ -325,10 +337,10 @@ Derive an id from canvas/font/hardware fingerprinting signals. actively killing; it tracks across sites and survives storage-clearing. **Directly contradicts "privacy-light."** Rejected outright — listed only for completeness. -### Recommendation +### Resolution (D5 — Option A) -**Option A (client-minted random first-party id) as the primary mechanism, with the metric honestly -labelled.** Reasons: +**Option A (client-minted random first-party id) is the chosen mechanism, with the metric honestly +labelled "listeners."** Reasons: - It fits the product: a band wants an *all-time* "N listeners reached" figure, which A supports and B (daily-only) structurally cannot. @@ -339,12 +351,18 @@ labelled.** Reasons: - We label the metric **"listeners," not "people,"** and accept the known over-count. For a vanity texture stat this is the right honesty/effort trade. -**If Daniel wants the stronger "stores nothing" posture, Option B is the fallback** — at the cost of -the metric becoming daily-unique and noisier. The two are not mutually exclusive long-term (A for -all-time reach, B-style for daily actives) but v1 should pick one. **This is D5.** +*Road not taken:* Option B (server-derived salted daily token — stores nothing client-side) was the +fallback for a stronger "stores nothing" posture, at the cost of the metric becoming daily-unique and +noisier; rejected because the product wants an *all-time* reach figure, which B structurally cannot +give. Option C (fingerprint) rejected outright — it is exactly what privacy regulation and browser +vendors are killing, and contradicts "privacy-light." A and B are not mutually exclusive long-term (A +for all-time reach, B-style for daily actives), but v1 commits to A. -**Either way, unique-listeners is the deferred §6 stretch** — the play/share counters (which need no -`anonId` at all) ship first and stand alone. +**Sequencing note (changed 2026-06-19):** unique-listeners is **no longer an indefinite stretch +tail.** Under the bottom-up re-sequencing, Daniel wants *everything* finished before the card lights +up — so the `anonId` layer is folded into the substrate build (§6, wave 16.3) as the lowest-priority +/ last of the metric layers, not an optional dangler. The play/share counters still need no `anonId` +and land first; unique-listeners stacks on top before the capstone card. --- @@ -373,11 +391,12 @@ small rolled-up counter table for the home-card hot read.** Reasoning: - (3) gets both without forcing a premature choice, and matches the existing `HomeStatsDto` pattern (the card reads a pre-aggregated DTO; it never queries raw). -For v1 the rollup can be **incremental-on-write** (the event-write transaction also bumps the counter -row) to avoid standing up a background job. If write volume ever makes that contended, a periodic -aggregation pass is the escape hatch — but for a collective-scale site, incremental is fine and -simplest. (**D6**: incremental-on-write rollup vs. periodic-batch rollup. Recommend incremental for -v1.) +**D6 RESOLVED (Daniel 2026-06-19, by default): incremental-on-write rollup.** The event-write +transaction also bumps the counter row — no background job to stand up. *Road not taken:* periodic +batch aggregation, the escape hatch if write volume ever makes the incremental bump contended; for a +collective-scale site incremental is fine and simplest. *Low-risk to revisit during its wave (§6) if +implementation surfaces a reason* — switching to a periodic pass later doesn't change the schema, only +how the counter is fed. ### 4.2 Conceptual SQL shape (not full schema — that's staff-engineer's) @@ -431,11 +450,14 @@ odometer treatment (the "90s visitor counter" vibe is *already the intended aest placeholder is literally styled as an odometer). The minimal, correct payoff: - **Primary figure:** total plays site-wide (sum across all tracks), rendered in the odometer. -- **Secondary line (optional, recommend yes):** something with texture that fits the card's existing - two-line shape (the other two cards both have a primary + secondary). Candidates: total shares - ("N shared"), or completion rate ("N% finished" = complete / total), or unique listeners once the - stretch lands. **Recommend completion-rate or share-count for v1**, swapping to listeners if/when - §6 ships. (**D7** — what's the card's secondary line.) +- **Secondary line: D7 RESOLVED (Daniel 2026-06-19, by default).** The card gets a secondary line + (the other two cards both have primary + secondary). **Because the bottom-up re-sequencing now lands + unique listeners *before* the card (§6), the secondary line can be unique listeners ("N listeners") + from day one** — the metric the card was always reaching for. Completion-rate ("N% finished") and + total-shares ("N shared") remain available alternatives if listeners reads oddly in the odometer + treatment; final pick is a small render-time call during the capstone wave, low-risk to revisit. + *(Under the prior early-card sequencing this had to be completion-rate or shares because listeners + shipped later; the re-sequencing removes that constraint.)* Mechanically: add `TotalPlays` (and the chosen secondary) to `HomeStatsDto`, populate it in `TrackRepository.GetHomeStatsAsync` from `play_counter`, and the card reads it through the existing @@ -447,67 +469,105 @@ its keep the moment the home number goes live, before any per-track surface or u --- -## 6. Suggested phasing +## 6. Phasing — bottom-up (Daniel directive, 2026-06-19) -Sequenced so the visible payoff lands early and the privacy-sensitive stretch lands last. Each wave is -independently shippable. +**Re-sequenced bottom-up: foundation first, metrics stacked on the substrate, the user-visible +Plays-card flip is the capstone built LAST.** This reverses the earlier "visible win comes early" +framing. Daniel: *"do the phasing from the bottom up, that seems more stable. I won't care about the +live card until everything is finished."* So the entire telemetry substrate and **all** metrics +(including unique listeners, which is no longer an indefinite tail) land before the card lights up. -- **16.A — Play & share counters (core).** The whole spine: player-service play tracker (§2.1) with - the three-bucket classification (§1a) and engagement floor (§1d/D2); share tracker in `SharePopover` - (§1b); `sendBeacon` interop + `POST api/event/{play,share}` endpoints (§2.2) with rate-limiting - (§2.5); `play_event`/`share_event` log + incremental `play_counter` rollup (§4); server-side release - resolution (§2.3, D4). **No `anonId` written yet** (unique listeners is 16.C). **Free-floating — - the cold-start wave; nothing gates it.** -- **16.B — Home Plays-card payoff (§5).** Extend `HomeStatsDto` + `GetHomeStatsAsync` with `TotalPlays` - (+ chosen secondary, D7); flip `NowPlayingStats`'s third card from placeholder to live. **Depends on - 16.A** (needs the counter to read). This is the visible win — sequence it immediately after 16.A. -- **16.C — Unique listeners (stretch / "plus").** The anonymity mechanism (§3, D5 — recommend Option - A): mint/read the anon token, thread it onto event payloads, count distinct server-side, expose via - the per-target stats reads and/or as the home card's secondary line. **Depends on 16.A** (extends the - event payload + storage). **Explicitly lower priority** — 16.A+16.B deliver the agreed core; 16.C is - the agreed stretch. Can be deferred indefinitely without stranding anything. -- **16.D — Per-target stats surfaces (adjacent, not committed).** Detail-page play/share/listener - display via `GET api/stats/{track,release}/{key}`; CMS analytics views (bucket splits, channel - splits, leaderboards). **Speculative** — flagged for when a surface actually wants these. Not part of - the agreed scope; listed so the substrate (the event log) is understood to already support it. +Waves run in strict sequence — each builds on the layer beneath it. -**Hard dependencies:** `16.A → 16.B`; `16.A → 16.C`; `16.A → 16.D`. 16.A is the only cold-start wave. -16.B and 16.C are parallel after 16.A (B is the priority; C is the stretch). +- **16.1 — Foundation: capture seam + transport + event log (no card consumption).** The substrate, + end to end, with nothing reading it yet: + - Player-service **play-session tracker** (§2.1): opens on playback-start, advances the high-water + mark on the existing progress callback, closes on track-switch / stop / organic-end / page-unload. + Applies the §1d/D2 **engagement floor** (≥3s or ≥5%, whichever smaller). + - Share tracker in `SharePopover` (§1b) with the per-(target,channel) debounce. + - **`sendBeacon` interop** + `pagehide`/`visibilitychange` unload handler (§2.2). + - **`POST api/event/{play,share}`** endpoints, **proxied through `DeepDrftPublic`** (§2.2), with + IP rate-limiting + payload validation (§2.5). + - **Append-only `play_event` / `share_event` SQL log** + **incremental `play_counter` rollup** (§4, + D6) in `DeepDrftData`. + - **Server-side release resolution** + derived release totals (§2.3, D4) — client sends only the + track key. + - At this point events flow and counters accumulate, but **no `anonId` is written** and **nothing + reads the counters**. This is the stable base everything stacks on. **Cold-start wave — nothing + gates it.** +- **16.2 — Completion-bucket classification + shares.** The metric texture on top of the raw capture: + the three-bucket classification (§1a/D1 — `partial`/`sampled`/`complete`, headline = sum) wired + through the tracker → event payload → log → counter columns, and the share-channel split + (`link`/`embed`) landing in `share_event`. (Much of the bucket plumbing is natural to build + alongside 16.1; 16.2 is the boundary where the classification is *correct and exhaustive* end to + end, including counter columns per bucket.) **Depends on 16.1.** +- **16.3 — Unique-listener `anonId` layer (D5, lowest-priority metric).** The anonymity mechanism + (§3, Option A): mint/read the client first-party `localStorage` id, thread `anonId` onto the event + payloads, store it nullable on the event log, count distinct server-side (all-time, D3). **The last + of the metric layers** — folded into "everything finished" per the directive, but explicitly the + lowest-priority and last-built of the substrate. **Depends on 16.1 (event payload + storage); builds + on 16.2.** +- **16.4 — Per-target / CMS stats surfaces** `[speculative]`**.** `GET api/stats/{track,release}/{key}` + per-target reads; CMS analytics views (bucket splits, channel splits, leaderboards). **Speculative, + not committed scope** — the event log already supports it. Position: **before the capstone** if a + per-target surface is wanted (e.g. to validate the metrics visually in the CMS before the public + card goes live), otherwise skippable. Does not gate the card. **Depends on 16.1–16.3** for the data + it would read. +- **16.5 — Home Plays-card payoff (CAPSTONE, built LAST).** Extend `HomeStatsDto` + + `GetHomeStatsAsync` with `TotalPlays` (+ the secondary line — unique listeners now available, D7); + flip `NowPlayingStats`'s third card from the `XXX / Plays (Coming Soon)` placeholder to live, + through the existing persistent-state-bridged `GET api/stats/home` round-trip (§5). **The final + wave — built only once the full substrate and all metrics (16.1–16.3) are in place.** Per Daniel, + the live card is explicitly the last thing built; there is no early-payoff intermediate. -**Verify-before-build:** confirm the `DeepDrftPublic` proxy can host a `POST api/event/*` write route -with the same proxy idiom as `api/track/*` (the WASM client cannot reach `DeepDrftAPI` directly). This -is the one infrastructural assumption the spec rests on; cheap to confirm, blocking if wrong. +**Hard dependencies (strict bottom-up chain):** +`16.1 → 16.2 → 16.3 → (16.4 optional) → 16.5`. +16.1 is the only cold-start wave. 16.5 (the card) sits at the top and depends on the whole stack +beneath it being finished. 16.4 is speculative and off the critical path to the card. + +**Verify-before-build (gates 16.1):** confirm the `DeepDrftPublic` proxy can host a `POST api/event/*` +write route with the same proxy idiom as `api/track/*` (the WASM client cannot reach `DeepDrftAPI` +directly). This is the one infrastructural assumption the spec rests on; cheap to confirm, blocking if +wrong. --- -## 10. Open product decisions (for Daniel) +## 10. Product decisions — RESOLVED (Daniel 2026-06-19) -Resolve these before 16.A is decomposed. Recommendations carried from the body; the call is Daniel's. +All seven decisions are settled. D1, D2, D4, D5 were resolved on Daniel's explicit pick of the +recommendation; D3, D6, D7 are **resolved-by-default** (recommendation adopted) and remain low-risk +to revisit during their wave if implementation surfaces a reason. The "road not taken" for each is +preserved in the body section so future implementers know what was rejected and why. -- **D1 — Middle-band (30–80%) treatment.** Recommend a third `sampled` bucket (exhaustive - partial/sampled/complete); headline plays = sum of all three. Alt: fold middle into a binary - partial/complete. (§1a) -- **D2 — Engagement floor before a play counts.** Recommend a floor (~≥3s or ≥5% of duration) so - archive-skimming doesn't inflate the count. Alt: floor = 0, every started playback counts. (§1d) -- **D3 — Unique-listener window.** All-time vs. rolling-30-day. Recommend all-time (fits the "N - listeners reached" framing and Option-A mechanism). (§1c) — only bites if 16.C is built. -- **D4 — Release plays: derived or separately counted.** Recommend derived (release plays = sum of its - tracks' plays), correct for multi-track Cuts and single-track Session/Mix alike. (§2.3) -- **D5 — Unique-listener anonymity mechanism.** Recommend Option A (client-minted random first-party - localStorage id, metric honestly labelled "listeners," all-time). Fallback Option B (server-derived - salted daily token — stores nothing client-side but only yields daily-unique). Option C - (fingerprint) rejected. **This is the headline decision.** (§3) -- **D6 — Rollup strategy.** Recommend incremental-on-write counter for v1 (no background job). Alt: - periodic batch aggregation. (§4.1) -- **D7 — Home Plays-card secondary line.** Recommend completion-rate or total-shares for v1, swapping - to unique listeners if/when 16.C ships. (§5) +- **D1 — Middle-band (30–80%) treatment. → RESOLVED: three-bucket `sampled`.** Exhaustive + partial/sampled/complete; headline plays = sum of all three. *Rejected:* binary partial/complete + fold. (§1a) +- **D2 — Engagement floor before a play counts. → RESOLVED: floor on.** A listen counts only at + **≥3s OR ≥5% of duration, whichever is smaller**; below that it's a dropped preview/skip. *Rejected:* + floor = 0. Single tunable constant. (§1d) +- **D3 — Unique-listener window. → RESOLVED (by default): all-time.** Fits "N listeners reached" and + the Option-A mechanism. *Rejected:* rolling-30-day. Low-risk to revisit in wave 16.3. (§1c) +- **D4 — Release plays: derived or separately counted. → RESOLVED: derived** (release plays = sum of + its tracks' plays), server-side. Correct for multi-track Cuts and single-track Session/Mix alike. + *Rejected:* separate "release was played" event. (§2.3) +- **D5 — Unique-listener anonymity mechanism. → RESOLVED: Option A** — client-minted random + first-party `localStorage` id, metric honestly labelled **"listeners,"** all-time. *Rejected:* + Option B (server-derived salted daily token — stores nothing but only daily-unique); Option C + (fingerprint — contradicts privacy-light) rejected outright. Headline decision. (§3) +- **D6 — Rollup strategy. → RESOLVED (by default): incremental-on-write** counter, no background job. + *Rejected:* periodic batch aggregation (kept as the escape hatch if write volume contends). Low-risk + to revisit in wave 16.1. (§4.1) +- **D7 — Home Plays-card secondary line. → RESOLVED (by default): unique listeners ("N listeners").** + The bottom-up re-sequence lands listeners (16.3) before the card (16.5), so the card can show the + metric it was always reaching for. *Alternatives kept available:* completion-rate or total-shares, + a small render-time call during 16.5. Low-risk to revisit in the capstone wave. (§5) ## Working with this spec - Mirrors the established product-notes convention (cross-refs up top, numbered sections, decisions called out, phasing with explicit dependencies) — see `phase-15-visualizer-controls-enhancements.md` / `phase-11-public-site-enhancements.md`. -- When Daniel resolves the D-decisions, fold the resolutions inline (mark the section "resolved by - Daniel YYYY-MM-DD") and flip the status to design-complete, the same way Phase 15 did. +- Decisions D1–D7 are **resolved** (Daniel 2026-06-19) and folded inline; status is design-complete. + Waves are re-sequenced bottom-up (16.1 → 16.5, card last) per Daniel's directive of the same date. - When waves land, doc-keeper moves them to `COMPLETED.md`; the per-wave bodies are written to travel cleanly.