Skip to content

Commit 2a2eb3b

Browse files
CopilotCopilot
andcommitted
feat(125a/slice-11): Blazor Data Health UI - ViewModel, page, shared components, NavMenu link
- Add DataHealthViewModel: LoadAsync, MergeDuplicatesAsync, DismissOutlierAsync, summary counts - Add DataHealth.razor page: summary bar, duplicate/outlier/gap/uncategorized sections, actions - Add DuplicateClusterCard: table view with Merge button - Add OutlierCard: deviation details with Dismiss button - Add DateGapCard: gap range and duration display - Add UncategorizedSummaryCard: per-account breakdown table - Add InlineAmountEditor and InlineDateEditor reusable components - Add Data Health link in NavMenu before Import link Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 55564f1 commit 2a2eb3b

File tree

9 files changed

+497
-0
lines changed

9 files changed

+497
-0
lines changed

src/BudgetExperiment.Client/Components/Navigation/NavMenu.razor

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -217,6 +217,16 @@
217217
}
218218
</li>
219219

220+
<li>
221+
<NavLink class="nav-item" href="datahealth" title="Data Health" aria-label="Data Health Dashboard">
222+
<span class="nav-icon" aria-hidden="true"><Icon Name="activity" Size="20" /></span>
223+
@if (!IsCollapsed)
224+
{
225+
<span class="nav-text">Data Health</span>
226+
}
227+
</NavLink>
228+
</li>
229+
220230
<li>
221231
<NavLink class="nav-item" href="import" title="Import Transactions" aria-label="Import Transactions">
222232
<span class="nav-icon" aria-hidden="true"><Icon Name="upload" Size="20" /></span>
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
@page "/datahealth"
2+
3+
@using BudgetExperiment.Client.Pages.DataHealth
4+
@using BudgetExperiment.Client.Shared.DataHealth
5+
@using BudgetExperiment.Contracts.Dtos
6+
7+
@inject IBudgetApiService ApiService
8+
9+
<PageTitle>Data Health - Budget Experiment</PageTitle>
10+
11+
<div class="page-container">
12+
<PageHeader Title="Data Health">
13+
<Actions>
14+
<button class="btn btn-secondary" @onclick="RefreshAsync" disabled="@_vm.IsLoading">
15+
<Icon Name="refresh" Size="16" /> Refresh
16+
</button>
17+
</Actions>
18+
</PageHeader>
19+
20+
@if (_vm.ErrorMessage is not null)
21+
{
22+
<div class="alert alert-danger" role="alert">@_vm.ErrorMessage</div>
23+
}
24+
25+
@if (_vm.IsLoading)
26+
{
27+
<div class="loading-container">
28+
<span class="loading-spinner" aria-label="Loading data health report"></span>
29+
</div>
30+
}
31+
else
32+
{
33+
<div class="summary-bar row g-3 mb-4">
34+
<div class="col-sm-6 col-md-3">
35+
<div class="stat-card">
36+
<span class="stat-label">Duplicates</span>
37+
<span class="stat-value @(_vm.DuplicateCount > 0 ? "text-warning" : "text-success")">@_vm.DuplicateCount</span>
38+
</div>
39+
</div>
40+
<div class="col-sm-6 col-md-3">
41+
<div class="stat-card">
42+
<span class="stat-label">Outliers</span>
43+
<span class="stat-value @(_vm.OutlierCount > 0 ? "text-warning" : "text-success")">@_vm.OutlierCount</span>
44+
</div>
45+
</div>
46+
<div class="col-sm-6 col-md-3">
47+
<div class="stat-card">
48+
<span class="stat-label">Date Gaps</span>
49+
<span class="stat-value @(_vm.DateGapCount > 0 ? "text-warning" : "text-success")">@_vm.DateGapCount</span>
50+
</div>
51+
</div>
52+
<div class="col-sm-6 col-md-3">
53+
<div class="stat-card">
54+
<span class="stat-label">Uncategorized</span>
55+
<span class="stat-value @(_vm.UncategorizedCount > 0 ? "text-warning" : "text-success")">@_vm.UncategorizedCount</span>
56+
</div>
57+
</div>
58+
</div>
59+
60+
@if (_vm.Report is not null)
61+
{
62+
@if (_vm.Report.Duplicates.Count > 0)
63+
{
64+
<section class="mb-4">
65+
<h2 class="section-title">Duplicate Transactions</h2>
66+
@foreach (var cluster in _vm.Report.Duplicates)
67+
{
68+
<DuplicateClusterCard Cluster="cluster" OnMerge="HandleMerge" />
69+
}
70+
</section>
71+
}
72+
73+
@if (_vm.Report.Outliers.Count > 0)
74+
{
75+
<section class="mb-4">
76+
<h2 class="section-title">Amount Outliers</h2>
77+
@foreach (var outlier in _vm.Report.Outliers)
78+
{
79+
<OutlierCard Outlier="outlier" OnDismiss="HandleDismiss" />
80+
}
81+
</section>
82+
}
83+
84+
@if (_vm.Report.DateGaps.Count > 0)
85+
{
86+
<section class="mb-4">
87+
<h2 class="section-title">Date Gaps</h2>
88+
@foreach (var gap in _vm.Report.DateGaps)
89+
{
90+
<DateGapCard Gap="gap" />
91+
}
92+
</section>
93+
}
94+
95+
<section class="mb-4">
96+
<h2 class="section-title">Uncategorized Transactions</h2>
97+
<UncategorizedSummaryCard Summary="_vm.Report.Uncategorized" />
98+
</section>
99+
100+
@if (_vm.DuplicateCount == 0 && _vm.OutlierCount == 0 && _vm.DateGapCount == 0 && _vm.UncategorizedCount == 0)
101+
{
102+
<div class="alert alert-success" role="alert">
103+
<Icon Name="check-circle" Size="16" /> All data health checks passed. No issues found.
104+
</div>
105+
}
106+
}
107+
}
108+
</div>
109+
110+
@code {
111+
private DataHealthViewModel _vm = default!;
112+
113+
/// <inheritdoc />
114+
protected override async Task OnInitializedAsync()
115+
{
116+
_vm = new DataHealthViewModel(ApiService);
117+
_vm.OnStateChanged = StateHasChanged;
118+
await _vm.LoadAsync(null);
119+
}
120+
121+
private async Task RefreshAsync()
122+
{
123+
await _vm.LoadAsync(null);
124+
}
125+
126+
private async Task HandleMerge((Guid Primary, IReadOnlyList<Guid> Duplicates) args)
127+
{
128+
await _vm.MergeDuplicatesAsync(args.Primary, args.Duplicates);
129+
}
130+
131+
private async Task HandleDismiss(Guid transactionId)
132+
{
133+
await _vm.DismissOutlierAsync(transactionId);
134+
}
135+
}
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
// <copyright file="DataHealthViewModel.cs" company="BecauseImClever">
2+
// Copyright (c) BecauseImClever. All rights reserved.
3+
// </copyright>
4+
5+
using BudgetExperiment.Client.Services;
6+
using BudgetExperiment.Contracts.Dtos;
7+
8+
namespace BudgetExperiment.Client.Pages.DataHealth;
9+
10+
/// <summary>
11+
/// ViewModel for the Data Health dashboard page.
12+
/// Loads and exposes the full data health report and supports merge/dismiss actions.
13+
/// </summary>
14+
public sealed class DataHealthViewModel
15+
{
16+
private readonly IBudgetApiService _api;
17+
18+
/// <summary>
19+
/// Initializes a new instance of the <see cref="DataHealthViewModel"/> class.
20+
/// </summary>
21+
/// <param name="api">The budget API service.</param>
22+
public DataHealthViewModel(IBudgetApiService api)
23+
{
24+
_api = api;
25+
}
26+
27+
/// <summary>Gets or sets the callback to notify the Razor page that state has changed.</summary>
28+
public Action? OnStateChanged { get; set; }
29+
30+
/// <summary>Gets a value indicating whether data is currently loading.</summary>
31+
public bool IsLoading { get; private set; }
32+
33+
/// <summary>Gets the current error message, if any.</summary>
34+
public string? ErrorMessage { get; private set; }
35+
36+
/// <summary>Gets the full data health report.</summary>
37+
public DataHealthReportDto? Report { get; private set; }
38+
39+
/// <summary>Gets the count of duplicate clusters.</summary>
40+
public int DuplicateCount => Report?.Duplicates.Count ?? 0;
41+
42+
/// <summary>Gets the count of outlier transactions.</summary>
43+
public int OutlierCount => Report?.Outliers.Count ?? 0;
44+
45+
/// <summary>Gets the count of date gaps.</summary>
46+
public int DateGapCount => Report?.DateGaps.Count ?? 0;
47+
48+
/// <summary>Gets the count of uncategorized transactions.</summary>
49+
public int UncategorizedCount => Report?.Uncategorized.TotalCount ?? 0;
50+
51+
/// <summary>Loads the data health report, optionally filtered by account.</summary>
52+
/// <param name="accountId">Optional account filter.</param>
53+
/// <param name="ct">Cancellation token.</param>
54+
/// <returns>A task representing the asynchronous operation.</returns>
55+
public async Task LoadAsync(Guid? accountId, CancellationToken ct = default)
56+
{
57+
IsLoading = true;
58+
ErrorMessage = null;
59+
NotifyStateChanged();
60+
61+
try
62+
{
63+
Report = await _api.GetDataHealthReportAsync(accountId);
64+
}
65+
catch (Exception ex)
66+
{
67+
ErrorMessage = ex.Message;
68+
}
69+
finally
70+
{
71+
IsLoading = false;
72+
NotifyStateChanged();
73+
}
74+
}
75+
76+
/// <summary>Merges duplicate transactions into a primary, then refreshes the report.</summary>
77+
/// <param name="primaryTransactionId">The primary transaction identifier.</param>
78+
/// <param name="duplicateIds">The duplicate transaction identifiers to merge.</param>
79+
/// <param name="ct">Cancellation token.</param>
80+
/// <returns>A task representing the asynchronous operation.</returns>
81+
public async Task MergeDuplicatesAsync(Guid primaryTransactionId, IReadOnlyList<Guid> duplicateIds, CancellationToken ct = default)
82+
{
83+
await _api.MergeDuplicatesAsync(new MergeDuplicatesRequest
84+
{
85+
PrimaryTransactionId = primaryTransactionId,
86+
DuplicateIds = duplicateIds,
87+
});
88+
await LoadAsync(null, ct);
89+
}
90+
91+
/// <summary>Dismisses an outlier transaction, then refreshes the report.</summary>
92+
/// <param name="transactionId">The transaction identifier to dismiss.</param>
93+
/// <param name="ct">Cancellation token.</param>
94+
/// <returns>A task representing the asynchronous operation.</returns>
95+
public async Task DismissOutlierAsync(Guid transactionId, CancellationToken ct = default)
96+
{
97+
await _api.DismissOutlierAsync(transactionId);
98+
await LoadAsync(null, ct);
99+
}
100+
101+
private void NotifyStateChanged() => OnStateChanged?.Invoke();
102+
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
@using BudgetExperiment.Contracts.Dtos
2+
3+
<div class="card mb-2">
4+
<div class="card-body py-2">
5+
<div class="d-flex justify-content-between align-items-center">
6+
<div>
7+
<span class="badge bg-info me-2">Gap</span>
8+
<strong>@Gap.AccountName</strong>
9+
<span class="ms-2 text-muted">
10+
@Gap.GapStart.ToString("yyyy-MM-dd") – @Gap.GapEnd.ToString("yyyy-MM-dd")
11+
</span>
12+
</div>
13+
<span class="badge bg-secondary">@Gap.DurationDays days</span>
14+
</div>
15+
</div>
16+
</div>
17+
18+
@code {
19+
/// <summary>Gets or sets the date gap to display.</summary>
20+
[Parameter]
21+
public DateGapDto Gap { get; set; } = default!;
22+
}
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
@using BudgetExperiment.Contracts.Dtos
2+
3+
<div class="card mb-3">
4+
<div class="card-body">
5+
<div class="d-flex justify-content-between align-items-start mb-2">
6+
<div>
7+
<span class="badge bg-warning text-dark me-2">Duplicate Group</span>
8+
<small class="text-muted">@Cluster.GroupKey</small>
9+
</div>
10+
<button class="btn btn-sm btn-primary" @onclick="MergePrimary" disabled="@_merging">
11+
@if (_merging) { <span class="spinner-border spinner-border-sm me-1" aria-hidden="true"></span> }
12+
Merge into First
13+
</button>
14+
</div>
15+
<table class="table table-sm">
16+
<thead>
17+
<tr>
18+
<th>Date</th>
19+
<th>Description</th>
20+
<th class="text-end">Amount</th>
21+
</tr>
22+
</thead>
23+
<tbody>
24+
@foreach (var tx in Cluster.Transactions)
25+
{
26+
<tr>
27+
<td>@tx.Date.ToString("yyyy-MM-dd")</td>
28+
<td>@tx.Description</td>
29+
<td class="text-end">@tx.Amount.Amount.ToString("N2") @tx.Amount.Currency</td>
30+
</tr>
31+
}
32+
</tbody>
33+
</table>
34+
</div>
35+
</div>
36+
37+
@code {
38+
private bool _merging;
39+
40+
/// <summary>Gets or sets the duplicate cluster to display.</summary>
41+
[Parameter]
42+
public DuplicateClusterDto Cluster { get; set; } = default!;
43+
44+
/// <summary>Gets or sets the callback invoked when the user chooses to merge.</summary>
45+
[Parameter]
46+
public EventCallback<(Guid Primary, IReadOnlyList<Guid> Duplicates)> OnMerge { get; set; }
47+
48+
private async Task MergePrimary()
49+
{
50+
if (Cluster.Transactions.Count < 2)
51+
{
52+
return;
53+
}
54+
55+
_merging = true;
56+
var primary = Cluster.Transactions[0].Id;
57+
var duplicates = Cluster.Transactions.Skip(1).Select(t => t.Id).ToList();
58+
await OnMerge.InvokeAsync((primary, duplicates));
59+
_merging = false;
60+
}
61+
}
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
<div class="inline-editor d-flex align-items-center gap-2">
2+
<input type="number"
3+
class="form-control form-control-sm"
4+
step="0.01"
5+
@bind="_value"
6+
@bind:event="oninput"
7+
style="width: 120px;"
8+
aria-label="Amount" />
9+
<button class="btn btn-sm btn-primary" @onclick="Save">Save</button>
10+
<button class="btn btn-sm btn-outline-secondary" @onclick="Cancel">Cancel</button>
11+
</div>
12+
13+
@code {
14+
private decimal _value;
15+
16+
/// <summary>Gets or sets the initial value.</summary>
17+
[Parameter]
18+
public decimal InitialValue { get; set; }
19+
20+
/// <summary>Gets or sets the callback invoked with the saved value.</summary>
21+
[Parameter]
22+
public EventCallback<decimal> OnSave { get; set; }
23+
24+
/// <summary>Gets or sets the callback invoked when editing is cancelled.</summary>
25+
[Parameter]
26+
public EventCallback OnCancel { get; set; }
27+
28+
/// <inheritdoc />
29+
protected override void OnParametersSet()
30+
{
31+
_value = InitialValue;
32+
}
33+
34+
private async Task Save()
35+
{
36+
await OnSave.InvokeAsync(_value);
37+
}
38+
39+
private async Task Cancel()
40+
{
41+
await OnCancel.InvokeAsync();
42+
}
43+
}

0 commit comments

Comments
 (0)