SDD §8 panels (PV/oil/meter-detail) + fix reference-data-in-Docker
Complete the SDD §8 dashboard views that were deferred at the M5 boundary,
and fix a shipping bug that left the Docker demo empty.
Bug: "Load reference data" created meters/tank/tariffs but imported zero
readings in Docker. Root cause: sampledata/ was excluded by .dockerignore and
never copied into the build stage, so the App csproj's linked Content glob
resolved to nothing at publish time; ReferenceDataImporter then silently
skipped the missing CSVs after already writing its marker meter, leaving the
DB permanently "loaded" but empty.
- .dockerignore: stop excluding sampledata/
- Dockerfile: COPY sampledata/ into the build stage
- ReferenceDataImporter: fail-fast (validate CSVs exist before the marker
meter) and throw instead of silently skipping a missing file
- Program.cs + MeterVaultOptions: opt-in MeterVault__SeedReferenceData
(compose METERVAULT_SEED=true) for a one-command populated demo
New SDD §8 panels (read models in Infrastructure/Dashboard, Blazor pages):
- §8.4 Solar/PV (/solar): generation from GenerationCounter meters;
self-consumption / autarky % / self-consumption % / savings derived from
meters tagged total_load & grid_import via Meter.Meta role config
(MeterRoles/MeterMeta) — nothing hardcoded by name.
- §8.5 Oil/consumable (/consumables): tank level (cm→L calibrated), fill
gauge, deliveries log, burner runtime, effective L/h (fixed/empirical),
forecast-to-empty, tariff cost, monthly series.
- §8.6 Meter detail (/meters/{id}): raw readings, normalized consumption,
source status, tariff timeline, events, measured-vs-estimated markers.
- Reusable SeriesChart component; nav links; Meters list rows link to detail.
Tests: MeterMetaTests (Core, +10); DashboardRenderTests extended to assert the
three panel services compute real figures and the new routes render (108 total,
all green). Live-verified in Docker: seed imports 302 readings / 347 consumption
rows; panels render (generation 16,481 kWh, oil 3,967 L) cross-checking the DB.
Claude-Session: https://claude.ai/code/session_01Lz2RqAsnQhetqWNoCDfexK
This commit is contained in:
@@ -0,0 +1,88 @@
|
||||
using MeterVault.Core.Domain;
|
||||
using MeterVault.Infrastructure.Persistence;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace MeterVault.Infrastructure.Dashboard;
|
||||
|
||||
/// <summary>
|
||||
/// Read model for the meter-detail view (SDD §8.6). Bounds the raw-reading and consumption pulls
|
||||
/// (this is the one place the UI touches raw rows) and gathers source status, the applicable tariff
|
||||
/// timeline and lifecycle events. DbContext factory keeps it Blazor-circuit safe.
|
||||
/// </summary>
|
||||
public sealed class MeterDetailService(IDbContextFactory<MeterVaultDbContext> contextFactory)
|
||||
{
|
||||
private const int MaxRows = 200;
|
||||
|
||||
private readonly IDbContextFactory<MeterVaultDbContext> _contextFactory = contextFactory;
|
||||
|
||||
public async Task<MeterDetailView?> GetAsync(int meterId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await using var db = await _contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var meter = await db.Meters.AsNoTracking()
|
||||
.Include(m => m.EnergyType)
|
||||
.Include(m => m.Sources)
|
||||
.FirstOrDefaultAsync(m => m.Id == meterId, cancellationToken).ConfigureAwait(false);
|
||||
if (meter is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var readingCount = await db.Readings.AsNoTracking().CountAsync(r => r.MeterId == meterId, cancellationToken).ConfigureAwait(false);
|
||||
var consumptionCount = await db.Consumption.AsNoTracking().CountAsync(c => c.MeterId == meterId, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var first = await db.Readings.AsNoTracking().Where(r => r.MeterId == meterId)
|
||||
.OrderBy(r => r.Time).Select(r => new { r.Time, r.Value })
|
||||
.FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false);
|
||||
var last = await db.Readings.AsNoTracking().Where(r => r.MeterId == meterId)
|
||||
.OrderByDescending(r => r.Time).Select(r => new { r.Time, r.Value })
|
||||
.FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var totalConsumption = await db.Consumption.AsNoTracking()
|
||||
.Where(c => c.MeterId == meterId && c.Kind == ConsumptionKind.Consumption)
|
||||
.SumAsync(c => (double?)c.Amount, cancellationToken).ConfigureAwait(false) ?? 0;
|
||||
var totalGeneration = await db.Consumption.AsNoTracking()
|
||||
.Where(c => c.MeterId == meterId && c.Kind == ConsumptionKind.Generation)
|
||||
.SumAsync(c => (double?)c.Amount, cancellationToken).ConfigureAwait(false) ?? 0;
|
||||
|
||||
var recentReadings = await db.Readings.AsNoTracking()
|
||||
.Where(r => r.MeterId == meterId)
|
||||
.OrderByDescending(r => r.Time).Take(MaxRows)
|
||||
.Select(r => new ReadingRow(r.Time, r.Value, r.Quality, r.Flags))
|
||||
.ToListAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var recentConsumption = await db.Consumption.AsNoTracking()
|
||||
.Where(c => c.MeterId == meterId)
|
||||
.OrderByDescending(c => c.Time).Take(MaxRows)
|
||||
.Select(c => new ConsumptionDetailRow(c.Time, c.Amount, c.Kind, c.Quality))
|
||||
.ToListAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var events = await db.MeterEvents.AsNoTracking()
|
||||
.Where(e => e.MeterId == meterId)
|
||||
.OrderByDescending(e => e.Time)
|
||||
.Select(e => new EventRow(e.Time, e.EventType, e.Amount, e.PrevValue, e.NewValue, e.Unit, e.Notes))
|
||||
.ToListAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var energyTypeId = meter.EnergyTypeId;
|
||||
var tariffs = await db.Tariffs.AsNoTracking()
|
||||
.Where(t => t.ScopeType == TariffScope.Global
|
||||
|| (t.ScopeType == TariffScope.EnergyType && t.ScopeId == energyTypeId)
|
||||
|| (t.ScopeType == TariffScope.Meter && t.ScopeId == meterId))
|
||||
.OrderBy(t => t.Component).ThenBy(t => t.ValidFrom)
|
||||
.Select(t => new TariffRow(t.ScopeType, t.ScopeId, t.Component, t.Value, t.Unit, t.ValidFrom, t.ValidTo))
|
||||
.ToListAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var sources = meter.Sources
|
||||
.OrderBy(s => s.Priority)
|
||||
.Select(s => new SourceRow(s.SourceType, s.IsEnabled, s.LastSeenAt, s.LastValue, s.LastStatus, s.Config))
|
||||
.ToList();
|
||||
|
||||
return new MeterDetailView(
|
||||
meter.Id, meter.Name, meter.EnergyType?.DisplayName ?? "—", meter.Mode, meter.Unit,
|
||||
meter.Location, meter.SerialNumber, meter.Manufacturer, meter.Model, meter.InitialBaseline, meter.IsActive,
|
||||
readingCount, consumptionCount,
|
||||
first?.Time, last?.Time, first?.Value, last?.Value,
|
||||
totalConsumption, totalGeneration,
|
||||
recentReadings, recentConsumption, events, tariffs, sources);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user