M5: Blazor dashboard (MudBlazor + ApexCharts)

- MudBlazor theme (dark default) + responsive drawer/appbar layout + nav.
- DashboardService read model: KPIs with period-over-period deltas, category breakdown,
  "what cost more/less" difference view, monthly trends.
- Pages: Overview (KPI cards + DeltaChip + donut + difference table), Trends (range-select
  bar chart), Meters (list + source status), Import (load reference dataset + CSV dry-run
  preview), Admin (energy types, tariffs). Charts isolated into components to avoid the
  ApexCharts/MudBlazor Color/Format name clashes.
- ReferenceDataImporter: one-click load of all four sheets as a starter dataset (meters,
  tank, tariff history, category memberships) — bundled sample CSVs copied to app output.
- End-to-end render test: import creates meters + consumption; overview/meters/trends/
  import/admin pages all return 200 with KPI cards rendered.

92 tests green (56 Core + 36 integration).

Deferred to polish: dedicated PV & oil/consumable panels, meter-detail page, full admin
CRUD, prev-year trend overlay.

Claude-Session: https://claude.ai/code/session_01WujdMtMJPbxDpDnMeK22rr
This commit is contained in:
2026-07-13 12:11:12 +02:00
parent 6fc710d55a
commit d5419729e5
25 changed files with 1094 additions and 74 deletions
@@ -0,0 +1,29 @@
namespace MeterVault.Infrastructure.Dashboard;
/// <summary>KPI card figures for a period plus the delta versus the previous comparable period.</summary>
public sealed record CostKpi(double Current, double Previous)
{
public double Delta => Current - Previous;
public double DeltaPercent => Previous == 0 ? 0 : (Current - Previous) / Math.Abs(Previous) * 100.0;
/// <summary>+1 up, -1 down, 0 flat.</summary>
public int Direction => Math.Sign(Math.Round(Delta, 2));
}
/// <summary>The overview KPIs: month and year cost, each with its previous-period comparison.</summary>
public sealed record DashboardSummary(DateOnly AsOf, CostKpi Month, CostKpi Year, double LatestMonthCost);
/// <summary>One slice of the cost breakdown / "what costs most" view.</summary>
public sealed record CategorySlice(string Name, string? ColorHex, double Cost);
/// <summary>A point on a monthly cost/consumption trend.</summary>
public sealed record TrendPoint(DateOnly Period, double Cost);
/// <summary>A row of the "what cost more / less" difference view.</summary>
public sealed record DifferenceRow(string Name, double Current, double Previous)
{
public double Delta => Current - Previous;
public double DeltaPercent => Previous == 0 ? 0 : (Current - Previous) / Math.Abs(Previous) * 100.0;
}
@@ -0,0 +1,128 @@
using MeterVault.Infrastructure.Costing;
using MeterVault.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
namespace MeterVault.Infrastructure.Dashboard;
/// <summary>
/// Read model for the dashboard (SDD §8): overview KPIs with period-over-period deltas, the cost
/// breakdown by category, the "what cost more / less" difference view, and monthly trends. Reads
/// only aggregated cost — never the raw hypertable.
/// </summary>
public sealed class DashboardService(MeterVaultDbContext db, CostService costService)
{
private readonly MeterVaultDbContext _db = db;
private readonly CostService _costService = costService;
public async Task<DashboardSummary> GetSummaryAsync(DateOnly asOf, CancellationToken cancellationToken = default)
{
var monthStart = new DateOnly(asOf.Year, asOf.Month, 1);
var prevMonthStart = monthStart.AddMonths(-1);
var yearStart = new DateOnly(asOf.Year, 1, 1);
var prevYearStart = yearStart.AddYears(-1);
var month = new CostKpi(
await TotalCostAsync(monthStart, monthStart.AddMonths(1), cancellationToken).ConfigureAwait(false),
await TotalCostAsync(prevMonthStart, monthStart, cancellationToken).ConfigureAwait(false));
var year = new CostKpi(
await TotalCostAsync(yearStart, yearStart.AddYears(1), cancellationToken).ConfigureAwait(false),
await TotalCostAsync(prevYearStart, yearStart, cancellationToken).ConfigureAwait(false));
var latest = await LatestMonthCostAsync(cancellationToken).ConfigureAwait(false);
return new DashboardSummary(asOf, month, year, latest);
}
public async Task<IReadOnlyList<CategorySlice>> GetCategoryBreakdownAsync(
DateOnly from, DateOnly to, CancellationToken cancellationToken = default)
{
var categories = await _db.CostCategories.OrderBy(c => c.Sort).ToListAsync(cancellationToken).ConfigureAwait(false);
var slices = new List<CategorySlice>();
foreach (var category in categories)
{
var rollup = await _costService
.GetCategoryCostsAsync(category.Id, ToUtc(from), ToUtc(to), cancellationToken).ConfigureAwait(false);
var total = rollup.Sum(r => r.Cost);
if (Math.Abs(total) > 0.005)
{
slices.Add(new CategorySlice(category.Name, category.ColorHex, total));
}
}
return [.. slices.OrderByDescending(s => s.Cost)];
}
public async Task<IReadOnlyList<DifferenceRow>> GetCategoryDifferenceAsync(
DateOnly currentStart, DateOnly previousStart, DateOnly span, CancellationToken cancellationToken = default)
{
// span length in months from currentStart.
var months = ((span.Year - currentStart.Year) * 12) + span.Month - currentStart.Month;
var currentEnd = currentStart.AddMonths(Math.Max(1, months));
var previousEnd = previousStart.AddMonths(Math.Max(1, months));
var categories = await _db.CostCategories.OrderBy(c => c.Sort).ToListAsync(cancellationToken).ConfigureAwait(false);
var rows = new List<DifferenceRow>();
foreach (var category in categories)
{
var current = (await _costService.GetCategoryCostsAsync(category.Id, ToUtc(currentStart), ToUtc(currentEnd), cancellationToken).ConfigureAwait(false)).Sum(r => r.Cost);
var previous = (await _costService.GetCategoryCostsAsync(category.Id, ToUtc(previousStart), ToUtc(previousEnd), cancellationToken).ConfigureAwait(false)).Sum(r => r.Cost);
if (Math.Abs(current) > 0.005 || Math.Abs(previous) > 0.005)
{
rows.Add(new DifferenceRow(category.Name, current, previous));
}
}
return [.. rows.OrderByDescending(r => Math.Abs(r.Delta))];
}
public async Task<IReadOnlyList<TrendPoint>> GetMonthlyTrendAsync(
DateOnly from, DateOnly to, CancellationToken cancellationToken = default)
{
var totals = new Dictionary<DateOnly, double>();
foreach (var meterId in await ActiveMeterIdsAsync(cancellationToken).ConfigureAwait(false))
{
foreach (var bucket in await _costService.GetMeterCostsAsync(meterId, ToUtc(from), ToUtc(to), CostBucket.Month, cancellationToken).ConfigureAwait(false))
{
totals[bucket.Period] = totals.GetValueOrDefault(bucket.Period) + bucket.Cost;
}
}
return [.. totals.OrderBy(kv => kv.Key).Select(kv => new TrendPoint(kv.Key, kv.Value))];
}
private async Task<double> TotalCostAsync(DateOnly from, DateOnly to, CancellationToken cancellationToken)
{
double total = 0;
foreach (var meterId in await ActiveMeterIdsAsync(cancellationToken).ConfigureAwait(false))
{
var costs = await _costService.GetMeterCostsAsync(meterId, ToUtc(from), ToUtc(to), CostBucket.Month, cancellationToken).ConfigureAwait(false);
total += costs.Sum(c => c.Cost);
}
var manual = await _db.ManualCosts
.Where(c => c.PeriodStart >= from && c.PeriodStart < to)
.SumAsync(c => (double?)c.Amount, cancellationToken).ConfigureAwait(false);
return total + (manual ?? 0);
}
private async Task<double> LatestMonthCostAsync(CancellationToken cancellationToken)
{
var latest = await _db.Consumption
.OrderByDescending(c => c.Time)
.Select(c => (DateTimeOffset?)c.Time)
.FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false);
if (latest is null)
{
return 0;
}
var monthStart = new DateOnly(latest.Value.Year, latest.Value.Month, 1);
return await TotalCostAsync(monthStart, monthStart.AddMonths(1), cancellationToken).ConfigureAwait(false);
}
private async Task<List<int>> ActiveMeterIdsAsync(CancellationToken cancellationToken) =>
await _db.Meters.Select(m => m.Id).ToListAsync(cancellationToken).ConfigureAwait(false);
private static DateTimeOffset ToUtc(DateOnly date) => new(date.Year, date.Month, date.Day, 0, 0, 0, TimeSpan.Zero);
}
@@ -25,9 +25,11 @@ public static class DependencyInjection
services.AddScoped<NormalizationService>();
services.AddScoped<CsvImporter>();
services.AddScoped<ImportService>();
services.AddScoped<ReferenceDataImporter>();
services.AddScoped<IngestionService>();
services.AddScoped<MqttMessageRouter>();
services.AddScoped<Costing.CostService>();
services.AddScoped<Dashboard.DashboardService>();
return services;
}
@@ -0,0 +1,146 @@
using MeterVault.Core.Domain;
using MeterVault.Infrastructure.Normalization;
using MeterVault.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
namespace MeterVault.Infrastructure.Import;
/// <summary>
/// Loads the four bundled <em>Energiebilanz</em> sheets as a ready-made demo/starter dataset:
/// creates the reference meters, tank, tariff history and category memberships, then imports each
/// sheet through the normal <see cref="ImportService"/>. Idempotent — a marker meter guards reruns.
/// </summary>
public sealed class ReferenceDataImporter(MeterVaultDbContext db, ImportService importService, CsvImporter csvImporter)
{
public const string ElectricityFile = "Energiebilanz - Strom Verbrauch.csv";
public const string WaterFile = "Energiebilanz - Wasser.csv";
public const string OilFile = "Energiebilanz - Heizöl Verbrauch.csv";
public const string CostsFile = "Energiebilanz - Kosten.csv";
private const string MarkerName = "Zähler Haus";
private readonly MeterVaultDbContext _db = db;
private readonly ImportService _importService = importService;
private readonly CsvImporter _csvImporter = csvImporter;
public async Task<bool> IsLoadedAsync(CancellationToken cancellationToken = default) =>
await _db.Meters.AnyAsync(m => m.Name == MarkerName, cancellationToken).ConfigureAwait(false);
public async Task LoadAsync(string sampleDataDirectory, CancellationToken cancellationToken = default)
{
if (await IsLoadedAsync(cancellationToken).ConfigureAwait(false))
{
return;
}
await DatabaseSeeder.SeedAsync(_db, cancellationToken).ConfigureAwait(false);
var electricity = await EnergyTypeIdAsync("electricity", cancellationToken).ConfigureAwait(false);
var water = await EnergyTypeIdAsync("water", cancellationToken).ConfigureAwait(false);
var oil = await EnergyTypeIdAsync("heating_oil", cancellationToken).ConfigureAwait(false);
var haus = Meter(MarkerName, electricity, MeterMode.CumulativeCounter, "kWh");
var netz = Meter("Zähler Netz", electricity, MeterMode.CumulativeCounter, "kWh");
var auto = Meter("Zähler Auto", electricity, MeterMode.CumulativeCounter, "kWh");
var solar1 = Meter("Zähler Solar 1", electricity, MeterMode.GenerationCounter, "kWh");
var solar2 = Meter("Zähler Solar 2", electricity, MeterMode.GenerationCounter, "kWh");
var wasser = Meter("Zähler Wasser", water, MeterMode.CumulativeCounter, "m3", initialBaseline: 820);
var oilTank = Meter("Öltank", oil, MeterMode.ConsumableBalance, "L");
var burner = Meter("Brenner", oil, MeterMode.RuntimeCounter, "h");
_db.Meters.AddRange(haus, netz, auto, solar1, solar2, wasser, oilTank, burner);
await _db.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
_db.Tanks.Add(new Tank
{
MeterId = oilTank.Id,
Capacity = 7000,
Unit = "L",
Calibration = $"{{\"volumePerUnit\":{ReferenceProfiles.OilLitresPerCm.ToString(System.Globalization.CultureInfo.InvariantCulture)}}}",
});
AddElectricityTariffs(electricity);
AddTariff(TariffScope.EnergyType, water, 5.00, "EUR/m3", new DateOnly(2022, 11, 1));
await LinkCategoriesAsync(haus.Id, netz.Id, auto.Id, solar1.Id, solar2.Id, wasser.Id, oilTank.Id, cancellationToken).ConfigureAwait(false);
await _db.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
var meterIds = new ReferenceMeterIds(haus.Id, netz.Id, auto.Id, solar1.Id, solar2.Id, wasser.Id, oilTank.Id, burner.Id);
var categoryIds = await CategoryIdsAsync(cancellationToken).ConfigureAwait(false);
await ImportSheetAsync(sampleDataDirectory, ElectricityFile, ReferenceProfiles.Electricity(meterIds), cancellationToken).ConfigureAwait(false);
await ImportSheetAsync(sampleDataDirectory, WaterFile, ReferenceProfiles.Water(meterIds), cancellationToken).ConfigureAwait(false);
await ImportSheetAsync(sampleDataDirectory, OilFile, ReferenceProfiles.HeatingOil(meterIds), cancellationToken).ConfigureAwait(false);
await ImportSheetAsync(sampleDataDirectory, CostsFile, ReferenceProfiles.Costs(categoryIds), cancellationToken).ConfigureAwait(false);
}
private async Task ImportSheetAsync(string dir, string file, MappingProfile profile, CancellationToken cancellationToken)
{
var path = Path.Combine(dir, file);
if (!File.Exists(path))
{
return;
}
StagedImport staged;
using (var reader = new StreamReader(path))
{
staged = _csvImporter.Stage(profile, reader);
}
var mappingJson = System.Text.Json.JsonSerializer.Serialize(new { profile = profile.Name });
await _importService.CommitAsync(staged, file, mappingJson, cancellationToken).ConfigureAwait(false);
}
private static Meter Meter(string name, short energyTypeId, MeterMode mode, string unit, double initialBaseline = 0) => new()
{
Name = name,
EnergyTypeId = energyTypeId,
Mode = mode,
Unit = unit,
InitialBaseline = initialBaseline,
};
private void AddElectricityTariffs(short energyTypeId)
{
AddTariff(TariffScope.EnergyType, energyTypeId, 0.16, "EUR/kWh", new DateOnly(2022, 9, 1));
AddTariff(TariffScope.EnergyType, energyTypeId, 0.44, "EUR/kWh", new DateOnly(2023, 1, 1));
AddTariff(TariffScope.EnergyType, energyTypeId, 0.37, "EUR/kWh", new DateOnly(2023, 5, 1));
AddTariff(TariffScope.EnergyType, energyTypeId, 0.27, "EUR/kWh", new DateOnly(2023, 11, 1));
AddTariff(TariffScope.EnergyType, energyTypeId, 0.36, "EUR/kWh", new DateOnly(2025, 1, 1));
AddTariff(TariffScope.EnergyType, energyTypeId, 0.27, "EUR/kWh", new DateOnly(2026, 1, 1));
}
private void AddTariff(TariffScope scope, int scopeId, double value, string unit, DateOnly validFrom) =>
_db.Tariffs.Add(new Tariff
{
ScopeType = scope,
ScopeId = scopeId,
Component = TariffComponent.UnitPrice,
Value = value,
Unit = unit,
ValidFrom = validFrom,
});
private async Task LinkCategoriesAsync(
int haus, int netz, int auto, int solar1, int solar2, int wasser, int oilTank, CancellationToken cancellationToken)
{
var categories = await CategoryIdsAsync(cancellationToken).ConfigureAwait(false);
foreach (var meterId in new[] { haus, netz, auto, solar1, solar2 })
{
_db.CostCategoryMembers.Add(new CostCategoryMember { CategoryId = categories.Strom, MeterId = meterId });
}
_db.CostCategoryMembers.Add(new CostCategoryMember { CategoryId = categories.Wasser, MeterId = wasser });
_db.CostCategoryMembers.Add(new CostCategoryMember { CategoryId = categories.Heizung, MeterId = oilTank });
}
private async Task<ReferenceCategoryIds> CategoryIdsAsync(CancellationToken cancellationToken)
{
var categories = await _db.CostCategories.ToListAsync(cancellationToken).ConfigureAwait(false);
int Find(string name) => categories.FirstOrDefault(c => c.Name == name)?.Id ?? 0;
return new ReferenceCategoryIds(Find("Heizung"), Find("Strom"), Find("Wasser"), Find("Pool Betrieb"));
}
private async Task<short> EnergyTypeIdAsync(string key, CancellationToken cancellationToken) =>
(await _db.EnergyTypes.FirstAsync(t => t.Key == key, cancellationToken).ConfigureAwait(false)).Id;
}
+87 -64
View File
@@ -1,13 +1,25 @@
namespace MeterVault.Infrastructure.Import;
/// <summary>Meter ids the reference profiles map columns to. Defaults are the fixed conventions
/// used by the golden reconciliation tests; the importer supplies real database ids at runtime.</summary>
public sealed record ReferenceMeterIds(
int Haus, int Netz, int Auto, int Solar1, int Solar2, int Wasser, int OilTank, int Burner)
{
public static readonly ReferenceMeterIds Default = new(1, 2, 3, 4, 5, 10, 20, 21);
}
/// <summary>Category ids the Kosten profile maps cost columns to.</summary>
public sealed record ReferenceCategoryIds(int Heizung, int Strom, int Wasser, int Pool)
{
public static readonly ReferenceCategoryIds Default = new(1, 2, 3, 4);
}
/// <summary>
/// Built-in mapping profiles for the four reference <em>Energiebilanz</em> sheets (SDD §6.3).
/// They ship as example imports and as the golden reconciliation fixtures. Meter and category ids
/// are fixed conventions for the reference data; the import wizard maps to real ids at runtime.
/// They ship as example imports and as the golden reconciliation fixtures.
/// </summary>
public static class ReferenceProfiles
{
// Reference meter ids.
public const int Haus = 1;
public const int Netz = 2;
public const int Auto = 3;
@@ -17,7 +29,6 @@ public static class ReferenceProfiles
public const int OilTank = 20;
public const int Burner = 21;
// Reference category ids (match DatabaseSeeder order).
public const int CategoryHeizung = 1;
public const int CategoryStrom = 2;
public const int CategoryWasser = 3;
@@ -26,71 +37,83 @@ public static class ReferenceProfiles
/// <summary>Linear heating-oil tank calibration: 7000 L / 150 cm ≈ 46.667 L/cm.</summary>
public const double OilLitresPerCm = 7000d / 150d;
public static MappingProfile Electricity() => new()
public static MappingProfile Electricity(ReferenceMeterIds? ids = null)
{
Name = "Energiebilanz — Strom",
DateColumn = 0,
DateKind = DateKind.MonthName,
FirstDataRowIndex = 1,
Columns =
[
new ColumnMapping { Index = 1, Role = MappingRole.Reading, MeterId = Haus, Unit = "kWh" },
new ColumnMapping { Index = 2, Role = MappingRole.Reading, MeterId = Netz, Unit = "kWh" },
// Index 3 is a blank spacer column — left unmapped (Ignore).
new ColumnMapping { Index = 4, Role = MappingRole.Reading, MeterId = Auto, Unit = "kWh" },
new ColumnMapping { Index = 5, Role = MappingRole.Reading, MeterId = Solar1, Unit = "kWh" },
new ColumnMapping { Index = 6, Role = MappingRole.Reading, MeterId = Solar2, Unit = "kWh" },
],
};
ids ??= ReferenceMeterIds.Default;
return new MappingProfile
{
Name = "Energiebilanz — Strom",
DateColumn = 0,
DateKind = DateKind.MonthName,
FirstDataRowIndex = 1,
Columns =
[
new ColumnMapping { Index = 1, Role = MappingRole.Reading, MeterId = ids.Haus, Unit = "kWh" },
new ColumnMapping { Index = 2, Role = MappingRole.Reading, MeterId = ids.Netz, Unit = "kWh" },
// Index 3 is a blank spacer column — left unmapped (Ignore).
new ColumnMapping { Index = 4, Role = MappingRole.Reading, MeterId = ids.Auto, Unit = "kWh" },
new ColumnMapping { Index = 5, Role = MappingRole.Reading, MeterId = ids.Solar1, Unit = "kWh" },
new ColumnMapping { Index = 6, Role = MappingRole.Reading, MeterId = ids.Solar2, Unit = "kWh" },
],
};
}
public static MappingProfile Water() => new()
public static MappingProfile Water(ReferenceMeterIds? ids = null)
{
Name = "Energiebilanz — Wasser",
DateColumn = 0,
DateKind = DateKind.MonthName,
FirstDataRowIndex = 1,
DetectCumulativeSwaps = true,
Columns =
[
new ColumnMapping
{
Index = 1,
Role = MappingRole.Reading,
MeterId = Wasser,
Unit = "m3",
SwapConsumptionColumn = 2, // Wasserverbrauch supplies the swap-month consumption.
},
],
};
ids ??= ReferenceMeterIds.Default;
return new MappingProfile
{
Name = "Energiebilanz — Wasser",
DateColumn = 0,
DateKind = DateKind.MonthName,
FirstDataRowIndex = 1,
DetectCumulativeSwaps = true,
Columns =
[
new ColumnMapping
{
Index = 1, Role = MappingRole.Reading, MeterId = ids.Wasser, Unit = "m3", SwapConsumptionColumn = 2,
},
],
};
}
public static MappingProfile HeatingOil() => new()
public static MappingProfile HeatingOil(ReferenceMeterIds? ids = null)
{
Name = "Energiebilanz — Heizöl",
DateColumn = 0,
DateKind = DateKind.Auto, // early rows DD.MM.YYYY, later rows month names.
AnchorMonthsToEnd = true, // a monthly snapshot sorts after same-month day-dated readings.
HeaderRowIndex = 3,
FirstDataRowIndex = 4,
Columns =
[
new ColumnMapping { Index = 1, Role = MappingRole.Reading, MeterId = Burner, Unit = "h" },
new ColumnMapping { Index = 4, Role = MappingRole.TankLevel, MeterId = OilTank, Unit = "cm" },
new ColumnMapping { Index = 7, Role = MappingRole.Delivery, MeterId = OilTank, Unit = "L" },
],
};
ids ??= ReferenceMeterIds.Default;
return new MappingProfile
{
Name = "Energiebilanz — Heizöl",
DateColumn = 0,
DateKind = DateKind.Auto, // early rows DD.MM.YYYY, later rows month names.
AnchorMonthsToEnd = true, // a monthly snapshot sorts after same-month day-dated readings.
HeaderRowIndex = 3,
FirstDataRowIndex = 4,
Columns =
[
new ColumnMapping { Index = 1, Role = MappingRole.Reading, MeterId = ids.Burner, Unit = "h" },
new ColumnMapping { Index = 4, Role = MappingRole.TankLevel, MeterId = ids.OilTank, Unit = "cm" },
new ColumnMapping { Index = 7, Role = MappingRole.Delivery, MeterId = ids.OilTank, Unit = "L" },
],
};
}
public static MappingProfile Costs() => new()
public static MappingProfile Costs(ReferenceCategoryIds? ids = null)
{
Name = "Energiebilanz — Kosten",
DateColumn = 0,
DateKind = DateKind.MonthName,
FirstDataRowIndex = 1,
Columns =
[
new ColumnMapping { Index = 3, Role = MappingRole.ManualCost, CategoryId = CategoryHeizung },
new ColumnMapping { Index = 4, Role = MappingRole.ManualCost, CategoryId = CategoryStrom },
new ColumnMapping { Index = 5, Role = MappingRole.ManualCost, CategoryId = CategoryWasser },
new ColumnMapping { Index = 6, Role = MappingRole.ManualCost, CategoryId = CategoryPool },
],
};
ids ??= ReferenceCategoryIds.Default;
return new MappingProfile
{
Name = "Energiebilanz — Kosten",
DateColumn = 0,
DateKind = DateKind.MonthName,
FirstDataRowIndex = 1,
Columns =
[
new ColumnMapping { Index = 3, Role = MappingRole.ManualCost, CategoryId = ids.Heizung },
new ColumnMapping { Index = 4, Role = MappingRole.ManualCost, CategoryId = ids.Strom },
new ColumnMapping { Index = 5, Role = MappingRole.ManualCost, CategoryId = ids.Wasser },
new ColumnMapping { Index = 6, Role = MappingRole.ManualCost, CategoryId = ids.Pool },
],
};
}
}