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.
This commit is contained in:
Claudio Schaad 2026-07-22 13:57:28 +02:00
parent a19bdf4558
commit 7f284b1032
12 changed files with 361 additions and 17 deletions

View file

@ -0,0 +1,4 @@
namespace Schaad.Accounting.Datasets.Charts
{
public record CategoryTotal(string Name, decimal Value);
}

View file

@ -33,5 +33,15 @@ namespace Schaad.Accounting.Datasets
else else
return Value; return Value;
} }
public decimal GetValue(bool withFxRate, string accountId)
{
if (accountId == OriginAccountId)
{
return -1 * GetValue(withFxRate);
}
return GetValue(withFxRate);
}
} }
} }

View file

@ -95,6 +95,49 @@ namespace Schaad.Accounting.Services
return list; return list;
} }
public async Task<IReadOnlyList<DataSerie>> GetBookingTextExpensesPerMonthAsync(string accountId)
{
var account = await viewService.GetAccountViewAsync(accountId);
var transactions = await viewService.GetTransactionViewListAsync(accountId);
if (transactions.Count == 0)
{
return new List<DataSerie>();
}
var year = settingsService.GetYear();
var maxMonth = transactions.Max(t => t.ValueDate.Month);
var list = new List<DataSerie>();
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, accountId)));
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<IReadOnlyList<CategoryTotal>> GetBookingTextTotalsAsync(string accountId)
{
var account = await viewService.GetAccountViewAsync(accountId);
var transactions = await viewService.GetTransactionViewListAsync(accountId);
return transactions
.Where(t => t.TargetAccountId == accountId)
.GroupBy(t => t.Text)
.Select(g => new CategoryTotal(g.Key, g.Sum(t => t.GetValue(account.IsFxAccount, accountId))))
.Where(c => c.Value != 0)
.ToList();
}
private static void EnsureEntryForEveryMonth(Dictionary<int, decimal> values, int maxMonth = 12) private static void EnsureEntryForEveryMonth(Dictionary<int, decimal> values, int maxMonth = 12)
{ {
for (int i = 1; i <= maxMonth; i++) for (int i = 1; i <= maxMonth; i++)

View file

@ -7,5 +7,9 @@ namespace Schaad.Accounting.Interfaces
public interface IChartService public interface IChartService
{ {
Task<IReadOnlyList<DataSerie>> GetExpensesPerMonthAsync(); Task<IReadOnlyList<DataSerie>> GetExpensesPerMonthAsync();
Task<IReadOnlyList<DataSerie>> GetBookingTextExpensesPerMonthAsync(string accountId);
Task<IReadOnlyList<CategoryTotal>> GetBookingTextTotalsAsync(string accountId);
} }
} }

View file

@ -132,8 +132,9 @@ namespace Schaad.Accounting.Services
} }
/// <summary> /// <summary>
/// Get transaction list filtered to a single account, with values sign-flipped /// Get transaction list filtered to a single account. Sign is not baked in here -
/// when the requested account is the origin of an activa booking. /// callers use <see cref="TransactionDataset.GetValue(bool, string)"/> with this
/// account's id to get a value that's negative when the account is paying out.
/// </summary> /// </summary>
public async Task<List<TransactionDataset>> GetTransactionViewListAsync(string accountId) public async Task<List<TransactionDataset>> GetTransactionViewListAsync(string accountId)
{ {
@ -143,22 +144,10 @@ namespace Schaad.Accounting.Services
return transactionList.Select(t => return transactionList.Select(t =>
new TransactionDataset( new TransactionDataset(
WithDisplaySign(t), t,
accountsById[t.OriginAccountId], accountsById[t.OriginAccountId],
accountsById[t.TargetAccountId])) accountsById[t.TargetAccountId]))
.ToList(); .ToList();
Transaction WithDisplaySign(Transaction t)
{
var originAccount = accountsById[t.OriginAccountId];
if (originAccount.Class == ClassIds.Activa && accountId == t.OriginAccountId)
{
var copy = t.Clone();
copy.Value *= -1;
return copy;
}
return t;
}
} }
/// <summary> /// <summary>

View file

@ -118,7 +118,7 @@ public class ChartServiceTestShould
private static AccountDataset AsDataset(Account a, string subClassName = "") => private static AccountDataset AsDataset(Account a, string subClassName = "") =>
new(a, balance: 0, balanceCHF: 0, startBalanceCHF: 0, className: "", subClassName: 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(
new Transaction new Transaction
{ {
@ -127,6 +127,7 @@ public class ChartServiceTestShould
TargetAccountId = target.Id, TargetAccountId = target.Id,
Value = value, Value = value,
FxRate = fxRate, FxRate = fxRate,
Text = text,
ValueDate = valueDate, ValueDate = valueDate,
BookingDate = valueDate BookingDate = valueDate
}, },
@ -168,4 +169,113 @@ public class ChartServiceTestShould
series.Single(s => s.Id == "42").Y[0].ShouldBe(200m); 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<TransactionDataset>
{
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 ShowPositiveValueWhenAccountIsTargetOfBookingTextTransaction()
{
var checking = MakeAccount("chk", 1010);
var salary = MakeAccount("sal", 3000);
viewService.GetAccountViewAsync("chk").Returns(AsDataset(checking));
viewService.GetTransactionViewListAsync("chk").Returns(new List<TransactionDataset>
{
MakeTransaction(salary, checking, value: 5000m, valueDate: new DateTime(2026, 1, 5), text: "Lohn")
});
var series = await BuildService().GetBookingTextExpensesPerMonthAsync("chk");
series.Single().Y.Single().ShouldBe(5000m);
}
[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<TransactionDataset>
{
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<TransactionDataset>
{
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 NetOutgoingAndIncomingBookingsWithTheSameTextWhenBuildingTotalsForAccount()
{
var checking = MakeAccount("chk", 1010);
var groceries = MakeAccount("groc", 4200);
viewService.GetAccountViewAsync("chk").Returns(AsDataset(checking));
viewService.GetTransactionViewListAsync("chk").Returns(new List<TransactionDataset>
{
MakeTransaction(checking, groceries, value: 50m, valueDate: new DateTime(2026, 1, 5), text: "Migros"),
MakeTransaction(groceries, checking, value: 20m, valueDate: new DateTime(2026, 1, 6), text: "Migros")
});
var totals = await BuildService().GetBookingTextTotalsAsync("chk");
totals.Single().Value.ShouldBe(-30m);
}
[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<TransactionDataset>
{
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();
}
} }

View file

@ -0,0 +1,54 @@
using Schaad.Accounting.Datasets;
using Schaad.Accounting.Models;
using Shouldly;
namespace Schaad.Accounting.Tests;
public class TransactionDatasetTestShould
{
private static Account MakeAccount(string id) => new() { Id = id, Name = id, Currency = "CHF" };
private static TransactionDataset MakeTransaction(Account origin, Account target, decimal value, decimal? fxRate = null) =>
new(
new Transaction
{
Id = Guid.NewGuid().ToString(),
OriginAccountId = origin.Id,
TargetAccountId = target.Id,
Value = value,
FxRate = fxRate,
ValueDate = DateTime.Now,
BookingDate = DateTime.Now
},
origin, target);
[Fact]
public void ReturnNegativeValueWhenAccountIsOrigin()
{
var checking = MakeAccount("chk");
var groceries = MakeAccount("groc");
var transaction = MakeTransaction(checking, groceries, value: 50m);
transaction.GetValue(withFxRate: false, accountId: "chk").ShouldBe(-50m);
}
[Fact]
public void ReturnPositiveValueWhenAccountIsTarget()
{
var checking = MakeAccount("chk");
var groceries = MakeAccount("groc");
var transaction = MakeTransaction(groceries, checking, value: 50m);
transaction.GetValue(withFxRate: false, accountId: "chk").ShouldBe(50m);
}
[Fact]
public void ApplyFxConversionBeforeApplyingSignWhenAccountIsOrigin()
{
var checking = MakeAccount("chk");
var groceries = MakeAccount("groc");
var transaction = MakeTransaction(checking, groceries, value: 100m, fxRate: 0.5m);
transaction.GetValue(withFxRate: true, accountId: "chk").ShouldBe(-200m);
}
}

View file

@ -0,0 +1,8 @@
@if (config is not null && layout is not null && data is not null)
{
<PlotlyChart style="height: 400px; min-height: 300px" @bind-Config="config" @bind-Layout="layout" @bind-Data="data" @ref="chart" />
}
else
{
<Loading/>
}

View file

@ -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<ITrace>? 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<ITrace>
{
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()
}
};
}
}

View file

@ -0,0 +1,8 @@
@if (config is not null && layout is not null)
{
<PlotlyChart style="height: 400px; min-height: 300px" @bind-Config="config" @bind-Layout="layout" @bind-Data="data" @ref="chart" />
}
else
{
<Loading/>
}

View file

@ -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<ITrace> data = new List<ITrace>();
[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<XAxis>
{
new()
{
Type = Plotly.Blazor.LayoutLib.XAxisLib.TypeEnum.Date
}
}
};
var newData = new List<ITrace>();
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();
}
}

View file

@ -1,5 +1,6 @@
@page "/transactions" @page "/transactions"
@rendermode InteractiveServer @rendermode InteractiveServer
@using Schaad.Accounting.UI.Components.Pages.Charts
<PageTitle>Transaktionen</PageTitle> <PageTitle>Transaktionen</PageTitle>
@ -43,6 +44,18 @@
</FluentGridItem> </FluentGridItem>
</FluentGrid> </FluentGrid>
@if (selectedAccount.Class == ClassIds.Expenses)
{
<FluentGrid Justify="JustifyContent.FlexStart">
<FluentGridItem xs="6">
<BookingTextOverTime @key="selectedAccount.Id" AccountId="@selectedAccount.Id"/>
</FluentGridItem>
<FluentGridItem xs="6">
<BookingTextBreakdown @key="selectedAccount.Id" AccountId="@selectedAccount.Id"/>
</FluentGridItem>
</FluentGrid>
}
<FluentPaginator State="@pagination" SummaryTemplate="@template"/> <FluentPaginator State="@pagination" SummaryTemplate="@template"/>
<FluentDataGrid Items="@transactionList" Pagination="@pagination" RowSize="DataGridRowSize.Medium"> <FluentDataGrid Items="@transactionList" Pagination="@pagination" RowSize="DataGridRowSize.Medium">
<PropertyColumn Property="@(p => p.BookingDate)" Format="dd.MM.yyyy" Sortable="true" IsDefaultSortColumn="true" InitialSortDirection="SortDirection.Descending"/> <PropertyColumn Property="@(p => p.BookingDate)" Format="dd.MM.yyyy" Sortable="true" IsDefaultSortColumn="true" InitialSortDirection="SortDirection.Descending"/>
@ -52,7 +65,7 @@
@GetAccountName(context) @GetAccountName(context)
</TemplateColumn> </TemplateColumn>
<TemplateColumn Title="Betrag" Align="Align.End"> <TemplateColumn Title="Betrag" Align="Align.End">
@context.GetValue(selectedAccount.IsFxAccount).ToString("N2") @selectedAccount.Currency @context.GetValue(selectedAccount.IsFxAccount, selectedAccountId).ToString("N2") @selectedAccount.Currency
</TemplateColumn> </TemplateColumn>
<TemplateColumn Align="Align.Center" Width="50px"> <TemplateColumn Align="Align.Center" Width="50px">
<FluentButton IconEnd="@(new Icons.Regular.Size16.Delete())" Appearance="Appearance.Neutral" title="Löschen" aria-label="Löschen" OnClick="@(() => DeleteAsync(context.Id))"/> <FluentButton IconEnd="@(new Icons.Regular.Size16.Delete())" Appearance="Appearance.Neutral" title="Löschen" aria-label="Löschen" OnClick="@(() => DeleteAsync(context.Id))"/>