Wire NowPlayingStats to live aggregates: add SQL track duration column, stats endpoint, and duration backfill
This commit is contained in:
@@ -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">∞</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();
|
||||
}
|
||||
@@ -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>();
|
||||
|
||||
Reference in New Issue
Block a user