Dashboard: add graphs (Chart.js) with category donut and 30-day cash flow; add SpendTrends provider and DI registration

This commit is contained in:
AJ
2025-10-19 00:22:18 -04:00
parent 23421bcc99
commit ef88d03c95
3 changed files with 152 additions and 8 deletions

View File

@@ -1,4 +1,4 @@
@page
@page
@model MoneyMap.Pages.IndexModel
@{
ViewData["Title"] = "MoneyMap";
@@ -10,7 +10,7 @@
<div class="card-body">
<div class="text-muted">Transactions</div>
<div class="fs-3 fw-bold">@Model.Stats.TotalTransactions</div>
<div class="small text-muted">Credits: @Model.Stats.Credits · Debits: @Model.Stats.Debits</div>
<div class="small text-muted">Credits: @Model.Stats.Credits · Debits: @Model.Stats.Debits</div>
</div>
</div>
</div>
@@ -22,7 +22,7 @@
<div class="small text-muted">
@if (Model.Stats.Uncategorized > 0)
{
<a asp-page="/Transactions" asp-route-category="(blank)" class="text-decoration-none">View uncategorized </a>
<a asp-page="/Transactions" asp-route-category="(blank)" class="text-decoration-none">View uncategorized ?</a>
}
else
{
@@ -63,7 +63,7 @@
<div class="card shadow-sm mb-3">
<div class="card-header">
Top expense categories (last 90 days)
<small class="text-muted">· excludes transfers</small>
<small class="text-muted">· excludes transfers</small>
</div>
<div class="card-body p-0">
<table class="table table-sm mb-0 table-hover">
@@ -125,7 +125,7 @@
@if (t.ReceiptCount > 0)
{
<span class="badge bg-success" title="@t.ReceiptCount receipt(s) attached">
📄 @t.ReceiptCount
?? @t.ReceiptCount
</span>
}
</div>
@@ -152,3 +152,56 @@
</table>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<script>
(function(){
const topLabels = @Html.Raw(System.Text.Json.JsonSerializer.Serialize(Model.TopCategories.Select(c => string.IsNullOrWhiteSpace(c.Category) ? "(uncategorized)" : c.Category)));
const topValues = @Html.Raw(System.Text.Json.JsonSerializer.Serialize(Model.TopCategories.Select(c => c.TotalSpend)));
const trendLabels = @Html.Raw(System.Text.Json.JsonSerializer.Serialize(Model.TrendLabels));
const trendDebits = @Html.Raw(System.Text.Json.JsonSerializer.Serialize(Model.TrendDebitsAbs));
const trendCredits = @Html.Raw(System.Text.Json.JsonSerializer.Serialize(Model.TrendCredits));
const trendNet = @Html.Raw(System.Text.Json.JsonSerializer.Serialize(Model.TrendNet));
const catCtx = document.getElementById('categoryChart');
if (catCtx && topLabels.length) {
new Chart(catCtx, {
type: 'doughnut',
data: {
labels: topLabels,
datasets: [{
data: topValues,
backgroundColor: ['#4e79a7','#f28e2c','#e15759','#76b7b2','#59a14f','#edc948','#b07aa1','#ff9da7']
}]
},
options: {
plugins: { legend: { position: 'bottom' } },
maintainAspectRatio: false
}
});
}
const trendCtx = document.getElementById('trendChart');
if (trendCtx) {
new Chart(trendCtx, {
data: {
labels: trendLabels,
datasets: [
{ type: 'bar', label: 'Debits', data: trendDebits, backgroundColor: 'rgba(225,87,89,0.6)' },
{ type: 'bar', label: 'Credits', data: trendCredits, backgroundColor: 'rgba(76,175,80,0.6)' },
{ type: 'line', label: 'Net', data: trendNet, borderColor: '#4e79a7', backgroundColor: 'transparent', tension: 0.2, yAxisID: 'y' }
]
},
options: {
scales: {
y: { beginAtZero: true }
},
maintainAspectRatio: false
}
});
}
})();
</script>

View File

@@ -21,6 +21,10 @@ namespace MoneyMap.Pages
public DashboardStats Stats { get; set; } = new();
public List<TopCategoryRow> TopCategories { get; set; } = new();
public List<RecentTxnRow> Recent { get; set; } = new();
public List<string> TrendLabels { get; set; } = new();
public List<decimal> TrendDebitsAbs { get; set; } = new();
public List<decimal> TrendCredits { get; set; } = new();
public List<decimal> TrendNet { get; set; } = new();
public async Task OnGet()
{
@@ -29,6 +33,10 @@ namespace MoneyMap.Pages
Stats = dashboard.Stats;
TopCategories = dashboard.TopCategories;
Recent = dashboard.RecentTransactions;
TrendLabels = dashboard.Trends.Labels;
TrendDebitsAbs = dashboard.Trends.DebitsAbs;
TrendCredits = dashboard.Trends.Credits;
TrendNet = dashboard.Trends.Net;
}
public record DashboardStats(
@@ -74,17 +82,20 @@ namespace MoneyMap.Pages
private readonly IDashboardStatsCalculator _statsCalculator;
private readonly ITopCategoriesProvider _topCategoriesProvider;
private readonly IRecentTransactionsProvider _recentTransactionsProvider;
private readonly ISpendTrendsProvider _spendTrendsProvider;
public DashboardService(
MoneyMapContext db,
IDashboardStatsCalculator statsCalculator,
ITopCategoriesProvider topCategoriesProvider,
IRecentTransactionsProvider recentTransactionsProvider)
IRecentTransactionsProvider recentTransactionsProvider,
ISpendTrendsProvider spendTrendsProvider)
{
_db = db;
_statsCalculator = statsCalculator;
_topCategoriesProvider = topCategoriesProvider;
_recentTransactionsProvider = recentTransactionsProvider;
_spendTrendsProvider = spendTrendsProvider;
}
public async Task<DashboardData> GetDashboardDataAsync(int topCategoriesCount = 8, int recentTransactionsCount = 20)
@@ -92,12 +103,14 @@ namespace MoneyMap.Pages
var stats = await _statsCalculator.CalculateAsync();
var topCategories = await _topCategoriesProvider.GetTopCategoriesAsync(topCategoriesCount);
var recent = await _recentTransactionsProvider.GetRecentTransactionsAsync(recentTransactionsCount);
var trends = await _spendTrendsProvider.GetDailyTrendsAsync(30);
return new DashboardData
{
Stats = stats,
TopCategories = topCategories,
RecentTransactions = recent
RecentTransactions = recent,
Trends = trends
};
}
}
@@ -264,5 +277,82 @@ namespace MoneyMap.Pages
public required IndexModel.DashboardStats Stats { get; init; }
public required List<IndexModel.TopCategoryRow> TopCategories { get; init; }
public required List<IndexModel.RecentTxnRow> RecentTransactions { get; init; }
public required SpendTrends Trends { get; init; }
}
// ===== Spend Trends Provider =====
public interface ISpendTrendsProvider
{
Task<SpendTrends> GetDailyTrendsAsync(int lastDays = 30);
}
public class SpendTrendsProvider : ISpendTrendsProvider
{
private readonly MoneyMapContext _db;
public SpendTrendsProvider(MoneyMapContext db)
{
_db = db;
}
public async Task<SpendTrends> GetDailyTrendsAsync(int lastDays = 30)
{
var today = DateTime.UtcNow.Date;
var since = today.AddDays(-(lastDays - 1));
var raw = await _db.Transactions
.Where(t => t.Date >= since)
.ExcludeTransfers()
.GroupBy(t => t.Date.Date)
.Select(g => new
{
Date = g.Key,
Debits = g.Where(t => t.Amount < 0).Sum(t => t.Amount),
Credits = g.Where(t => t.Amount > 0).Sum(t => t.Amount)
})
.ToListAsync();
var dict = raw.ToDictionary(x => x.Date, x => x);
var labels = new List<string>();
var debitsAbs = new List<decimal>();
var credits = new List<decimal>();
var net = new List<decimal>();
for (var d = since; d <= today; d = d.AddDays(1))
{
labels.Add(d.ToString("yyyy-MM-dd"));
if (dict.TryGetValue(d, out var v))
{
var debit = v.Debits;
var credit = v.Credits;
debitsAbs.Add(Math.Abs(debit));
credits.Add(credit);
net.Add(credit + debit);
}
else
{
debitsAbs.Add(0);
credits.Add(0);
net.Add(0);
}
}
return new SpendTrends
{
Labels = labels,
DebitsAbs = debitsAbs,
Credits = credits,
Net = net
};
}
}
public class SpendTrends
{
public List<string> Labels { get; set; } = new();
public List<decimal> DebitsAbs { get; set; } = new();
public List<decimal> Credits { get; set; } = new();
public List<decimal> Net { get; set; } = new();
}
}

View File

@@ -31,6 +31,7 @@ builder.Services.AddScoped<IDashboardService, DashboardService>();
builder.Services.AddScoped<IDashboardStatsCalculator, DashboardStatsCalculator>();
builder.Services.AddScoped<ITopCategoriesProvider, TopCategoriesProvider>();
builder.Services.AddScoped<IRecentTransactionsProvider, RecentTransactionsProvider>();
builder.Services.AddScoped<ISpendTrendsProvider, SpendTrendsProvider>();
builder.Services.AddScoped<IReceiptManager, ReceiptManager>();
builder.Services.AddScoped<IReceiptAutoMapper, ReceiptAutoMapper>();
builder.Services.AddHttpClient<IReceiptParser, OpenAIReceiptParser>();