Skip to content

Commit d180d4d

Browse files
committed
feat(ui): add a dashboard with current state of finances
1 parent ad975f5 commit d180d4d

File tree

11 files changed

+476
-9
lines changed

11 files changed

+476
-9
lines changed

docs/changelog.html

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,10 @@ <h3>Added</h3>
7272
Support for PostgreSQL 17 in
7373
<a href="https://github.com/VMelnalksnis/Gnomeshade/pull/1394">#1394</a>
7474
</li>
75+
<li>
76+
Dashboard with current state of finances in
77+
<a href="https://github.com/VMelnalksnis/Gnomeshade/pull/1395">#1395</a>
78+
</li>
7579
</ul>
7680
</section>
7781

Lines changed: 375 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,375 @@
1+
// Copyright 2021 Valters Melnalksnis
2+
// Licensed under the GNU Affero General Public License v3.0 or later.
3+
// See LICENSE.txt file in the project root for full license information.
4+
5+
using System;
6+
using System.Collections.Generic;
7+
using System.Collections.ObjectModel;
8+
using System.Linq;
9+
using System.Threading.Tasks;
10+
11+
using Gnomeshade.Avalonia.Core.Products;
12+
using Gnomeshade.Avalonia.Core.Reports;
13+
using Gnomeshade.Avalonia.Core.Reports.Splits;
14+
using Gnomeshade.WebApi.Client;
15+
using Gnomeshade.WebApi.Models.Accounts;
16+
using Gnomeshade.WebApi.Models.Transactions;
17+
18+
using LiveChartsCore.Defaults;
19+
using LiveChartsCore.Kernel.Sketches;
20+
using LiveChartsCore.SkiaSharpView;
21+
using LiveChartsCore.SkiaSharpView.Painting;
22+
23+
using NodaTime;
24+
25+
using PropertyChanged.SourceGenerator;
26+
27+
using SkiaSharp;
28+
29+
namespace Gnomeshade.Avalonia.Core;
30+
31+
/// <summary>Quick overview of all accounts.</summary>
32+
public sealed partial class DashboardViewModel : ViewModelBase
33+
{
34+
private readonly IGnomeshadeClient _gnomeshadeClient;
35+
private readonly IClock _clock;
36+
private readonly IDateTimeZoneProvider _dateTimeZoneProvider;
37+
38+
/// <summary>Gets the data series of the balance of all user accounts with positive balance.</summary>
39+
[Notify(Setter.Private)]
40+
private PieSeries<decimal>[] _balanceSeries = [];
41+
42+
/// <summary>Gets the data series of the balance of all user accounts with negative balance.</summary>
43+
[Notify(Setter.Private)]
44+
private PieSeries<decimal>[] _liabilitiesBalanceSeries = [];
45+
46+
/// <summary>Gets the data series of spending by category.</summary>
47+
[Notify(Setter.Private)]
48+
private PieSeries<decimal>[] _categoriesSeries = [];
49+
50+
/// <summary>Gets the data series of balance of the users account over time.</summary>
51+
[Notify(Setter.Private)]
52+
private CandlesticksSeries<FinancialPointI>[] _cashflowSeries = [];
53+
54+
/// <summary>Gets a collection of all accounts of the current user.</summary>
55+
[Notify(Setter.Private)]
56+
private List<Account> _userAccounts = [];
57+
58+
/// <summary>Gets or sets a collection of accounts selected from <see cref="UserAccounts"/>.</summary>
59+
[Notify]
60+
private ObservableCollection<Account> _selectedAccounts = [];
61+
62+
/// <summary>Gets a collection of all currencies used in <see cref="UserAccounts"/>.</summary>
63+
[Notify(Setter.Private)]
64+
private List<Currency> _currencies = [];
65+
66+
/// <summary>Gets or sets the selected currency from <see cref="Currencies"/>.</summary>
67+
[Notify]
68+
private Currency? _selectedCurrency;
69+
70+
/// <summary>Gets the y axes for <see cref="CashflowSeries"/>.</summary>
71+
[Notify(Setter.Private)]
72+
private ICartesianAxis[] _yAxes = [new Axis()];
73+
74+
/// <summary>Gets the x axes for <see cref="CashflowSeries"/>.</summary>
75+
[Notify(Setter.Private)]
76+
private ICartesianAxis[] _xAxes = [new Axis()];
77+
78+
/// <summary>Gets the collection of account summary rows.</summary>
79+
[Notify(Setter.Private)]
80+
private AccountRow[] _accountRows = [];
81+
82+
/// <summary>Gets the collection of rows of various totals of <see cref="AccountRows"/>.</summary>
83+
[Notify(Setter.Private)]
84+
private AccountRow[] _accountRowsTotals = [];
85+
86+
/// <summary>Initializes a new instance of the <see cref="DashboardViewModel"/> class.</summary>
87+
/// <param name="activityService">Service for indicating the activity of the application to the user.</param>
88+
/// <param name="gnomeshadeClient">A strongly typed API client.</param>
89+
/// <param name="clock">Clock which can provide the current instant.</param>
90+
/// <param name="dateTimeZoneProvider">Time zone provider for localizing instants to local time.</param>
91+
public DashboardViewModel(
92+
IActivityService activityService,
93+
IGnomeshadeClient gnomeshadeClient,
94+
IClock clock,
95+
IDateTimeZoneProvider dateTimeZoneProvider)
96+
: base(activityService)
97+
{
98+
_gnomeshadeClient = gnomeshadeClient;
99+
_clock = clock;
100+
_dateTimeZoneProvider = dateTimeZoneProvider;
101+
}
102+
103+
/// <inheritdoc />
104+
protected override async Task Refresh()
105+
{
106+
var (counterparty, allAccounts, currencies) = await
107+
(_gnomeshadeClient.GetMyCounterpartyAsync(),
108+
_gnomeshadeClient.GetAccountsAsync(),
109+
_gnomeshadeClient.GetCurrenciesAsync())
110+
.WhenAll();
111+
112+
var selected = SelectedAccounts.Select(account => account.Id).ToArray();
113+
var selectedCurrency = SelectedCurrency?.Id;
114+
115+
UserAccounts = allAccounts.Where(account => account.CounterpartyId == counterparty.Id).ToList();
116+
Currencies = currencies
117+
.Where(currency => UserAccounts.SelectMany(account => account.Currencies).Any(aic => aic.CurrencyId == currency.Id))
118+
.ToList();
119+
120+
SelectedAccounts = new(UserAccounts.Where(account => selected.Contains(account.Id)));
121+
SelectedCurrency = selectedCurrency is { } id ? Currencies.Single(currency => currency.Id == id) : null;
122+
123+
IReadOnlyCollection<Account> accounts = SelectedAccounts.Count is not 0 ? SelectedAccounts : UserAccounts;
124+
125+
var timeZone = _dateTimeZoneProvider.GetSystemDefault();
126+
var currentInstant = _clock.GetCurrentInstant();
127+
var localTime = currentInstant.InZone(timeZone).LocalDateTime;
128+
129+
var startTime = localTime.With(DateAdjusters.StartOfMonth).InZoneStrictly(timeZone);
130+
var endTime = localTime.With(DateAdjusters.EndOfMonth).InZoneStrictly(timeZone);
131+
132+
await Task.WhenAll(
133+
RefreshCashFlow(accounts, startTime, endTime, timeZone, counterparty),
134+
RefreshBalance(),
135+
RefreshCategories(accounts, timeZone, startTime));
136+
}
137+
138+
private async Task RefreshCashFlow(
139+
IReadOnlyCollection<Account> accounts,
140+
ZonedDateTime startTime,
141+
ZonedDateTime endTime,
142+
DateTimeZone timeZone,
143+
Counterparty counterparty)
144+
{
145+
var inCurrencyIds = accounts
146+
.SelectMany(account => account.Currencies.Where(aic => aic.CurrencyId == (SelectedCurrency?.Id ?? account.PreferredCurrencyId)).Select(aic => aic.Id))
147+
.ToArray();
148+
149+
var transfers = (await _gnomeshadeClient.GetTransfersAsync())
150+
.Where(transfer =>
151+
inCurrencyIds.Contains(transfer.SourceAccountId) ||
152+
inCurrencyIds.Contains(transfer.TargetAccountId))
153+
.OrderBy(transfer => transfer.ValuedAt ?? transfer.BookedAt)
154+
.ThenBy(transfer => transfer.CreatedAt)
155+
.ThenBy(transfer => transfer.ModifiedAt)
156+
.ToArray();
157+
var reportSplit = SplitProvider.DailySplit;
158+
159+
var values = reportSplit
160+
.GetSplits(startTime, endTime)
161+
.Select(date =>
162+
{
163+
var splitZonedDate = date.AtStartOfDayInZone(timeZone);
164+
var splitInstant = splitZonedDate.ToInstant();
165+
166+
var transfersBefore = transfers
167+
.Where(transfer => new ZonedDateTime(transfer.ValuedAt ?? transfer.BookedAt!.Value, timeZone).ToInstant() < splitInstant);
168+
169+
var transfersIn = transfers
170+
.Where(transfer => reportSplit.Equals(
171+
splitZonedDate,
172+
new(transfer.ValuedAt ?? transfer.BookedAt!.Value, timeZone)))
173+
.ToArray();
174+
175+
var sumBefore = transfersBefore.SumForAccounts(inCurrencyIds);
176+
177+
var sumAfter = sumBefore + transfersIn.SumForAccounts(inCurrencyIds);
178+
var sums = transfersIn
179+
.Select((_, index) => sumBefore + transfersIn.Where((_, i) => i <= index).SumForAccounts(inCurrencyIds))
180+
.ToArray();
181+
182+
return new FinancialPointI(
183+
(double)sums.Concat([sumBefore, sumAfter]).Max(),
184+
(double)sumBefore,
185+
(double)sumAfter,
186+
(double)sums.Append(sumBefore).Min());
187+
});
188+
189+
CashflowSeries = [new() { Values = values.ToArray(), Name = counterparty.Name }];
190+
XAxes = [reportSplit.GetXAxis(startTime, endTime)];
191+
}
192+
193+
private async Task RefreshBalance()
194+
{
195+
var accountRowTasks = UserAccounts
196+
.Select(async account =>
197+
{
198+
var balances = await _gnomeshadeClient.GetAccountBalanceAsync(account.Id);
199+
var preferredAccountInCurrency = account
200+
.Currencies
201+
.Single(currency => currency.CurrencyId == account.PreferredCurrencyId);
202+
203+
var balance =
204+
balances.Single(balance => balance.AccountInCurrencyId == preferredAccountInCurrency.Id);
205+
206+
return new AccountRow(account.Name, balance.TargetAmount - balance.SourceAmount);
207+
});
208+
209+
var rows = await Task.WhenAll(accountRowTasks);
210+
AccountRows = rows.OrderByDescending(row => row.Balance).ToArray();
211+
AccountRowsTotals =
212+
[
213+
new("Balance", AccountRows.Where(row => row.Balance > 0).Sum(row => row.Balance)),
214+
new("Liabilities", AccountRows.Where(row => row.Balance < 0).Sum(row => row.Balance)),
215+
new("Total", AccountRows.Sum(row => row.Balance)),
216+
];
217+
218+
var balanceSeries = AccountRows
219+
.Select(row => new PieSeries<decimal>
220+
{
221+
Name = row.Name,
222+
Values = [row.Balance],
223+
DataLabelsPaint = new SolidColorPaint(SKColors.Black),
224+
DataLabelsFormatter = point => point.Model.ToString("N2"),
225+
})
226+
.ToArray();
227+
228+
BalanceSeries = balanceSeries.Where(series => series.Values?.Sum() > 0).ToArray();
229+
LiabilitiesBalanceSeries = balanceSeries
230+
.Where(series => series.Values?.Sum() < 0)
231+
.Select(series =>
232+
{
233+
series.Values = series.Values?.Select(x => -x) ?? [];
234+
return series;
235+
})
236+
.ToArray();
237+
}
238+
239+
private async Task RefreshCategories(IReadOnlyCollection<Account> accounts, DateTimeZone timeZone, ZonedDateTime startTime)
240+
{
241+
var accountsInCurrency = accounts.SelectMany(account => account.Currencies).ToArray();
242+
var (allTransactions, categories, products) = await (
243+
_gnomeshadeClient.GetDetailedTransactionsAsync(new(Instant.MinValue, Instant.MaxValue)),
244+
_gnomeshadeClient.GetCategoriesAsync(),
245+
_gnomeshadeClient.GetProductsAsync())
246+
.WhenAll();
247+
var displayableTransactions = allTransactions
248+
.Select(transaction => transaction with { TransferBalance = -transaction.TransferBalance })
249+
.Where(transaction => transaction.TransferBalance > 0)
250+
.ToArray();
251+
252+
var transactions = new CategoryReportViewModel.TransactionData[displayableTransactions.Length];
253+
for (var i = 0; i < displayableTransactions.Length; i++)
254+
{
255+
var transaction = displayableTransactions[i];
256+
var date = new ZonedDateTime(transaction.ValuedAt ?? transaction.BookedAt!.Value, timeZone);
257+
258+
var transactionTransfers = transaction.Transfers;
259+
var transferAccounts = transactionTransfers
260+
.Select(transfer => transfer.SourceAccountId)
261+
.Concat(transactionTransfers.Select(transfer => transfer.TargetAccountId))
262+
.ToArray();
263+
264+
var sourceCurrencyIds = accountsInCurrency
265+
.IntersectBy(transferAccounts, currency => currency.Id)
266+
.Select(currency => currency.CurrencyId)
267+
.Distinct()
268+
.ToArray();
269+
270+
var targetCurrencyIds = accountsInCurrency
271+
.IntersectBy(transferAccounts, currency => currency.Id)
272+
.Select(account => account.CurrencyId)
273+
.Distinct()
274+
.ToArray();
275+
276+
transactions[i] = new(transaction, date, sourceCurrencyIds, targetCurrencyIds);
277+
}
278+
279+
var nodes = categories
280+
.Where(category => category.CategoryId == null)
281+
.Select(category => CategoryNode.FromCategory(category, categories))
282+
.ToList();
283+
284+
var uncategorizedTransfers = transactions
285+
.Where(data => data.Transaction.TransferBalance > data.Transaction.PurchaseTotal)
286+
.Select(data =>
287+
{
288+
var purchase = new Purchase { Price = data.Transaction.TransferBalance - data.Transaction.PurchaseTotal };
289+
return new CategoryReportViewModel.PurchaseData(purchase, null, data);
290+
})
291+
.ToList();
292+
293+
var groupings = transactions
294+
.SelectMany(data => data.Transaction.Purchases.Select(purchase =>
295+
{
296+
var product = products.SingleOrDefault(product => product.Id == purchase.ProductId);
297+
var node = product?.CategoryId is not { } categoryId
298+
? null
299+
: nodes.SingleOrDefault(node => node.Contains(categoryId));
300+
301+
return new CategoryReportViewModel.PurchaseData(purchase, node, data);
302+
}))
303+
.Concat(uncategorizedTransfers)
304+
.GroupBy(data => data.Node)
305+
.ToArray();
306+
307+
CategoriesSeries = groupings
308+
.Select(grouping =>
309+
{
310+
var zonedSplit = startTime;
311+
312+
var purchasesToSum = grouping
313+
.Where(purchase => SplitProvider.MonthlySplit.Equals(zonedSplit, purchase.Date))
314+
.ToArray();
315+
316+
var sum = 0m;
317+
for (var purchaseIndex = 0; purchaseIndex < purchasesToSum.Length; purchaseIndex++)
318+
{
319+
var purchase = purchasesToSum[purchaseIndex];
320+
var sourceCurrencyIds = purchase.SourceCurrencyIds;
321+
var targetCurrencyIds = purchase.TargetCurrencyIds;
322+
323+
if (sourceCurrencyIds.Length is not 1 || targetCurrencyIds.Length is not 1)
324+
{
325+
// todo cannot handle multiple currencies (#686)
326+
sum += purchase.Purchase.Price;
327+
continue;
328+
}
329+
330+
var sourceCurrency = sourceCurrencyIds.Single();
331+
var targetCurrency = targetCurrencyIds.Single();
332+
333+
if (sourceCurrency == targetCurrency)
334+
{
335+
sum += purchase.Purchase.Price;
336+
continue;
337+
}
338+
339+
var transfer = purchase.Transfers.Single();
340+
var ratio = transfer.SourceAmount / transfer.TargetAmount;
341+
sum += Math.Round(purchase.Purchase.Price * ratio, 2);
342+
}
343+
344+
return new PieSeries<decimal>
345+
{
346+
Name = grouping.Key?.Name ?? "Uncategorized",
347+
Values = [sum],
348+
DataLabelsPaint = new SolidColorPaint(SKColors.Black),
349+
DataLabelsFormatter = point => point.Model.ToString("N2"),
350+
};
351+
})
352+
.Where(series => series.Values?.Sum() > 0)
353+
.OrderByDescending(series => series.Values?.Sum())
354+
.ToArray();
355+
}
356+
357+
/// <summary>Minimal overview of a single <see cref="Account"/>.</summary>
358+
public sealed class AccountRow : PropertyChangedBase
359+
{
360+
/// <summary>Initializes a new instance of the <see cref="AccountRow"/> class.</summary>
361+
/// <param name="name">The name of the account.</param>
362+
/// <param name="balance">The balance of the account in the preferred currency.</param>
363+
public AccountRow(string name, decimal balance)
364+
{
365+
Name = name;
366+
Balance = balance;
367+
}
368+
369+
/// <summary>Gets the name of the account.</summary>
370+
public string Name { get; }
371+
372+
/// <summary>Gets the account balance in the preferred currency.</summary>
373+
public decimal Balance { get; }
374+
}
375+
}

source/Gnomeshade.Avalonia.Core/DesignTime/DesignTimeData.cs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -236,6 +236,10 @@ public static class DesignTimeData
236236
public static ProjectViewModel ProjectViewModel { get; } =
237237
InitializeViewModel<ProjectViewModel>(new(ActivityService, GnomeshadeClient, DateTimeZoneProvider));
238238

239+
/// <summary>Gets an instance of <see cref="DashboardViewModel"/> for use during design time.</summary>
240+
public static DashboardViewModel DashboardViewModel { get; } =
241+
InitializeViewModel<DashboardViewModel>(new(ActivityService, GnomeshadeClient, Clock, DateTimeZoneProvider));
242+
239243
[UnconditionalSuppressMessage(
240244
"Trimming",
241245
"IL2026:Members annotated with 'RequiresUnreferencedCodeAttribute' require dynamic access otherwise can break functionality when trimming application code",

0 commit comments

Comments
 (0)