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
+ };
+ }
+ }
+}