@page "/" @attribute [Authorize] @inject DashboardService DashboardService @inject TransactionService TransactionService @inject AuthenticationStateProvider AuthStateProvider @inject IJSRuntime JS @implements IAsyncDisposable @rendermode InteractiveServer Dashboard

Dashboard

@if (data is null) {

Loading...

} else if (data.Accounts.Count == 0) {
Welcome!

Get started by adding an account and then recording transactions.

} else {
Balance Trend
to
@* ── Edit / Add Modal ── *@ @if (showModal) { }
@if (activeTab == "recent") { @if (data.RecentTransactions.Count == 0) {

No recent transactions.

} else {
@foreach (var t in data.RecentTransactions.Skip((recentPage - 1) * PageSize).Take(PageSize)) {
@t.Description @if (t.RecurrenceType != RecurrenceType.None || t.ParentTransactionId is not null) { 🔁 @GetRecurrenceLabel(t) }
@t.Date.ToString("MMM dd, yyyy") @t.Account.Name @if (t.Category is not null) { @(string.IsNullOrEmpty(t.Category.Icon) ? "" : t.Category.Icon + " ")@t.Category.Name }
@(t.Type == TransactionType.Income ? "+" : "−")@t.Account.FormatAmount(t.Amount)
@(t.Type == TransactionType.Income ? "Income" : "Expense")
@if (selectedTransactionId == t.Id) {
}
}
@if (RecentTotalPages > 1) {
Page @recentPage of @RecentTotalPages
} } } else { @if (data.UpcomingTransactions.Count == 0) {

No upcoming transactions.

} else {
@foreach (var t in data.UpcomingTransactions.Skip((upcomingPage - 1) * PageSize).Take(PageSize)) {
@t.Description @if (t.RecurrenceType != RecurrenceType.None || t.ParentTransactionId is not null) { 🔁 @GetRecurrenceLabel(t) }
@t.Date.ToString("MMM dd, yyyy") @t.Account.Name @if (t.Category is not null) { @(string.IsNullOrEmpty(t.Category.Icon) ? "" : t.Category.Icon + " ")@t.Category.Name }
@(t.Type == TransactionType.Income ? "+" : "−")@t.Account.FormatAmount(t.Amount)
@if (projectedBalances.TryGetValue(t.Id, out var projected)) { Projected: @t.Account.FormatAmount(projected) } else { @(t.Type == TransactionType.Income ? "Income" : "Expense") }
@if (selectedTransactionId == t.Id) {
}
}
@if (UpcomingTotalPages > 1) {
Page @upcomingPage of @UpcomingTotalPages
} } }
@if (showDeleteModal && pendingDeleteTransaction is not null) { } } @code { private DashboardData? data; private string? userId; private int? selectedTransactionId; private string activeTab = "recent"; private Dictionary projectedBalances = new(); // Modal state private bool showModal; private int? editingTransactionId; private bool editIsPartOfSeries; private ModalTransactionForm modalForm = new(); // Chart redraw tracking private int chartDataVersion; private int lastRenderedChartVersion; // Time window private DateTime windowStart = DateTime.Today.AddDays(-30); private DateTime windowEnd = DateTime.Today.AddDays(30); private string windowPreset = "30d"; // Pagination private int recentPage = 1; private int upcomingPage = 1; private const int PageSize = 10; private int RecentTotalPages => data is null ? 0 : (int)Math.Ceiling(data.RecentTransactions.Count / (double)PageSize); private int UpcomingTotalPages => data is null ? 0 : (int)Math.Ceiling(data.UpcomingTransactions.Count / (double)PageSize); protected override async Task OnInitializedAsync() { var authState = await AuthStateProvider.GetAuthenticationStateAsync(); userId = authState.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value; if (userId is not null) { await RefreshData(); } } protected override async Task OnAfterRenderAsync(bool firstRender) { if (data?.ChartData.Datasets.Count > 0 && chartDataVersion != lastRenderedChartVersion) { lastRenderedChartVersion = chartDataVersion; var datasets = data.ChartData.Datasets.Select(ds => new { label = ds.Label, color = ds.Color, data = ds.Data.Select(d => (double)d).ToArray(), currencySymbol = ds.CurrencySymbol }).ToArray(); var dayLabels = data.ChartData.DayTransactionLabels .Select(d => d.ToArray()).ToArray(); int highlightIndex = GetHighlightIndex(); await JS.InvokeVoidAsync("chartInterop.createOrUpdateChart", "balanceChart", data.ChartData.Labels.ToArray(), datasets, dayLabels, highlightIndex, data.CurrencySymbol); } } private int GetHighlightIndex() { if (selectedTransactionId is null || data is null) return -1; var txn = data.RecentTransactions.FirstOrDefault(t => t.Id == selectedTransactionId) ?? data.UpcomingTransactions.FirstOrDefault(t => t.Id == selectedTransactionId); if (txn is null) return -1; var label = txn.Date.ToString("MMM dd"); return data.ChartData.Labels.IndexOf(label); } private async Task SelectTransaction(int id) { selectedTransactionId = selectedTransactionId == id ? null : id; if (data?.ChartData.Datasets.Count > 0) { int highlightIndex = GetHighlightIndex(); await JS.InvokeVoidAsync("chartInterop.highlightDate", "balanceChart", highlightIndex); } } private void OpenAddModal() { editingTransactionId = null; modalForm = new ModalTransactionForm { AccountId = data?.Accounts.FirstOrDefault()?.Id ?? 0 }; showModal = true; } private void OpenEditModal(Transaction t) { editingTransactionId = t.Id; editIsPartOfSeries = t.RecurrenceType != RecurrenceType.None || t.ParentTransactionId is not null; modalForm = new ModalTransactionForm { Description = t.Description, Amount = t.Amount, Date = t.Date, Type = t.Type, AccountId = t.AccountId, CategoryId = t.CategoryId, RecurrenceType = t.RecurrenceType, RecurrenceEndDate = t.RecurrenceEndDate }; showModal = true; } private void CloseModal() => showModal = false; private async Task SaveModal() { if (userId is null) return; if (editingTransactionId is null) { var transaction = new Transaction { Description = modalForm.Description, Amount = modalForm.Amount, Date = modalForm.Date, Type = modalForm.Type, AccountId = modalForm.AccountId, CategoryId = modalForm.CategoryId, RecurrenceType = modalForm.RecurrenceType, RecurrenceEndDate = modalForm.RecurrenceEndDate }; await TransactionService.CreateTransactionAsync(transaction); await RefreshData(); // Keep modal open and reset form for the next entry var previousAccountId = modalForm.AccountId; var previousCategoryId = modalForm.CategoryId; var previousType = modalForm.Type; modalForm = new ModalTransactionForm { AccountId = previousAccountId, CategoryId = previousCategoryId, Type = previousType }; } else { await TransactionService.UpdateTransactionAsync( editingTransactionId.Value, userId, modalForm.Description, modalForm.Amount, modalForm.Date, modalForm.Type, modalForm.AccountId, modalForm.CategoryId, modalForm.RecurrenceType, modalForm.RecurrenceEndDate); showModal = false; await RefreshData(); } } // Delete confirmation state private bool showDeleteModal; private Transaction? pendingDeleteTransaction; private void ConfirmDelete(Transaction t) { pendingDeleteTransaction = t; if (t.RecurrenceType != RecurrenceType.None || t.ParentTransactionId is not null) { showDeleteModal = true; } else { _ = DeleteSingle(); } } private async Task DeleteSingle() { if (userId is null || pendingDeleteTransaction is null) return; showDeleteModal = false; await TransactionService.DeleteTransactionAsync(pendingDeleteTransaction.Id, userId); pendingDeleteTransaction = null; await RefreshData(); } private async Task DeleteSeries() { if (userId is null || pendingDeleteTransaction is null) return; showDeleteModal = false; await TransactionService.DeleteTransactionSeriesAsync(pendingDeleteTransaction.Id, userId); pendingDeleteTransaction = null; await RefreshData(); } private void CancelDelete() { showDeleteModal = false; pendingDeleteTransaction = null; } private static string GetRecurrenceLabel(Transaction t) { if (t.RecurrenceType != RecurrenceType.None) return t.RecurrenceType.ToString(); return t.ParentTransaction?.RecurrenceType.ToString() ?? "Recurring"; } private void ComputeProjectedBalances() { projectedBalances.Clear(); if (data is null) return; var accountBalances = data.Accounts.ToDictionary(a => a.Id, a => a.CurrentBalance); foreach (var t in data.UpcomingTransactions) { if (!accountBalances.ContainsKey(t.AccountId)) continue; accountBalances[t.AccountId] += t.Type == TransactionType.Income ? t.Amount : -t.Amount; projectedBalances[t.Id] = accountBalances[t.AccountId]; } } private async Task RefreshData() { if (userId is null) return; data = await DashboardService.GetDashboardDataAsync(userId, windowStart, windowEnd); chartDataVersion++; ComputeProjectedBalances(); recentPage = 1; upcomingPage = 1; } private async Task SetWindow(int days) { windowPreset = days switch { 7 => "7d", 30 => "30d", 90 => "90d", 365 => "1y", _ => "custom" }; windowStart = DateTime.Today.AddDays(-days); windowEnd = DateTime.Today.AddDays(days); await RefreshData(); } private async Task OnWindowDateChanged() { windowPreset = "custom"; await RefreshData(); } public async ValueTask DisposeAsync() { try { await JS.InvokeVoidAsync("chartInterop.destroyChart", "balanceChart"); } catch (JSDisconnectedException) { } } private sealed class ModalTransactionForm : System.ComponentModel.DataAnnotations.IValidatableObject { [System.ComponentModel.DataAnnotations.Required(ErrorMessage = "Description is required")] [System.ComponentModel.DataAnnotations.MaxLength(200)] public string Description { get; set; } = string.Empty; [System.ComponentModel.DataAnnotations.Range(0.01, (double)decimal.MaxValue, ErrorMessage = "Amount must be greater than zero")] public decimal Amount { get; set; } public DateTime Date { get; set; } = DateTime.Today; public TransactionType Type { get; set; } = TransactionType.Expense; public int AccountId { get; set; } public int? CategoryId { get; set; } public RecurrenceType RecurrenceType { get; set; } = RecurrenceType.None; public DateTime? RecurrenceEndDate { get; set; } public IEnumerable Validate(System.ComponentModel.DataAnnotations.ValidationContext validationContext) { if (RecurrenceType != RecurrenceType.None && RecurrenceEndDate.HasValue) { var maxEnd = Date.AddYears(1); if (RecurrenceEndDate.Value > maxEnd) { yield return new System.ComponentModel.DataAnnotations.ValidationResult( "Recurrence end date cannot be more than 1 year from the transaction date.", [nameof(RecurrenceEndDate)]); } if (RecurrenceEndDate.Value <= Date) { yield return new System.ComponentModel.DataAnnotations.ValidationResult( "Recurrence end date must be after the transaction date.", [nameof(RecurrenceEndDate)]); } } } } }