Skip to content

Commit

Permalink
feat(ui): add a dashboard with current state of finances
Browse files Browse the repository at this point in the history
  • Loading branch information
VMelnalksnis committed Sep 29, 2024
1 parent ad975f5 commit d180d4d
Show file tree
Hide file tree
Showing 11 changed files with 476 additions and 9 deletions.
4 changes: 4 additions & 0 deletions docs/changelog.html
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,10 @@ <h3>Added</h3>
Support for PostgreSQL 17 in
<a href="https://github.com/VMelnalksnis/Gnomeshade/pull/1394">#1394</a>
</li>
<li>
Dashboard with current state of finances in
<a href="https://github.com/VMelnalksnis/Gnomeshade/pull/1395">#1395</a>
</li>
</ul>
</section>

Expand Down
375 changes: 375 additions & 0 deletions source/Gnomeshade.Avalonia.Core/DashboardViewModel.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,375 @@
// Copyright 2021 Valters Melnalksnis
// Licensed under the GNU Affero General Public License v3.0 or later.
// See LICENSE.txt file in the project root for full license information.

using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Linq;
using System.Threading.Tasks;

using Gnomeshade.Avalonia.Core.Products;
using Gnomeshade.Avalonia.Core.Reports;
using Gnomeshade.Avalonia.Core.Reports.Splits;
using Gnomeshade.WebApi.Client;
using Gnomeshade.WebApi.Models.Accounts;
using Gnomeshade.WebApi.Models.Transactions;

using LiveChartsCore.Defaults;
using LiveChartsCore.Kernel.Sketches;
using LiveChartsCore.SkiaSharpView;
using LiveChartsCore.SkiaSharpView.Painting;

using NodaTime;

using PropertyChanged.SourceGenerator;

using SkiaSharp;

namespace Gnomeshade.Avalonia.Core;

/// <summary>Quick overview of all accounts.</summary>
public sealed partial class DashboardViewModel : ViewModelBase
{
private readonly IGnomeshadeClient _gnomeshadeClient;
private readonly IClock _clock;
private readonly IDateTimeZoneProvider _dateTimeZoneProvider;

/// <summary>Gets the data series of the balance of all user accounts with positive balance.</summary>
[Notify(Setter.Private)]
private PieSeries<decimal>[] _balanceSeries = [];

/// <summary>Gets the data series of the balance of all user accounts with negative balance.</summary>
[Notify(Setter.Private)]
private PieSeries<decimal>[] _liabilitiesBalanceSeries = [];

/// <summary>Gets the data series of spending by category.</summary>
[Notify(Setter.Private)]
private PieSeries<decimal>[] _categoriesSeries = [];

/// <summary>Gets the data series of balance of the users account over time.</summary>
[Notify(Setter.Private)]
private CandlesticksSeries<FinancialPointI>[] _cashflowSeries = [];

/// <summary>Gets a collection of all accounts of the current user.</summary>
[Notify(Setter.Private)]
private List<Account> _userAccounts = [];

/// <summary>Gets or sets a collection of accounts selected from <see cref="UserAccounts"/>.</summary>
[Notify]
private ObservableCollection<Account> _selectedAccounts = [];

/// <summary>Gets a collection of all currencies used in <see cref="UserAccounts"/>.</summary>
[Notify(Setter.Private)]
private List<Currency> _currencies = [];

/// <summary>Gets or sets the selected currency from <see cref="Currencies"/>.</summary>
[Notify]
private Currency? _selectedCurrency;

/// <summary>Gets the y axes for <see cref="CashflowSeries"/>.</summary>
[Notify(Setter.Private)]
private ICartesianAxis[] _yAxes = [new Axis()];

/// <summary>Gets the x axes for <see cref="CashflowSeries"/>.</summary>
[Notify(Setter.Private)]
private ICartesianAxis[] _xAxes = [new Axis()];

/// <summary>Gets the collection of account summary rows.</summary>
[Notify(Setter.Private)]
private AccountRow[] _accountRows = [];

/// <summary>Gets the collection of rows of various totals of <see cref="AccountRows"/>.</summary>
[Notify(Setter.Private)]
private AccountRow[] _accountRowsTotals = [];

/// <summary>Initializes a new instance of the <see cref="DashboardViewModel"/> class.</summary>
/// <param name="activityService">Service for indicating the activity of the application to the user.</param>
/// <param name="gnomeshadeClient">A strongly typed API client.</param>
/// <param name="clock">Clock which can provide the current instant.</param>
/// <param name="dateTimeZoneProvider">Time zone provider for localizing instants to local time.</param>
public DashboardViewModel(
IActivityService activityService,
IGnomeshadeClient gnomeshadeClient,
IClock clock,
IDateTimeZoneProvider dateTimeZoneProvider)
: base(activityService)
{
_gnomeshadeClient = gnomeshadeClient;
_clock = clock;
_dateTimeZoneProvider = dateTimeZoneProvider;
}

/// <inheritdoc />
protected override async Task Refresh()
{
var (counterparty, allAccounts, currencies) = await
(_gnomeshadeClient.GetMyCounterpartyAsync(),
_gnomeshadeClient.GetAccountsAsync(),
_gnomeshadeClient.GetCurrenciesAsync())
.WhenAll();

var selected = SelectedAccounts.Select(account => account.Id).ToArray();
var selectedCurrency = SelectedCurrency?.Id;

UserAccounts = allAccounts.Where(account => account.CounterpartyId == counterparty.Id).ToList();
Currencies = currencies
.Where(currency => UserAccounts.SelectMany(account => account.Currencies).Any(aic => aic.CurrencyId == currency.Id))
.ToList();

SelectedAccounts = new(UserAccounts.Where(account => selected.Contains(account.Id)));
SelectedCurrency = selectedCurrency is { } id ? Currencies.Single(currency => currency.Id == id) : null;

IReadOnlyCollection<Account> accounts = SelectedAccounts.Count is not 0 ? SelectedAccounts : UserAccounts;

var timeZone = _dateTimeZoneProvider.GetSystemDefault();
var currentInstant = _clock.GetCurrentInstant();
var localTime = currentInstant.InZone(timeZone).LocalDateTime;

var startTime = localTime.With(DateAdjusters.StartOfMonth).InZoneStrictly(timeZone);
var endTime = localTime.With(DateAdjusters.EndOfMonth).InZoneStrictly(timeZone);

await Task.WhenAll(
RefreshCashFlow(accounts, startTime, endTime, timeZone, counterparty),
RefreshBalance(),
RefreshCategories(accounts, timeZone, startTime));
}

private async Task RefreshCashFlow(
IReadOnlyCollection<Account> accounts,
ZonedDateTime startTime,
ZonedDateTime endTime,
DateTimeZone timeZone,
Counterparty counterparty)
{
var inCurrencyIds = accounts
.SelectMany(account => account.Currencies.Where(aic => aic.CurrencyId == (SelectedCurrency?.Id ?? account.PreferredCurrencyId)).Select(aic => aic.Id))
.ToArray();

var transfers = (await _gnomeshadeClient.GetTransfersAsync())
.Where(transfer =>
inCurrencyIds.Contains(transfer.SourceAccountId) ||
inCurrencyIds.Contains(transfer.TargetAccountId))
.OrderBy(transfer => transfer.ValuedAt ?? transfer.BookedAt)
.ThenBy(transfer => transfer.CreatedAt)
.ThenBy(transfer => transfer.ModifiedAt)
.ToArray();
var reportSplit = SplitProvider.DailySplit;

var values = reportSplit
.GetSplits(startTime, endTime)
.Select(date =>
{
var splitZonedDate = date.AtStartOfDayInZone(timeZone);
var splitInstant = splitZonedDate.ToInstant();

var transfersBefore = transfers
.Where(transfer => new ZonedDateTime(transfer.ValuedAt ?? transfer.BookedAt!.Value, timeZone).ToInstant() < splitInstant);

var transfersIn = transfers
.Where(transfer => reportSplit.Equals(
splitZonedDate,
new(transfer.ValuedAt ?? transfer.BookedAt!.Value, timeZone)))
.ToArray();

var sumBefore = transfersBefore.SumForAccounts(inCurrencyIds);

var sumAfter = sumBefore + transfersIn.SumForAccounts(inCurrencyIds);
var sums = transfersIn
.Select((_, index) => sumBefore + transfersIn.Where((_, i) => i <= index).SumForAccounts(inCurrencyIds))
.ToArray();

return new FinancialPointI(
(double)sums.Concat([sumBefore, sumAfter]).Max(),
(double)sumBefore,
(double)sumAfter,
(double)sums.Append(sumBefore).Min());
});

CashflowSeries = [new() { Values = values.ToArray(), Name = counterparty.Name }];
XAxes = [reportSplit.GetXAxis(startTime, endTime)];
}

private async Task RefreshBalance()
{
var accountRowTasks = UserAccounts
.Select(async account =>
{
var balances = await _gnomeshadeClient.GetAccountBalanceAsync(account.Id);
var preferredAccountInCurrency = account
.Currencies
.Single(currency => currency.CurrencyId == account.PreferredCurrencyId);

var balance =
balances.Single(balance => balance.AccountInCurrencyId == preferredAccountInCurrency.Id);

return new AccountRow(account.Name, balance.TargetAmount - balance.SourceAmount);
});

var rows = await Task.WhenAll(accountRowTasks);
AccountRows = rows.OrderByDescending(row => row.Balance).ToArray();
AccountRowsTotals =
[
new("Balance", AccountRows.Where(row => row.Balance > 0).Sum(row => row.Balance)),
new("Liabilities", AccountRows.Where(row => row.Balance < 0).Sum(row => row.Balance)),
new("Total", AccountRows.Sum(row => row.Balance)),
];

var balanceSeries = AccountRows
.Select(row => new PieSeries<decimal>
{
Name = row.Name,
Values = [row.Balance],
DataLabelsPaint = new SolidColorPaint(SKColors.Black),
DataLabelsFormatter = point => point.Model.ToString("N2"),

Check warning on line 224 in source/Gnomeshade.Avalonia.Core/DashboardViewModel.cs

View check run for this annotation

Codecov / codecov/patch

source/Gnomeshade.Avalonia.Core/DashboardViewModel.cs#L224

Added line #L224 was not covered by tests
})
.ToArray();

BalanceSeries = balanceSeries.Where(series => series.Values?.Sum() > 0).ToArray();
LiabilitiesBalanceSeries = balanceSeries
.Where(series => series.Values?.Sum() < 0)
.Select(series =>
{
series.Values = series.Values?.Select(x => -x) ?? [];
return series;
})
.ToArray();
}

private async Task RefreshCategories(IReadOnlyCollection<Account> accounts, DateTimeZone timeZone, ZonedDateTime startTime)
{
var accountsInCurrency = accounts.SelectMany(account => account.Currencies).ToArray();
var (allTransactions, categories, products) = await (
_gnomeshadeClient.GetDetailedTransactionsAsync(new(Instant.MinValue, Instant.MaxValue)),
_gnomeshadeClient.GetCategoriesAsync(),
_gnomeshadeClient.GetProductsAsync())
.WhenAll();
var displayableTransactions = allTransactions
.Select(transaction => transaction with { TransferBalance = -transaction.TransferBalance })
.Where(transaction => transaction.TransferBalance > 0)
.ToArray();

var transactions = new CategoryReportViewModel.TransactionData[displayableTransactions.Length];
for (var i = 0; i < displayableTransactions.Length; i++)
{
var transaction = displayableTransactions[i];

Check warning on line 255 in source/Gnomeshade.Avalonia.Core/DashboardViewModel.cs

View check run for this annotation

Codecov / codecov/patch

source/Gnomeshade.Avalonia.Core/DashboardViewModel.cs#L255

Added line #L255 was not covered by tests
var date = new ZonedDateTime(transaction.ValuedAt ?? transaction.BookedAt!.Value, timeZone);

var transactionTransfers = transaction.Transfers;
var transferAccounts = transactionTransfers
.Select(transfer => transfer.SourceAccountId)
.Concat(transactionTransfers.Select(transfer => transfer.TargetAccountId))
.ToArray();

Check warning on line 262 in source/Gnomeshade.Avalonia.Core/DashboardViewModel.cs

View check run for this annotation

Codecov / codecov/patch

source/Gnomeshade.Avalonia.Core/DashboardViewModel.cs#L258-L262

Added lines #L258 - L262 were not covered by tests

var sourceCurrencyIds = accountsInCurrency
.IntersectBy(transferAccounts, currency => currency.Id)
.Select(currency => currency.CurrencyId)
.Distinct()
.ToArray();

Check warning on line 268 in source/Gnomeshade.Avalonia.Core/DashboardViewModel.cs

View check run for this annotation

Codecov / codecov/patch

source/Gnomeshade.Avalonia.Core/DashboardViewModel.cs#L264-L268

Added lines #L264 - L268 were not covered by tests

var targetCurrencyIds = accountsInCurrency
.IntersectBy(transferAccounts, currency => currency.Id)
.Select(account => account.CurrencyId)
.Distinct()
.ToArray();

Check warning on line 274 in source/Gnomeshade.Avalonia.Core/DashboardViewModel.cs

View check run for this annotation

Codecov / codecov/patch

source/Gnomeshade.Avalonia.Core/DashboardViewModel.cs#L270-L274

Added lines #L270 - L274 were not covered by tests

transactions[i] = new(transaction, date, sourceCurrencyIds, targetCurrencyIds);

Check warning on line 276 in source/Gnomeshade.Avalonia.Core/DashboardViewModel.cs

View check run for this annotation

Codecov / codecov/patch

source/Gnomeshade.Avalonia.Core/DashboardViewModel.cs#L276

Added line #L276 was not covered by tests
}

var nodes = categories
.Where(category => category.CategoryId == null)
.Select(category => CategoryNode.FromCategory(category, categories))
.ToList();

var uncategorizedTransfers = transactions
.Where(data => data.Transaction.TransferBalance > data.Transaction.PurchaseTotal)

Check warning on line 285 in source/Gnomeshade.Avalonia.Core/DashboardViewModel.cs

View check run for this annotation

Codecov / codecov/patch

source/Gnomeshade.Avalonia.Core/DashboardViewModel.cs#L285

Added line #L285 was not covered by tests
.Select(data =>
{
var purchase = new Purchase { Price = data.Transaction.TransferBalance - data.Transaction.PurchaseTotal };
return new CategoryReportViewModel.PurchaseData(purchase, null, data);

Check warning on line 289 in source/Gnomeshade.Avalonia.Core/DashboardViewModel.cs

View check run for this annotation

Codecov / codecov/patch

source/Gnomeshade.Avalonia.Core/DashboardViewModel.cs#L288-L289

Added lines #L288 - L289 were not covered by tests
})
.ToList();

var groupings = transactions
.SelectMany(data => data.Transaction.Purchases.Select(purchase =>
{
var product = products.SingleOrDefault(product => product.Id == purchase.ProductId);

Check warning on line 296 in source/Gnomeshade.Avalonia.Core/DashboardViewModel.cs

View check run for this annotation

Codecov / codecov/patch

source/Gnomeshade.Avalonia.Core/DashboardViewModel.cs#L294-L296

Added lines #L294 - L296 were not covered by tests
var node = product?.CategoryId is not { } categoryId
? null
: nodes.SingleOrDefault(node => node.Contains(categoryId));

return new CategoryReportViewModel.PurchaseData(purchase, node, data);
}))

Check warning on line 302 in source/Gnomeshade.Avalonia.Core/DashboardViewModel.cs

View check run for this annotation

Codecov / codecov/patch

source/Gnomeshade.Avalonia.Core/DashboardViewModel.cs#L298-L302

Added lines #L298 - L302 were not covered by tests
.Concat(uncategorizedTransfers)
.GroupBy(data => data.Node)

Check warning on line 304 in source/Gnomeshade.Avalonia.Core/DashboardViewModel.cs

View check run for this annotation

Codecov / codecov/patch

source/Gnomeshade.Avalonia.Core/DashboardViewModel.cs#L304

Added line #L304 was not covered by tests
.ToArray();

CategoriesSeries = groupings
.Select(grouping =>
{
var zonedSplit = startTime;

Check warning on line 310 in source/Gnomeshade.Avalonia.Core/DashboardViewModel.cs

View check run for this annotation

Codecov / codecov/patch

source/Gnomeshade.Avalonia.Core/DashboardViewModel.cs#L310

Added line #L310 was not covered by tests

var purchasesToSum = grouping
.Where(purchase => SplitProvider.MonthlySplit.Equals(zonedSplit, purchase.Date))
.ToArray();

Check warning on line 314 in source/Gnomeshade.Avalonia.Core/DashboardViewModel.cs

View check run for this annotation

Codecov / codecov/patch

source/Gnomeshade.Avalonia.Core/DashboardViewModel.cs#L312-L314

Added lines #L312 - L314 were not covered by tests

var sum = 0m;

Check warning on line 316 in source/Gnomeshade.Avalonia.Core/DashboardViewModel.cs

View check run for this annotation

Codecov / codecov/patch

source/Gnomeshade.Avalonia.Core/DashboardViewModel.cs#L316

Added line #L316 was not covered by tests
for (var purchaseIndex = 0; purchaseIndex < purchasesToSum.Length; purchaseIndex++)
{
var purchase = purchasesToSum[purchaseIndex];
var sourceCurrencyIds = purchase.SourceCurrencyIds;
var targetCurrencyIds = purchase.TargetCurrencyIds;

Check warning on line 321 in source/Gnomeshade.Avalonia.Core/DashboardViewModel.cs

View check run for this annotation

Codecov / codecov/patch

source/Gnomeshade.Avalonia.Core/DashboardViewModel.cs#L319-L321

Added lines #L319 - L321 were not covered by tests

if (sourceCurrencyIds.Length is not 1 || targetCurrencyIds.Length is not 1)
{
// todo cannot handle multiple currencies (#686)
sum += purchase.Purchase.Price;
continue;

Check warning on line 327 in source/Gnomeshade.Avalonia.Core/DashboardViewModel.cs

View check run for this annotation

Codecov / codecov/patch

source/Gnomeshade.Avalonia.Core/DashboardViewModel.cs#L326-L327

Added lines #L326 - L327 were not covered by tests
}

var sourceCurrency = sourceCurrencyIds.Single();
var targetCurrency = targetCurrencyIds.Single();

Check warning on line 331 in source/Gnomeshade.Avalonia.Core/DashboardViewModel.cs

View check run for this annotation

Codecov / codecov/patch

source/Gnomeshade.Avalonia.Core/DashboardViewModel.cs#L330-L331

Added lines #L330 - L331 were not covered by tests

if (sourceCurrency == targetCurrency)
{
sum += purchase.Purchase.Price;
continue;

Check warning on line 336 in source/Gnomeshade.Avalonia.Core/DashboardViewModel.cs

View check run for this annotation

Codecov / codecov/patch

source/Gnomeshade.Avalonia.Core/DashboardViewModel.cs#L335-L336

Added lines #L335 - L336 were not covered by tests
}

var transfer = purchase.Transfers.Single();
var ratio = transfer.SourceAmount / transfer.TargetAmount;
sum += Math.Round(purchase.Purchase.Price * ratio, 2);

Check warning on line 341 in source/Gnomeshade.Avalonia.Core/DashboardViewModel.cs

View check run for this annotation

Codecov / codecov/patch

source/Gnomeshade.Avalonia.Core/DashboardViewModel.cs#L339-L341

Added lines #L339 - L341 were not covered by tests
}

return new PieSeries<decimal>
{
Name = grouping.Key?.Name ?? "Uncategorized",
Values = [sum],
DataLabelsPaint = new SolidColorPaint(SKColors.Black),
DataLabelsFormatter = point => point.Model.ToString("N2"),
};

Check warning on line 350 in source/Gnomeshade.Avalonia.Core/DashboardViewModel.cs

View check run for this annotation

Codecov / codecov/patch

source/Gnomeshade.Avalonia.Core/DashboardViewModel.cs#L345-L350

Added lines #L345 - L350 were not covered by tests
})
.Where(series => series.Values?.Sum() > 0)
.OrderByDescending(series => series.Values?.Sum())
.ToArray();
}

/// <summary>Minimal overview of a single <see cref="Account"/>.</summary>
public sealed class AccountRow : PropertyChangedBase
{
/// <summary>Initializes a new instance of the <see cref="AccountRow"/> class.</summary>
/// <param name="name">The name of the account.</param>
/// <param name="balance">The balance of the account in the preferred currency.</param>
public AccountRow(string name, decimal balance)
{
Name = name;
Balance = balance;
}

/// <summary>Gets the name of the account.</summary>
public string Name { get; }

/// <summary>Gets the account balance in the preferred currency.</summary>
public decimal Balance { get; }
}
}
4 changes: 4 additions & 0 deletions source/Gnomeshade.Avalonia.Core/DesignTime/DesignTimeData.cs
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,10 @@ public static class DesignTimeData
public static ProjectViewModel ProjectViewModel { get; } =
InitializeViewModel<ProjectViewModel>(new(ActivityService, GnomeshadeClient, DateTimeZoneProvider));

/// <summary>Gets an instance of <see cref="DashboardViewModel"/> for use during design time.</summary>
public static DashboardViewModel DashboardViewModel { get; } =
InitializeViewModel<DashboardViewModel>(new(ActivityService, GnomeshadeClient, Clock, DateTimeZoneProvider));

[UnconditionalSuppressMessage(
"Trimming",
"IL2026:Members annotated with 'RequiresUnreferencedCodeAttribute' require dynamic access otherwise can break functionality when trimming application code",
Expand Down
Loading

0 comments on commit d180d4d

Please sign in to comment.