diff --git a/MoneyMap/Models/Dashboard/DashboardModels.cs b/MoneyMap/Models/Dashboard/DashboardModels.cs new file mode 100644 index 0000000..daf3a2e --- /dev/null +++ b/MoneyMap/Models/Dashboard/DashboardModels.cs @@ -0,0 +1,62 @@ +namespace MoneyMap.Models.Dashboard +{ + /// + /// Statistics displayed on the dashboard. + /// + public record DashboardStats( + int TotalTransactions = 0, + int Credits = 0, + int Debits = 0, + int Uncategorized = 0, + int Receipts = 0, + int Cards = 0); + + /// + /// Row representing spending in a category. + /// + 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; } + } + + /// + /// Row representing a recent transaction. + /// + 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; } + } + + /// + /// Spending trends over time. + /// + public class SpendTrends + { + public List Labels { get; set; } = new(); + public List DebitsAbs { get; set; } = new(); + public List Credits { get; set; } = new(); + public List Net { get; set; } = new(); + } + + /// + /// Complete dashboard data package. + /// + public class DashboardData + { + public required DashboardStats Stats { get; init; } + public required List TopCategories { get; init; } + public required List RecentTransactions { get; init; } + public required SpendTrends Trends { get; init; } + } +} diff --git a/MoneyMap/Pages/Index.cshtml.cs b/MoneyMap/Pages/Index.cshtml.cs index 33b975e..96c0406 100644 --- a/MoneyMap/Pages/Index.cshtml.cs +++ b/MoneyMap/Pages/Index.cshtml.cs @@ -1,7 +1,5 @@ using Microsoft.AspNetCore.Mvc.RazorPages; -using Microsoft.EntityFrameworkCore; -using MoneyMap.Data; -using MoneyMap.Models; +using MoneyMap.Models.Dashboard; using MoneyMap.Services; namespace MoneyMap.Pages @@ -17,7 +15,7 @@ namespace MoneyMap.Pages public DashboardStats Stats { get; set; } = new(); public List TopCategories { get; set; } = new(); - public List Recent { get; set; } = new(); + public List Recent { get; set; } = new(); public List TrendLabels { get; set; } = new(); public List TrendDebitsAbs { get; set; } = new(); public List TrendCredits { get; set; } = new(); @@ -35,321 +33,5 @@ namespace MoneyMap.Pages TrendCredits = dashboard.Trends.Credits; 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 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 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 CalculateAsync(); - } - - public class DashboardStatsCalculator : IDashboardStatsCalculator - { - private readonly MoneyMapContext _db; - - public DashboardStatsCalculator(MoneyMapContext db) - { - _db = db; - } - - public async Task 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 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> GetTopCategoriesAsync(int count = 8, int lastDays = 90); - } - - public class TopCategoriesProvider : ITopCategoriesProvider - { - private readonly MoneyMapContext _db; - - public TopCategoriesProvider(MoneyMapContext db) - { - _db = db; - } - - public async Task> 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> GetRecentTransactionsAsync(int count = 20); - } - - public class RecentTransactionsProvider : IRecentTransactionsProvider - { - private readonly MoneyMapContext _db; - - public RecentTransactionsProvider(MoneyMapContext db) - { - _db = db; - } - - public async Task> 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 $"���� {cardLast4}"; - } - } - - // ===== Data Transfer Objects ===== - - public class DashboardData - { - public required IndexModel.DashboardStats Stats { get; init; } - public required List TopCategories { get; init; } - public required List RecentTransactions { get; init; } - public required SpendTrends Trends { get; init; } - } - - // ===== Spend Trends Provider ===== - - public interface ISpendTrendsProvider - { - Task GetDailyTrendsAsync(int lastDays = 30); - } - - public class SpendTrendsProvider : ISpendTrendsProvider - { - private readonly MoneyMapContext _db; - - public SpendTrendsProvider(MoneyMapContext db) - { - _db = db; - } - - public async Task 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(); - var debitsAbs = new List(); - var credits = new List(); - var net = new List(); - - 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 Labels { get; set; } = new(); - public List DebitsAbs { get; set; } = new(); - public List Credits { get; set; } = new(); - public List Net { get; set; } = new(); } } diff --git a/MoneyMap/Services/DashboardService.cs b/MoneyMap/Services/DashboardService.cs new file mode 100644 index 0000000..380b70d --- /dev/null +++ b/MoneyMap/Services/DashboardService.cs @@ -0,0 +1,263 @@ +using Microsoft.EntityFrameworkCore; +using MoneyMap.Data; +using MoneyMap.Models.Dashboard; + +namespace MoneyMap.Services +{ + /// + /// Service for retrieving dashboard data. + /// + public interface IDashboardService + { + Task 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 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 + }; + } + } + + /// + /// Calculates dashboard statistics. + /// + public interface IDashboardStatsCalculator + { + Task CalculateAsync(); + } + + public class DashboardStatsCalculator : IDashboardStatsCalculator + { + private readonly MoneyMapContext _db; + + public DashboardStatsCalculator(MoneyMapContext db) + { + _db = db; + } + + public async Task 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 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; } + } + } + + /// + /// Provides top spending categories. + /// + public interface ITopCategoriesProvider + { + Task> GetTopCategoriesAsync(int count = 8, int lastDays = 90); + } + + public class TopCategoriesProvider : ITopCategoriesProvider + { + private readonly MoneyMapContext _db; + + public TopCategoriesProvider(MoneyMapContext db) + { + _db = db; + } + + public async Task> 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; + } + } + + /// + /// Provides recent transactions. + /// + public interface IRecentTransactionsProvider + { + Task> GetRecentTransactionsAsync(int count = 20); + } + + public class RecentTransactionsProvider : IRecentTransactionsProvider + { + private readonly MoneyMapContext _db; + + public RecentTransactionsProvider(MoneyMapContext db) + { + _db = db; + } + + public async Task> 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(); + } + } + + /// + /// Provides spending trends over time. + /// + public interface ISpendTrendsProvider + { + Task GetDailyTrendsAsync(int lastDays = 30); + } + + public class SpendTrendsProvider : ISpendTrendsProvider + { + private readonly MoneyMapContext _db; + + public SpendTrendsProvider(MoneyMapContext db) + { + _db = db; + } + + public async Task 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(); + var debitsAbs = new List(); + var credits = new List(); + var net = new List(); + + 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 + }; + } + } +}