docs(phase-16): resolve decisions D1-D7; re-sequence waves bottom-up, card last
This commit is contained in:
@@ -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.
|
**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.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.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.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.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.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.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.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.
|
**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.
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,16 @@
|
|||||||
# Phase 16 — Anonymous Play & Share Tracking (Design Spec)
|
# Phase 16 — Anonymous Play & Share Tracking (Design Spec)
|
||||||
|
|
||||||
Status: **design-draft, open for Daniel review** (decision points in §10 are unresolved). Author:
|
Status: **design-complete — decisions D1–D7 resolved by Daniel 2026-06-19.** Author:
|
||||||
product-designer. Date: 2026-06-18. **No code has been written by this doc.** This is the phase
|
product-designer. Drafted 2026-06-18; decisions resolved and phasing re-sequenced 2026-06-19.
|
||||||
deferred behind the home-hero "Plays" stat card, which today renders a static
|
**No code has been written by this doc.** This is the phase deferred behind the home-hero "Plays"
|
||||||
`XXX / Plays (Coming Soon)` odometer placeholder in `NowPlayingStats.razor`.
|
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:
|
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
|
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
|
- **partial** — playback reached **< 30%** of the track's duration before the session ended
|
||||||
(switched track, stopped, navigated away, closed tab).
|
(switched track, stopped, navigated away, closed tab).
|
||||||
- **complete** — playback reached **> 80%** of duration.
|
- **complete** — playback reached **> 80%** of duration.
|
||||||
- **middle band (30%–80%)** — see decision **D1** below. **Recommendation: count the middle band as
|
- **middle band (30%–80%)** — **D1 RESOLVED (Daniel 2026-06-19): three-bucket `sampled`.** The
|
||||||
its own bucket, `sampled`** (a real listen that wasn't a skip and wasn't a finish), so the three
|
middle band is its own bucket, `sampled` (a real listen that wasn't a skip and wasn't a finish), so
|
||||||
buckets are exhaustive and non-overlapping: `partial` [0, 30%), `sampled` [30%, 80%], `complete`
|
the three buckets are exhaustive and non-overlapping: `partial` [0, 30%), `sampled` [30%, 80%],
|
||||||
(80%, 100%]. The headline "Plays" number is the **sum of all three** (every started listen counts
|
`complete` (80%, 100%]. The headline "Plays" number is the **sum of all three** (every started
|
||||||
as a play); the buckets are the texture beneath it.
|
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,
|
*Road not taken:* folding the middle into "complete" (threshold "≥30% = a real play, else partial")
|
||||||
else partial"). Simpler, two buckets — but it throws away the distinction between "listened to half"
|
— simpler, two buckets, but it discards the "listened to half" vs. "listened to the end"
|
||||||
and "listened to the end," which is the most editorially interesting signal for a music collective
|
distinction, which is the most editorially interesting signal for a music collective ("which mixes
|
||||||
("which mixes do people actually finish?"). Rejected in favour of three buckets, but it's a genuine
|
do people actually finish?"). Three buckets chosen for that texture.
|
||||||
Daniel call (**D1**).
|
|
||||||
|
|
||||||
**What starts a play candidate:** a track's audio actually begins streaming for playback — i.e.
|
**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`
|
`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)
|
### 1c. Unique listeners (stretch / "plus" — lower priority)
|
||||||
|
|
||||||
A **unique listener** is an approximate distinct-listener count over a window (all-time, or rolling
|
A **unique listener** is an approximate distinct-listener count, tied to a track or release. **D3
|
||||||
30 days — **D3**), tied to a track or release. This is the metric most in tension with the no-PII
|
RESOLVED (Daniel 2026-06-19, by default): all-time window** — not rolling-30-day. All-time fits the
|
||||||
constraint, and it is explicitly the **last** thing to build (§6). It is approximate by design — we
|
"N listeners reached" framing and the Option-A mechanism (§3). This is the metric most in tension
|
||||||
are not building identity, we are estimating reach. Mechanism options and recommendation: **§3**.
|
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)
|
### 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
|
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.)
|
tweak — flag as a tuning knob, not a v1 requirement.)
|
||||||
- **Rapid skips.** Listener clicks through ten tracks in five seconds. Each reaches `< 30%` →
|
- **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
|
ten `partial` plays. **D2 RESOLVED (Daniel 2026-06-19): apply a minimum-engagement floor.** A
|
||||||
at all** — e.g. playback must reach **≥ 3 seconds OR ≥ 5% of duration** (whichever is smaller) for
|
listen registers as a play only once playback reaches **≥ 3 seconds OR ≥ 5% of duration, whichever
|
||||||
the listen to register as a play. Below the floor it's a *preview/skip*, not a play, and is dropped
|
is smaller** (so a sub-60s clip floors on the percentage; anything longer floors on the 3-second
|
||||||
entirely. This keeps the headline number honest (a skim through the archive isn't 40 plays) while
|
wall). Below the floor it is a *preview/skip*, dropped entirely (no event sent). This keeps the
|
||||||
still capturing genuine short partial listens. The floor is a single tunable constant. **D2 is a
|
headline number honest — a skim through the archive isn't 40 plays — while still capturing genuine
|
||||||
Daniel call** — the alternative is "every started playback counts, floor = 0," which is simpler and
|
short partial listens. The floor is a single tunable constant (one place to change if the band
|
||||||
defensible ("they hit play, it's a play") but inflates the number on a browsing session.
|
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.
|
- **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.
|
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
|
- **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
|
client plumbing; the release dimension is always correct even for plays that started without
|
||||||
release context (e.g. StreamNow random track).
|
release context (e.g. StreamNow random track).
|
||||||
|
|
||||||
**Recommendation: option 2 (resolve server-side).** The track→release join is authoritative and
|
**RESOLVED: option 2 (resolve server-side).** The track→release join is authoritative and already in
|
||||||
already in `DeepDrftData`; sending only the track key keeps the client dumb and the payload minimal,
|
`DeepDrftData`; sending only the track key keeps the client dumb and the payload minimal, and a
|
||||||
and it means a random-track play still gets correctly attributed to its release without the client
|
random-track play still gets correctly attributed to its release without the client knowing. The
|
||||||
knowing. The client sends what it cheaply knows (track key); the server enriches. This also means
|
client sends what it cheaply knows (track key); the server enriches. **D4 RESOLVED (Daniel
|
||||||
**release play counts are derived** (sum of plays of the release's tracks) rather than separately
|
2026-06-19): release plays are derived** (sum of plays of the release's tracks), not a
|
||||||
counted — which is exactly right for multi-track Cuts and trivially correct for single-track
|
separately-counted "release was played" event — exactly right for multi-track Cuts and trivially
|
||||||
Session/Mix. (**D4**: confirm release plays = sum-of-track-plays, not a separately-counted "release
|
correct for single-track Session/Mix. The client sends only the track key; release attribution and
|
||||||
was played" event. Recommend derived.)
|
release-total derivation are both server-side.
|
||||||
|
|
||||||
Shares already carry the right target directly (`SharePopover` knows track vs. release), so share
|
Shares already carry the right target directly (`SharePopover` knows track vs. release), so share
|
||||||
attribution needs no resolution step.
|
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)
|
## 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
|
**D5 RESOLVED (Daniel 2026-06-19): Option A — client-minted random first-party `localStorage` id,
|
||||||
eyes (**D5**). The unique-listener metric needs *some* notion of "the same anonymous listener seen
|
metric labelled "listeners," fingerprinting (Option C) rejected.** Rationale and the road not taken
|
||||||
again" without identifying who they are. Three mechanism families, with trade-offs:
|
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)
|
### 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
|
actively killing; it tracks across sites and survives storage-clearing. **Directly contradicts
|
||||||
"privacy-light."** Rejected outright — listed only for completeness.
|
"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
|
**Option A (client-minted random first-party id) is the chosen mechanism, with the metric honestly
|
||||||
labelled.** Reasons:
|
labelled "listeners."** Reasons:
|
||||||
|
|
||||||
- It fits the product: a band wants an *all-time* "N listeners reached" figure, which A supports and
|
- It fits the product: a band wants an *all-time* "N listeners reached" figure, which A supports and
|
||||||
B (daily-only) structurally cannot.
|
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
|
- 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.
|
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
|
*Road not taken:* Option B (server-derived salted daily token — stores nothing client-side) was the
|
||||||
the metric becoming daily-unique and noisier. The two are not mutually exclusive long-term (A for
|
fallback for a stronger "stores nothing" posture, at the cost of the metric becoming daily-unique and
|
||||||
all-time reach, B-style for daily actives) but v1 should pick one. **This is D5.**
|
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
|
**Sequencing note (changed 2026-06-19):** unique-listeners is **no longer an indefinite stretch
|
||||||
`anonId` at all) ship first and stand alone.
|
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
|
- (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).
|
(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
|
**D6 RESOLVED (Daniel 2026-06-19, by default): incremental-on-write rollup.** The event-write
|
||||||
row) to avoid standing up a background job. If write volume ever makes that contended, a periodic
|
transaction also bumps the counter row — no background job to stand up. *Road not taken:* periodic
|
||||||
aggregation pass is the escape hatch — but for a collective-scale site, incremental is fine and
|
batch aggregation, the escape hatch if write volume ever makes the incremental bump contended; for a
|
||||||
simplest. (**D6**: incremental-on-write rollup vs. periodic-batch rollup. Recommend incremental for
|
collective-scale site incremental is fine and simplest. *Low-risk to revisit during its wave (§6) if
|
||||||
v1.)
|
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)
|
### 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:
|
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.
|
- **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
|
- **Secondary line: D7 RESOLVED (Daniel 2026-06-19, by default).** The card gets a secondary line
|
||||||
two-line shape (the other two cards both have a primary + secondary). Candidates: total shares
|
(the other two cards both have primary + secondary). **Because the bottom-up re-sequencing now lands
|
||||||
("N shared"), or completion rate ("N% finished" = complete / total), or unique listeners once the
|
unique listeners *before* the card (§6), the secondary line can be unique listeners ("N listeners")
|
||||||
stretch lands. **Recommend completion-rate or share-count for v1**, swapping to listeners if/when
|
from day one** — the metric the card was always reaching for. Completion-rate ("N% finished") and
|
||||||
§6 ships. (**D7** — what's the card's secondary line.)
|
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
|
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
|
`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
|
**Re-sequenced bottom-up: foundation first, metrics stacked on the substrate, the user-visible
|
||||||
independently shippable.
|
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
|
Waves run in strict sequence — each builds on the layer beneath it.
|
||||||
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.
|
|
||||||
|
|
||||||
**Hard dependencies:** `16.A → 16.B`; `16.A → 16.C`; `16.A → 16.D`. 16.A is the only cold-start wave.
|
- **16.1 — Foundation: capture seam + transport + event log (no card consumption).** The substrate,
|
||||||
16.B and 16.C are parallel after 16.A (B is the priority; C is the stretch).
|
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
|
**Hard dependencies (strict bottom-up chain):**
|
||||||
with the same proxy idiom as `api/track/*` (the WASM client cannot reach `DeepDrftAPI` directly). This
|
`16.1 → 16.2 → 16.3 → (16.4 optional) → 16.5`.
|
||||||
is the one infrastructural assumption the spec rests on; cheap to confirm, blocking if wrong.
|
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
|
- **D1 — Middle-band (30–80%) treatment. → RESOLVED: three-bucket `sampled`.** Exhaustive
|
||||||
partial/sampled/complete); headline plays = sum of all three. Alt: fold middle into a binary
|
partial/sampled/complete; headline plays = sum of all three. *Rejected:* binary partial/complete
|
||||||
partial/complete. (§1a)
|
fold. (§1a)
|
||||||
- **D2 — Engagement floor before a play counts.** Recommend a floor (~≥3s or ≥5% of duration) so
|
- **D2 — Engagement floor before a play counts. → RESOLVED: floor on.** A listen counts only at
|
||||||
archive-skimming doesn't inflate the count. Alt: floor = 0, every started playback counts. (§1d)
|
**≥3s OR ≥5% of duration, whichever is smaller**; below that it's a dropped preview/skip. *Rejected:*
|
||||||
- **D3 — Unique-listener window.** All-time vs. rolling-30-day. Recommend all-time (fits the "N
|
floor = 0. Single tunable constant. (§1d)
|
||||||
listeners reached" framing and Option-A mechanism). (§1c) — only bites if 16.C is built.
|
- **D3 — Unique-listener window. → RESOLVED (by default): all-time.** Fits "N listeners reached" and
|
||||||
- **D4 — Release plays: derived or separately counted.** Recommend derived (release plays = sum of its
|
the Option-A mechanism. *Rejected:* rolling-30-day. Low-risk to revisit in wave 16.3. (§1c)
|
||||||
tracks' plays), correct for multi-track Cuts and single-track Session/Mix alike. (§2.3)
|
- **D4 — Release plays: derived or separately counted. → RESOLVED: derived** (release plays = sum of
|
||||||
- **D5 — Unique-listener anonymity mechanism.** Recommend Option A (client-minted random first-party
|
its tracks' plays), server-side. Correct for multi-track Cuts and single-track Session/Mix alike.
|
||||||
localStorage id, metric honestly labelled "listeners," all-time). Fallback Option B (server-derived
|
*Rejected:* separate "release was played" event. (§2.3)
|
||||||
salted daily token — stores nothing client-side but only yields daily-unique). Option C
|
- **D5 — Unique-listener anonymity mechanism. → RESOLVED: Option A** — client-minted random
|
||||||
(fingerprint) rejected. **This is the headline decision.** (§3)
|
first-party `localStorage` id, metric honestly labelled **"listeners,"** all-time. *Rejected:*
|
||||||
- **D6 — Rollup strategy.** Recommend incremental-on-write counter for v1 (no background job). Alt:
|
Option B (server-derived salted daily token — stores nothing but only daily-unique); Option C
|
||||||
periodic batch aggregation. (§4.1)
|
(fingerprint — contradicts privacy-light) rejected outright. Headline decision. (§3)
|
||||||
- **D7 — Home Plays-card secondary line.** Recommend completion-rate or total-shares for v1, swapping
|
- **D6 — Rollup strategy. → RESOLVED (by default): incremental-on-write** counter, no background job.
|
||||||
to unique listeners if/when 16.C ships. (§5)
|
*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
|
## Working with this spec
|
||||||
|
|
||||||
- Mirrors the established product-notes convention (cross-refs up top, numbered sections, decisions
|
- 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`
|
called out, phasing with explicit dependencies) — see `phase-15-visualizer-controls-enhancements.md`
|
||||||
/ `phase-11-public-site-enhancements.md`.
|
/ `phase-11-public-site-enhancements.md`.
|
||||||
- When Daniel resolves the D-decisions, fold the resolutions inline (mark the section "resolved by
|
- Decisions D1–D7 are **resolved** (Daniel 2026-06-19) and folded inline; status is design-complete.
|
||||||
Daniel YYYY-MM-DD") and flip the status to design-complete, the same way Phase 15 did.
|
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
|
- When waves land, doc-keeper moves them to `COMPLETED.md`; the per-wave bodies are written to travel
|
||||||
cleanly.
|
cleanly.
|
||||||
|
|||||||
Reference in New Issue
Block a user