From be1a55fd37475257e7304364b3e5b5948e7fffd4 Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Fri, 19 Jun 2026 15:26:07 -0400 Subject: [PATCH] feat(stats): flip home Plays card live (Phase 16.5) Add TotalPlays + UniqueListeners to HomeStatsDto, composed at StatsController from IEventService (no migration). Card reads via existing persistent-state-bridged round-trip. --- DeepDrftAPI/Controllers/StatsController.cs | 37 ++++++++++++++---- DeepDrftData/EventManager.cs | 14 +++++++ DeepDrftData/IEventService.cs | 7 ++++ DeepDrftData/Repositories/EventRepository.cs | 10 +++++ DeepDrftModels/DTOs/HomeStatsDto.cs | 18 ++++++++- .../Controls/NowPlayingStats.razor | 11 ++++-- DeepDrftTests/PlayEventQueryTests.cs | 39 +++++++++++++++++++ 7 files changed, 123 insertions(+), 13 deletions(-) diff --git a/DeepDrftAPI/Controllers/StatsController.cs b/DeepDrftAPI/Controllers/StatsController.cs index 7f03e49..392a4dd 100644 --- a/DeepDrftAPI/Controllers/StatsController.cs +++ b/DeepDrftAPI/Controllers/StatsController.cs @@ -8,29 +8,52 @@ namespace DeepDrftAPI.Controllers; public class StatsController : ControllerBase { private readonly ITrackService _sqlTrackService; + private readonly IEventService _eventService; private readonly ILogger _logger; - public StatsController(ITrackService sqlTrackService, ILogger logger) + public StatsController( + ITrackService sqlTrackService, IEventService eventService, ILogger logger) { _sqlTrackService = sqlTrackService; + _eventService = eventService; _logger = logger; } // GET api/stats/home (unauthenticated) // Aggregate figures behind the public home hero stat row — one read for all three cards. Same auth - // posture as the other public browse reads (GET api/track/page). The aggregation lives in the SQL - // service/repository; this controller stays a thin HTTP boundary. + // posture as the other public browse reads (GET api/track/page). The figures span two domains: + // the track-domain aggregation (Cuts/Mixes cards) lives in the SQL track service; the play-domain + // figures (Phase 16 Plays card — total plays + unique listeners) live in the event service. This + // controller is the thin composition seam that assembles both into one HomeStatsDto — neither + // domain reaches into the other's tables. Play/listener figures are best-effort: a telemetry read + // failure (or the not-yet-applied migration) leaves them at zero rather than failing the whole card. [HttpGet("home")] public async Task GetHome(CancellationToken ct = default) { - var result = await _sqlTrackService.GetHomeStats(ct); - if (!result.Success || result.Value is null) + var trackResult = await _sqlTrackService.GetHomeStats(ct); + if (!trackResult.Success || trackResult.Value is null) { - var error = result.Messages.FirstOrDefault()?.Message ?? "Unknown error"; + var error = trackResult.Messages.FirstOrDefault()?.Message ?? "Unknown error"; _logger.LogError("GetHome stats failed: {Error}", error); return StatusCode(500, "Failed to load stats"); } - return Ok(result.Value); + var stats = trackResult.Value; + + var playsResult = await _eventService.GetTotalPlayCount(ct); + if (playsResult is { Success: true }) + stats.TotalPlays = playsResult.Value; + else + _logger.LogWarning("GetHome total-plays read failed; Plays card falls back to 0: {Error}", + playsResult.Messages.FirstOrDefault()?.Message); + + var listenersResult = await _eventService.GetDistinctListenerCount(ct); + if (listenersResult is { Success: true }) + stats.UniqueListeners = listenersResult.Value; + else + _logger.LogWarning("GetHome unique-listeners read failed; secondary line falls back to 0: {Error}", + listenersResult.Messages.FirstOrDefault()?.Message); + + return Ok(stats); } } diff --git a/DeepDrftData/EventManager.cs b/DeepDrftData/EventManager.cs index 75ce05e..2cfae35 100644 --- a/DeepDrftData/EventManager.cs +++ b/DeepDrftData/EventManager.cs @@ -54,6 +54,20 @@ public class EventManager : IEventService } } + public async Task> GetTotalPlayCount(CancellationToken cancellationToken = default) + { + try + { + var count = await _repository.CountTotalPlaysAsync(cancellationToken); + return ResultContainer.CreatePassResult(count); + } + catch (Exception e) + { + _logger.LogError(e, "Failed to count total plays"); + return ResultContainer.CreateFailResult(e.Message); + } + } + public async Task> GetDistinctListenerCount(CancellationToken cancellationToken = default) { try diff --git a/DeepDrftData/IEventService.cs b/DeepDrftData/IEventService.cs index 5feaf77..a16299a 100644 --- a/DeepDrftData/IEventService.cs +++ b/DeepDrftData/IEventService.cs @@ -21,6 +21,13 @@ public interface IEventService /// Record one share: append a share_event row. Target and channel come straight from the client. Task RecordShare(ShareTargetType targetType, string targetKey, ShareChannel channel, string? anonId = null, CancellationToken cancellationToken = default); + /// + /// Site-wide total play count (Phase 16 §5 — all-time): the sum of every play_counter row's + /// three bucket columns. Zero until the telemetry migration is applied. The home Plays card's primary + /// figure; the controller composes it onto HomeStatsDto alongside the track-domain figures. + /// + Task> GetTotalPlayCount(CancellationToken cancellationToken = default); + /// /// Site-wide distinct-listener count (Phase 16 §3, D3 — all-time): distinct non-null anon_id /// values across all play events. Null tokens are excluded (not a known listener). The capability for diff --git a/DeepDrftData/Repositories/EventRepository.cs b/DeepDrftData/Repositories/EventRepository.cs index bc77171..b562d83 100644 --- a/DeepDrftData/Repositories/EventRepository.cs +++ b/DeepDrftData/Repositories/EventRepository.cs @@ -83,6 +83,16 @@ public class EventRepository } } + /// + /// Site-wide total plays: the sum of every counter's three bucket columns across all rows (Phase 16 + /// §5). Sums the mapped columns directly rather than , which is an + /// EF-ignored computed property and so not translatable. An empty counter table sums to 0 (the home + /// card's expected reading until the telemetry migration is applied). + /// + public Task CountTotalPlaysAsync(CancellationToken ct = default) + => _context.PlayCounters + .SumAsync(c => c.PartialCount + c.SampledCount + c.CompleteCount, ct); + /// /// Count distinct non-null anon ids across every play event (Phase 16 §3 / §4.2 — the all-time /// unique-listener metric, D3). Null anon ids (events where the listener sent no token, or storage diff --git a/DeepDrftModels/DTOs/HomeStatsDto.cs b/DeepDrftModels/DTOs/HomeStatsDto.cs index 5d582fd..feab175 100644 --- a/DeepDrftModels/DTOs/HomeStatsDto.cs +++ b/DeepDrftModels/DTOs/HomeStatsDto.cs @@ -4,8 +4,8 @@ namespace DeepDrftModels.DTOs; /// /// Aggregate figures behind the public home hero stat row (NowPlayingStats). A single read returns -/// everything the three cards need so the client makes one round-trip. All counts exclude soft-deleted -/// rows. The Plays card is a static placeholder and has no field here. +/// everything the three cards need so the client makes one round-trip. The track-domain counts exclude +/// soft-deleted rows; the play-domain figures (Phase 16) come from the event domain. /// public class HomeStatsDto { @@ -27,6 +27,20 @@ public class HomeStatsDto /// duration (not yet backfilled) contribute 0. The Mixes card's secondary figure, rendered hh:mm. /// public double MixRuntimeSeconds { get; set; } + + /// + /// Site-wide total plays across all tracks — the sum of every play_counter's bucket columns + /// (partial + sampled + complete), all-time (Phase 16 §5). The Plays card's primary odometer figure. + /// Reads zero until the play-telemetry migration is applied; that is expected, not an error. + /// + public long TotalPlays { get; set; } + + /// + /// Site-wide distinct anonymous listeners — distinct non-null anon_id across all play events, + /// all-time (Phase 16 §3 / D7). The Plays card's secondary line ("N listeners"). Over-counts by + /// design (one token per browser-install, honestly labelled "listeners"). + /// + public int UniqueListeners { get; set; } } /// One row of the Cut release-type breakdown: a ReleaseType and how many Cut releases have it. diff --git a/DeepDrftPublic.Client/Controls/NowPlayingStats.razor b/DeepDrftPublic.Client/Controls/NowPlayingStats.razor index 36d07df..c02e77d 100644 --- a/DeepDrftPublic.Client/Controls/NowPlayingStats.razor +++ b/DeepDrftPublic.Client/Controls/NowPlayingStats.razor @@ -29,11 +29,14 @@
@RuntimeFormat.ToHoursMinutes(_stats.MixRuntimeSeconds) runtime
- @* Plays — static placeholder (real play/share tracking is a future phase). Odometer treatment over - the existing card style; copy is placeholder pending sign-off. *@ + @* Plays — live site-wide play total in the odometer (the "90s visitor counter" aesthetic is the + intended treatment). Secondary line is unique anonymous listeners (Phase 16 D7). Both read from + the same HomeStatsDto round-trip the other two cards use — no extra fetch. Reads zero until the + play-telemetry migration is applied. *@
-
XXX
-
Plays (Coming Soon)
+
@_stats.TotalPlays
+
Plays
+
@_stats.UniqueListeners listeners
diff --git a/DeepDrftTests/PlayEventQueryTests.cs b/DeepDrftTests/PlayEventQueryTests.cs index 052b02e..8832507 100644 --- a/DeepDrftTests/PlayEventQueryTests.cs +++ b/DeepDrftTests/PlayEventQueryTests.cs @@ -159,6 +159,45 @@ public class PlayEventQueryTests Assert.That(await _context.PlayCounters.AnyAsync(c => c.TrackId == track.Id), Is.False); } + // --- Site-wide total plays (§5 — the home Plays card's primary figure) --- + + // An empty counter table sums to zero rather than throwing — the card's reading until the + // telemetry migration is applied and the first play lands. + [Test] + public async Task CountTotalPlaysAsync_NoCounters_IsZero() + { + Assert.That(await CreateRepository().CountTotalPlaysAsync(), Is.EqualTo(0L)); + } + + // Total plays sums all three bucket columns of a single track's counter. + [Test] + public async Task CountTotalPlaysAsync_SumsAllBucketsOfOneCounter() + { + await SeedTrackAsync("track-1"); + var repo = CreateRepository(); + await repo.RecordPlayAsync("track-1", PlayBucket.Partial, null); + await repo.RecordPlayAsync("track-1", PlayBucket.Sampled, null); + await repo.RecordPlayAsync("track-1", PlayBucket.Sampled, null); + await repo.RecordPlayAsync("track-1", PlayBucket.Complete, null); + + Assert.That(await repo.CountTotalPlaysAsync(), Is.EqualTo(4L)); + } + + // Total plays is site-wide: it sums across every track's counter, not one track's. + [Test] + public async Task CountTotalPlaysAsync_SumsAcrossAllTracks() + { + await SeedTrackAsync("track-1"); + await SeedTrackAsync("track-2"); + var repo = CreateRepository(); + await repo.RecordPlayAsync("track-1", PlayBucket.Complete, null); + await repo.RecordPlayAsync("track-1", PlayBucket.Partial, null); + await repo.RecordPlayAsync("track-2", PlayBucket.Sampled, null); + + Assert.That(await repo.CountTotalPlaysAsync(), Is.EqualTo(3L), + "site-wide total spans every track's counter"); + } + // A share append writes one row with the target, channel, and a null anonId. [Test] public async Task RecordShareAsync_AppendsRow()