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
+4
View File
@@ -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>
+33 -2
View File
@@ -1,9 +1,40 @@
@inherits LayoutComponentBase
@inherits LayoutComponentBase
@using MeterVault.App.Theme
@Body
<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;
}
+11
View File
@@ -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();
}
+102
View File
@@ -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));
}
}
-7
View File
@@ -1,7 +0,0 @@
@page "/"
<PageTitle>Home</PageTitle>
<h1>Hello, world!</h1>
Welcome to your new app.
+128
View File
@@ -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(),
};
}
+64
View File
@@ -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();
}
}
+47
View File
@@ -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 },
};
}
+19
View File
@@ -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);
}
+6 -1
View File
@@ -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
+24
View File
@@ -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 => "▼",
_ => "—",
};
}
+7
View File
@@ -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>
+2
View File
@@ -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();
+37
View File
@@ -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",
},
};
}
+5
View File
@@ -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;
}