3b01efd8a6
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
269 lines
8.8 KiB
C#
269 lines
8.8 KiB
C#
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>();
|
|
var runningBalance = new List<decimal>();
|
|
decimal cumulative = 0;
|
|
|
|
for (var d = since; d <= today; d = d.AddDays(1))
|
|
{
|
|
labels.Add(d.ToString("MMM d"));
|
|
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);
|
|
cumulative += credit + debit;
|
|
}
|
|
else
|
|
{
|
|
debitsAbs.Add(0);
|
|
credits.Add(0);
|
|
net.Add(0);
|
|
}
|
|
runningBalance.Add(cumulative);
|
|
}
|
|
|
|
return new SpendTrends
|
|
{
|
|
Labels = labels,
|
|
DebitsAbs = debitsAbs,
|
|
Credits = credits,
|
|
Net = net,
|
|
RunningBalance = runningBalance
|
|
};
|
|
}
|
|
}
|
|
}
|