Refactor: Extract dashboard services from Index page
Extract to separate files for better maintainability: - Models/Dashboard/DashboardModels.cs - Dashboard DTOs - Services/DashboardService.cs - All dashboard-related services - IDashboardService, IDashboardStatsCalculator - ITopCategoriesProvider, IRecentTransactionsProvider - ISpendTrendsProvider Reduces Index.cshtml.cs from 355 lines to 37 lines. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
62
MoneyMap/Models/Dashboard/DashboardModels.cs
Normal file
62
MoneyMap/Models/Dashboard/DashboardModels.cs
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
namespace MoneyMap.Models.Dashboard
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Statistics displayed on the dashboard.
|
||||||
|
/// </summary>
|
||||||
|
public record DashboardStats(
|
||||||
|
int TotalTransactions = 0,
|
||||||
|
int Credits = 0,
|
||||||
|
int Debits = 0,
|
||||||
|
int Uncategorized = 0,
|
||||||
|
int Receipts = 0,
|
||||||
|
int Cards = 0);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Row representing spending in a category.
|
||||||
|
/// </summary>
|
||||||
|
public class TopCategoryRow
|
||||||
|
{
|
||||||
|
public string Category { get; set; } = "";
|
||||||
|
public decimal TotalSpend { get; set; }
|
||||||
|
public int Count { get; set; }
|
||||||
|
public decimal PercentageOfTotal { get; set; }
|
||||||
|
public decimal AveragePerTransaction { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Row representing a recent transaction.
|
||||||
|
/// </summary>
|
||||||
|
public class RecentTransactionRow
|
||||||
|
{
|
||||||
|
public long Id { get; set; }
|
||||||
|
public DateTime Date { get; set; }
|
||||||
|
public string Name { get; set; } = "";
|
||||||
|
public string Memo { get; set; } = "";
|
||||||
|
public decimal Amount { get; set; }
|
||||||
|
public string Category { get; set; } = "";
|
||||||
|
public string CardLabel { get; set; } = "";
|
||||||
|
public int ReceiptCount { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Spending trends over time.
|
||||||
|
/// </summary>
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Complete dashboard data package.
|
||||||
|
/// </summary>
|
||||||
|
public class DashboardData
|
||||||
|
{
|
||||||
|
public required DashboardStats Stats { get; init; }
|
||||||
|
public required List<TopCategoryRow> TopCategories { get; init; }
|
||||||
|
public required List<RecentTransactionRow> RecentTransactions { get; init; }
|
||||||
|
public required SpendTrends Trends { get; init; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,5 @@
|
|||||||
using Microsoft.AspNetCore.Mvc.RazorPages;
|
using Microsoft.AspNetCore.Mvc.RazorPages;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using MoneyMap.Models.Dashboard;
|
||||||
using MoneyMap.Data;
|
|
||||||
using MoneyMap.Models;
|
|
||||||
using MoneyMap.Services;
|
using MoneyMap.Services;
|
||||||
|
|
||||||
namespace MoneyMap.Pages
|
namespace MoneyMap.Pages
|
||||||
@@ -17,7 +15,7 @@ namespace MoneyMap.Pages
|
|||||||
|
|
||||||
public DashboardStats Stats { get; set; } = new();
|
public DashboardStats Stats { get; set; } = new();
|
||||||
public List<TopCategoryRow> TopCategories { get; set; } = new();
|
public List<TopCategoryRow> TopCategories { get; set; } = new();
|
||||||
public List<RecentTxnRow> Recent { get; set; } = new();
|
public List<RecentTransactionRow> Recent { get; set; } = new();
|
||||||
public List<string> TrendLabels { get; set; } = new();
|
public List<string> TrendLabels { get; set; } = new();
|
||||||
public List<decimal> TrendDebitsAbs { get; set; } = new();
|
public List<decimal> TrendDebitsAbs { get; set; } = new();
|
||||||
public List<decimal> TrendCredits { get; set; } = new();
|
public List<decimal> TrendCredits { get; set; } = new();
|
||||||
@@ -35,321 +33,5 @@ namespace MoneyMap.Pages
|
|||||||
TrendCredits = dashboard.Trends.Credits;
|
TrendCredits = dashboard.Trends.Credits;
|
||||||
TrendNet = dashboard.Trends.Net;
|
TrendNet = dashboard.Trends.Net;
|
||||||
}
|
}
|
||||||
|
|
||||||
public record DashboardStats(
|
|
||||||
int TotalTransactions = 0,
|
|
||||||
int Credits = 0,
|
|
||||||
int Debits = 0,
|
|
||||||
int Uncategorized = 0,
|
|
||||||
int Receipts = 0,
|
|
||||||
int Cards = 0);
|
|
||||||
|
|
||||||
public class TopCategoryRow
|
|
||||||
{
|
|
||||||
public string Category { get; set; } = "";
|
|
||||||
public decimal TotalSpend { get; set; }
|
|
||||||
public int Count { get; set; }
|
|
||||||
public decimal PercentageOfTotal { get; set; }
|
|
||||||
public decimal AveragePerTransaction { get; set; }
|
|
||||||
}
|
|
||||||
|
|
||||||
public class RecentTxnRow
|
|
||||||
{
|
|
||||||
public long Id { get; set; }
|
|
||||||
public DateTime Date { get; set; }
|
|
||||||
public string Name { get; set; } = "";
|
|
||||||
public string Memo { get; set; } = "";
|
|
||||||
public decimal Amount { get; set; }
|
|
||||||
public string Category { get; set; } = "";
|
|
||||||
public string CardLabel { get; set; } = "";
|
|
||||||
public int ReceiptCount { get; set; }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===== Service Layer =====
|
|
||||||
|
|
||||||
public interface IDashboardService
|
|
||||||
{
|
|
||||||
Task<DashboardData> GetDashboardDataAsync(int topCategoriesCount = 8, int recentTransactionsCount = 20);
|
|
||||||
}
|
|
||||||
|
|
||||||
public class DashboardService : IDashboardService
|
|
||||||
{
|
|
||||||
private readonly MoneyMapContext _db;
|
|
||||||
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,
|
|
||||||
ISpendTrendsProvider spendTrendsProvider)
|
|
||||||
{
|
|
||||||
_db = db;
|
|
||||||
_statsCalculator = statsCalculator;
|
|
||||||
_topCategoriesProvider = topCategoriesProvider;
|
|
||||||
_recentTransactionsProvider = recentTransactionsProvider;
|
|
||||||
_spendTrendsProvider = spendTrendsProvider;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<DashboardData> GetDashboardDataAsync(int topCategoriesCount = 8, int recentTransactionsCount = 20)
|
|
||||||
{
|
|
||||||
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,
|
|
||||||
Trends = trends
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===== Stats Calculator =====
|
|
||||||
|
|
||||||
public interface IDashboardStatsCalculator
|
|
||||||
{
|
|
||||||
Task<IndexModel.DashboardStats> CalculateAsync();
|
|
||||||
}
|
|
||||||
|
|
||||||
public class DashboardStatsCalculator : IDashboardStatsCalculator
|
|
||||||
{
|
|
||||||
private readonly MoneyMapContext _db;
|
|
||||||
|
|
||||||
public DashboardStatsCalculator(MoneyMapContext db)
|
|
||||||
{
|
|
||||||
_db = db;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<IndexModel.DashboardStats> CalculateAsync()
|
|
||||||
{
|
|
||||||
var transactionStats = await GetTransactionStatsAsync();
|
|
||||||
var receiptsCount = await _db.Receipts.CountAsync();
|
|
||||||
var cardsCount = await _db.Cards.CountAsync();
|
|
||||||
|
|
||||||
return new IndexModel.DashboardStats(
|
|
||||||
transactionStats.Total,
|
|
||||||
transactionStats.Credits,
|
|
||||||
transactionStats.Debits,
|
|
||||||
transactionStats.Uncategorized,
|
|
||||||
receiptsCount,
|
|
||||||
cardsCount
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task<TransactionStats> GetTransactionStatsAsync()
|
|
||||||
{
|
|
||||||
var stats = await _db.Transactions
|
|
||||||
.GroupBy(_ => 1)
|
|
||||||
.Select(g => new TransactionStats
|
|
||||||
{
|
|
||||||
Total = g.Count(),
|
|
||||||
Credits = g.Count(t => t.Amount > 0),
|
|
||||||
Debits = g.Count(t => t.Amount < 0),
|
|
||||||
Uncategorized = g.Count(t => t.Category == null || t.Category == "")
|
|
||||||
})
|
|
||||||
.FirstOrDefaultAsync();
|
|
||||||
|
|
||||||
return stats ?? new TransactionStats();
|
|
||||||
}
|
|
||||||
|
|
||||||
private class TransactionStats
|
|
||||||
{
|
|
||||||
public int Total { get; set; }
|
|
||||||
public int Credits { get; set; }
|
|
||||||
public int Debits { get; set; }
|
|
||||||
public int Uncategorized { get; set; }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===== Top Categories Provider =====
|
|
||||||
|
|
||||||
public interface ITopCategoriesProvider
|
|
||||||
{
|
|
||||||
Task<List<IndexModel.TopCategoryRow>> GetTopCategoriesAsync(int count = 8, int lastDays = 90);
|
|
||||||
}
|
|
||||||
|
|
||||||
public class TopCategoriesProvider : ITopCategoriesProvider
|
|
||||||
{
|
|
||||||
private readonly MoneyMapContext _db;
|
|
||||||
|
|
||||||
public TopCategoriesProvider(MoneyMapContext db)
|
|
||||||
{
|
|
||||||
_db = db;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<List<IndexModel.TopCategoryRow>> GetTopCategoriesAsync(int count = 8, int lastDays = 90)
|
|
||||||
{
|
|
||||||
var since = DateTime.UtcNow.Date.AddDays(-lastDays);
|
|
||||||
|
|
||||||
// Get all expense transactions for the period
|
|
||||||
var expenseTransactions = await _db.Transactions
|
|
||||||
.Where(t => t.Date >= since && t.Amount < 0)
|
|
||||||
.ExcludeTransfers() // Exclude credit card payments and transfers
|
|
||||||
.ToListAsync();
|
|
||||||
|
|
||||||
// Calculate total spend
|
|
||||||
var totalSpend = expenseTransactions.Sum(t => -t.Amount);
|
|
||||||
|
|
||||||
// Group by category and calculate percentages
|
|
||||||
var topCategories = expenseTransactions
|
|
||||||
.GroupBy(t => t.Category ?? "")
|
|
||||||
.Select(g => new IndexModel.TopCategoryRow
|
|
||||||
{
|
|
||||||
Category = g.Key,
|
|
||||||
TotalSpend = g.Sum(x => -x.Amount),
|
|
||||||
Count = g.Count(),
|
|
||||||
PercentageOfTotal = totalSpend > 0 ? (g.Sum(x => -x.Amount) / totalSpend) * 100 : 0,
|
|
||||||
AveragePerTransaction = g.Count() > 0 ? g.Sum(x => -x.Amount) / g.Count() : 0
|
|
||||||
})
|
|
||||||
.OrderByDescending(x => x.TotalSpend)
|
|
||||||
.Take(count)
|
|
||||||
.ToList();
|
|
||||||
|
|
||||||
return topCategories;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===== Recent Transactions Provider =====
|
|
||||||
|
|
||||||
public interface IRecentTransactionsProvider
|
|
||||||
{
|
|
||||||
Task<List<IndexModel.RecentTxnRow>> GetRecentTransactionsAsync(int count = 20);
|
|
||||||
}
|
|
||||||
|
|
||||||
public class RecentTransactionsProvider : IRecentTransactionsProvider
|
|
||||||
{
|
|
||||||
private readonly MoneyMapContext _db;
|
|
||||||
|
|
||||||
public RecentTransactionsProvider(MoneyMapContext db)
|
|
||||||
{
|
|
||||||
_db = db;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<List<IndexModel.RecentTxnRow>> GetRecentTransactionsAsync(int count = 20)
|
|
||||||
{
|
|
||||||
return await _db.Transactions
|
|
||||||
.Include(t => t.Card)
|
|
||||||
.OrderByDescending(t => t.Date)
|
|
||||||
.ThenByDescending(t => t.Id)
|
|
||||||
.Select(t => new IndexModel.RecentTxnRow
|
|
||||||
{
|
|
||||||
Id = t.Id,
|
|
||||||
Date = t.Date,
|
|
||||||
Name = t.Name,
|
|
||||||
Memo = t.Memo,
|
|
||||||
Amount = t.Amount,
|
|
||||||
Category = t.Category ?? "",
|
|
||||||
CardLabel = t.PaymentMethodLabel,
|
|
||||||
ReceiptCount = t.Receipts.Count()
|
|
||||||
})
|
|
||||||
.Take(count)
|
|
||||||
.AsNoTracking()
|
|
||||||
.ToListAsync();
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string FormatCardLabel(Card? card, string? cardLast4)
|
|
||||||
{
|
|
||||||
if (card != null)
|
|
||||||
return $"{card.Issuer} {card.Last4}";
|
|
||||||
|
|
||||||
if (string.IsNullOrEmpty(cardLast4))
|
|
||||||
return "";
|
|
||||||
|
|
||||||
return $"<22><><EFBFBD><EFBFBD> {cardLast4}";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===== Data Transfer Objects =====
|
|
||||||
|
|
||||||
public class DashboardData
|
|
||||||
{
|
|
||||||
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();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
263
MoneyMap/Services/DashboardService.cs
Normal file
263
MoneyMap/Services/DashboardService.cs
Normal file
@@ -0,0 +1,263 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using MoneyMap.Data;
|
||||||
|
using MoneyMap.Models.Dashboard;
|
||||||
|
|
||||||
|
namespace MoneyMap.Services
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Service for retrieving dashboard data.
|
||||||
|
/// </summary>
|
||||||
|
public interface IDashboardService
|
||||||
|
{
|
||||||
|
Task<DashboardData> GetDashboardDataAsync(int topCategoriesCount = 8, int recentTransactionsCount = 20);
|
||||||
|
}
|
||||||
|
|
||||||
|
public class DashboardService : IDashboardService
|
||||||
|
{
|
||||||
|
private readonly IDashboardStatsCalculator _statsCalculator;
|
||||||
|
private readonly ITopCategoriesProvider _topCategoriesProvider;
|
||||||
|
private readonly IRecentTransactionsProvider _recentTransactionsProvider;
|
||||||
|
private readonly ISpendTrendsProvider _spendTrendsProvider;
|
||||||
|
|
||||||
|
public DashboardService(
|
||||||
|
IDashboardStatsCalculator statsCalculator,
|
||||||
|
ITopCategoriesProvider topCategoriesProvider,
|
||||||
|
IRecentTransactionsProvider recentTransactionsProvider,
|
||||||
|
ISpendTrendsProvider spendTrendsProvider)
|
||||||
|
{
|
||||||
|
_statsCalculator = statsCalculator;
|
||||||
|
_topCategoriesProvider = topCategoriesProvider;
|
||||||
|
_recentTransactionsProvider = recentTransactionsProvider;
|
||||||
|
_spendTrendsProvider = spendTrendsProvider;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<DashboardData> GetDashboardDataAsync(int topCategoriesCount = 8, int recentTransactionsCount = 20)
|
||||||
|
{
|
||||||
|
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,
|
||||||
|
Trends = trends
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Calculates dashboard statistics.
|
||||||
|
/// </summary>
|
||||||
|
public interface IDashboardStatsCalculator
|
||||||
|
{
|
||||||
|
Task<DashboardStats> CalculateAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
public class DashboardStatsCalculator : IDashboardStatsCalculator
|
||||||
|
{
|
||||||
|
private readonly MoneyMapContext _db;
|
||||||
|
|
||||||
|
public DashboardStatsCalculator(MoneyMapContext db)
|
||||||
|
{
|
||||||
|
_db = db;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<DashboardStats> CalculateAsync()
|
||||||
|
{
|
||||||
|
var transactionStats = await GetTransactionStatsAsync();
|
||||||
|
var receiptsCount = await _db.Receipts.CountAsync();
|
||||||
|
var cardsCount = await _db.Cards.CountAsync();
|
||||||
|
|
||||||
|
return new DashboardStats(
|
||||||
|
transactionStats.Total,
|
||||||
|
transactionStats.Credits,
|
||||||
|
transactionStats.Debits,
|
||||||
|
transactionStats.Uncategorized,
|
||||||
|
receiptsCount,
|
||||||
|
cardsCount
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<TransactionStatsInternal> GetTransactionStatsAsync()
|
||||||
|
{
|
||||||
|
var stats = await _db.Transactions
|
||||||
|
.GroupBy(_ => 1)
|
||||||
|
.Select(g => new TransactionStatsInternal
|
||||||
|
{
|
||||||
|
Total = g.Count(),
|
||||||
|
Credits = g.Count(t => t.Amount > 0),
|
||||||
|
Debits = g.Count(t => t.Amount < 0),
|
||||||
|
Uncategorized = g.Count(t => t.Category == null || t.Category == "")
|
||||||
|
})
|
||||||
|
.FirstOrDefaultAsync();
|
||||||
|
|
||||||
|
return stats ?? new TransactionStatsInternal();
|
||||||
|
}
|
||||||
|
|
||||||
|
private class TransactionStatsInternal
|
||||||
|
{
|
||||||
|
public int Total { get; set; }
|
||||||
|
public int Credits { get; set; }
|
||||||
|
public int Debits { get; set; }
|
||||||
|
public int Uncategorized { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Provides top spending categories.
|
||||||
|
/// </summary>
|
||||||
|
public interface ITopCategoriesProvider
|
||||||
|
{
|
||||||
|
Task<List<TopCategoryRow>> GetTopCategoriesAsync(int count = 8, int lastDays = 90);
|
||||||
|
}
|
||||||
|
|
||||||
|
public class TopCategoriesProvider : ITopCategoriesProvider
|
||||||
|
{
|
||||||
|
private readonly MoneyMapContext _db;
|
||||||
|
|
||||||
|
public TopCategoriesProvider(MoneyMapContext db)
|
||||||
|
{
|
||||||
|
_db = db;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<List<TopCategoryRow>> GetTopCategoriesAsync(int count = 8, int lastDays = 90)
|
||||||
|
{
|
||||||
|
var since = DateTime.UtcNow.Date.AddDays(-lastDays);
|
||||||
|
|
||||||
|
var expenseTransactions = await _db.Transactions
|
||||||
|
.Where(t => t.Date >= since && t.Amount < 0)
|
||||||
|
.ExcludeTransfers()
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
var totalSpend = expenseTransactions.Sum(t => -t.Amount);
|
||||||
|
|
||||||
|
var topCategories = expenseTransactions
|
||||||
|
.GroupBy(t => t.Category ?? "")
|
||||||
|
.Select(g => new TopCategoryRow
|
||||||
|
{
|
||||||
|
Category = g.Key,
|
||||||
|
TotalSpend = g.Sum(x => -x.Amount),
|
||||||
|
Count = g.Count(),
|
||||||
|
PercentageOfTotal = totalSpend > 0 ? (g.Sum(x => -x.Amount) / totalSpend) * 100 : 0,
|
||||||
|
AveragePerTransaction = g.Count() > 0 ? g.Sum(x => -x.Amount) / g.Count() : 0
|
||||||
|
})
|
||||||
|
.OrderByDescending(x => x.TotalSpend)
|
||||||
|
.Take(count)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
return topCategories;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Provides recent transactions.
|
||||||
|
/// </summary>
|
||||||
|
public interface IRecentTransactionsProvider
|
||||||
|
{
|
||||||
|
Task<List<RecentTransactionRow>> GetRecentTransactionsAsync(int count = 20);
|
||||||
|
}
|
||||||
|
|
||||||
|
public class RecentTransactionsProvider : IRecentTransactionsProvider
|
||||||
|
{
|
||||||
|
private readonly MoneyMapContext _db;
|
||||||
|
|
||||||
|
public RecentTransactionsProvider(MoneyMapContext db)
|
||||||
|
{
|
||||||
|
_db = db;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<List<RecentTransactionRow>> GetRecentTransactionsAsync(int count = 20)
|
||||||
|
{
|
||||||
|
return await _db.Transactions
|
||||||
|
.Include(t => t.Card)
|
||||||
|
.OrderByDescending(t => t.Date)
|
||||||
|
.ThenByDescending(t => t.Id)
|
||||||
|
.Select(t => new RecentTransactionRow
|
||||||
|
{
|
||||||
|
Id = t.Id,
|
||||||
|
Date = t.Date,
|
||||||
|
Name = t.Name,
|
||||||
|
Memo = t.Memo,
|
||||||
|
Amount = t.Amount,
|
||||||
|
Category = t.Category ?? "",
|
||||||
|
CardLabel = t.PaymentMethodLabel,
|
||||||
|
ReceiptCount = t.Receipts.Count()
|
||||||
|
})
|
||||||
|
.Take(count)
|
||||||
|
.AsNoTracking()
|
||||||
|
.ToListAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Provides spending trends over time.
|
||||||
|
/// </summary>
|
||||||
|
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
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user