@page "/consumables" @using MeterVault.App.Components.Pages.Specialized @using MeterVault.Core.Analysis @implements IDisposable @inject NavigationManager Nav @inject InstanceClock Clock @inject ConsumableService ConsumablesSvc @inject ILogger Logger @* Tanks & consumables (brief ยง7.5, D-54): the shared header, toolbar and missing-data semantics around every tank's specialised measures. Each tank keeps its state now โ€” the last dipstick as measured, the contents estimated from it, the forecast as a projection โ€” apart from the selected period, which has its own usage, deliveries, burner runtime, cost and, for a period that is over, the contents at its end. *@ @if (_query is not null) { } @* A consumable meter without a tank has no capacity or calibration, so it cannot be summarised โ€” but it must not just be missing from the page, with nothing saying where it went. *@ @foreach (var meter in view.Analysis.Unconfigured) { @Loc.F(S.Consumables_TankNotConfigured, meter.Name) @S.Consumables_SetUpTank } @if (view.Analysis.Tanks.Count == 0 && view.Analysis.Unconfigured.Count == 0) {
} else if (!view.Analysis.IsRefused) { @foreach (var tank in view.Tanks) { } }
@code { private static readonly AnalysisDefaults Defaults = AnalysisDefaults.History; private readonly LoadSequencer _loads = new(); private readonly LoadState _state = new(); private AnalysisQuery? _query; /// One committed result: the query it answers, the read model, every tank's series and the export link. private sealed record ConsumablesPageView(AnalysisQuery Query, ConsumableAnalysis Analysis, IReadOnlyList Tanks, string? ExportHref); protected override void OnInitialized() => Nav.LocationChanged += OnLocationChanged; protected override Task OnParametersSetAsync() => ReloadIfChangedAsync(); private void OnLocationChanged(object? sender, LocationChangedEventArgs e) => _ = InvokeAsync(ReloadIfChangedAsync); private async Task ReloadIfChangedAsync() { var query = AnalysisQuery.Parse(Nav.Uri, Defaults); if (query == _query) { return; } _query = query; await LoadAsync(query); StateHasChanged(); } private Task RetryAsync() => _query is null ? Task.CompletedTask : LoadAsync(_query); private Task LoadAsync(AnalysisQuery query) => _loads.RunAsync(_state, async token => { // "Now" is read once per load (D-01); "all" spans what the tanks have data for (D-19). var now = Clock.Now; var availability = query.Period == PeriodPreset.AllHistory ? await ConsumablesSvc.GetAvailabilityAsync(now, token) : null; var period = query.Resolve(now, ConsumablesSvc.Zone, availability); var analysis = await ConsumablesSvc.GetAsync(new ConsumableRequest(period) { Bucket = query.Bucket, Comparison = query.Comparison }, token); var tanks = analysis.Tanks.Select(t => TankView.Build(t, analysis.Quantities, query, analysis.Currency)).ToList(); // The CSV export of what the tables show: every tank's usage (D-55), within the chart's series limit. var ids = analysis.Tanks.Select(t => t.MeterId).ToList(); var export = ids.Count is > 0 and <= MeterVault.Infrastructure.Analysis.AnalysisLimits.MaxSeries ? AnalysisLinks.Export(query.WithScope(QueryScope.ForMeters(ids)).WithMetric(AnalysisMetric.Consumption)) : null; return new ConsumablesPageView(query, analysis, tanks, export); }, Logger); private void OnQueryChanged(AnalysisQuery query) => AnalysisNavigation.Replace(Nav, query, Defaults); /// The reader's problems about one tank and the burners it feeds. private static IReadOnlyList ProblemsOf(ConsumableAnalysis analysis, TankAnalysis tank) { var ids = tank.Runtime.Select(r => r.MeterId).OfType().Append(tank.MeterId).ToHashSet(); var problems = (analysis.Quantities?.Problems ?? []).Concat(tank.Cost?.QuantityProblems ?? []); return [.. problems.Where(p => p.MeterId is { } id ? ids.Contains(id) : p.MeterIds.Any(ids.Contains))]; } public void Dispose() { Nav.LocationChanged -= OnLocationChanged; _loads.Dispose(); } }