Wire NowPlayingStats to live aggregates: add SQL track duration column, stats endpoint, and duration backfill

This commit is contained in:
daniel-c-harvey
2026-06-18 11:53:49 -04:00
parent 8ddecb4acc
commit 5f0422a263
26 changed files with 1089 additions and 9 deletions
@@ -0,0 +1,39 @@
using DeepDrftModels.DTOs;
using NetBlocks.Models;
using System.Text.Json;
namespace DeepDrftPublic.Client.Clients;
/// <summary>
/// HTTP client for the public stats read surface. Uses the named <c>"DeepDrft.API"</c> client like
/// <see cref="TrackClient"/> and <see cref="ReleaseClient"/>: on WASM it points at the public host and
/// proxies through <c>StatsProxyController</c>; on SSR prerender it points directly at DeepDrftAPI. The
/// route is an unauthenticated read; the response deserializes as a bare DTO (no ApiResultDto envelope),
/// matching the API's <c>Ok(value)</c> shape.
/// </summary>
public class StatsClient
{
private static readonly JsonSerializerOptions JsonOptions = new() { PropertyNameCaseInsensitive = true };
private readonly HttpClient _http;
public StatsClient(IHttpClientFactory httpClientFactory)
{
_http = httpClientFactory.CreateClient("DeepDrft.API");
}
public async Task<ApiResult<HomeStatsDto>> GetHomeStats()
{
var response = await _http.GetAsync("api/stats/home");
if (!response.IsSuccessStatusCode)
return ApiResult<HomeStatsDto>.CreateFailResult($"HTTP {(int)response.StatusCode}");
var json = await response.Content.ReadAsStringAsync();
var stats = JsonSerializer.Deserialize<HomeStatsDto>(json, JsonOptions);
return stats is not null
? ApiResult<HomeStatsDto>.CreatePassResult(stats)
: ApiResult<HomeStatsDto>.CreateFailResult("Failed to deserialize response");
}
}
@@ -31,7 +31,8 @@
<div class="now-playing-content">
<NowPlayingCard />
@* Stat row - hard-coded for now. TODO Phase 2: wire to real track count / identity model. *@
@* Stat row — live aggregate figures (Cut track count + type breakdown, Mix sets + runtime);
the Plays card is a static placeholder pending real play tracking. *@
<NowPlayingStats />
</div>
</div>
@@ -1,14 +1,94 @@
@using DeepDrftModels.DTOs
@using DeepDrftModels.Enums
@using DeepDrftPublic.Client.Helpers
@using DeepDrftPublic.Client.Services
@implements IDisposable
<div class="hero-stat-row">
@* Studio Cuts — primary figure is the total Cut-medium track count; the secondary breakdown lists
per-ReleaseType Cut release counts, zero-count types already suppressed server-side. *@
<div class="hero-stat">
<div class="hero-stat-num">47+</div>
<div class="hero-stat-label">Live Sessions</div>
<div class="hero-stat-num">@_stats.CutTrackCount</div>
<div class="hero-stat-label">Studio Cuts</div>
@if (_stats.CutReleaseTypeCounts.Count > 0)
{
<div class="hero-stat-breakdown">
@foreach (var row in _stats.CutReleaseTypeCounts)
{
<span class="hero-stat-breakdown-item">@row.Count @PluralizeReleaseType(row.ReleaseType, row.Count)</span>
}
</div>
}
</div>
@* Mixes — primary figure is the Mix release count labelled "Sets"; the secondary figure is total
mix runtime as hh:mm. *@
<div class="hero-stat">
<div class="hero-stat-num">2</div>
<div class="hero-stat-label">Members</div>
<div class="hero-stat-num">@_stats.MixReleaseCount</div>
<div class="hero-stat-label">Sets</div>
<div class="hero-stat-sub">@RuntimeFormat.ToHoursMinutes(_stats.MixRuntimeSeconds) runtime</div>
</div>
@* Plays — static placeholder (real play/share tracking is a future phase). Odometer treatment over
the existing card style; copy is placeholder pending sign-off. *@
<div class="hero-stat">
<div class="hero-stat-num">&infin;</div>
<div class="hero-stat-label">Drift Points</div>
<div class="hero-stat-num hero-stat-odometer">XXX</div>
<div class="hero-stat-label">Plays (Coming Soon)</div>
</div>
</div>
</div>
@code {
[Inject] public required IStatsDataService StatsData { get; set; }
[Inject] public required PersistentComponentState PersistentState { get; set; }
private const string PersistKey = "home-stats";
private HomeStatsDto _stats = new();
private bool _loaded;
private PersistingComponentStateSubscription _persistingSubscription;
protected override async Task OnInitializedAsync()
{
_persistingSubscription = PersistentState.RegisterOnPersisting(Persist);
// Bridge the prerendered fetch across the prerender -> WASM seam so the WASM boot does not
// re-fetch and flicker the figures (the TracksView persistent-state seam, applied to stats).
if (PersistentState.TryTakeFromJson<HomeStatsDto>(PersistKey, out var restored) && restored is not null)
{
_stats = restored;
_loaded = true;
return;
}
var result = await StatsData.GetHomeStats();
if (result is { Success: true, Value: { } stats })
{
_stats = stats;
_loaded = true;
}
}
// Only bridge a successful fetch. If prerender failed, persist nothing so the WASM pass re-fetches
// rather than restoring zeros — mirrors the guard on the medium-browse persist path.
private Task Persist()
{
if (_loaded)
PersistentState.PersistAsJson(PersistKey, _stats);
return Task.CompletedTask;
}
private static string PluralizeReleaseType(ReleaseType type, int count)
{
var label = type switch
{
ReleaseType.Single => "Single",
ReleaseType.EP => "EP",
ReleaseType.Album => "Album",
_ => type.ToString()
};
// EP pluralizes as "EPs"; Single/Album take a plain trailing s.
return count == 1 ? label : label + "s";
}
public void Dispose() => _persistingSubscription.Dispose();
}
@@ -27,6 +27,42 @@
margin-top: 0.4rem;
}
/* Studio Cuts per-ReleaseType breakdown — mono caption rows below the label, reusing the label's
palette so the card reads as one block. */
.hero-stat-breakdown {
display: flex;
flex-direction: column;
gap: 0.1rem;
margin-top: 0.5rem;
}
.hero-stat-breakdown-item {
font-family: var(--deepdrft-font-mono);
font-size: 0.58rem;
letter-spacing: 0.12em;
color: rgba(250, 250, 248, 0.55);
}
/* Mixes runtime sub-figure — sits under the label, slightly brighter than the label caption. */
.hero-stat-sub {
font-family: var(--deepdrft-font-mono);
font-size: 0.58rem;
letter-spacing: 0.12em;
color: rgba(250, 250, 248, 0.55);
margin-top: 0.5rem;
}
/* Plays placeholder — a light 90s visitor-counter / odometer embellishment over the existing
numeric treatment: monospace digits, boxed and tracked out like a mechanical counter. */
.hero-stat-odometer {
font-family: var(--deepdrft-font-mono);
letter-spacing: 0.18em;
background: rgba(0, 0, 0, 0.35);
border: 1px solid rgba(250, 250, 248, 0.12);
padding: 0.1rem 0.35rem;
display: inline-block;
}
@media (max-width: 599px) {
.hero-stat-row {
flex-direction: column;
@@ -0,0 +1,19 @@
namespace DeepDrftPublic.Client.Helpers;
/// <summary>
/// Formats a runtime expressed in seconds as a compact <c>hh:mm</c> string for the home hero stat row.
/// Hours are not zero-padded and may exceed two digits (mixes are few, so a large total simply renders
/// "123:45"); minutes are always two digits. Negative or non-finite inputs clamp to "0:00".
/// </summary>
public static class RuntimeFormat
{
public static string ToHoursMinutes(double totalSeconds)
{
if (double.IsNaN(totalSeconds) || double.IsInfinity(totalSeconds) || totalSeconds <= 0)
return "0:00";
var total = TimeSpan.FromSeconds(totalSeconds);
var hours = (int)total.TotalHours;
return $"{hours}:{total.Minutes:D2}";
}
}
@@ -0,0 +1,15 @@
using DeepDrftModels.DTOs;
using NetBlocks.Models;
namespace DeepDrftPublic.Client.Services;
/// <summary>
/// Home-stats read abstraction. Both SSR and WASM renders are served by <c>StatsClientDataService</c>
/// in this assembly, which delegates to <see cref="Clients.StatsClient"/> over HTTP. Components inject
/// this single seam so they do not branch on render mode — mirrors <see cref="IReleaseDataService"/>.
/// </summary>
public interface IStatsDataService
{
/// <summary>Aggregate figures behind the public home hero stat row, in one round-trip.</summary>
Task<ApiResult<HomeStatsDto>> GetHomeStats();
}
@@ -0,0 +1,22 @@
using DeepDrftModels.DTOs;
using DeepDrftPublic.Client.Clients;
using NetBlocks.Models;
namespace DeepDrftPublic.Client.Services;
/// <summary>
/// <see cref="IStatsDataService"/> backed by <see cref="StatsClient"/> (HTTP to the <c>DeepDrft.API</c>
/// backend). Used on both the SSR prerender and WASM interactive passes — the stats read surface is
/// HTTP-only, so there is no separate in-process implementation.
/// </summary>
public class StatsClientDataService : IStatsDataService
{
private readonly StatsClient _statsClient;
public StatsClientDataService(StatsClient statsClient)
{
_statsClient = statsClient;
}
public Task<ApiResult<HomeStatsDto>> GetHomeStats() => _statsClient.GetHomeStats();
}
+4
View File
@@ -26,6 +26,10 @@ public static class Startup
services.AddScoped<ReleaseDetailViewModel>();
services.AddScoped<CutDetailViewModel>();
// Home hero stats read surface — same HTTP posture as the track/release clients.
services.AddScoped<StatsClient>();
services.AddScoped<IStatsDataService, StatsClientDataService>();
// Waveform visualizer controls — scoped so the eight slider positions persist across navigation
// within a session and reset on a fresh page load (see WaveformVisualizerControlState).
services.AddScoped<WaveformVisualizerControlState>();