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:
@@ -7,6 +7,7 @@
|
||||
<base href="/" />
|
||||
<ResourcePreloader />
|
||||
<link rel="stylesheet" href="@Assets["app.css"]" />
|
||||
<link rel="stylesheet" href="_content/MudBlazor/MudBlazor.min.css" />
|
||||
<link rel="stylesheet" href="@Assets["MeterVault.App.styles.css"]" />
|
||||
<ImportMap />
|
||||
<HeadOutlet />
|
||||
@@ -16,6 +17,9 @@
|
||||
<Routes />
|
||||
<ReconnectModal />
|
||||
<script src="@Assets["_framework/blazor.web.js"]"></script>
|
||||
<script src="_content/MudBlazor/MudBlazor.min.js"></script>
|
||||
<script src="_content/Blazor-ApexCharts/js/apex-charts.min.js"></script>
|
||||
<script src="_content/Blazor-ApexCharts/js/blazor-apex-charts.min.js"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
|
||||
@@ -1,9 +1,40 @@
|
||||
@inherits LayoutComponentBase
|
||||
@inherits LayoutComponentBase
|
||||
@using MeterVault.App.Theme
|
||||
|
||||
<MudThemeProvider Theme="MeterVaultTheme.Instance" @bind-IsDarkMode="_darkMode" />
|
||||
<MudPopoverProvider />
|
||||
<MudDialogProvider />
|
||||
<MudSnackbarProvider />
|
||||
|
||||
<MudLayout>
|
||||
<MudAppBar Elevation="1" Dense="true">
|
||||
<MudIcon Icon="@Icons.Material.Filled.Bolt" Class="mr-2" />
|
||||
<MudText Typo="Typo.h6">MeterVault</MudText>
|
||||
<MudSpacer />
|
||||
<MudTooltip Text="@(_darkMode ? "Light mode" : "Dark mode")">
|
||||
<MudIconButton Icon="@(_darkMode ? Icons.Material.Filled.LightMode : Icons.Material.Filled.DarkMode)"
|
||||
Color="Color.Inherit" OnClick="@(() => _darkMode = !_darkMode)" />
|
||||
</MudTooltip>
|
||||
</MudAppBar>
|
||||
|
||||
<MudDrawer @bind-Open="_drawerOpen" Elevation="1" ClipMode="DrawerClipMode.Always" Variant="DrawerVariant.Responsive">
|
||||
<NavMenu />
|
||||
</MudDrawer>
|
||||
|
||||
<MudMainContent>
|
||||
<MudContainer MaxWidth="MaxWidth.False" Class="pa-4 mv-main">
|
||||
@Body
|
||||
</MudContainer>
|
||||
</MudMainContent>
|
||||
</MudLayout>
|
||||
|
||||
<div id="blazor-error-ui" data-nosnippet>
|
||||
An unhandled error has occurred.
|
||||
<a href="." class="reload">Reload</a>
|
||||
<span class="dismiss">🗙</span>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
private bool _drawerOpen = true;
|
||||
private bool _darkMode = true;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
<MudNavMenu>
|
||||
<MudNavLink Href="/" Match="NavLinkMatch.All" Icon="@Icons.Material.Filled.Dashboard">Overview</MudNavLink>
|
||||
<MudNavLink Href="/trends" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.ShowChart">Trends</MudNavLink>
|
||||
<MudNavLink Href="/meters" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.Speed">Meters</MudNavLink>
|
||||
<MudNavLink Href="/import" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.UploadFile">Import</MudNavLink>
|
||||
<MudDivider Class="my-2" />
|
||||
<MudNavGroup Title="Admin" Icon="@Icons.Material.Filled.Settings" Expanded="false">
|
||||
<MudNavLink Href="/admin/energy-types" Icon="@Icons.Material.Filled.Category">Energy types</MudNavLink>
|
||||
<MudNavLink Href="/admin/tariffs" Icon="@Icons.Material.Filled.Euro">Tariffs</MudNavLink>
|
||||
</MudNavGroup>
|
||||
</MudNavMenu>
|
||||
@@ -0,0 +1,37 @@
|
||||
@page "/admin/energy-types"
|
||||
@rendermode InteractiveServer
|
||||
@inject MeterVault.Infrastructure.Persistence.MeterVaultDbContext Db
|
||||
@using Microsoft.EntityFrameworkCore
|
||||
|
||||
<PageTitle>MeterVault — Energy types</PageTitle>
|
||||
|
||||
<MudText Typo="Typo.h4" Class="mb-4">Energy types</MudText>
|
||||
|
||||
@if (_types is null)
|
||||
{
|
||||
<MudProgressLinear Indeterminate="true" Color="Color.Primary" />
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudTable Items="_types" Dense="true" Hover="true" Elevation="2">
|
||||
<HeaderContent>
|
||||
<MudTh>Key</MudTh>
|
||||
<MudTh>Display name</MudTh>
|
||||
<MudTh>Base unit</MudTh>
|
||||
<MudTh>Default mode</MudTh>
|
||||
</HeaderContent>
|
||||
<RowTemplate>
|
||||
<MudTd DataLabel="Key">@context.Key</MudTd>
|
||||
<MudTd DataLabel="Display name">@context.DisplayName</MudTd>
|
||||
<MudTd DataLabel="Base unit">@context.BaseUnit</MudTd>
|
||||
<MudTd DataLabel="Default mode">@context.DefaultMode</MudTd>
|
||||
</RowTemplate>
|
||||
</MudTable>
|
||||
}
|
||||
|
||||
@code {
|
||||
private List<EnergyType>? _types;
|
||||
|
||||
protected override async Task OnInitializedAsync() =>
|
||||
_types = await Db.EnergyTypes.AsNoTracking().OrderBy(t => t.Id).ToListAsync();
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
@page "/admin/tariffs"
|
||||
@rendermode InteractiveServer
|
||||
@inject MeterVault.Infrastructure.Persistence.MeterVaultDbContext Db
|
||||
@using Microsoft.EntityFrameworkCore
|
||||
|
||||
<PageTitle>MeterVault — Tariffs</PageTitle>
|
||||
|
||||
<MudText Typo="Typo.h4" Class="mb-4">Tariffs</MudText>
|
||||
|
||||
@if (_tariffs is null)
|
||||
{
|
||||
<MudProgressLinear Indeterminate="true" Color="Color.Primary" />
|
||||
}
|
||||
else if (_tariffs.Count == 0)
|
||||
{
|
||||
<MudAlert Severity="Severity.Info">No tariffs yet. Load the reference data from <MudLink Href="/import">Import</MudLink>.</MudAlert>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudTable Items="_tariffs" Dense="true" Hover="true" Elevation="2">
|
||||
<HeaderContent>
|
||||
<MudTh>Scope</MudTh>
|
||||
<MudTh>Component</MudTh>
|
||||
<MudTh>Value</MudTh>
|
||||
<MudTh>Unit</MudTh>
|
||||
<MudTh>Valid from</MudTh>
|
||||
<MudTh>Valid to</MudTh>
|
||||
</HeaderContent>
|
||||
<RowTemplate>
|
||||
<MudTd DataLabel="Scope">@context.ScopeType @(context.ScopeId is { } id ? $"#{id}" : "")</MudTd>
|
||||
<MudTd DataLabel="Component">@context.Component</MudTd>
|
||||
<MudTd DataLabel="Value">@Format.Number(context.Value, 4)</MudTd>
|
||||
<MudTd DataLabel="Unit">@context.Unit</MudTd>
|
||||
<MudTd DataLabel="Valid from">@context.ValidFrom.ToString("yyyy-MM-dd")</MudTd>
|
||||
<MudTd DataLabel="Valid to">@(context.ValidTo?.ToString("yyyy-MM-dd") ?? "open")</MudTd>
|
||||
</RowTemplate>
|
||||
</MudTable>
|
||||
}
|
||||
|
||||
@code {
|
||||
private List<Tariff>? _tariffs;
|
||||
|
||||
protected override async Task OnInitializedAsync() =>
|
||||
_tariffs = await Db.Tariffs.AsNoTracking()
|
||||
.OrderBy(t => t.Component).ThenBy(t => t.ValidFrom)
|
||||
.ToListAsync();
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
@page "/"
|
||||
@rendermode InteractiveServer
|
||||
@inject DashboardService Dash
|
||||
|
||||
<PageTitle>MeterVault — Overview</PageTitle>
|
||||
|
||||
<MudText Typo="Typo.h4" Class="mb-4">Overview</MudText>
|
||||
|
||||
@if (_summary is null)
|
||||
{
|
||||
<MudProgressLinear Indeterminate="true" Color="Color.Primary" />
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudGrid>
|
||||
<MudItem xs="12" sm="4">
|
||||
<MudPaper Class="pa-4" Elevation="2">
|
||||
<MudText Typo="Typo.overline" Color="Color.Secondary">This month</MudText>
|
||||
<MudText Typo="Typo.h5">@Format.Euro(_summary.Month.Current)</MudText>
|
||||
<DeltaChip Kpi="_summary.Month" />
|
||||
</MudPaper>
|
||||
</MudItem>
|
||||
<MudItem xs="12" sm="4">
|
||||
<MudPaper Class="pa-4" Elevation="2">
|
||||
<MudText Typo="Typo.overline" Color="Color.Secondary">This year</MudText>
|
||||
<MudText Typo="Typo.h5">@Format.Euro(_summary.Year.Current)</MudText>
|
||||
<DeltaChip Kpi="_summary.Year" />
|
||||
</MudPaper>
|
||||
</MudItem>
|
||||
<MudItem xs="12" sm="4">
|
||||
<MudPaper Class="pa-4" Elevation="2">
|
||||
<MudText Typo="Typo.overline" Color="Color.Secondary">Latest month with data</MudText>
|
||||
<MudText Typo="Typo.h5">@Format.Euro(_summary.LatestMonthCost)</MudText>
|
||||
</MudPaper>
|
||||
</MudItem>
|
||||
|
||||
<MudItem xs="12" md="6">
|
||||
<MudPaper Class="pa-4" Elevation="2" Style="height:100%">
|
||||
<MudText Typo="Typo.h6" Class="mb-2">What costs most (this year)</MudText>
|
||||
@if (_breakdown is { Count: > 0 })
|
||||
{
|
||||
<CategoryDonut Slices="_breakdown" />
|
||||
<MudSimpleTable Dense="true" Hover="true" Class="mt-2">
|
||||
<tbody>
|
||||
@foreach (var slice in _breakdown)
|
||||
{
|
||||
<tr>
|
||||
<td>@slice.Name</td>
|
||||
<td style="text-align:right">@Format.Euro(slice.Cost)</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</MudSimpleTable>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudText Typo="Typo.body2" Color="Color.Secondary">No cost data yet — import a sheet or add tariffs.</MudText>
|
||||
}
|
||||
</MudPaper>
|
||||
</MudItem>
|
||||
|
||||
<MudItem xs="12" md="6">
|
||||
<MudPaper Class="pa-4" Elevation="2" Style="height:100%">
|
||||
<MudText Typo="Typo.h6" Class="mb-2">What cost more / less (year vs last year)</MudText>
|
||||
<MudSimpleTable Dense="true" Hover="true">
|
||||
<thead>
|
||||
<tr><th>Category</th><th style="text-align:right">Now</th><th style="text-align:right">Prev</th><th style="text-align:right">Δ</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var row in _difference)
|
||||
{
|
||||
<tr>
|
||||
<td>@row.Name</td>
|
||||
<td style="text-align:right">@Format.Euro(row.Current)</td>
|
||||
<td style="text-align:right">@Format.Euro(row.Previous)</td>
|
||||
<td style="text-align:right" class="@(row.Delta >= 0 ? "mv-up" : "mv-down")">
|
||||
@Format.DirectionIcon(Math.Sign(row.Delta)) @Format.Euro(Math.Abs(row.Delta))
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</MudSimpleTable>
|
||||
</MudPaper>
|
||||
</MudItem>
|
||||
</MudGrid>
|
||||
}
|
||||
|
||||
@code {
|
||||
private DashboardSummary? _summary;
|
||||
private IReadOnlyList<CategorySlice> _breakdown = [];
|
||||
private IReadOnlyList<DifferenceRow> _difference = [];
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
var asOf = DateOnly.FromDateTime(DateTime.UtcNow);
|
||||
_summary = await Dash.GetSummaryAsync(asOf);
|
||||
|
||||
var yearStart = new DateOnly(asOf.Year, 1, 1);
|
||||
_breakdown = await Dash.GetCategoryBreakdownAsync(yearStart, asOf.AddMonths(1));
|
||||
_difference = await Dash.GetCategoryDifferenceAsync(yearStart, yearStart.AddYears(-1), asOf.AddMonths(1));
|
||||
}
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
@page "/"
|
||||
|
||||
<PageTitle>Home</PageTitle>
|
||||
|
||||
<h1>Hello, world!</h1>
|
||||
|
||||
Welcome to your new app.
|
||||
@@ -0,0 +1,128 @@
|
||||
@page "/import"
|
||||
@rendermode InteractiveServer
|
||||
@inject MeterVault.Infrastructure.Import.ReferenceDataImporter ReferenceImporter
|
||||
@inject MeterVault.Infrastructure.Import.CsvImporter CsvImporter
|
||||
@inject ISnackbar Snackbar
|
||||
@using MeterVault.Infrastructure.Import
|
||||
|
||||
<PageTitle>MeterVault — Import</PageTitle>
|
||||
|
||||
<MudText Typo="Typo.h4" Class="mb-4">Import</MudText>
|
||||
|
||||
<MudGrid>
|
||||
<MudItem xs="12" md="6">
|
||||
<MudPaper Class="pa-4" Elevation="2" Style="height:100%">
|
||||
<MudText Typo="Typo.h6">Reference dataset</MudText>
|
||||
<MudText Typo="Typo.body2" Color="Color.Secondary" Class="mb-3">
|
||||
Load the bundled Energiebilanz sheets (electricity, water, heating oil, costs) as a
|
||||
starter dataset with meters, tariffs and categories.
|
||||
</MudText>
|
||||
<MudButton Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.CloudDownload"
|
||||
OnClick="LoadReferenceAsync" Disabled="_loadingReference || _referenceLoaded">
|
||||
@(_referenceLoaded ? "Loaded" : "Load reference data")
|
||||
</MudButton>
|
||||
@if (_loadingReference)
|
||||
{
|
||||
<MudProgressLinear Indeterminate="true" Color="Color.Primary" Class="mt-3" />
|
||||
}
|
||||
</MudPaper>
|
||||
</MudItem>
|
||||
|
||||
<MudItem xs="12" md="6">
|
||||
<MudPaper Class="pa-4" Elevation="2" Style="height:100%">
|
||||
<MudText Typo="Typo.h6">Dry-run a CSV</MudText>
|
||||
<MudText Typo="Typo.body2" Color="Color.Secondary" Class="mb-3">
|
||||
Upload a sheet and preview what would be staged (no changes are made).
|
||||
</MudText>
|
||||
<MudSelect T="string" @bind-Value="_profileName" Label="Sheet type" Dense="true" Class="mb-2">
|
||||
<MudSelectItem T="string" Value="@("Strom")">Electricity (Strom)</MudSelectItem>
|
||||
<MudSelectItem T="string" Value="@("Wasser")">Water (Wasser)</MudSelectItem>
|
||||
<MudSelectItem T="string" Value="@("Heizöl")">Heating oil (Heizöl)</MudSelectItem>
|
||||
<MudSelectItem T="string" Value="@("Kosten")">Costs (Kosten)</MudSelectItem>
|
||||
</MudSelect>
|
||||
<MudButton HtmlTag="label" Variant="Variant.Outlined" StartIcon="@Icons.Material.Filled.UploadFile" for="csvUpload">
|
||||
Choose CSV
|
||||
</MudButton>
|
||||
<InputFile id="csvUpload" OnChange="PreviewAsync" accept=".csv" style="display:none" />
|
||||
</MudPaper>
|
||||
</MudItem>
|
||||
|
||||
@if (_preview is not null)
|
||||
{
|
||||
<MudItem xs="12">
|
||||
<MudPaper Class="pa-4" Elevation="2">
|
||||
<MudText Typo="Typo.h6" Class="mb-2">Preview</MudText>
|
||||
<div class="d-flex" style="gap:2rem; flex-wrap:wrap">
|
||||
<MudText>Readings: <b>@_preview.Readings.Count</b></MudText>
|
||||
<MudText>Events: <b>@_preview.Events.Count</b></MudText>
|
||||
<MudText>Manual costs: <b>@_preview.ManualCosts.Count</b></MudText>
|
||||
<MudText>Skipped rows: <b>@_preview.SkippedRows</b></MudText>
|
||||
</div>
|
||||
@if (_preview.Warnings.Count > 0)
|
||||
{
|
||||
<MudExpansionPanels Class="mt-3">
|
||||
<MudExpansionPanel Text="@($"{_preview.Warnings.Count} warnings")">
|
||||
@foreach (var warning in _preview.Warnings.Take(50))
|
||||
{
|
||||
<MudText Typo="Typo.body2">@warning</MudText>
|
||||
}
|
||||
</MudExpansionPanel>
|
||||
</MudExpansionPanels>
|
||||
}
|
||||
</MudPaper>
|
||||
</MudItem>
|
||||
}
|
||||
</MudGrid>
|
||||
|
||||
@code {
|
||||
private bool _loadingReference;
|
||||
private bool _referenceLoaded;
|
||||
private string _profileName = "Strom";
|
||||
private StagedImport? _preview;
|
||||
|
||||
protected override async Task OnInitializedAsync() =>
|
||||
_referenceLoaded = await ReferenceImporter.IsLoadedAsync();
|
||||
|
||||
private async Task LoadReferenceAsync()
|
||||
{
|
||||
_loadingReference = true;
|
||||
try
|
||||
{
|
||||
var dir = Path.Combine(AppContext.BaseDirectory, "sampledata");
|
||||
await ReferenceImporter.LoadAsync(dir);
|
||||
_referenceLoaded = true;
|
||||
Snackbar.Add("Reference data loaded.", Severity.Success);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Snackbar.Add($"Import failed: {ex.Message}", Severity.Error);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_loadingReference = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task PreviewAsync(InputFileChangeEventArgs args)
|
||||
{
|
||||
var file = args.File;
|
||||
if (file is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var profile = SelectedProfile();
|
||||
using var reader = new StreamReader(file.OpenReadStream(maxAllowedSize: 5 * 1024 * 1024));
|
||||
var content = await reader.ReadToEndAsync();
|
||||
using var stringReader = new StringReader(content);
|
||||
_preview = CsvImporter.Stage(profile, stringReader);
|
||||
}
|
||||
|
||||
private MappingProfile SelectedProfile() => _profileName switch
|
||||
{
|
||||
"Wasser" => ReferenceProfiles.Water(),
|
||||
"Heizöl" => ReferenceProfiles.HeatingOil(),
|
||||
"Kosten" => ReferenceProfiles.Costs(),
|
||||
_ => ReferenceProfiles.Electricity(),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
@page "/meters"
|
||||
@rendermode InteractiveServer
|
||||
@inject MeterVault.Infrastructure.Persistence.MeterVaultDbContext Db
|
||||
@using Microsoft.EntityFrameworkCore
|
||||
|
||||
<PageTitle>MeterVault — Meters</PageTitle>
|
||||
|
||||
<MudText Typo="Typo.h4" Class="mb-4">Meters</MudText>
|
||||
|
||||
@if (_meters is null)
|
||||
{
|
||||
<MudProgressLinear Indeterminate="true" Color="Color.Primary" />
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudTable Items="_meters" Dense="true" Hover="true" Elevation="2">
|
||||
<HeaderContent>
|
||||
<MudTh>Name</MudTh>
|
||||
<MudTh>Type</MudTh>
|
||||
<MudTh>Mode</MudTh>
|
||||
<MudTh>Unit</MudTh>
|
||||
<MudTh>Sources</MudTh>
|
||||
<MudTh>Last seen</MudTh>
|
||||
</HeaderContent>
|
||||
<RowTemplate>
|
||||
<MudTd DataLabel="Name">@context.Name</MudTd>
|
||||
<MudTd DataLabel="Type">@context.EnergyType?.DisplayName</MudTd>
|
||||
<MudTd DataLabel="Mode">@context.Mode</MudTd>
|
||||
<MudTd DataLabel="Unit">@context.Unit</MudTd>
|
||||
<MudTd DataLabel="Sources">@context.Sources.Count</MudTd>
|
||||
<MudTd DataLabel="Last seen">
|
||||
@{
|
||||
var lastSeen = context.Sources
|
||||
.Where(s => s.LastSeenAt != null)
|
||||
.Select(s => s.LastSeenAt)
|
||||
.DefaultIfEmpty(null)
|
||||
.Max();
|
||||
}
|
||||
@(lastSeen?.ToString("yyyy-MM-dd HH:mm") ?? "—")
|
||||
</MudTd>
|
||||
</RowTemplate>
|
||||
</MudTable>
|
||||
|
||||
@if (_meters.Count == 0)
|
||||
{
|
||||
<MudAlert Severity="Severity.Info" Class="mt-4">
|
||||
No meters yet. Go to <MudLink Href="/import">Import</MudLink> to load the reference data.
|
||||
</MudAlert>
|
||||
}
|
||||
}
|
||||
|
||||
@code {
|
||||
private List<Meter>? _meters;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
_meters = await Db.Meters
|
||||
.AsNoTracking()
|
||||
.Include(m => m.EnergyType)
|
||||
.Include(m => m.Sources)
|
||||
.OrderBy(m => m.EnergyTypeId).ThenBy(m => m.Name)
|
||||
.ToListAsync();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
@page "/trends"
|
||||
@rendermode InteractiveServer
|
||||
@inject DashboardService Dash
|
||||
|
||||
<PageTitle>MeterVault — Trends</PageTitle>
|
||||
|
||||
<MudText Typo="Typo.h4" Class="mb-4">Cost trend</MudText>
|
||||
|
||||
<MudPaper Class="pa-4" Elevation="2">
|
||||
<div class="d-flex align-center mb-3" style="gap:1rem">
|
||||
<MudSelect T="int" @bind-Value="_months" Label="Range" Dense="true" Style="max-width:180px">
|
||||
<MudSelectItem T="int" Value="12">Last 12 months</MudSelectItem>
|
||||
<MudSelectItem T="int" Value="24">Last 24 months</MudSelectItem>
|
||||
<MudSelectItem T="int" Value="48">Last 48 months</MudSelectItem>
|
||||
</MudSelect>
|
||||
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="LoadAsync">Apply</MudButton>
|
||||
</div>
|
||||
|
||||
@if (_loading)
|
||||
{
|
||||
<MudProgressLinear Indeterminate="true" Color="Color.Primary" />
|
||||
}
|
||||
else
|
||||
{
|
||||
<TrendChart Points="_points" />
|
||||
<MudText Typo="Typo.subtitle2" Class="mt-4">
|
||||
Total over range: @Format.Euro(_points.Sum(p => p.Cost))
|
||||
</MudText>
|
||||
}
|
||||
</MudPaper>
|
||||
|
||||
@code {
|
||||
private int _months = 24;
|
||||
private bool _loading = true;
|
||||
private IReadOnlyList<TrendPoint> _points = [];
|
||||
|
||||
protected override Task OnInitializedAsync() => LoadAsync();
|
||||
|
||||
private async Task LoadAsync()
|
||||
{
|
||||
_loading = true;
|
||||
var asOf = DateOnly.FromDateTime(DateTime.UtcNow);
|
||||
var from = asOf.AddMonths(-_months);
|
||||
_points = await Dash.GetMonthlyTrendAsync(new DateOnly(from.Year, from.Month, 1), asOf.AddMonths(1));
|
||||
_loading = false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
@using ApexCharts
|
||||
|
||||
@if (Slices is { Count: > 0 })
|
||||
{
|
||||
<ApexChart TItem="CategorySlice" Options="_options" Height="260">
|
||||
<ApexPointSeries TItem="CategorySlice"
|
||||
Items="Slices"
|
||||
SeriesType="SeriesType.Donut"
|
||||
Name="Cost"
|
||||
XValue="s => s.Name"
|
||||
YValue="s => (decimal)Math.Round(s.Cost, 2)" />
|
||||
</ApexChart>
|
||||
}
|
||||
|
||||
@code {
|
||||
[Parameter, EditorRequired]
|
||||
public IReadOnlyList<CategorySlice> Slices { get; set; } = [];
|
||||
|
||||
private readonly ApexChartOptions<CategorySlice> _options = new()
|
||||
{
|
||||
Legend = new Legend { Position = LegendPosition.Bottom },
|
||||
Theme = new Theme { Mode = Mode.Dark },
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
@if (Kpi.Previous != 0 || Kpi.Current != 0)
|
||||
{
|
||||
<MudChip T="string" Size="Size.Small" Variant="Variant.Text" Color="@ChipColor" Class="mt-1">
|
||||
@Format.DirectionIcon(Kpi.Direction) @Format.Euro(Math.Abs(Kpi.Delta)) (@Format.Percent(Kpi.DeltaPercent))
|
||||
</MudChip>
|
||||
}
|
||||
|
||||
@code {
|
||||
[Parameter, EditorRequired]
|
||||
public CostKpi Kpi { get; set; } = new(0, 0);
|
||||
|
||||
// For cost, up (more expensive) is bad → red; down is good → green.
|
||||
private Color ChipColor => Kpi.Direction switch
|
||||
{
|
||||
> 0 => Color.Error,
|
||||
< 0 => Color.Success,
|
||||
_ => Color.Default,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
@using ApexCharts
|
||||
@using System.Globalization
|
||||
|
||||
@if (Points is { Count: > 0 })
|
||||
{
|
||||
<ApexChart TItem="TrendPoint" Options="_options" Height="320">
|
||||
<ApexPointSeries TItem="TrendPoint"
|
||||
Items="Points"
|
||||
SeriesType="SeriesType.Bar"
|
||||
Name="Monthly cost"
|
||||
XValue="Label"
|
||||
YValue="p => (decimal)Math.Round(p.Cost, 2)" />
|
||||
</ApexChart>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudText Typo="Typo.body2" Color="MudBlazor.Color.Secondary">No data in this range.</MudText>
|
||||
}
|
||||
|
||||
@code {
|
||||
[Parameter, EditorRequired]
|
||||
public IReadOnlyList<TrendPoint> Points { get; set; } = [];
|
||||
|
||||
private readonly ApexChartOptions<TrendPoint> _options = new()
|
||||
{
|
||||
Theme = new Theme { Mode = Mode.Dark },
|
||||
DataLabels = new DataLabels { Enabled = false },
|
||||
};
|
||||
|
||||
private static object Label(TrendPoint p) => p.Period.ToString("MMM yy", CultureInfo.InvariantCulture);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
@using System.Net.Http
|
||||
@using System.Net.Http
|
||||
@using System.Net.Http.Json
|
||||
@using Microsoft.AspNetCore.Components.Forms
|
||||
@using Microsoft.AspNetCore.Components.Routing
|
||||
@@ -6,6 +6,11 @@
|
||||
@using static Microsoft.AspNetCore.Components.Web.RenderMode
|
||||
@using Microsoft.AspNetCore.Components.Web.Virtualization
|
||||
@using Microsoft.JSInterop
|
||||
@using MudBlazor
|
||||
@using MeterVault.App
|
||||
@using MeterVault.App.Components
|
||||
@using MeterVault.App.Components.Layout
|
||||
@using MeterVault.App.Components.Shared
|
||||
@using MeterVault.Core.Domain
|
||||
@using MeterVault.Infrastructure.Costing
|
||||
@using MeterVault.Infrastructure.Dashboard
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
using System.Globalization;
|
||||
|
||||
namespace MeterVault.App;
|
||||
|
||||
/// <summary>Small display formatters for the UI (locale-aware formatting arrives with i18n in M7).</summary>
|
||||
public static class Format
|
||||
{
|
||||
private static readonly CultureInfo Culture = CultureInfo.GetCultureInfo("de-DE");
|
||||
|
||||
public static string Euro(double value) => value.ToString("N2", Culture) + " €";
|
||||
|
||||
public static string Number(double value, int decimals = 0) =>
|
||||
value.ToString("N" + decimals.ToString(CultureInfo.InvariantCulture), Culture);
|
||||
|
||||
public static string Percent(double value) =>
|
||||
(value >= 0 ? "+" : "") + value.ToString("N1", Culture) + " %";
|
||||
|
||||
public static string DirectionIcon(int direction) => direction switch
|
||||
{
|
||||
> 0 => "▲",
|
||||
< 0 => "▼",
|
||||
_ => "—",
|
||||
};
|
||||
}
|
||||
@@ -12,10 +12,17 @@
|
||||
</PackageReference>
|
||||
<PackageReference Include="Serilog.AspNetCore" />
|
||||
<PackageReference Include="Serilog.Sinks.Console" />
|
||||
<PackageReference Include="MudBlazor" />
|
||||
<PackageReference Include="Blazor-ApexCharts" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Infrastructure\MeterVault.Infrastructure.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- Ship the reference sheets so the app can load demo data at runtime. -->
|
||||
<ItemGroup>
|
||||
<Content Include="..\..\sampledata\*.csv" Link="sampledata\%(Filename)%(Extension)" CopyToOutputDirectory="PreserveNewest" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -3,6 +3,7 @@ using MeterVault.Infrastructure;
|
||||
using MeterVault.Infrastructure.Options;
|
||||
using MeterVault.Infrastructure.Persistence;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using MudBlazor.Services;
|
||||
using Serilog;
|
||||
|
||||
Log.Logger = new LoggerConfiguration()
|
||||
@@ -33,6 +34,7 @@ try
|
||||
builder.Services.AddMeterVaultIngestion();
|
||||
}
|
||||
|
||||
builder.Services.AddMudServices();
|
||||
builder.Services.AddRazorComponents()
|
||||
.AddInteractiveServerComponents();
|
||||
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
using MudBlazor;
|
||||
|
||||
namespace MeterVault.App.Theme;
|
||||
|
||||
/// <summary>The MeterVault MudBlazor theme — an energy-dashboard palette, dark by default.</summary>
|
||||
public static class MeterVaultTheme
|
||||
{
|
||||
public const string PrimaryTeal = "#14B8A6";
|
||||
public const string SecondaryAmber = "#F6C445";
|
||||
|
||||
public static readonly MudTheme Instance = new()
|
||||
{
|
||||
PaletteLight = new PaletteLight
|
||||
{
|
||||
Primary = PrimaryTeal,
|
||||
Secondary = SecondaryAmber,
|
||||
AppbarBackground = "#0E7C6B",
|
||||
Background = "#F7F8FA",
|
||||
Surface = "#FFFFFF",
|
||||
},
|
||||
PaletteDark = new PaletteDark
|
||||
{
|
||||
Primary = PrimaryTeal,
|
||||
Secondary = SecondaryAmber,
|
||||
Background = "#0D1117",
|
||||
Surface = "#161B22",
|
||||
AppbarBackground = "#10151C",
|
||||
DrawerBackground = "#10151C",
|
||||
TextPrimary = "#E6EDF3",
|
||||
},
|
||||
LayoutProperties = new LayoutProperties
|
||||
{
|
||||
DrawerWidthLeft = "250px",
|
||||
DefaultBorderRadius = "8px",
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -2,6 +2,11 @@ h1:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* MeterVault helpers */
|
||||
.mv-up { color: #ef5350; }
|
||||
.mv-down { color: #66bb6a; }
|
||||
.mv-main { max-width: 1400px; }
|
||||
|
||||
.valid.modified:not([type=checkbox]) {
|
||||
outline: 1px solid #26b050;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,7 +37,10 @@ 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)
|
||||
{
|
||||
ids ??= ReferenceMeterIds.Default;
|
||||
return new MappingProfile
|
||||
{
|
||||
Name = "Energiebilanz — Strom",
|
||||
DateColumn = 0,
|
||||
@@ -34,16 +48,20 @@ public static class ReferenceProfiles
|
||||
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" },
|
||||
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 = 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" },
|
||||
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)
|
||||
{
|
||||
ids ??= ReferenceMeterIds.Default;
|
||||
return new MappingProfile
|
||||
{
|
||||
Name = "Energiebilanz — Wasser",
|
||||
DateColumn = 0,
|
||||
@@ -54,16 +72,16 @@ public static class ReferenceProfiles
|
||||
[
|
||||
new ColumnMapping
|
||||
{
|
||||
Index = 1,
|
||||
Role = MappingRole.Reading,
|
||||
MeterId = Wasser,
|
||||
Unit = "m3",
|
||||
SwapConsumptionColumn = 2, // Wasserverbrauch supplies the swap-month consumption.
|
||||
Index = 1, Role = MappingRole.Reading, MeterId = ids.Wasser, Unit = "m3", SwapConsumptionColumn = 2,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
public static MappingProfile HeatingOil() => new()
|
||||
public static MappingProfile HeatingOil(ReferenceMeterIds? ids = null)
|
||||
{
|
||||
ids ??= ReferenceMeterIds.Default;
|
||||
return new MappingProfile
|
||||
{
|
||||
Name = "Energiebilanz — Heizöl",
|
||||
DateColumn = 0,
|
||||
@@ -73,13 +91,17 @@ public static class ReferenceProfiles
|
||||
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" },
|
||||
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)
|
||||
{
|
||||
ids ??= ReferenceCategoryIds.Default;
|
||||
return new MappingProfile
|
||||
{
|
||||
Name = "Energiebilanz — Kosten",
|
||||
DateColumn = 0,
|
||||
@@ -87,10 +109,11 @@ public static class ReferenceProfiles
|
||||
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 },
|
||||
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 },
|
||||
],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
using MeterVault.Infrastructure.Import;
|
||||
using MeterVault.Infrastructure.Persistence;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace MeterVault.Integration.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// End-to-end M5 check: load the reference dataset, then confirm the dashboard and admin pages
|
||||
/// render (server prerender) without error and show real data. Cleans up the shared container.
|
||||
/// </summary>
|
||||
[Collection("Timescale")]
|
||||
public sealed class DashboardRenderTests(TimescaleFixture fx)
|
||||
{
|
||||
[Fact]
|
||||
public async Task Pages_render_with_reference_data()
|
||||
{
|
||||
using var factory = new MeterVaultAppFactory(fx.ConnectionString);
|
||||
|
||||
using (var scope = factory.Services.CreateScope())
|
||||
{
|
||||
var importer = scope.ServiceProvider.GetRequiredService<ReferenceDataImporter>();
|
||||
await importer.LoadAsync(Path.Combine(AppContext.BaseDirectory, "fixtures"));
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// The import created the reference meters and their normalized consumption.
|
||||
await using (var db = fx.CreateContext())
|
||||
{
|
||||
Assert.Contains(await db.Meters.Select(m => m.Name).ToListAsync(), n => n == "Zähler Haus");
|
||||
Assert.True(await db.Meters.CountAsync() >= 8);
|
||||
Assert.True(await db.Consumption.AnyAsync());
|
||||
}
|
||||
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
var overview = await client.GetAsync(new Uri("/", UriKind.Relative));
|
||||
overview.EnsureSuccessStatusCode();
|
||||
var html = await overview.Content.ReadAsStringAsync();
|
||||
Assert.Contains("Overview", html, StringComparison.Ordinal);
|
||||
// These labels live only in the rendered-KPI-card branch, so their presence proves the
|
||||
// summary loaded and the cards rendered (non-ASCII like € is HTML-entity-encoded).
|
||||
Assert.Contains("This month", html, StringComparison.Ordinal);
|
||||
Assert.Contains("This year", html, StringComparison.Ordinal);
|
||||
Assert.Contains("Latest month with data", html, StringComparison.Ordinal);
|
||||
|
||||
foreach (var path in new[] { "/meters", "/trends", "/import", "/admin/tariffs", "/admin/energy-types" })
|
||||
{
|
||||
var response = await client.GetAsync(new Uri(path, UriKind.Relative));
|
||||
response.EnsureSuccessStatusCode();
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
await using var db = fx.CreateContext();
|
||||
await ClearDataAsync(db);
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task ClearDataAsync(MeterVaultDbContext db)
|
||||
{
|
||||
await db.Consumption.ExecuteDeleteAsync();
|
||||
await db.Readings.ExecuteDeleteAsync();
|
||||
await db.MeterEvents.ExecuteDeleteAsync();
|
||||
await db.ManualCosts.ExecuteDeleteAsync();
|
||||
await db.CostCategoryMembers.ExecuteDeleteAsync();
|
||||
await db.Tariffs.ExecuteDeleteAsync();
|
||||
await db.Tanks.ExecuteDeleteAsync();
|
||||
await db.MeterSources.ExecuteDeleteAsync();
|
||||
await db.Meters.ExecuteDeleteAsync();
|
||||
await db.ImportBatches.ExecuteDeleteAsync();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user