From 1dcccf89b14f9a582a1bb10418b6ebf2795effb0 Mon Sep 17 00:00:00 2001 From: Claudio Schaad Date: Wed, 22 Jul 2026 13:57:28 +0200 Subject: [PATCH] Add booking-text charts to the transactions page Adds two charts to the per-account transactions view: a stacked bar chart of monthly totals and a pie chart of overall totals, both grouped by booking text, so recurring bookings (rent, subscriptions, etc.) are visible at a glance. Chart components are keyed on the account id so switching accounts remounts them instead of reusing a stale Plotly instance. --- .../Datasets/Charts/CategoryTotal.cs | 4 + Schaad.Accounting.Services/ChartService.cs | 42 ++++++++++ .../Interfaces/IChartService.cs | 4 + .../ChartServiceTestShould.cs | 79 ++++++++++++++++++- .../Pages/Charts/BookingTextBreakdown.razor | 8 ++ .../Charts/BookingTextBreakdown.razor.cs | 44 +++++++++++ .../Pages/Charts/BookingTextOverTime.razor | 8 ++ .../Pages/Charts/BookingTextOverTime.razor.cs | 57 +++++++++++++ .../Components/Pages/Transactions.razor | 10 +++ 9 files changed, 255 insertions(+), 1 deletion(-) create mode 100644 Schaad.Accounting.Common/Datasets/Charts/CategoryTotal.cs create mode 100644 Schaad.Accounting.UI/Components/Pages/Charts/BookingTextBreakdown.razor create mode 100644 Schaad.Accounting.UI/Components/Pages/Charts/BookingTextBreakdown.razor.cs create mode 100644 Schaad.Accounting.UI/Components/Pages/Charts/BookingTextOverTime.razor create mode 100644 Schaad.Accounting.UI/Components/Pages/Charts/BookingTextOverTime.razor.cs diff --git a/Schaad.Accounting.Common/Datasets/Charts/CategoryTotal.cs b/Schaad.Accounting.Common/Datasets/Charts/CategoryTotal.cs new file mode 100644 index 0000000..346e6d0 --- /dev/null +++ b/Schaad.Accounting.Common/Datasets/Charts/CategoryTotal.cs @@ -0,0 +1,4 @@ +namespace Schaad.Accounting.Datasets.Charts +{ + public record CategoryTotal(string Name, decimal Value); +} diff --git a/Schaad.Accounting.Services/ChartService.cs b/Schaad.Accounting.Services/ChartService.cs index 6a72fb0..03cf130 100644 --- a/Schaad.Accounting.Services/ChartService.cs +++ b/Schaad.Accounting.Services/ChartService.cs @@ -95,6 +95,48 @@ namespace Schaad.Accounting.Services return list; } + public async Task> GetBookingTextExpensesPerMonthAsync(string accountId) + { + var account = await viewService.GetAccountViewAsync(accountId); + var transactions = await viewService.GetTransactionViewListAsync(accountId); + if (transactions.Count == 0) + { + return new List(); + } + + var year = settingsService.GetYear(); + var maxMonth = transactions.Max(t => t.ValueDate.Month); + + var list = new List(); + foreach (var grp in transactions.GroupBy(t => t.Text)) + { + var groupedByMonth = grp + .GroupBy(t => t.ValueDate.Month) + .ToDictionary(g => g.Key, g => g.Sum(t => t.GetValue(account.IsFxAccount))); + EnsureEntryForEveryMonth(groupedByMonth, maxMonth); + + list.Add(new DataSerie( + Id: grp.Key, + Name: grp.Key, + X: groupedByMonth.OrderBy(g => g.Key).Select(g => new DateOnly(year, g.Key, 1)).ToList(), + Y: groupedByMonth.OrderBy(g => g.Key).Select(g => g.Value).ToList() + )); + } + return list; + } + + public async Task> GetBookingTextTotalsAsync(string accountId) + { + var account = await viewService.GetAccountViewAsync(accountId); + var transactions = await viewService.GetTransactionViewListAsync(accountId); + + return transactions + .GroupBy(t => t.Text) + .Select(g => new CategoryTotal(g.Key, g.Sum(t => t.GetValue(account.IsFxAccount)))) + .Where(c => c.Value != 0) + .ToList(); + } + private static void EnsureEntryForEveryMonth(Dictionary values, int maxMonth = 12) { for (int i = 1; i <= maxMonth; i++) diff --git a/Schaad.Accounting.Services/Interfaces/IChartService.cs b/Schaad.Accounting.Services/Interfaces/IChartService.cs index c722964..64296b1 100644 --- a/Schaad.Accounting.Services/Interfaces/IChartService.cs +++ b/Schaad.Accounting.Services/Interfaces/IChartService.cs @@ -7,5 +7,9 @@ namespace Schaad.Accounting.Interfaces public interface IChartService { Task> GetExpensesPerMonthAsync(); + + Task> GetBookingTextExpensesPerMonthAsync(string accountId); + + Task> GetBookingTextTotalsAsync(string accountId); } } diff --git a/Schaad.Accounting.Tests/ChartServiceTestShould.cs b/Schaad.Accounting.Tests/ChartServiceTestShould.cs index decedb4..5e00978 100644 --- a/Schaad.Accounting.Tests/ChartServiceTestShould.cs +++ b/Schaad.Accounting.Tests/ChartServiceTestShould.cs @@ -118,7 +118,7 @@ public class ChartServiceTestShould private static AccountDataset AsDataset(Account a, string subClassName = "") => new(a, balance: 0, balanceCHF: 0, startBalanceCHF: 0, className: "", subClassName: subClassName); - private static TransactionDataset MakeTransaction(Account origin, Account target, decimal value, DateTime valueDate, decimal? fxRate = null) => + private static TransactionDataset MakeTransaction(Account origin, Account target, decimal value, DateTime valueDate, decimal? fxRate = null, string text = "") => new( new Transaction { @@ -127,6 +127,7 @@ public class ChartServiceTestShould TargetAccountId = target.Id, Value = value, FxRate = fxRate, + Text = text, ValueDate = valueDate, BookingDate = valueDate }, @@ -168,4 +169,80 @@ public class ChartServiceTestShould series.Single(s => s.Id == "42").Y[0].ShouldBe(200m); } + + [Fact] + public async Task GroupBookingTextExpensesByTextWhenBuildingMonthlySeriesForAccount() + { + var checking = MakeAccount("chk", 1010); + var groceries = MakeAccount("groc", 4200); + viewService.GetAccountViewAsync("chk").Returns(AsDataset(checking)); + viewService.GetTransactionViewListAsync("chk").Returns(new List + { + MakeTransaction(checking, groceries, value: 50m, valueDate: new DateTime(2026, 1, 5), text: "Migros"), + MakeTransaction(checking, groceries, value: 20m, valueDate: new DateTime(2026, 2, 3), text: "Coop") + }); + + var series = await BuildService().GetBookingTextExpensesPerMonthAsync("chk"); + + series.Count.ShouldBe(2); + var migros = series.Single(s => s.Name == "Migros"); + migros.Y[0].ShouldBe(50m); + migros.Y[1].ShouldBe(0m); + var coop = series.Single(s => s.Name == "Coop"); + coop.Y[0].ShouldBe(0m); + coop.Y[1].ShouldBe(20m); + } + + [Fact] + public async Task ConvertFxAccountValuesWhenBuildingBookingTextSeries() + { + var usChecking = MakeAccount("chk", 1010, currency: "USD"); + var groceries = MakeAccount("groc", 4200); + viewService.GetAccountViewAsync("chk").Returns(AsDataset(usChecking)); + viewService.GetTransactionViewListAsync("chk").Returns(new List + { + MakeTransaction(usChecking, groceries, value: 100m, valueDate: new DateTime(2026, 1, 5), fxRate: 0.5m, text: "Migros") + }); + + var series = await BuildService().GetBookingTextExpensesPerMonthAsync("chk"); + + series.Single().Y.Single().ShouldBe(200m); + } + + [Fact] + public async Task SumBookingTextTotalsAcrossMonthsWhenBuildingTotalsForAccount() + { + var checking = MakeAccount("chk", 1010); + var groceries = MakeAccount("groc", 4200); + viewService.GetAccountViewAsync("chk").Returns(AsDataset(checking)); + viewService.GetTransactionViewListAsync("chk").Returns(new List + { + MakeTransaction(checking, groceries, value: 50m, valueDate: new DateTime(2026, 1, 5), text: "Migros"), + MakeTransaction(checking, groceries, value: 30m, valueDate: new DateTime(2026, 2, 3), text: "Migros"), + MakeTransaction(checking, groceries, value: 20m, valueDate: new DateTime(2026, 2, 4), text: "Coop") + }); + + var totals = await BuildService().GetBookingTextTotalsAsync("chk"); + + totals.Count.ShouldBe(2); + totals.ShouldContain(t => t.Name == "Migros" && t.Value == 80m); + totals.ShouldContain(t => t.Name == "Coop" && t.Value == 20m); + } + + [Fact] + public async Task ExcludeZeroNetBookingTextsWhenBuildingTotalsForAccount() + { + var checking = MakeAccount("chk", 1010); + var groceries = MakeAccount("groc", 4200); + viewService.GetAccountViewAsync("chk").Returns(AsDataset(checking)); + viewService.GetTransactionViewListAsync("chk").Returns(new List + { + MakeTransaction(checking, groceries, value: 50m, valueDate: new DateTime(2026, 1, 5), text: "Migros"), + MakeTransaction(checking, groceries, value: -50m, valueDate: new DateTime(2026, 1, 6), text: "Migros") + }); + + var totals = await BuildService().GetBookingTextTotalsAsync("chk"); + + totals.ShouldBeEmpty(); + } } diff --git a/Schaad.Accounting.UI/Components/Pages/Charts/BookingTextBreakdown.razor b/Schaad.Accounting.UI/Components/Pages/Charts/BookingTextBreakdown.razor new file mode 100644 index 0000000..c6079a4 --- /dev/null +++ b/Schaad.Accounting.UI/Components/Pages/Charts/BookingTextBreakdown.razor @@ -0,0 +1,8 @@ +@if (config is not null && layout is not null && data is not null) +{ + +} +else +{ + +} diff --git a/Schaad.Accounting.UI/Components/Pages/Charts/BookingTextBreakdown.razor.cs b/Schaad.Accounting.UI/Components/Pages/Charts/BookingTextBreakdown.razor.cs new file mode 100644 index 0000000..d8b6e8f --- /dev/null +++ b/Schaad.Accounting.UI/Components/Pages/Charts/BookingTextBreakdown.razor.cs @@ -0,0 +1,44 @@ +using Microsoft.AspNetCore.Components; +using Plotly.Blazor; +using Plotly.Blazor.Traces; +using Schaad.Accounting.Interfaces; + +namespace Schaad.Accounting.UI.Components.Pages.Charts; + +public partial class BookingTextBreakdown : ComponentBase +{ + private PlotlyChart chart = null!; + private Config? config; + private Plotly.Blazor.Layout? layout; + private IList? data; + + [Parameter, EditorRequired] + public string AccountId { get; set; } = null!; + + [Inject] + private IChartService chartService { get; set; } = null!; + + protected override async Task OnInitializedAsync() + { + config = new Config + { + Responsive = true + }; + + layout = new Plotly.Blazor.Layout + { + Title = new Plotly.Blazor.LayoutLib.Title { Text = "Nach Buchungstext" } + }; + + var totals = await chartService.GetBookingTextTotalsAsync(AccountId); + data = new List + { + new Pie + { + // Values are shown as their absolute magnitude - a pie slice can't represent a signed amount. + Values = totals.Select(t => (object)Math.Abs(t.Value)).ToList(), + Labels = totals.Select(t => (object)t.Name).ToList() + } + }; + } +} diff --git a/Schaad.Accounting.UI/Components/Pages/Charts/BookingTextOverTime.razor b/Schaad.Accounting.UI/Components/Pages/Charts/BookingTextOverTime.razor new file mode 100644 index 0000000..659020f --- /dev/null +++ b/Schaad.Accounting.UI/Components/Pages/Charts/BookingTextOverTime.razor @@ -0,0 +1,8 @@ +@if (config is not null && layout is not null) +{ + +} +else +{ + +} diff --git a/Schaad.Accounting.UI/Components/Pages/Charts/BookingTextOverTime.razor.cs b/Schaad.Accounting.UI/Components/Pages/Charts/BookingTextOverTime.razor.cs new file mode 100644 index 0000000..732fb71 --- /dev/null +++ b/Schaad.Accounting.UI/Components/Pages/Charts/BookingTextOverTime.razor.cs @@ -0,0 +1,57 @@ +using Microsoft.AspNetCore.Components; +using Plotly.Blazor; +using Plotly.Blazor.LayoutLib; +using Plotly.Blazor.Traces; +using Schaad.Accounting.Interfaces; + +namespace Schaad.Accounting.UI.Components.Pages.Charts; + +public partial class BookingTextOverTime : ComponentBase +{ + private PlotlyChart chart = null!; + private Config? config; + private Plotly.Blazor.Layout? layout; + private IList data = new List(); + + [Parameter, EditorRequired] + public string AccountId { get; set; } = null!; + + [Inject] + private IChartService chartService { get; set; } = null!; + + protected override async Task OnInitializedAsync() + { + config = new Config + { + Responsive = true + }; + + layout = new Plotly.Blazor.Layout + { + Title = new Title { Text = "Nach Buchungstext im Verlauf" }, + BarMode = BarModeEnum.Stack, + XAxis = new List + { + new() + { + Type = Plotly.Blazor.LayoutLib.XAxisLib.TypeEnum.Date + } + } + }; + + var newData = new List(); + var dataSeries = await chartService.GetBookingTextExpensesPerMonthAsync(AccountId); + foreach (var dataSerie in dataSeries) + { + newData.Add(new Bar + { + Name = dataSerie.Name, + X = dataSerie.X.Select(d => (object)d).ToList(), + Y = dataSerie.Y.Select(d => (object)d).ToList(), + }); + } + data = newData; + + await base.OnInitializedAsync(); + } +} diff --git a/Schaad.Accounting.UI/Components/Pages/Transactions.razor b/Schaad.Accounting.UI/Components/Pages/Transactions.razor index d55d04a..0d168ff 100644 --- a/Schaad.Accounting.UI/Components/Pages/Transactions.razor +++ b/Schaad.Accounting.UI/Components/Pages/Transactions.razor @@ -1,5 +1,6 @@ @page "/transactions" @rendermode InteractiveServer +@using Schaad.Accounting.UI.Components.Pages.Charts Transaktionen @@ -43,6 +44,15 @@ + + + + + + + + +