From 116b0b65723c264fbed0596a21871b90e96e9354 Mon Sep 17 00:00:00 2001 From: Bertrand THOMAS Date: Fri, 17 Jul 2026 02:07:25 +0200 Subject: [PATCH 01/10] Add the health journal (profiles, records, reimbursement balance check) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A third Car/House-shaped pair: HealthProfile (whose journal - the owner, family members later) with HealthRecord children (Appointment/Sickness/Other, date AND time like CarHistory, specialty, practitioner, description, notes). The money model is the French reimbursement flow, four numbers the app does the math over: Price, PublicReimbursement (assurance maladie), InsuranceReimbursement (mutuelle), NotCovered (reste à charge). A record is settled exactly when they sum to zero; anything else lands in the metrics' "Reimbursements to check" list with the signed missing amount (positive = money still expected, negative = over-received), plus a "to check" badge on the journal row and a live balance hint in the edit modal. HealthMetricsService also answers "when did I last see this practitioner" (last visit + count per practitioner/specialty) and renders the yearly Paid/Reimbursed/OutOfPocket review with an out-of-pocket bar chart. Member-only, cascade delete of the journal with its profile, covered by HealthMetricsServiceTest (balance/tolerance/over-payment cases), HealthProfile/HealthRecord resource tests (metrics + cascade + time-of-day round trip) and a Playwright smoke test; the mobile screenshot harness now seeds and captures the health pages. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_019MJHfZQLFr4Ghn1rnf1Jhh --- CLAUDE.md | 12 + scripts/mongodb-create-index.js | 2 + .../Clients/HealthProfileApiClient.cs | 15 + .../Clients/HealthRecordApiClient.cs | 9 + .../Inventory/Pages/HealthProfileDetail.razor | 482 ++++++++++++++++++ .../Inventory/Pages/HealthProfiles.razor | 39 ++ .../Inventory/Pages/HealthProfiles.razor.cs | 13 + .../Inventory/Pages/HealthRecordRow.razor | 47 ++ src/BlazorApp/Components/Layout/NavMenu.razor | 8 + ...frastructureServiceCollectionExtensions.cs | 4 + src/Domain/Models/HealthEventType.cs | 14 + src/Domain/Models/HealthMetricsModel.cs | 66 +++ src/Domain/Models/HealthProfileModel.cs | 19 + src/Domain/Models/HealthRecordModel.cs | 51 ++ .../Repositories/IHealthProfileRepository.cs | 7 + .../Repositories/IHealthRecordRepository.cs | 14 + src/Domain/Services/HealthMetricsService.cs | 101 ++++ .../Entities/HealthProfile.cs | 19 + .../Entities/HealthRecord.cs | 45 ++ .../Mappers/HealthProfileStorageMapper.cs | 16 + .../Mappers/HealthRecordStorageMapper.cs | 24 + .../Repositories/HealthProfileRepository.cs | 22 + .../Repositories/HealthRecordRepository.cs | 39 ++ src/WebApi.Contracts/Dto/HealthEventType.cs | 12 + src/WebApi.Contracts/Dto/HealthMetricsDto.cs | 111 ++++ src/WebApi.Contracts/Dto/HealthProfileDto.cs | 24 + src/WebApi.Contracts/Dto/HealthRecordDto.cs | 72 +++ .../Controllers/HealthProfileController.cs | 47 ++ .../Controllers/HealthRecordController.cs | 13 + ...frastructureServiceCollectionExtensions.cs | 4 + src/WebApi/Mappers/HealthMetricsDtoMapper.cs | 15 + src/WebApi/Mappers/HealthProfileDtoMapper.cs | 16 + src/WebApi/Mappers/HealthRecordDtoMapper.cs | 15 + src/WebApi/Program.cs | 4 + .../Pages/HealthProfileDetailPage.cs | 5 + .../Pages/PageBase.cs | 2 + .../Smoke/HealthSmokeTest.cs | 52 ++ .../Smoke/MobileScreenshotTest.cs | 36 ++ .../Resources/HealthProfileResourceTest.cs | 128 +++++ .../Resources/HealthRecordResourceTest.cs | 91 ++++ .../Controllers/FreeTierTest.cs | 2 + .../Services/HealthMetricsServiceTest.cs | 181 +++++++ 42 files changed, 1898 insertions(+) create mode 100644 src/BlazorApp/Components/Inventory/Clients/HealthProfileApiClient.cs create mode 100644 src/BlazorApp/Components/Inventory/Clients/HealthRecordApiClient.cs create mode 100644 src/BlazorApp/Components/Inventory/Pages/HealthProfileDetail.razor create mode 100644 src/BlazorApp/Components/Inventory/Pages/HealthProfiles.razor create mode 100644 src/BlazorApp/Components/Inventory/Pages/HealthProfiles.razor.cs create mode 100644 src/BlazorApp/Components/Inventory/Pages/HealthRecordRow.razor create mode 100644 src/Domain/Models/HealthEventType.cs create mode 100644 src/Domain/Models/HealthMetricsModel.cs create mode 100644 src/Domain/Models/HealthProfileModel.cs create mode 100644 src/Domain/Models/HealthRecordModel.cs create mode 100644 src/Domain/Repositories/IHealthProfileRepository.cs create mode 100644 src/Domain/Repositories/IHealthRecordRepository.cs create mode 100644 src/Domain/Services/HealthMetricsService.cs create mode 100644 src/Infrastructure.MongoDb/Entities/HealthProfile.cs create mode 100644 src/Infrastructure.MongoDb/Entities/HealthRecord.cs create mode 100644 src/Infrastructure.MongoDb/Mappers/HealthProfileStorageMapper.cs create mode 100644 src/Infrastructure.MongoDb/Mappers/HealthRecordStorageMapper.cs create mode 100644 src/Infrastructure.MongoDb/Repositories/HealthProfileRepository.cs create mode 100644 src/Infrastructure.MongoDb/Repositories/HealthRecordRepository.cs create mode 100644 src/WebApi.Contracts/Dto/HealthEventType.cs create mode 100644 src/WebApi.Contracts/Dto/HealthMetricsDto.cs create mode 100644 src/WebApi.Contracts/Dto/HealthProfileDto.cs create mode 100644 src/WebApi.Contracts/Dto/HealthRecordDto.cs create mode 100644 src/WebApi/Controllers/HealthProfileController.cs create mode 100644 src/WebApi/Controllers/HealthRecordController.cs create mode 100644 src/WebApi/Mappers/HealthMetricsDtoMapper.cs create mode 100644 src/WebApi/Mappers/HealthProfileDtoMapper.cs create mode 100644 src/WebApi/Mappers/HealthRecordDtoMapper.cs create mode 100644 test/BlazorApp.PlaywrightTests/Pages/HealthProfileDetailPage.cs create mode 100644 test/BlazorApp.PlaywrightTests/Smoke/HealthSmokeTest.cs create mode 100644 test/WebApi.IntegrationTests/Resources/HealthProfileResourceTest.cs create mode 100644 test/WebApi.IntegrationTests/Resources/HealthRecordResourceTest.cs create mode 100644 test/WebApi.UnitTests/Services/HealthMetricsServiceTest.cs diff --git a/CLAUDE.md b/CLAUDE.md index 759c1c3a..f577fb47 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -186,6 +186,18 @@ This avoids Car's bespoke hand-written `DateTime.SpecifyKind`/`ModalTimeText` "H `HouseHistoryModel.Provider` is a single field (contractor/technician/utility company/store name) covering every category, unlike Car's Refuel-only `StationBrandName` vs. Maintenance-only `Garage` split. House has no event type where "who was involved" doesn't apply, so one field suffices. +`HealthProfile`/`HealthRecord` (the health journal: appointments, sicknesses, reimbursement tracking) is the third Car/House-shaped pair, and mixes its two siblings deliberately. +The parent is a *person* (`HealthProfileModel.Name` - the owner, and one profile per family member later), so "create yourself first" is the House-like one-time setup step. +`HealthRecordModel.HistoryDate` is a full `DateTime` like **Car**'s (an appointment's time of day is real data - the detail modal reuses Car's exact ModalDate/ModalTimeText proxy pair and its "HH:mm" free-text field), +stamped `DateTimeKind.Utc` in `HealthRecordStorageMapper` via an explicit `[MapProperty(Use = ...)]` user mapping rather than Car's fully hand-written mapper (Health's fields are flat, only the date needs special casing). +`HealthEventType` (`Appointment`/`Sickness`/`Other`) follows the CarHistoryType/HouseEventType discriminated-enum rule; appointment-only fields (Specialty, Practitioner, the money) hide in the modal for other types. +The money model is the French reimbursement flow, four numbers the app does the math over: `Price` (paid), `PublicReimbursement` (assurance maladie), `InsuranceReimbursement` (mutuelle), `NotCovered` (reste à charge). +A record is *settled* exactly when `price - public - insurance - notCovered == 0` (within `HealthMetricsService.BalanceTolerance`, 0.005 - double arithmetic must never flag a settled record); +anything else lands in `HealthMetricsModel.UnbalancedRecords` with the signed missing amount (positive = money still expected, negative = over-received, both worth chasing - the owner's explicit reconciliation goal). +The balance rule lives ONLY in `HealthMetricsService` (`ComputeMissingAmount`/`IsBalanced`); the journal rows' "to check" badges come from the metrics' id list, never re-derived client-side. +`HealthMetricsService` also computes `LastVisits` ("when did I last see this practitioner" - appointments grouped by practitioner+specialty, most recent first) and the yearly Paid/Reimbursed/OutOfPocket table + out-of-pocket bar chart (SvgChartHelpers again). +Both controllers are `MemberOnly` (health data is never part of the free preview tier). + `HouseDetail.razor`'s yearly cost chart is a single-series bar chart (total cost per year) plus a plain HTML breakdown table underneath (rows = years, columns = the 6 categories + total), not a 6-color stacked bar chart. A stacked chart with that many categories would be visually noisy and add real code, and the table already carries the actual per-category precision an insurance review needs at a glance. The chart's axis-drawing code (`ChartGeometry`, `RenderAxes`, `EvenlySpacedIndices`) was extracted from `CarDetail.razor` into `src/BlazorApp/Components/Shared/SvgChartHelpers.cs` specifically so House's chart wouldn't duplicate it. diff --git a/scripts/mongodb-create-index.js b/scripts/mongodb-create-index.js index 29b67507..f56043ea 100644 --- a/scripts/mongodb-create-index.js +++ b/scripts/mongodb-create-index.js @@ -24,6 +24,8 @@ ensureIndex(db.car, { owner_id: 1 }, { name: "car_owner" }); ensureIndex(db.car_history, { owner_id: 1 }, { name: "car_history_owner" }); ensureIndex(db.house, { owner_id: 1 }, { name: "house_owner" }); ensureIndex(db.house_history, { owner_id: 1 }, { name: "house_history_owner" }); +ensureIndex(db.health_profile, { owner_id: 1 }, { name: "health_profile_owner" }); +ensureIndex(db.health_record, { owner_id: 1 }, { name: "health_record_owner" }); ensureIndex(db.movie, { owner_id: 1 }, { name: "movie_owner" }); ensureIndex(db.tvshow, { owner_id: 1 }, { name: "tvshow_owner" }); ensureIndex(db.videogame, { owner_id: 1 }, { name: "videogame_owner" }); diff --git a/src/BlazorApp/Components/Inventory/Clients/HealthProfileApiClient.cs b/src/BlazorApp/Components/Inventory/Clients/HealthProfileApiClient.cs new file mode 100644 index 00000000..6347307d --- /dev/null +++ b/src/BlazorApp/Components/Inventory/Clients/HealthProfileApiClient.cs @@ -0,0 +1,15 @@ +using Keeptrack.WebApi.Contracts.Dto; + +namespace Keeptrack.BlazorApp.Components.Inventory.Clients; + +public class HealthProfileApiClient(HttpClient http) + : InventoryApiClientBase(http) +{ + protected override string ApiResourceName => "/api/health-profiles"; + + public async Task GetMetricsAsync(string id) + { + var result = await Http.GetFromJsonAsync($"{ApiResourceName}/{id}/metrics"); + return result!; + } +} diff --git a/src/BlazorApp/Components/Inventory/Clients/HealthRecordApiClient.cs b/src/BlazorApp/Components/Inventory/Clients/HealthRecordApiClient.cs new file mode 100644 index 00000000..07f74ddf --- /dev/null +++ b/src/BlazorApp/Components/Inventory/Clients/HealthRecordApiClient.cs @@ -0,0 +1,9 @@ +using Keeptrack.WebApi.Contracts.Dto; + +namespace Keeptrack.BlazorApp.Components.Inventory.Clients; + +public sealed class HealthRecordApiClient(HttpClient http) + : InventoryApiClientBase(http) +{ + protected override string ApiResourceName => "/api/health-records"; +} diff --git a/src/BlazorApp/Components/Inventory/Pages/HealthProfileDetail.razor b/src/BlazorApp/Components/Inventory/Pages/HealthProfileDetail.razor new file mode 100644 index 00000000..cf726272 --- /dev/null +++ b/src/BlazorApp/Components/Inventory/Pages/HealthProfileDetail.razor @@ -0,0 +1,482 @@ +@page "/health/{Id}" +@rendermode InteractiveServer +@attribute [Authorize] +@using System.Globalization + +@if (_loading) +{ +
+
+ Loading… +
+} +else if (_profile is null) +{ +
+
+

Health profile not found.

+
+} +else +{ + + +
+
+ +
+
+ +
+ + +
+ + @if (_metrics is { UnbalancedRecords.Count: > 0 }) + { +

Reimbursements to check

+
+

+ These don't balance to zero (paid − public reimbursement − insurance reimbursement − not covered) - + @_metrics.UnbalancedRecords.Sum(p => p.MissingAmount).ToString("F2") € unaccounted for across + @_metrics.UnbalancedRecords.Count entr@(_metrics.UnbalancedRecords.Count == 1 ? "y" : "ies"), oldest first. + A positive amount is money still expected; a negative one means more arrived than the price accounts for. +

+
+ + + + + + + + + + + + @foreach (var unbalanced in _metrics.UnbalancedRecords) + { + + + + + + + + } + +
DateWhatPaidMissing
@unbalanced.HistoryDate.ToString("yyyy-MM-dd")@unbalanced.Label@unbalanced.Price.ToString("F2")@unbalanced.MissingAmount.ToString("F2") + +
+
+
+ } + + @if (_metrics is { LastVisits.Count: > 0 }) + { +

Last seen

+
+
+ + + + + + + + + + + @foreach (var visit in _metrics.LastVisits) + { + + + + + + + } + +
PractitionerSpecialtyVisitsLast visit
@visit.Practitioner@visit.Specialty@visit.VisitCount@visit.LastVisitDate.ToString("yyyy-MM-dd")
+
+
+ } + + @if (_metrics is { CostHistory.Count: > 0 }) + { + var metrics = _metrics; +

Yearly cost review

+
+

+ Out-of-pocket by year (paid minus reimbursed) - + @metrics.CostHistory.Sum(p => p.OutOfPocket).ToString("F2") € across the whole journal +

+ @RenderYearlyOutOfPocketChart(metrics.CostHistory) + +
+ + + + + + + + + + + @foreach (var point in metrics.CostHistory) + { + + + + + + + } + +
YearPaidReimbursedOut of pocket
@point.Year@point.TotalPaid.ToString("F2")@point.TotalReimbursed.ToString("F2")@point.OutOfPocket.ToString("F2")
+
+
+ } + +
+

Journal

+ +
+
+
+ + + + + + + + + + + + + + @if (_records.Count == 0) + { + + } + @foreach (var entry in _records) + { + + } + +
DateEventWhoPaidReimbursedDescription
No entries yet - add the first one above.
+
+
+ + @if (_showModal) + { +
+
+
+
@(IsEditMode ? "Edit" : "Add") journal entry
+ +
+
+
+
+ +
+ @foreach (var eventType in Enum.GetValues()) + { + + } +
+
+
+ + +
+
+ + +
+ @if (_modalEntry.EventType == HealthEventType.Appointment) + { +
+ + +
+
+ + @* data-testid: the label/input pair has no for/id association, so GetByLabel + can't resolve it in e2e tests - same as every inventory Add form *@ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ @if (_modalEntry.Price is > 0) + { + var missing = ModalMissingAmount; +
+ @if (Math.Abs(missing) <= 0.005) + { + ✓ Balanced - paid, reimbursements and not-covered add up to zero. + } + else + { + + @missing.ToString("F2") € @(missing > 0 ? "still unaccounted for - this entry will appear under \"Reimbursements to check\"." : "received beyond the price - worth a look.") + + } +
+ } + } +
+ + +
+
+ + +
+
+
+ +
+
+ } +} + +@code { + [Parameter] public required string Id { get; set; } + + [Inject] private HealthProfileApiClient HealthProfileApi { get; set; } = null!; + + [Inject] private HealthRecordApiClient HealthRecordApi { get; set; } = null!; + + private bool _loading = true; + private HealthProfileDto? _profile; + private List _records = []; + private HealthMetricsDto? _metrics; + private HealthRecordDto _modalEntry = NewEntry(""); + private bool _showModal; + + private bool IsEditMode => !string.IsNullOrEmpty(_modalEntry.Id); + + /// The journal rows' warning flags come from the metrics (the one balance computation, + /// server-side in HealthMetricsService) rather than re-deriving the arithmetic per row. + private HashSet UnbalancedRecordIds => + _metrics is null ? [] : [.. _metrics.UnbalancedRecords.Select(u => u.RecordId)]; + + /// Live preview of the same balance the server computes - what the modal shows while typing. + private double ModalMissingAmount => + (_modalEntry.Price ?? 0) - (_modalEntry.PublicReimbursement ?? 0) - (_modalEntry.InsuranceReimbursement ?? 0) - (_modalEntry.NotCovered ?? 0); + + private string DescriptionPlaceholder => _modalEntry.EventType == HealthEventType.Sickness + ? "What was wrong (fever, migraine, gastro, …)" + : "Reason for the visit / what happened"; + + protected override async Task OnParametersSetAsync() => await LoadAsync(); + + private static HealthRecordDto NewEntry(string profileId) => new() + { + HealthProfileId = profileId, + // today at the current hour, minute zeroed - a "right now" default the time field can refine + HistoryDate = DateTime.Today.AddHours(DateTime.Now.Hour), + EventType = HealthEventType.Appointment + }; + + /// Separate Date/Time proxies over the single _modalEntry.HistoryDate - see CarDetail.razor's + /// identical pair for why a plain @bind to HistoryDate would lose the time on every date change. + private DateOnly ModalDate + { + get => DateOnly.FromDateTime(_modalEntry.HistoryDate); + set => _modalEntry.HistoryDate = value.ToDateTime(TimeOnly.FromDateTime(_modalEntry.HistoryDate)); + } + + /// Plain "HH:mm" text instead of <input type="time"> - see CarDetail.razor's ModalTimeText + /// for why the native picker can't be forced to 24h. + private string ModalTimeText + { + get => TimeOnly.FromDateTime(_modalEntry.HistoryDate).ToString("HH:mm"); + set + { + if (TimeOnly.TryParseExact(value, "HH:mm", CultureInfo.InvariantCulture, DateTimeStyles.None, out var time)) + { + _modalEntry.HistoryDate = DateOnly.FromDateTime(_modalEntry.HistoryDate).ToDateTime(time); + } + } + } + + private async Task LoadAsync() + { + _loading = true; + _profile = await HealthProfileApi.GetOneAsync(Id); + if (_profile is not null) + { + var result = await HealthRecordApi.GetAsync("", 1, int.MaxValue, new Dictionary { ["HealthProfileId"] = Id }); + // most recent first - a journal reads with the newest entry on top, same call as HouseDetail + _records = result.Items.OrderByDescending(r => r.HistoryDate).ToList(); + _metrics = await HealthProfileApi.GetMetricsAsync(Id); + } + _loading = false; + } + + private async Task SaveProfileAsync() + { + if (_profile is null) return; + await HealthProfileApi.UpdateAsync(_profile); + } + + private async Task SaveNameAsync(ChangeEventArgs e) + { + if (_profile is null) return; + var name = e.Value?.ToString(); + if (string.IsNullOrWhiteSpace(name)) return; + _profile.Name = name; + await SaveProfileAsync(); + } + + private async Task SaveNotesAsync(ChangeEventArgs e) + { + if (_profile is null) return; + _profile.Notes = e.Value?.ToString(); + await SaveProfileAsync(); + } + + private void ShowAddModal() + { + _modalEntry = NewEntry(Id); + _showModal = true; + } + + /// Edits a copy, not the row's own object, so Cancel discards changes instead of leaving the + /// table showing edits that were never saved. + private void ShowEditModal(HealthRecordDto entry) + { + _modalEntry = CloneEntry(entry); + _showModal = true; + } + + /// The "Enter reimbursement" shortcut from the pending list - same edit modal, found by id. + private async Task EditRecordByIdAsync(string recordId) + { + var entry = _records.FirstOrDefault(r => r.Id == recordId); + if (entry is not null) + { + ShowEditModal(entry); + return; + } + + // not in the local list (shouldn't happen, but the metrics and the journal are two separate reads) + await LoadAsync(); + } + + private void CancelModal() => _showModal = false; + + /// Every journal mutation reloads the whole profile (journal + metrics) rather than patching + /// local state - an edit can change the yearly totals, the pending list and the last-visit table, so + /// recomputing from the server is simpler and safer than syncing derived state by hand. + private async Task SaveModalEntryAsync() + { + if (IsEditMode) await HealthRecordApi.UpdateAsync(_modalEntry); + else await HealthRecordApi.AddAsync(_modalEntry); + + _showModal = false; + await LoadAsync(); + } + + private static HealthRecordDto CloneEntry(HealthRecordDto entry) => new() + { + Id = entry.Id, + HealthProfileId = entry.HealthProfileId, + HistoryDate = entry.HistoryDate, + EventType = entry.EventType, + Specialty = entry.Specialty, + Practitioner = entry.Practitioner, + Description = entry.Description, + Notes = entry.Notes, + Price = entry.Price, + PublicReimbursement = entry.PublicReimbursement, + InsuranceReimbursement = entry.InsuranceReimbursement, + NotCovered = entry.NotCovered + }; + + private async Task DeleteEntryAsync(string id) + { + await HealthRecordApi.DeleteAsync(id); + await LoadAsync(); + } + + /// Hand-rolled SVG bar chart - out-of-pocket per year, one bar each, same single-series shape + /// as HouseDetail's yearly cost chart (axis drawing shared via ). The + /// table right below carries the paid/reimbursed detail, so the chart only answers "what did health + /// really cost me, year by year" at a glance. + private static RenderFragment RenderYearlyOutOfPocketChart(List points) => builder => + { + var geometry = SvgChartHelpers.FullWidthGeometry; + var (viewWidth, viewHeight, plotLeft, plotRight, plotTop, plotBottom) = geometry; + + var maxValue = points.Max(p => p.OutOfPocket); + if (maxValue <= 0) maxValue = 1; + + var plotWidth = plotRight - plotLeft; + var barWidth = plotWidth / points.Count * 0.6; + var gap = plotWidth / points.Count * 0.4; + + double ScaleHeight(double value) => value <= 0 ? 0 : value / maxValue * (plotBottom - plotTop); + + var yTicks = Enumerable.Range(0, 4) + .Select(i => maxValue * i / 3.0) + .Select(v => (plotBottom - ScaleHeight(v), v.ToString("F0"))) + .ToList(); + var xTicks = points.Select((p, i) => (plotLeft + i * (barWidth + gap) + barWidth / 2, p.Year.ToString())).ToList(); + + var seq = 0; + builder.OpenElement(seq++, "svg"); + builder.AddAttribute(seq++, "viewBox", $"0 0 {viewWidth} {viewHeight}"); + builder.AddAttribute(seq++, "class", "kt-chart-svg"); + + SvgChartHelpers.RenderAxes(builder, ref seq, geometry, "kt-chart-arrow-health-cost", "Year", "€", yTicks, xTicks); + + for (var i = 0; i < points.Count; i++) + { + var point = points[i]; + var x = plotLeft + i * (barWidth + gap); + var height = ScaleHeight(point.OutOfPocket); + var y = plotBottom - height; + + builder.OpenElement(seq++, "rect"); + builder.AddAttribute(seq++, "x", x.ToString("F1")); + builder.AddAttribute(seq++, "y", y.ToString("F1")); + builder.AddAttribute(seq++, "width", barWidth.ToString("F1")); + builder.AddAttribute(seq++, "height", height.ToString("F1")); + builder.AddAttribute(seq++, "fill", "var(--kt-accent)"); + builder.OpenElement(seq++, "title"); + builder.AddContent(seq++, $"{point.Year}: {point.OutOfPocket:F2}"); + builder.CloseElement(); + builder.CloseElement(); + } + + builder.CloseElement(); + }; +} diff --git a/src/BlazorApp/Components/Inventory/Pages/HealthProfiles.razor b/src/BlazorApp/Components/Inventory/Pages/HealthProfiles.razor new file mode 100644 index 00000000..f1ebfeda --- /dev/null +++ b/src/BlazorApp/Components/Inventory/Pages/HealthProfiles.razor @@ -0,0 +1,39 @@ +@page "/health" +@inherits InventoryPageBase +@rendermode InteractiveServer +@attribute [Authorize] + + + + @if (!string.IsNullOrEmpty(profile.Notes)) + { + @profile.Notes + } + + +
+ +
+
+
diff --git a/src/BlazorApp/Components/Inventory/Pages/HealthProfiles.razor.cs b/src/BlazorApp/Components/Inventory/Pages/HealthProfiles.razor.cs new file mode 100644 index 00000000..5c6d98fd --- /dev/null +++ b/src/BlazorApp/Components/Inventory/Pages/HealthProfiles.razor.cs @@ -0,0 +1,13 @@ +using Keeptrack.WebApi.Contracts.Dto; +using Microsoft.AspNetCore.Components; + +namespace Keeptrack.BlazorApp.Components.Inventory.Pages; + +public partial class HealthProfiles : InventoryPageBase +{ + [Inject] private HealthProfileApiClient HealthProfileApi { get; set; } = null!; + + protected override InventoryApiClientBase Api => HealthProfileApi; + + protected override string ListRoute => "/health"; +} diff --git a/src/BlazorApp/Components/Inventory/Pages/HealthRecordRow.razor b/src/BlazorApp/Components/Inventory/Pages/HealthRecordRow.razor new file mode 100644 index 00000000..2f81e56c --- /dev/null +++ b/src/BlazorApp/Components/Inventory/Pages/HealthRecordRow.razor @@ -0,0 +1,47 @@ +@* One row of the health journal - a compact summary of the most important fields. Editing happens + through the same modal used to add a new entry (HealthProfileDetail.razor), opened via the Edit + button below. *@ + + + @Entry.HistoryDate.ToString("yyyy-MM-dd HH:mm") + @Entry.EventType + + @Entry.Practitioner + @if (!string.IsNullOrEmpty(Entry.Specialty)) + { + @* ms-1, not a text space: Razor collapses the leading whitespace inside the span *@ + @Entry.Specialty + } + + + @Entry.Price?.ToString("F2") + @if (IsUnbalanced) + { + ⚠ to check + } + + @(TotalReimbursed?.ToString("F2")) + @Entry.Description + +
+ + +
+ + + +@code { + [Parameter] public required HealthRecordDto Entry { get; set; } + + /// Computed by HealthMetricsService (via the parent's metrics), never re-derived here - + /// the balance rule lives in exactly one place. U+26A0 defaults to text presentation (no FE0F). + [Parameter] public bool IsUnbalanced { get; set; } + + [Parameter] public EventCallback OnEdit { get; set; } + + [Parameter] public EventCallback OnDelete { get; set; } + + private double? TotalReimbursed => Entry.PublicReimbursement is null && Entry.InsuranceReimbursement is null + ? null + : (Entry.PublicReimbursement ?? 0) + (Entry.InsuranceReimbursement ?? 0); +} diff --git a/src/BlazorApp/Components/Layout/NavMenu.razor b/src/BlazorApp/Components/Layout/NavMenu.razor index ab9f4f36..34aca4c3 100644 --- a/src/BlazorApp/Components/Layout/NavMenu.razor +++ b/src/BlazorApp/Components/Layout/NavMenu.razor @@ -69,6 +69,14 @@ Houses + +
+

Import health journal

+
+ +
+

+ Upload the personal "Journal_sante.xlsx" spreadsheet (one sheet, every family member mixed in one + "Personne" column - health profiles are created or matched by name). + The "Reste à charge" column isn't imported: the app recomputes the balance itself, so rows that + don't balance land in each profile's "Reimbursements to check" list for review. + Only run this once - re-uploading the same file will duplicate the journal entries. +

+ +
+ + +
+ + @if (_healthImporting) + { +

Importing…

+ } + + @if (_healthError is not null) + { +
@_healthError
+ } + + @if (_healthResult is not null) + { +
+

Profiles: @_healthResult.ProfilesCreated created, @_healthResult.ProfilesSkipped already existed

+

Journal entries created: @_healthResult.RecordsCreated

+ @if (_healthResult.Warnings.Count > 0) + { +
+

Good to know:

+
    + @foreach (var warning in _healthResult.Warnings) + { +
  • @warning
  • + } +
+ } +
+ } +
+ @code { private const long MaxFileSize = 50_000_000; private static readonly TimeSpan PollInterval = TimeSpan.FromMilliseconds(600); @@ -113,6 +161,12 @@ private string? _carError; private CarHistoryImportResultDto? _carResult; + [Inject] private HealthImportApiClient HealthImportApi { get; set; } = null!; + + private bool _healthImporting; + private string? _healthError; + private HealthImportResultDto? _healthResult; + [System.Diagnostics.CodeAnalysis.SuppressMessage("Security", "S5693:Make sure the content length limit is safe here", Justification = "The limit IS set (MaxFileSize, 50 MB, matching the API's own RequestSizeLimit).")] private async Task OnFileSelectedAsync(InputFileChangeEventArgs e) @@ -184,6 +238,29 @@ } } + [System.Diagnostics.CodeAnalysis.SuppressMessage("Security", "S5693:Make sure the content length limit is safe here", + Justification = "The limit IS set (MaxFileSize, 50 MB, matching the API's own RequestSizeLimit).")] + private async Task OnHealthFileSelectedAsync(InputFileChangeEventArgs e) + { + _healthImporting = true; + _healthError = null; + _healthResult = null; + + try + { + await using var stream = e.File.OpenReadStream(MaxFileSize); + _healthResult = await HealthImportApi.ImportAsync(stream, e.File.Name); + } + catch (Exception ex) + { + _healthError = ex.Message; + } + finally + { + _healthImporting = false; + } + } + private static int ProgressPercent(ImportStage stage) => stage switch { ImportStage.Parsing => 10, diff --git a/src/BlazorApp/Components/Inventory/Pages/HealthProfileDetail.razor b/src/BlazorApp/Components/Inventory/Pages/HealthProfileDetail.razor index cf726272..643a8033 100644 --- a/src/BlazorApp/Components/Inventory/Pages/HealthProfileDetail.razor +++ b/src/BlazorApp/Components/Inventory/Pages/HealthProfileDetail.razor @@ -32,46 +32,9 @@ else - @if (_metrics is { UnbalancedRecords.Count: > 0 }) - { -

Reimbursements to check

-
-

- These don't balance to zero (paid − public reimbursement − insurance reimbursement − not covered) - - @_metrics.UnbalancedRecords.Sum(p => p.MissingAmount).ToString("F2") € unaccounted for across - @_metrics.UnbalancedRecords.Count entr@(_metrics.UnbalancedRecords.Count == 1 ? "y" : "ies"), oldest first. - A positive amount is money still expected; a negative one means more arrived than the price accounts for. -

-
- - - - - - - - - - - - @foreach (var unbalanced in _metrics.UnbalancedRecords) - { - - - - - - - - } - -
DateWhatPaidMissing
@unbalanced.HistoryDate.ToString("yyyy-MM-dd")@unbalanced.Label@unbalanced.Price.ToString("F2")@unbalanced.MissingAmount.ToString("F2") - -
-
-
- } - + @* No summary panels before the journal on purpose (owner feedback): the journal is the page's + primary content, and the per-row "to check" badge is the whole warning surface - a record that + doesn't balance is spotted in place, not in a duplicate list above. *@ @if (_metrics is { LastVisits.Count: > 0 }) {

Last seen

@@ -81,7 +44,6 @@ else Practitioner - Specialty Visits Last visit @@ -91,7 +53,6 @@ else { @visit.Practitioner - @visit.Specialty @visit.VisitCount @visit.LastVisitDate.ToString("yyyy-MM-dd") @@ -102,43 +63,6 @@ else } - @if (_metrics is { CostHistory.Count: > 0 }) - { - var metrics = _metrics; -

Yearly cost review

-
-

- Out-of-pocket by year (paid minus reimbursed) - - @metrics.CostHistory.Sum(p => p.OutOfPocket).ToString("F2") € across the whole journal -

- @RenderYearlyOutOfPocketChart(metrics.CostHistory) - -
- - - - - - - - - - - @foreach (var point in metrics.CostHistory) - { - - - - - - - } - -
YearPaidReimbursedOut of pocket
@point.Year@point.TotalPaid.ToString("F2")@point.TotalReimbursed.ToString("F2")@point.OutOfPocket.ToString("F2")
-
-
- } -

Journal

@@ -152,7 +76,6 @@ else Event Who Paid - Reimbursed Description @@ -160,7 +83,7 @@ else @if (_records.Count == 0) { - No entries yet - add the first one above. + No entries yet - add the first one above. } @foreach (var entry in _records) { @@ -171,6 +94,36 @@ else
+ @if (_metrics is { CostHistory.Count: > 0 }) + { +

Yearly cost review

+
+
+ + + + + + + + + + + @foreach (var point in _metrics.CostHistory) + { + + + + + + + } + +
YearPaidReimbursedOut of pocket
@point.Year@point.TotalPaid.ToString("F2")@point.TotalReimbursed.ToString("F2")@point.OutOfPocket.ToString("F2")
+
+
+ } + @if (_showModal) {
@@ -377,24 +330,10 @@ else _showModal = true; } - /// The "Enter reimbursement" shortcut from the pending list - same edit modal, found by id. - private async Task EditRecordByIdAsync(string recordId) - { - var entry = _records.FirstOrDefault(r => r.Id == recordId); - if (entry is not null) - { - ShowEditModal(entry); - return; - } - - // not in the local list (shouldn't happen, but the metrics and the journal are two separate reads) - await LoadAsync(); - } - private void CancelModal() => _showModal = false; /// Every journal mutation reloads the whole profile (journal + metrics) rather than patching - /// local state - an edit can change the yearly totals, the pending list and the last-visit table, so + /// local state - an edit can change the yearly totals, the row badges and the last-visit table, so /// recomputing from the server is simpler and safer than syncing derived state by hand. private async Task SaveModalEntryAsync() { @@ -426,57 +365,4 @@ else await HealthRecordApi.DeleteAsync(id); await LoadAsync(); } - - /// Hand-rolled SVG bar chart - out-of-pocket per year, one bar each, same single-series shape - /// as HouseDetail's yearly cost chart (axis drawing shared via ). The - /// table right below carries the paid/reimbursed detail, so the chart only answers "what did health - /// really cost me, year by year" at a glance. - private static RenderFragment RenderYearlyOutOfPocketChart(List points) => builder => - { - var geometry = SvgChartHelpers.FullWidthGeometry; - var (viewWidth, viewHeight, plotLeft, plotRight, plotTop, plotBottom) = geometry; - - var maxValue = points.Max(p => p.OutOfPocket); - if (maxValue <= 0) maxValue = 1; - - var plotWidth = plotRight - plotLeft; - var barWidth = plotWidth / points.Count * 0.6; - var gap = plotWidth / points.Count * 0.4; - - double ScaleHeight(double value) => value <= 0 ? 0 : value / maxValue * (plotBottom - plotTop); - - var yTicks = Enumerable.Range(0, 4) - .Select(i => maxValue * i / 3.0) - .Select(v => (plotBottom - ScaleHeight(v), v.ToString("F0"))) - .ToList(); - var xTicks = points.Select((p, i) => (plotLeft + i * (barWidth + gap) + barWidth / 2, p.Year.ToString())).ToList(); - - var seq = 0; - builder.OpenElement(seq++, "svg"); - builder.AddAttribute(seq++, "viewBox", $"0 0 {viewWidth} {viewHeight}"); - builder.AddAttribute(seq++, "class", "kt-chart-svg"); - - SvgChartHelpers.RenderAxes(builder, ref seq, geometry, "kt-chart-arrow-health-cost", "Year", "€", yTicks, xTicks); - - for (var i = 0; i < points.Count; i++) - { - var point = points[i]; - var x = plotLeft + i * (barWidth + gap); - var height = ScaleHeight(point.OutOfPocket); - var y = plotBottom - height; - - builder.OpenElement(seq++, "rect"); - builder.AddAttribute(seq++, "x", x.ToString("F1")); - builder.AddAttribute(seq++, "y", y.ToString("F1")); - builder.AddAttribute(seq++, "width", barWidth.ToString("F1")); - builder.AddAttribute(seq++, "height", height.ToString("F1")); - builder.AddAttribute(seq++, "fill", "var(--kt-accent)"); - builder.OpenElement(seq++, "title"); - builder.AddContent(seq++, $"{point.Year}: {point.OutOfPocket:F2}"); - builder.CloseElement(); - builder.CloseElement(); - } - - builder.CloseElement(); - }; } diff --git a/src/BlazorApp/Components/Inventory/Pages/HealthRecordRow.razor b/src/BlazorApp/Components/Inventory/Pages/HealthRecordRow.razor index 2f81e56c..919e579a 100644 --- a/src/BlazorApp/Components/Inventory/Pages/HealthRecordRow.razor +++ b/src/BlazorApp/Components/Inventory/Pages/HealthRecordRow.razor @@ -1,9 +1,9 @@ -@* One row of the health journal - a compact summary of the most important fields. Editing happens - through the same modal used to add a new entry (HealthProfileDetail.razor), opened via the Edit - button below. *@ +@* One row of the health journal - only the absolute essentials (owner feedback: this table grows large, + so no per-row reimbursement breakdown; the ⚠ badge is the whole warning surface and the edit modal + carries the full money detail). *@ - @Entry.HistoryDate.ToString("yyyy-MM-dd HH:mm") + @DateText @Entry.EventType @Entry.Practitioner @@ -17,10 +17,9 @@ @Entry.Price?.ToString("F2") @if (IsUnbalanced) { - ⚠ to check + ⚠ to check } - @(TotalReimbursed?.ToString("F2")) @Entry.Description
@@ -41,7 +40,9 @@ [Parameter] public EventCallback OnDelete { get; set; } - private double? TotalReimbursed => Entry.PublicReimbursement is null && Entry.InsuranceReimbursement is null - ? null - : (Entry.PublicReimbursement ?? 0) + (Entry.InsuranceReimbursement ?? 0); + /// Midnight means "no time recorded" (the import's default, and sickness entries rarely have + /// one) - showing 00:00 on most rows would just be noise. + private string DateText => Entry.HistoryDate.TimeOfDay == TimeSpan.Zero + ? Entry.HistoryDate.ToString("yyyy-MM-dd") + : Entry.HistoryDate.ToString("yyyy-MM-dd HH:mm"); } diff --git a/src/BlazorApp/DependencyInjection/InfrastructureServiceCollectionExtensions.cs b/src/BlazorApp/DependencyInjection/InfrastructureServiceCollectionExtensions.cs index 315c3f69..53dff133 100644 --- a/src/BlazorApp/DependencyInjection/InfrastructureServiceCollectionExtensions.cs +++ b/src/BlazorApp/DependencyInjection/InfrastructureServiceCollectionExtensions.cs @@ -41,6 +41,8 @@ internal static void AddWebApiHttpClient(this IServiceCollection services, strin .AddHttpMessageHandler(); services.AddHttpClient(client => client.BaseAddress = webApiUri) .AddHttpMessageHandler(); + services.AddHttpClient(client => client.BaseAddress = webApiUri) + .AddHttpMessageHandler(); services.AddHttpClient(client => client.BaseAddress = webApiUri) .AddHttpMessageHandler(); services.AddHttpClient(client => client.BaseAddress = webApiUri) diff --git a/src/WebApi.Contracts/Dto/HealthImportResultDto.cs b/src/WebApi.Contracts/Dto/HealthImportResultDto.cs new file mode 100644 index 00000000..30c10cce --- /dev/null +++ b/src/WebApi.Contracts/Dto/HealthImportResultDto.cs @@ -0,0 +1,24 @@ +using System.Collections.Generic; + +namespace Keeptrack.WebApi.Contracts.Dto; + +/// +/// Result of importing the personal "Journal_sante.xlsx" spreadsheet (one health journal, every family +/// member mixed in one sheet). +/// +public class HealthImportResultDto +{ + /// How many health profiles the "Personne" column created. + public int ProfilesCreated { get; set; } + + /// How many "Personne" values matched an already-existing profile instead. + public int ProfilesSkipped { get; set; } + + /// How many journal entries were created. + public int RecordsCreated { get; set; } + + /// + /// Rows that couldn't be parsed (an unreadable date, no person) and were skipped rather than guessed at. + /// + public List Warnings { get; set; } = []; +} diff --git a/src/WebApi/Import/CarHistoryImportController.cs b/src/WebApi/Import/CarHistoryImportController.cs index b2bc6680..726d630d 100644 --- a/src/WebApi/Import/CarHistoryImportController.cs +++ b/src/WebApi/Import/CarHistoryImportController.cs @@ -5,7 +5,9 @@ namespace Keeptrack.WebApi.Import; [ApiController] -[Authorize] +// MemberOnly like CarController itself: this import creates cars/history through the repositories, +// which would otherwise let a free-preview account bypass the controller-level policy entirely +[Authorize(Policy = "MemberOnly")] [Route("api/import")] public class CarHistoryImportController(CarHistoryImportService importService) : ControllerBase { diff --git a/src/WebApi/Import/CarHistoryImportService.cs b/src/WebApi/Import/CarHistoryImportService.cs index d85cad2c..56ea928a 100644 --- a/src/WebApi/Import/CarHistoryImportService.cs +++ b/src/WebApi/Import/CarHistoryImportService.cs @@ -87,7 +87,7 @@ private async Task ImportFuelSheetAsync(IXLWorksheet sheet, string carId, s continue; } - var time = ParseTime(Cell(row, headers, "Heure")) ?? TimeOnly.MinValue; + var time = ExcelCellParser.ParseTime(Cell(row, headers, "Heure")) ?? TimeOnly.MinValue; var entry = new CarHistoryModel { @@ -95,19 +95,19 @@ private async Task ImportFuelSheetAsync(IXLWorksheet sheet, string carId, s CarId = carId, HistoryDate = date.Value.ToDateTime(time), EventType = CarHistoryType.Refuel, - City = StringOrNull(Cell(row, headers, "Ville")), - PostalCode = StringOrNull(Cell(row, headers, "CP")), - FuelCategory = StringOrNull(Cell(row, headers, "Type carburant")), - FuelVolume = DoubleOrNull(Cell(row, headers, "Volume (L)")) ?? DoubleOrNull(Cell(row, headers, "Quantité (L)")), - FuelUnitPrice = PriceOrNull(Cell(row, headers, "Prix (€ / L)")), - Cost = PriceOrNull(Cell(row, headers, "Prix (€)")) ?? PriceOrNull(Cell(row, headers, "Montant (€)")), - Mileage = IntOrNull(Cell(row, headers, "Km")), - DeltaMileage = DoubleOrNull(Cell(row, headers, "Distance (km)")), + City = ExcelCellParser.StringOrNull(Cell(row, headers, "Ville")), + PostalCode = ExcelCellParser.StringOrNull(Cell(row, headers, "CP")), + FuelCategory = ExcelCellParser.StringOrNull(Cell(row, headers, "Type carburant")), + FuelVolume = ExcelCellParser.DoubleOrNull(Cell(row, headers, "Volume (L)")) ?? ExcelCellParser.DoubleOrNull(Cell(row, headers, "Quantité (L)")), + FuelUnitPrice = ExcelCellParser.PriceOrNull(Cell(row, headers, "Prix (€ / L)")), + Cost = ExcelCellParser.PriceOrNull(Cell(row, headers, "Prix (€)")) ?? ExcelCellParser.PriceOrNull(Cell(row, headers, "Montant (€)")), + Mileage = ExcelCellParser.IntOrNull(Cell(row, headers, "Km")), + DeltaMileage = ExcelCellParser.DoubleOrNull(Cell(row, headers, "Distance (km)")), IsFullRefill = IsFlagSet(Cell(row, headers, "Plein")), - StationBrandName = StringOrNull(Cell(row, headers, "Distributeur")), - Description = JoinNonEmpty("; ", - StringOrNull(Cell(row, headers, "Voyage")) is { } voyage ? $"Voyage : {voyage}" : null, - StringOrNull(Cell(row, headers, "Détails")), + StationBrandName = ExcelCellParser.StringOrNull(Cell(row, headers, "Distributeur")), + Description = ExcelCellParser.JoinNonEmpty("; ", + ExcelCellParser.StringOrNull(Cell(row, headers, "Voyage")) is { } voyage ? $"Voyage : {voyage}" : null, + ExcelCellParser.StringOrNull(Cell(row, headers, "Détails")), IsFlagSet(Cell(row, headers, "Autoroute Eloigné")) == true ? "Autoroute/éloigné" : null, IsFlagSet(Cell(row, headers, "Autoroute")) == true ? "Autoroute" : null, IsFlagSet(Cell(row, headers, "Gonflage")) == true ? "Gonflage pneus" : null) @@ -137,8 +137,8 @@ private async Task ImportMaintenanceSheetAsync(IXLWorksheet sheet, string c continue; } - var nature = StringOrNull(Cell(row, headers, "Nature")); - var invoiceNumber = StringOrNull(Cell(row, headers, "N° facture")); + var nature = ExcelCellParser.StringOrNull(Cell(row, headers, "Nature")); + var invoiceNumber = ExcelCellParser.StringOrNull(Cell(row, headers, "N° facture")); var entry = new CarHistoryModel { @@ -148,15 +148,15 @@ private async Task ImportMaintenanceSheetAsync(IXLWorksheet sheet, string c HistoryDate = date.Value.ToDateTime(TimeOnly.MinValue), // "Achat" (purchase) is not a maintenance action - everything else in these two sheets is. EventType = string.Equals(nature, "Achat", StringComparison.OrdinalIgnoreCase) ? CarHistoryType.Other : CarHistoryType.Maintenance, - City = StringOrNull(Cell(row, headers, "Ville")), - PostalCode = StringOrNull(Cell(row, headers, "CP")), - Cost = PriceOrNull(Cell(row, headers, "Prix (TTC)")), - Mileage = IntOrNull(Cell(row, headers, "Km")), - Garage = StringOrNull(Cell(row, headers, "Garage")), - Description = JoinNonEmpty("; ", + City = ExcelCellParser.StringOrNull(Cell(row, headers, "Ville")), + PostalCode = ExcelCellParser.StringOrNull(Cell(row, headers, "CP")), + Cost = ExcelCellParser.PriceOrNull(Cell(row, headers, "Prix (TTC)")), + Mileage = ExcelCellParser.IntOrNull(Cell(row, headers, "Km")), + Garage = ExcelCellParser.StringOrNull(Cell(row, headers, "Garage")), + Description = ExcelCellParser.JoinNonEmpty("; ", nature, - StringOrNull(Cell(row, headers, "Détail")), - StringOrNull(Cell(row, headers, "Complément")), + ExcelCellParser.StringOrNull(Cell(row, headers, "Détail")), + ExcelCellParser.StringOrNull(Cell(row, headers, "Complément")), invoiceNumber is not null ? $"Facture {invoiceNumber}" : null) }; @@ -205,45 +205,10 @@ private static Dictionary ReadHeaders(IXLWorksheet sheet) return DateOnly.TryParseExact(raw, "d/M/yyyy", French, DateTimeStyles.None, out var parsed) ? parsed : null; } - /// - /// The "Heure" column is inconsistent in this file - some cells are real Excel time values (a fraction - /// of a day), others are plain text like "11:57" - but GetFormattedString() renders both the - /// same way, so a single text parse handles both. - /// - private static TimeOnly? ParseTime(IXLCell? cell) - { - if (cell is null || cell.IsEmpty()) return null; - var text = cell.GetFormattedString().Trim(); - return TimeOnly.TryParse(text, CultureInfo.InvariantCulture, out var parsed) ? parsed : null; - } - private static bool? IsFlagSet(IXLCell? cell) { if (cell is null || cell.IsEmpty()) return null; return string.Equals(cell.GetString().Trim(), "O", StringComparison.OrdinalIgnoreCase); } - private static string? StringOrNull(IXLCell? cell) - { - if (cell is null || cell.IsEmpty()) return null; - var text = cell.GetString().Trim(); - return text.Length > 0 ? text : null; - } - - private static double? DoubleOrNull(IXLCell? cell) => cell is not null && !cell.IsEmpty() && cell.TryGetValue(out double value) ? value : null; - - /// - /// Several euro-amount columns are Excel formulas (e.g. volume × unit price), which routinely produce - /// floating-point noise like 180.77539999999996 instead of a clean 180.78 - rounded to the cent here so - /// every imported price is a real, displayable amount. - /// - private static double? PriceOrNull(IXLCell? cell) => DoubleOrNull(cell) is { } value ? Math.Round(value, 2) : null; - - private static int? IntOrNull(IXLCell? cell) => DoubleOrNull(cell) is { } value ? (int)Math.Round(value) : null; - - private static string? JoinNonEmpty(string separator, params string?[] parts) - { - var joined = string.Join(separator, parts.Where(p => !string.IsNullOrWhiteSpace(p))); - return joined.Length > 0 ? joined : null; - } } diff --git a/src/WebApi/Import/ExcelCellParser.cs b/src/WebApi/Import/ExcelCellParser.cs new file mode 100644 index 00000000..9a8c74a7 --- /dev/null +++ b/src/WebApi/Import/ExcelCellParser.cs @@ -0,0 +1,47 @@ +using System.Globalization; +using ClosedXML.Excel; + +namespace Keeptrack.WebApi.Import; + +/// +/// Cell-parsing helpers shared by the one-off Excel importers (, +/// ) - extracted rather than duplicated per importer. +/// +internal static class ExcelCellParser +{ + /// + /// "Heure" columns are inconsistent in these personal files - some cells are real Excel time values + /// (a fraction of a day), others plain text like "11:57" - but GetFormattedString() renders + /// both the same way, so a single text parse handles both. + /// + internal static TimeOnly? ParseTime(IXLCell? cell) + { + if (cell is null || cell.IsEmpty()) return null; + var text = cell.GetFormattedString().Trim(); + return TimeOnly.TryParse(text, CultureInfo.InvariantCulture, out var parsed) ? parsed : null; + } + + internal static string? StringOrNull(IXLCell? cell) + { + if (cell is null || cell.IsEmpty()) return null; + var text = cell.GetString().Trim(); + return text.Length > 0 ? text : null; + } + + internal static double? DoubleOrNull(IXLCell? cell) => cell is not null && !cell.IsEmpty() && cell.TryGetValue(out double value) ? value : null; + + /// + /// Euro-amount columns are often Excel formulas (sums of several acts), which routinely produce + /// floating-point noise like 36.049999999999997 - rounded to the cent so every imported price is a + /// real, displayable amount. + /// + internal static double? PriceOrNull(IXLCell? cell) => DoubleOrNull(cell) is { } value ? Math.Round(value, 2) : null; + + internal static int? IntOrNull(IXLCell? cell) => DoubleOrNull(cell) is { } value ? (int)Math.Round(value) : null; + + internal static string? JoinNonEmpty(string separator, params string?[] parts) + { + var joined = string.Join(separator, parts.Where(p => !string.IsNullOrWhiteSpace(p))); + return joined.Length > 0 ? joined : null; + } +} diff --git a/src/WebApi/Import/HealthImportController.cs b/src/WebApi/Import/HealthImportController.cs new file mode 100644 index 00000000..c3a089c7 --- /dev/null +++ b/src/WebApi/Import/HealthImportController.cs @@ -0,0 +1,34 @@ +using Keeptrack.WebApi.Controllers; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace Keeptrack.WebApi.Import; + +[ApiController] +[Authorize(Policy = "MemberOnly")] +[Route("api/import")] +public class HealthImportController(HealthImportService importService) : ControllerBase +{ + /// + /// Imports the personal "Journal_sante.xlsx" spreadsheet (one sheet of health events, every family + /// member mixed in one "Personne" column - profiles are created/matched by name). Runs synchronously, + /// same as the car import: no external API call in the loop, so a few hundred rows complete well + /// within a normal request. + /// + [HttpPost("health")] + [RequestSizeLimit(10_000_000)] + [Consumes("multipart/form-data")] + [ProducesResponseType(200)] + [ProducesResponseType(400)] + public async Task> ImportHealth(IFormFile file) + { + if (file.Length == 0) + { + return BadRequest(); + } + + await using var stream = file.OpenReadStream(); + var result = await importService.ImportAsync(stream, this.GetUserId()); + return Ok(result); + } +} diff --git a/src/WebApi/Import/HealthImportService.cs b/src/WebApi/Import/HealthImportService.cs new file mode 100644 index 00000000..7bdb8dfa --- /dev/null +++ b/src/WebApi/Import/HealthImportService.cs @@ -0,0 +1,160 @@ +using ClosedXML.Excel; +using Keeptrack.Domain.Models; +using Keeptrack.Domain.Repositories; +using HealthEventType = Keeptrack.Domain.Models.HealthEventType; + +namespace Keeptrack.WebApi.Import; + +/// +/// One-off personal import of the "Journal_sante.xlsx" spreadsheet: a single sheet of hand-tracked +/// health events in French, mixing every family member in one "Personne" column - so unlike the car +/// import, rows are dispatched to (and, when needed, create) one per +/// distinct person. Same non-idempotence trade-off as : profiles +/// are matched by name so re-running doesn't duplicate the people, but re-uploading the same file will +/// duplicate journal entries - there is no per-row natural key to de-duplicate against. +/// The "Reste à charge" column is deliberately NOT imported: it's a formula in the source file +/// (paid - ameli - mutuelle), i.e. derived data the app recomputes itself (see +/// ) - importing its cached value as +/// would silently mark every historical row as settled. +/// Rows the balance check then flags are exactly the ones the owner wants to re-verify. +/// +public class HealthImportService(IHealthProfileRepository healthProfileRepository, IHealthRecordRepository healthRecordRepository) +{ + /// + /// Column positions resolved from the header row. The file has TWO columns titled "Personne" - the + /// first is who was treated (the profile), the second the practitioner - so a plain + /// header-name-to-column dictionary (the car importer's approach) can't represent this sheet. + /// "Jour" (derived day-of-week) and "Reste à charge" (derived, see the class remarks) are ignored. + /// + private sealed class SheetColumns + { + public int? Date { get; set; } + public int? Time { get; set; } + public int? Person { get; set; } + public int? Practitioner { get; set; } + public int? Specialty { get; set; } + public int? Location { get; set; } + public int? Description { get; set; } + public int? Notes { get; set; } + public int? Paid { get; set; } + public int? PublicReimbursement { get; set; } + public int? PublicTransfer { get; set; } + public int? PublicDate { get; set; } + public int? InsuranceReimbursement { get; set; } + public int? InsuranceDate { get; set; } + public int? Comment { get; set; } + } + + public async Task ImportAsync(Stream xlsxStream, string ownerId) + { + using var workbook = new XLWorkbook(xlsxStream); + var sheet = workbook.Worksheets.First(); + var columns = ReadColumns(sheet); + var result = new HealthImportResultDto(); + + if (columns.Date is null || columns.Person is null) + { + result.Warnings.Add("The sheet has no \"Date\"/\"Personne\" header row - nothing was imported."); + return result; + } + + var profilesByName = new Dictionary(StringComparer.OrdinalIgnoreCase); + var existingProfiles = (await healthProfileRepository.FindAllAsync(ownerId, 1, int.MaxValue, null, + new HealthProfileModel { OwnerId = ownerId, Name = string.Empty })).Items; + foreach (var profile in existingProfiles) profilesByName.TryAdd(profile.Name.Trim(), profile); + + foreach (var row in sheet.RowsUsed().Skip(1)) + { + var dateCell = row.Cell(columns.Date.Value); + if (dateCell.IsEmpty()) continue; + if (!dateCell.TryGetValue(out DateTime date)) + { + result.Warnings.Add($"Row {row.RowNumber()}: skipped, unreadable date (\"{dateCell.GetString()}\")."); + continue; + } + + var personName = ExcelCellParser.StringOrNull(Cell(row, columns.Person)); + if (personName is null) + { + result.Warnings.Add($"Row {row.RowNumber()}: skipped, no \"Personne\" to attach the entry to."); + continue; + } + + if (!profilesByName.TryGetValue(personName, out var target)) + { + target = await healthProfileRepository.CreateAsync(new HealthProfileModel { OwnerId = ownerId, Name = personName }); + profilesByName[personName] = target; + result.ProfilesCreated++; + } + + var time = ExcelCellParser.ParseTime(Cell(row, columns.Time)) ?? TimeOnly.MinValue; + + await healthRecordRepository.CreateAsync(new HealthRecordModel + { + OwnerId = ownerId, + HealthProfileId = target.Id!, + HistoryDate = DateOnly.FromDateTime(date).ToDateTime(time), + // every row in this journal is a care event; sicknesses weren't tracked in the spreadsheet + EventType = HealthEventType.Appointment, + Specialty = ExcelCellParser.StringOrNull(Cell(row, columns.Specialty)), + Practitioner = ExcelCellParser.StringOrNull(Cell(row, columns.Practitioner)), + Description = ExcelCellParser.StringOrNull(Cell(row, columns.Description)), + Price = ExcelCellParser.PriceOrNull(Cell(row, columns.Paid)), + PublicReimbursement = ExcelCellParser.PriceOrNull(Cell(row, columns.PublicReimbursement)), + InsuranceReimbursement = ExcelCellParser.PriceOrNull(Cell(row, columns.InsuranceReimbursement)), + // the sparse bookkeeping columns (place, actual ameli bank transfer, payment dates, + // comment) have no dedicated fields - preserved as labeled notes instead of dropped + Notes = ExcelCellParser.JoinNonEmpty("; ", + ExcelCellParser.StringOrNull(Cell(row, columns.Notes)), + ExcelCellParser.StringOrNull(Cell(row, columns.Location)) is { } location ? $"Lieu : {location}" : null, + ExcelCellParser.PriceOrNull(Cell(row, columns.PublicTransfer)) is { } transfer ? $"Virement Ameli : {transfer:F2}" : null, + DateTextOrNull(Cell(row, columns.PublicDate)) is { } publicDate ? $"Payé Ameli le {publicDate}" : null, + DateTextOrNull(Cell(row, columns.InsuranceDate)) is { } insuranceDate ? $"Payé mutuelle le {insuranceDate}" : null, + ExcelCellParser.StringOrNull(Cell(row, columns.Comment))) + }); + result.RecordsCreated++; + } + + result.ProfilesSkipped = profilesByName.Count - result.ProfilesCreated; + return result; + } + + private static SheetColumns ReadColumns(IXLWorksheet sheet) + { + var columns = new SheetColumns(); + foreach (var cell in sheet.Row(1).CellsUsed()) + { + // headers like "Rbrst\nAmeli" span two lines within one cell; normalize the whitespace + var text = string.Join(' ', cell.GetString().Split([' ', '\r', '\n', '\t'], StringSplitOptions.RemoveEmptyEntries)); + var column = cell.Address.ColumnNumber; + switch (text) + { + case "Date": columns.Date ??= column; break; + case "Heure": columns.Time ??= column; break; + case "Personne" when columns.Person is null: columns.Person = column; break; + case "Personne": columns.Practitioner ??= column; break; + case "Spécialité": columns.Specialty ??= column; break; + case "Lieu": columns.Location ??= column; break; + case "Fait": columns.Description ??= column; break; + case "Notes": columns.Notes ??= column; break; + case "Paiement": columns.Paid ??= column; break; + case "Rbrst Ameli": columns.PublicReimbursement ??= column; break; + case "Virt Ameli": columns.PublicTransfer ??= column; break; + case "Date Ameli": columns.PublicDate ??= column; break; + case "Rbrst Mutuelle": columns.InsuranceReimbursement ??= column; break; + case "Date mutuelle": columns.InsuranceDate ??= column; break; + case "Commentaire": columns.Comment ??= column; break; + } + } + + return columns; + } + + private static IXLCell? Cell(IXLRow row, int? column) => column is null ? null : row.Cell(column.Value); + + private static string? DateTextOrNull(IXLCell? cell) + { + if (cell is null || cell.IsEmpty()) return null; + return cell.TryGetValue(out DateTime date) ? date.ToString("yyyy-MM-dd") : ExcelCellParser.StringOrNull(cell); + } +} diff --git a/src/WebApi/Program.cs b/src/WebApi/Program.cs index 8d72bb30..82ef5702 100644 --- a/src/WebApi/Program.cs +++ b/src/WebApi/Program.cs @@ -44,6 +44,7 @@ builder.Services.AddScoped(typeof(Keeptrack.WebApi.Jobs.JobStore<,>)); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddSingleton(configuration.TmdbSettings); builder.Services.AddHttpClient(client => { diff --git a/test/BlazorApp.PlaywrightTests/Smoke/HealthSmokeTest.cs b/test/BlazorApp.PlaywrightTests/Smoke/HealthSmokeTest.cs index 8ff7454d..9bce734b 100644 --- a/test/BlazorApp.PlaywrightTests/Smoke/HealthSmokeTest.cs +++ b/test/BlazorApp.PlaywrightTests/Smoke/HealthSmokeTest.cs @@ -9,8 +9,8 @@ namespace Keeptrack.BlazorApp.PlaywrightTests.Smoke; /// /// Walks the health journal end-to-end: create a profile, add an appointment through the modal (with a -/// price but no reimbursement), and check the balance surfaces both as the "Reimbursements to check" -/// panel and the row's own "to check" badge - the core promise of the feature. +/// price but no reimbursement), and check the balance surfaces as the row's "to check" badge - the +/// journal table is deliberately the only warning surface (no summary panel above it). /// [Trait("Category", "E2eTests")] [Trait("Mode", "Mutating")] @@ -41,7 +41,6 @@ public async Task AddProfileAndUnbalancedAppointment_ThenDelete() await Page.GetByTestId("price-input").FillAsync("60"); await Page.Locator(".kt-modal").GetByRole(AriaRole.Button, new LocatorGetByRoleOptions { Name = "Save" }).ClickAsync(); - await Assertions.Expect(Page.GetByRole(AriaRole.Heading, new PageGetByRoleOptions { Name = "Reimbursements to check" })).ToBeVisibleAsync(); await Assertions.Expect(Page.GetByText("to check").First).ToBeVisibleAsync(); await Assertions.Expect(Page.GetByText("Dr E2e").First).ToBeVisibleAsync(); diff --git a/test/WebApi.UnitTests/Controllers/FreeTierTest.cs b/test/WebApi.UnitTests/Controllers/FreeTierTest.cs index 88fedaec..07281504 100644 --- a/test/WebApi.UnitTests/Controllers/FreeTierTest.cs +++ b/test/WebApi.UnitTests/Controllers/FreeTierTest.cs @@ -156,6 +156,8 @@ public async Task Post_NeverCapsAnAdmin() [InlineData(typeof(HealthProfileController), "MemberOnly")] [InlineData(typeof(HealthRecordController), "MemberOnly")] [InlineData(typeof(TvTimeImportController), "MemberOnly")] + [InlineData(typeof(CarHistoryImportController), "MemberOnly")] + [InlineData(typeof(HealthImportController), "MemberOnly")] [InlineData(typeof(MovieController), null)] [InlineData(typeof(TvShowController), null)] [InlineData(typeof(EpisodeController), null)] diff --git a/test/WebApi.UnitTests/Import/HealthImportServiceTest.cs b/test/WebApi.UnitTests/Import/HealthImportServiceTest.cs new file mode 100644 index 00000000..97dacabc --- /dev/null +++ b/test/WebApi.UnitTests/Import/HealthImportServiceTest.cs @@ -0,0 +1,170 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using AwesomeAssertions; +using ClosedXML.Excel; +using Keeptrack.Common.System; +using Keeptrack.Domain.Models; +using Keeptrack.Domain.Repositories; +using Keeptrack.WebApi.Import; +using Xunit; + +namespace Keeptrack.WebApi.UnitTests.Import; + +/// +/// Exercises against an in-memory workbook shaped exactly like the +/// real "Journal_sante.xlsx" (verified against the actual file): the duplicate "Personne" header (first = +/// who was treated, second = practitioner), two-line headers like "Rbrst\nAmeli", Excel-native dates and +/// time fractions, formula-backed amounts, and the derived "Reste à charge" column that must NOT be +/// imported. +/// +[Trait("Category", "UnitTests")] +public class HealthImportServiceTest +{ + private readonly FakeHealthProfileRepository _profiles = new(); + private readonly FakeHealthRecordRepository _records = new(); + + private HealthImportService CreateService() => new(_profiles, _records); + + private static byte[] BuildWorkbook(Action? mutate = null) + { + using var workbook = new XLWorkbook(); + var sheet = workbook.AddWorksheet("Journal"); + + string[] headers = ["Jour", "Date", "Heure", "Personne", "Spécialité", "Personne", "Lieu", "Fait", "Notes", "Paiement", "Rbrst\nAmeli", "Virt Ameli", "Date Ameli", "Rbrst\nMutuelle", "Date\nmutuelle", "Reste\nà charge", "Commentaire"]; + for (var i = 0; i < headers.Length; i++) sheet.Cell(1, i + 1).Value = headers[i]; + + // a settled consultation for Bertrand, with a time of day + sheet.Cell(2, 1).Value = "Lundi"; + sheet.Cell(2, 2).Value = new DateTime(2022, 9, 21); + sheet.Cell(2, 3).Value = new TimeSpan(10, 0, 0); + sheet.Cell(2, 4).Value = "Bertrand"; + sheet.Cell(2, 5).Value = "Ostéopathe"; + sheet.Cell(2, 6).Value = "Dr Roche"; + sheet.Cell(2, 8).Value = "Consultation"; + sheet.Cell(2, 10).Value = 60; + sheet.Cell(2, 11).Value = 0; + sheet.Cell(2, 14).Value = 60; + sheet.Cell(2, 16).FormulaA1 = "=J2-K2-N2"; // the derived column the import must ignore + + // a partially reimbursed one for a second person, with the bookkeeping extras + sheet.Cell(3, 2).Value = new DateTime(2022, 9, 27); + sheet.Cell(3, 4).Value = "Vinciane"; + sheet.Cell(3, 5).Value = "Médecin généraliste"; + sheet.Cell(3, 8).Value = "Consultation"; + sheet.Cell(3, 10).Value = 25; + sheet.Cell(3, 14).Value = 7.5; + sheet.Cell(3, 17).Value = "Envoyé le 22/12 à NoveoCare"; + + // a formula-backed amount with floating-point noise, same person as row 2 + sheet.Cell(4, 2).Value = new DateTime(2022, 12, 18); + sheet.Cell(4, 4).Value = "Bertrand"; + sheet.Cell(4, 10).FormulaA1 = "=17.5+18.55"; + + // an unreadable date and a missing person - both skipped with a warning, never guessed at + sheet.Cell(5, 2).Value = "pas une date"; + sheet.Cell(5, 4).Value = "Bertrand"; + sheet.Cell(6, 2).Value = new DateTime(2023, 1, 5); + + mutate?.Invoke(sheet); + + using var stream = new MemoryStream(); + workbook.SaveAs(stream); + return stream.ToArray(); + } + + [Fact] + public async Task Import_CreatesOneProfilePerDistinctPerson_AndAttachesTheirRecords() + { + var result = await CreateService().ImportAsync(new MemoryStream(BuildWorkbook()), "owner-1"); + + result.ProfilesCreated.Should().Be(2); + result.ProfilesSkipped.Should().Be(0); + result.RecordsCreated.Should().Be(3); + result.Warnings.Should().HaveCount(2); + + var bertrand = _profiles.Items.Single(p => p.Name == "Bertrand"); + _records.Items.Count(r => r.HealthProfileId == bertrand.Id).Should().Be(2); + } + + [Fact] + public async Task Import_MapsEveryColumn_IncludingTheSecondPersonneAsPractitioner_AndKeepsTheTime() + { + await CreateService().ImportAsync(new MemoryStream(BuildWorkbook()), "owner-1"); + + var consultation = _records.Items.Single(r => r.Specialty == "Ostéopathe"); + consultation.HistoryDate.Should().Be(new DateTime(2022, 9, 21, 10, 0, 0)); + consultation.EventType.Should().Be(HealthEventType.Appointment); + consultation.Practitioner.Should().Be("Dr Roche"); + consultation.Description.Should().Be("Consultation"); + consultation.Price.Should().Be(60); + consultation.PublicReimbursement.Should().Be(0); + consultation.InsuranceReimbursement.Should().Be(60); + // the derived "Reste à charge" column is never imported - the app recomputes the balance + consultation.NotCovered.Should().BeNull(); + } + + [Fact] + public async Task Import_PreservesBookkeepingColumnsAsNotes_AndRoundsFormulaAmounts() + { + await CreateService().ImportAsync(new MemoryStream(BuildWorkbook()), "owner-1"); + + var vinciane = _records.Items.Single(r => r.Specialty == "Médecin généraliste"); + vinciane.Notes.Should().Be("Envoyé le 22/12 à NoveoCare"); + + var formulaRow = _records.Items.Single(r => r.HistoryDate.Date == new DateTime(2022, 12, 18)); + formulaRow.Price.Should().Be(36.05); + } + + [Fact] + public async Task Import_MatchesExistingProfilesByName_InsteadOfDuplicatingThem() + { + await _profiles.CreateAsync(new HealthProfileModel { OwnerId = "owner-1", Name = "bertrand" }); // case differs on purpose + + var result = await CreateService().ImportAsync(new MemoryStream(BuildWorkbook()), "owner-1"); + + result.ProfilesCreated.Should().Be(1); // only Vinciane + result.ProfilesSkipped.Should().Be(1); + _profiles.Items.Should().HaveCount(2); + } + + private class InMemoryRepository + where TModel : class, IHasIdAndOwnerId + { + public List Items { get; } = []; + + public Task FindOneAsync(string id, string ownerId) => + Task.FromResult(Items.FirstOrDefault(x => x.Id == id && x.OwnerId == ownerId)); + + public Task CountAsync(string ownerId) => + Task.FromResult((long)Items.Count(x => x.OwnerId == ownerId)); + + public Task> FindAllAsync(string ownerId, int page, int pageSize, string? search, TModel input) + { + var items = Items.Where(x => x.OwnerId == ownerId).ToList(); + return Task.FromResult(new PagedResult(items, items.Count, page, pageSize)); + } + + public Task CreateAsync(TModel model) + { + model.Id ??= Guid.NewGuid().ToString(); + Items.Add(model); + return Task.FromResult(model); + } + + public Task UpdateAsync(string id, TModel model, string ownerId) => Task.FromResult(1L); + + public Task DeleteAsync(string id, string ownerId) => + Task.FromResult((long)Items.RemoveAll(x => x.Id == id && x.OwnerId == ownerId)); + } + + private sealed class FakeHealthProfileRepository : InMemoryRepository, IHealthProfileRepository; + + private sealed class FakeHealthRecordRepository : InMemoryRepository, IHealthRecordRepository + { + public Task DeleteAllForProfileAsync(string healthProfileId, string ownerId) => + Task.FromResult((long)Items.RemoveAll(x => x.HealthProfileId == healthProfileId && x.OwnerId == ownerId)); + } +} From d712c6ea85fa20b35bb232b43034f8cc89668f54 Mon Sep 17 00:00:00 2001 From: Bertrand THOMAS Date: Fri, 17 Jul 2026 17:16:53 +0200 Subject: [PATCH 03/10] Missing updates from last commit --- docs/code-quality-findings.md | 6 ++- .../Inventory/Pages/HealthProfileDetail.razor | 51 ++++++------------- .../Inventory/Pages/HealthRecordRow.razor | 34 +++++-------- src/BlazorApp/wwwroot/app.css | 11 ++++ src/Domain/Models/HealthMetricsModel.cs | 9 ++-- src/Domain/Services/HealthMetricsService.cs | 16 +++--- src/WebApi.Contracts/Dto/HealthMetricsDto.cs | 18 ++----- .../Smoke/HealthSmokeTest.cs | 3 +- .../Resources/HealthProfileResourceTest.cs | 2 +- .../Services/HealthMetricsServiceTest.cs | 13 +++-- 10 files changed, 68 insertions(+), 95 deletions(-) diff --git a/docs/code-quality-findings.md b/docs/code-quality-findings.md index 29a9cd4b..5d5cf71f 100644 --- a/docs/code-quality-findings.md +++ b/docs/code-quality-findings.md @@ -32,7 +32,8 @@ This was the same class of bug as the `Creator`/empty-string gotchas already doc The reverse mapping (`CarHistory -> CarHistoryModel`) read it back with `x.Coordinates != null ? x.Coordinates[0] : null`, which an empty-but-non-null list defeats. `x.Coordinates[0]` threw `IndexOutOfRangeException` on every `POST`/`PUT` of a `CarHistory` entry with no location set. Fixed with `.AllowNull()` on that `ForMember`, same fix shape as the `Creator` case in CLAUDE.md. -The `AllowNull()` opt-out itself no longer exists - the AutoMapper -> Mapperly migration deleted `CarDataStorageMappingProfile` entirely; the same null-vs-empty-list handling now lives, hand-written, in `CarHistoryStorageMapper.BuildLocation`. +The `AllowNull()` opt-out itself no longer exists - the AutoMapper -> Mapperly migration deleted `CarDataStorageMappingProfile` entirely; +the same null-vs-empty-list handling now lives, hand-written, in `CarHistoryStorageMapper.BuildLocation`. File (at the time of the fix): `src/WebApi/MappingProfiles/CarDataStorageMappingProfile.cs`, now `src/Infrastructure.MongoDb/Mappers/CarHistoryStorageMapper.cs` @@ -56,7 +57,8 @@ Files: Found on 2026-07-06 while building the reference-data (TMDB) feature, in the first draft of `TvShowRepository.SetReferenceIdForTitleYearAsync`/`FindDistinctUnresolvedTitleYearsAsync`. The filter checked `Builders.Filter.Eq(f => f.ReferenceId, null)`, expecting it to match every show that had never been linked. -It matched zero documents, because `AddAutoMapper` is configured with `AllowNullDestinationValues = false` (`WebApi/Program.cs`) - mapping a model whose string property is null stores an **empty string** in MongoDB, never an actual BSON null. +It matched zero documents, because `AddAutoMapper` is configured with `AllowNullDestinationValues = false` (`WebApi/Program.cs`) - +mapping a model whose string property is null stores an **empty string** in MongoDB, never an actual BSON null. Every "is this string field unset" filter in the codebase needs to check for null *or* empty string, not just null. Fixed by adding a shared `UnresolvedFilter()` helper (in both `TvShowRepository` and `MovieRepository`) that matches either. This is a real-database-only bug: it doesn't throw, so a unit test against a mocked repository can't catch it. diff --git a/src/BlazorApp/Components/Inventory/Pages/HealthProfileDetail.razor b/src/BlazorApp/Components/Inventory/Pages/HealthProfileDetail.razor index 643a8033..b2722c2d 100644 --- a/src/BlazorApp/Components/Inventory/Pages/HealthProfileDetail.razor +++ b/src/BlazorApp/Components/Inventory/Pages/HealthProfileDetail.razor @@ -33,33 +33,19 @@ else
@* No summary panels before the journal on purpose (owner feedback): the journal is the page's - primary content, and the per-row "to check" badge is the whole warning surface - a record that - doesn't balance is spotted in place, not in a duplicate list above. *@ + primary content, and the per-row warning sign is the whole warning surface. "Last seen" is a bare + specialty + last-date list - no names, no counts, no field labels. *@ @if (_metrics is { LastVisits.Count: > 0 }) {

Last seen

-
- - - - - - - - - - @foreach (var visit in _metrics.LastVisits) - { - - - - - - } - -
PractitionerVisitsLast visit
@visit.Practitioner@visit.VisitCount@visit.LastVisitDate.ToString("yyyy-MM-dd")
-
+ @foreach (var visit in _metrics.LastVisits) + { +
+ @visit.Specialty + @visit.LastVisitDate.ToString("yyyy-MM-dd") +
+ }
} @@ -67,23 +53,16 @@ else

Journal

+ @* Excel-like on purpose: no header row, no per-field labels, aligned columns at every width + (kt-table-grid opts out of the mobile stacked-cards treatment) - date, specialty, name, a warning + sign when money remains unaccounted, and edit/delete icons. Everything else lives in the modal. *@
- - - - - - - - - - - +
DateEventWhoPaidDescription
@if (_records.Count == 0) { - + } @foreach (var entry in _records) { @@ -99,7 +78,7 @@ else

Yearly cost review

-
No entries yet - add the first one above.
No entries yet - add the first one above.
+
diff --git a/src/BlazorApp/Components/Inventory/Pages/HealthRecordRow.razor b/src/BlazorApp/Components/Inventory/Pages/HealthRecordRow.razor index 919e579a..e6e6e362 100644 --- a/src/BlazorApp/Components/Inventory/Pages/HealthRecordRow.razor +++ b/src/BlazorApp/Components/Inventory/Pages/HealthRecordRow.razor @@ -1,30 +1,24 @@ -@* One row of the health journal - only the absolute essentials (owner feedback: this table grows large, - so no per-row reimbursement breakdown; the ⚠ badge is the whole warning surface and the edit modal - carries the full money detail). *@ +@* One journal line, Excel-like (owner's explicit display choice): date, specialty, name, a warning sign + when money remains unaccounted, edit/delete icons - no field labels, no money columns. The edit modal + carries every other field. ⚠ (U+26A0) and ✎ (U+270E) default to text presentation (no FE0F). *@ - - - + + @* wider screens have room for more than the mobile essentials (d-md-table-cell = hidden below 768px) *@ + + + - @@ -33,7 +27,7 @@ [Parameter] public required HealthRecordDto Entry { get; set; } /// Computed by HealthMetricsService (via the parent's metrics), never re-derived here - - /// the balance rule lives in exactly one place. U+26A0 defaults to text presentation (no FE0F). + /// the balance rule lives in exactly one place. [Parameter] public bool IsUnbalanced { get; set; } [Parameter] public EventCallback OnEdit { get; set; } diff --git a/src/BlazorApp/wwwroot/app.css b/src/BlazorApp/wwwroot/app.css index 35d530e5..f083debc 100644 --- a/src/BlazorApp/wwwroot/app.css +++ b/src/BlazorApp/wwwroot/app.css @@ -949,6 +949,17 @@ a.kt-item-row { color: inherit; text-decoration: none; } letter-spacing: 0.05em; } .table td[data-label]:empty { display: none; } + + /* Opt-out of the stacked-cards treatment above: .kt-table-grid stays a real spreadsheet-like grid at + every width (one header row, one line per entry, horizontal scroll via .table-responsive when + narrow). For a long, dense journal the per-row labels read as noise, not help - owner feedback. */ + .table.kt-table-grid thead { display: table-header-group; } + .table.kt-table-grid { display: table; } + .table.kt-table-grid tbody { display: table-row-group; } + .table.kt-table-grid tr { display: table-row; border-bottom: none; padding: 0; } + .table.kt-table-grid td { display: table-cell; width: auto; white-space: nowrap; border-bottom: 1px solid var(--kt-border) !important; padding: 0.4rem 0.6rem !important; } + .table.kt-table-grid td[data-label]::before { content: none; } + .table.kt-table-grid td[data-label]:empty { display: table-cell; } } @media (min-width: 768px) and (max-width: 1024px) { diff --git a/src/Domain/Models/HealthMetricsModel.cs b/src/Domain/Models/HealthMetricsModel.cs index f46e5954..167594da 100644 --- a/src/Domain/Models/HealthMetricsModel.cs +++ b/src/Domain/Models/HealthMetricsModel.cs @@ -31,17 +31,14 @@ public class HealthCostHistoryPointModel } /// -/// "When did I last see this practitioner" - one row per (practitioner, specialty) seen in appointments. +/// "When did I last see this kind of doctor" - one line per specialty seen in appointments, nothing more +/// (no names, no counts - owner's explicit display choice). /// public class HealthLastVisitModel { - public required string Practitioner { get; set; } - - public string? Specialty { get; set; } + public required string Specialty { get; set; } public required DateTime LastVisitDate { get; set; } - - public required int VisitCount { get; set; } } /// diff --git a/src/Domain/Services/HealthMetricsService.cs b/src/Domain/Services/HealthMetricsService.cs index 888fa896..c760aa33 100644 --- a/src/Domain/Services/HealthMetricsService.cs +++ b/src/Domain/Services/HealthMetricsService.cs @@ -59,19 +59,19 @@ private static List ComputeAnnualCostHistory(IEnume .ToList(); /// - /// One row per (practitioner, specialty) across every appointment - most recently seen first, so - /// "when did I last see my dentist" is the top of the list, not a search. + /// One line per specialty across every appointment - most recently seen first, so "when did I last + /// see a dentist" is the top of the list, not a search. Grouped by specialty rather than by + /// practitioner name (owner's call): the question is about the kind of care, and the journal itself + /// carries the names. /// private static List ComputeLastVisits(IEnumerable records) => records - .Where(r => r.EventType == HealthEventType.Appointment && !string.IsNullOrWhiteSpace(r.Practitioner)) - .GroupBy(r => (Practitioner: r.Practitioner!.Trim(), Specialty: r.Specialty?.Trim())) + .Where(r => r.EventType == HealthEventType.Appointment && !string.IsNullOrWhiteSpace(r.Specialty)) + .GroupBy(r => r.Specialty!.Trim()) .Select(g => new HealthLastVisitModel { - Practitioner = g.Key.Practitioner, - Specialty = g.Key.Specialty, - LastVisitDate = g.Max(r => r.HistoryDate), - VisitCount = g.Count() + Specialty = g.Key, + LastVisitDate = g.Max(r => r.HistoryDate) }) .OrderByDescending(v => v.LastVisitDate) .ToList(); diff --git a/src/WebApi.Contracts/Dto/HealthMetricsDto.cs b/src/WebApi.Contracts/Dto/HealthMetricsDto.cs index bce2e06f..a5c71a3f 100644 --- a/src/WebApi.Contracts/Dto/HealthMetricsDto.cs +++ b/src/WebApi.Contracts/Dto/HealthMetricsDto.cs @@ -53,29 +53,19 @@ public class HealthCostHistoryPointDto } /// -/// When a practitioner was last seen. +/// When a specialty was last seen. /// public class HealthLastVisitDto { /// - /// The practitioner's name. + /// The medical specialty. /// - public required string Practitioner { get; set; } + public required string Specialty { get; set; } /// - /// The practitioner's specialty, when recorded. - /// - public string? Specialty { get; set; } - - /// - /// The most recent appointment date. + /// The most recent appointment date for that specialty. /// public required DateTime LastVisitDate { get; set; } - - /// - /// How many appointments are recorded with this practitioner. - /// - public required int VisitCount { get; set; } } /// diff --git a/test/BlazorApp.PlaywrightTests/Smoke/HealthSmokeTest.cs b/test/BlazorApp.PlaywrightTests/Smoke/HealthSmokeTest.cs index 9bce734b..d3ab9ffc 100644 --- a/test/BlazorApp.PlaywrightTests/Smoke/HealthSmokeTest.cs +++ b/test/BlazorApp.PlaywrightTests/Smoke/HealthSmokeTest.cs @@ -41,7 +41,8 @@ public async Task AddProfileAndUnbalancedAppointment_ThenDelete() await Page.GetByTestId("price-input").FillAsync("60"); await Page.Locator(".kt-modal").GetByRole(AriaRole.Button, new LocatorGetByRoleOptions { Name = "Save" }).ClickAsync(); - await Assertions.Expect(Page.GetByText("to check").First).ToBeVisibleAsync(); + // the bare warning sign in the row is the whole warning surface + await Assertions.Expect(Page.GetByText("⚠").First).ToBeVisibleAsync(); await Assertions.Expect(Page.GetByText("Dr E2e").First).ToBeVisibleAsync(); list = await detail.OpenHealthAsync(); diff --git a/test/WebApi.IntegrationTests/Resources/HealthProfileResourceTest.cs b/test/WebApi.IntegrationTests/Resources/HealthProfileResourceTest.cs index 82be7db7..78e8272f 100644 --- a/test/WebApi.IntegrationTests/Resources/HealthProfileResourceTest.cs +++ b/test/WebApi.IntegrationTests/Resources/HealthProfileResourceTest.cs @@ -93,7 +93,7 @@ public async Task HealthProfileMetrics_ComputeCostsLastVisitsAndUnbalanced_FromR year.OutOfPocket.Should().Be(121.5); metrics.LastVisits.Should().HaveCount(2); - metrics.LastVisits[0].Practitioner.Should().Be("Dr Diaz"); + metrics.LastVisits[0].Specialty.Should().Be("dentiste"); var unbalanced = metrics.UnbalancedRecords.Should().ContainSingle().Subject; unbalanced.Label.Should().Be("Dr Diaz"); diff --git a/test/WebApi.UnitTests/Services/HealthMetricsServiceTest.cs b/test/WebApi.UnitTests/Services/HealthMetricsServiceTest.cs index c34a0513..5224102a 100644 --- a/test/WebApi.UnitTests/Services/HealthMetricsServiceTest.cs +++ b/test/WebApi.UnitTests/Services/HealthMetricsServiceTest.cs @@ -84,31 +84,30 @@ public void ComputeMetrics_CostHistory_ExcludesRecordsWithNoMoneyAtAll() } [Fact] - public void ComputeMetrics_LastVisits_GroupsByPractitionerAndSpecialty_MostRecentFirst() + public void ComputeMetrics_LastVisits_GroupsBySpecialty_MostRecentFirst() { var records = new[] { Record("r1", new DateTime(2025, 1, 10, 9, 0, 0), practitioner: "Dr Martin", specialty: "dentiste"), - Record("r2", new DateTime(2026, 2, 20, 11, 0, 0), practitioner: "Dr Martin", specialty: "dentiste"), + Record("r2", new DateTime(2026, 2, 20, 11, 0, 0), practitioner: "Dr Roche", specialty: "dentiste"), Record("r3", new DateTime(2025, 6, 1, 15, 30, 0), practitioner: "Dr Diaz", specialty: "généraliste") }; var result = _service.ComputeMetrics(records); result.LastVisits.Should().HaveCount(2); - result.LastVisits[0].Practitioner.Should().Be("Dr Martin"); + result.LastVisits[0].Specialty.Should().Be("dentiste"); result.LastVisits[0].LastVisitDate.Should().Be(new DateTime(2026, 2, 20, 11, 0, 0)); - result.LastVisits[0].VisitCount.Should().Be(2); - result.LastVisits[1].Practitioner.Should().Be("Dr Diaz"); + result.LastVisits[1].Specialty.Should().Be("généraliste"); } [Fact] - public void ComputeMetrics_LastVisits_IgnoreSicknessEntriesAndAppointmentsWithoutAPractitioner() + public void ComputeMetrics_LastVisits_IgnoreSicknessEntriesAndAppointmentsWithoutASpecialty() { var records = new[] { Record("r1", new DateTime(2025, 1, 10, 9, 0, 0), HealthEventType.Sickness, description: "Migraine"), - Record("r2", new DateTime(2025, 2, 20, 11, 0, 0), specialty: "laboratoire") + Record("r2", new DateTime(2025, 2, 20, 11, 0, 0), practitioner: "Dr Diaz") }; _service.ComputeMetrics(records).LastVisits.Should().BeEmpty(); From 998fdc06bb05abfaad721763a9b4a54e48e90b35 Mon Sep 17 00:00:00 2001 From: Bertrand THOMAS Date: Fri, 17 Jul 2026 20:20:22 +0200 Subject: [PATCH 04/10] List sorting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every list is now deterministically ordered: - Default is newest first (_id descending — ObjectIds embed creation time, so no schema change), which also fixes the latent paging bug where an unsorted skip/limit read could duplicate or drop items across pages. Every other sort appends _id as tie-break. - A sort picker in InventoryList's search bar on all nine list pages: Recently added / A–Z everywhere, plus Rating on the five media types. It's a ?sort= URL parameter through the existing ApplyQueryChanges path, so browser back/forward and bookmarks preserve it like search and filters. - The key travels as PagedRequest.Sort (constants in Common.System/ListSort.cs); repositories opt in by overriding SortTitleField/SortRatingField — expressions, not field-name strings, which matters because Car.Name stores as commercial_name and a string sort would silently miss it. - On the "optimized" front: the title sort is case/diacritic-insensitive via a per-query MongoDB collation (no shadow field, no extra index), rating descending puts unrated items last server-side, and it's still exactly one count + one find per page load. I deliberately added no new indexes — every query filters by owner first, and a single tenant's collection is small enough that sorting that subset in memory is negligible; ten speculative compound indexes would cost more on writes than they'd ever save on reads. One constraint documented in CLAUDE.md: collation can't combine with $text, which is safe because every repository searches via regex Contains. Owned-versions editor — now follows the app's own two conventions instead of inventing a third: - "+ Add version" opens a draft card with Save/Cancel (the list pages' Add-form pattern). Nothing persists — and the item doesn't become Owned — until Save; Cancel discards silently. - The ambiguous ✕ is replaced by the same bin icon as list rows (extracted into a shared Components/Shared/TrashIcon.razor) with a ConfirmModal ("Remove this copy?"). The confirm is skipped when the copy is entirely empty, so undoing an accidental add stays one click. - Saved copies keep auto-saving field edits, consistent with the rest of the detail page. Verification - Full suite green: 265 passed, 0 failed (30 skipped are the usual gated e2e/live-sync tests). - New ListSortingRepositoryTest (real MongoDB) proves the collation's case-insensitive ordering, nulls-last rating sort, newest-first default, unknown-key fallback, and that title-sorted pages partition exactly with no duplicates/drops. - OwnershipSmokeTest (Playwright, updated for the draft + confirm flow) and ListStateSmokeTest both pass against the real browser; the mobile screenshot harness confirms the visuals. No data migration or index script change is needed — this deploys as-is. CLAUDE.md documents both patterns (including how a future list adds a sort key). --- CLAUDE.md | 19 ++++ Directory.Build.props | 2 +- scripts/migrate-is-owned-to-owned-versions.js | 20 ++-- .../Clients/InventoryApiClientBase.cs | 6 +- .../Components/Inventory/InventoryPageBase.cs | 18 ++- .../Components/Inventory/Pages/Albums.razor | 5 +- .../Components/Inventory/Pages/Books.razor | 5 +- .../Components/Inventory/Pages/Cars.razor | 4 +- .../Inventory/Pages/HealthProfileDetail.razor | 2 +- .../Inventory/Pages/HealthProfiles.razor | 4 +- .../Components/Inventory/Pages/Houses.razor | 4 +- .../Components/Inventory/Pages/Movies.razor | 5 +- .../Inventory/Pages/Playlists.razor | 4 +- .../Components/Inventory/Pages/TvShows.razor | 5 +- .../Inventory/Pages/VideoGames.razor | 5 +- .../Inventory/Shared/InventoryList.razor | 28 +++-- .../Shared/OwnedVersionsEditor.razor | 106 +++++++++++++++-- .../Components/Shared/TrashIcon.razor | 16 +++ src/BlazorApp/appsettings.json | 5 + src/BlazorApp/wwwroot/app.css | 5 + src/Common.System/ListSort.cs | 16 +++ src/Common.System/PagedRequest.cs | 6 + src/Domain/Repositories/IDataRepository.cs | 7 +- .../Repositories/AlbumRepository.cs | 6 + .../Repositories/BookRepository.cs | 6 + .../Repositories/CarRepository.cs | 6 +- .../Repositories/HealthProfileRepository.cs | 4 + .../Repositories/HouseRepository.cs | 4 + .../Repositories/MongoDbRepositoryBase.cs | 48 +++++++- .../Repositories/MovieRepository.cs | 6 + .../Repositories/PlaylistRepository.cs | 4 + .../Repositories/TvShowRepository.cs | 8 +- .../Repositories/VideoGameRepository.cs | 6 + .../Controllers/DataCrudControllerBase.cs | 3 +- .../Smoke/OwnershipSmokeTest.cs | 13 ++- .../Resources/ListSortingRepositoryTest.cs | 107 ++++++++++++++++++ .../Controllers/FreeTierTest.cs | 2 +- .../Import/HealthImportServiceTest.cs | 2 +- .../TvTimeImportServiceIdempotencyTest.cs | 2 +- 39 files changed, 462 insertions(+), 62 deletions(-) create mode 100644 src/BlazorApp/Components/Shared/TrashIcon.razor create mode 100644 src/Common.System/ListSort.cs create mode 100644 test/WebApi.IntegrationTests/Resources/ListSortingRepositoryTest.cs diff --git a/CLAUDE.md b/CLAUDE.md index e330c6ba..18683a79 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -145,6 +145,11 @@ and the `*_owned` partial indexes match that rendered `{ "owned_versions.0": { $ The storage mappers ignore `IsOwned` in both directions - don't map it to an entity field again. `BlazorApp`'s detail pages share one `OwnedVersionsEditor` component (`Components/Inventory/Shared/`) instead of the old header toggle; list rows derive their "Owned" badge from `OwnedVersions.Count > 0` (video games show their platform badges instead - an extra Owned badge would be redundant). +The editor follows the app's two existing conventions rather than inventing a third: a NEW copy is a draft card with explicit Save/Cancel buttons +(the list pages' Add-form pattern - nothing persists, and the item does not become owned, until Save; "+ Add version" used to persist an empty owned copy instantly, which felt accidental), +while an already-saved copy's fields auto-save on change like every other detail-page field. +Removing a saved copy uses the shared bin icon (`Components/Shared/TrashIcon.razor`, extracted from `InventoryList`'s inline SVG rather than duplicated) plus a `ConfirmModal`, +skipped when the copy is entirely empty (no price/date/vendor/reference) so undoing an accidental add stays one click. Existing data needs the one-off `scripts/migrate-is-owned-to-owned-versions.js` (seeds one default Physical version per `is_owned: true` document, then unsets the flag; games with no platform entry are printed for manual re-entry), then a `scripts/mongodb-create-index.js` re-run to replace the old `is_owned` index definitions. Covered by the resource tests' owned-filter cases (including a full decimal/date round-trip in `MovieResourceTest`/`AlbumResourceTest`) and `OwnershipSmokeTest` (Playwright, end-to-end through the editor). @@ -723,6 +728,20 @@ This is what makes browser back from an item's detail page restore the exact lis and it means a button click and browser back/forward share one code path instead of two. A new list filter therefore needs three things: a `[SupplyParameterFromQuery]` property, an `ExtraQuery` entry (the API-facing key, e.g. `IsFavorite`), and a razor button calling `ToggleFilter`/`SetFilter` with the URL parameter name (e.g. `favorite`) - don't add a mutate-a-field-then-`LoadAsync` handler, that's the pre-URL-state pattern this replaced. + +List ordering is deterministic everywhere: `MongoDbRepositoryBase.FindAllAsync` sorts every page read, defaulting to newest first via `_id` descending +(ObjectIds embed their creation timestamp, so no separate created-at field exists or is needed), with `_id` also appended as the tie-break under every other key - +an unsorted skip/limit page could duplicate or drop items across pages, so "no sort" was a paging-correctness bug, not just arbitrary-feeling UX. +`PagedRequest.Sort` carries an optional `ListSort` key (`Common.System/ListSort.cs`: `title`, `rating`) end-to-end: +`InventoryList`'s sort picker navigates a `?sort=` query parameter through the same URL-state path as search/filters ("" = the newest-first default, kept out of the URL), +`DataCrudControllerBase.Get` passes it through, and a repository opts into a key by overriding `SortTitleField`/`SortRatingField` +(an expression, never an element-name string, so the BSON mapping stays with the entity class - `Car.Name` stores as `commercial_name`, which a string-based sort would silently miss). +An unknown or unsupported key falls back to newest-first rather than erroring; `HasRatingSort` on `InventoryList` shows the Rating option only for the five media types that have a rating field. +The title sort attaches a per-query `Collation` ("en", strength 2) for case/diacritic-insensitive ordering with no normalized shadow field and no index changes - +per-owner subsets are small enough that MongoDB's in-memory sort of an owner-filtered read is negligible, so no sort indexes were added. +**Gotcha:** MongoDB rejects a collation combined with a `$text` filter; this is safe today only because every repository's `GetFilter` searches via regex `Contains` +(the base class's `builder.Text` default is effectively dead) - a future `$text`-searching repository must not also offer the title sort without gating the collation. +Covered by `ListSortingRepositoryTest` (integration, real MongoDB - the collation's ordering and descending-sort null placement are server-side semantics a mocked repository can never prove). `InventoryList`'s search box keeps a deliberate local copy of the text (so a parent re-render racing fast typing can't revert characters) and adopts an externally-changed `Search` parameter only when it didn't originate from its own `OnSearchChanged` report - see the sent/received tracking in its `OnParametersSet` before touching that logic. Covered end-to-end by `ListStateSmokeTest` (Playwright), including the back-navigation-from-detail scenario. diff --git a/Directory.Build.props b/Directory.Build.props index c973a917..ba3f273c 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -5,7 +5,7 @@ - 2.1.0 + 2.2.0 diff --git a/scripts/migrate-is-owned-to-owned-versions.js b/scripts/migrate-is-owned-to-owned-versions.js index 3441c7e5..135ed76c 100644 --- a/scripts/migrate-is-owned-to-owned-versions.js +++ b/scripts/migrate-is-owned-to-owned-versions.js @@ -1,18 +1,14 @@ -// One-off data migration: the stored `is_owned` flag was removed - ownership is now derived from -// having at least one owned copy (`owned_versions` on movie/tvshow/book/album, `platforms` on -// videogame). Existing documents flagged `is_owned: true` get one default Physical owned version -// (price/vendor/reference unknown, left unset), so nothing the user marked as owned loses that fact; -// then the obsolete flag is removed from every document. +// One-off data migration: the stored `is_owned` flag was removed - +// ownership is now derived from having at least one owned copy (`owned_versions` on movie/tvshow/book/album, `platforms` on videogame). +// Existing documents flagged `is_owned: true` get one default Physical owned version (price/vendor/reference unknown, left unset), +// so nothing the user marked as owned loses that fact; then the obsolete flag is removed from every document. // -// Video games get no synthesized version: their copies are their platform entries, and a platform -// can't be invented. A game flagged owned but with no platform entry is reported below so the owner -// can re-add the platform by hand - the flag alone carried no platform information anyway. +// Video games get no synthesized version: their copies are their platform entries, and a platform can't be invented. +// A game flagged owned but with no platform entry is reported below so the owner can re-add the platform by hand - the flag alone carried no platform information anyway. // -// Idempotent: the $set only touches documents that still have `is_owned: true` and no owned_versions -// yet, and the $unset only matches documents still carrying the field. +// Idempotent: the $set only touches documents that still have `is_owned: true` and no owned_versions yet, and the $unset only matches documents still carrying the field. // -// Run once per environment with data older than this change (then re-run mongodb-create-index.js to -// replace the old is_owned partial indexes), e.g.: +// Run once per environment with data older than this change (then re-run mongodb-create-index.js to replace the old is_owned partial indexes), e.g.: // mongosh "mongodb://localhost:27017/keeptrack_dev" scripts/migrate-is-owned-to-owned-versions.js function migrate(collection) { const seeded = collection.updateMany( diff --git a/src/BlazorApp/Components/Inventory/Clients/InventoryApiClientBase.cs b/src/BlazorApp/Components/Inventory/Clients/InventoryApiClientBase.cs index 9206f367..b0f30496 100644 --- a/src/BlazorApp/Components/Inventory/Clients/InventoryApiClientBase.cs +++ b/src/BlazorApp/Components/Inventory/Clients/InventoryApiClientBase.cs @@ -13,9 +13,13 @@ public abstract class InventoryApiClientBase(HttpClient http) /// protected HttpClient Http => http; - public async Task> GetAsync(string search, int page, int pageSize, IReadOnlyDictionary? extraQuery = null) + public async Task> GetAsync(string search, int page, int pageSize, IReadOnlyDictionary? extraQuery = null, string? sort = null) { var query = $"{ApiResourceName}?search={Uri.EscapeDataString(search)}&page={page}&pageSize={pageSize}"; + if (!string.IsNullOrEmpty(sort)) + { + query += $"&sort={Uri.EscapeDataString(sort)}"; + } if (extraQuery is not null) { foreach (var (key, value) in extraQuery) diff --git a/src/BlazorApp/Components/Inventory/InventoryPageBase.cs b/src/BlazorApp/Components/Inventory/InventoryPageBase.cs index 0092e1ed..19eb7ab1 100644 --- a/src/BlazorApp/Components/Inventory/InventoryPageBase.cs +++ b/src/BlazorApp/Components/Inventory/InventoryPageBase.cs @@ -21,6 +21,8 @@ public abstract class InventoryPageBase : ComponentBase protected string _search = ""; + protected string _sort = ""; + protected int _page = 1; protected long _totalCount; @@ -42,6 +44,9 @@ public abstract class InventoryPageBase : ComponentBase [SupplyParameterFromQuery(Name = "page")] public int? PageQuery { get; set; } + [SupplyParameterFromQuery(Name = "sort")] + public string? SortQuery { get; set; } + protected abstract InventoryApiClientBase Api { get; } /// @@ -66,6 +71,7 @@ public abstract class InventoryPageBase : ComponentBase protected override async Task OnParametersSetAsync() { _search = SearchQuery ?? ""; + _sort = SortQuery ?? ""; _page = PageQuery is > 0 ? PageQuery.Value : 1; var query = BuildQuerySignature(); if (query != _loadedQuery) @@ -106,6 +112,14 @@ protected void ToggleFilter(string name, bool current) => protected void SetFilter(string name, string? value) => ApplyQueryChanges(new Dictionary { [name] = value, ["page"] = null }); + /// + /// Applies a sort key from the list's sort picker ("" = the newest-first default, which keeps the + /// URL clean of a redundant parameter) and resets to page 1, through the same URL-navigation path + /// as every other list-state change. + /// + protected void SetSort(string value) => + ApplyQueryChanges(new Dictionary { ["sort"] = string.IsNullOrEmpty(value) ? null : value, ["page"] = null }); + /// /// Navigates to the current list URL with the given query-parameter changes applied (a null value /// removes the parameter). The actual reload happens in once the @@ -158,7 +172,7 @@ protected async Task LoadAsync() try { _loading = true; - var result = await Api.GetAsync(_search, _page, PageSize, ExtraQuery); + var result = await Api.GetAsync(_search, _page, PageSize, ExtraQuery, _sort); _items = result.Items; _totalCount = result.TotalCount; } @@ -175,6 +189,6 @@ protected async Task LoadAsync() private string BuildQuerySignature() { var extra = ExtraQuery is null ? "" : string.Join('&', ExtraQuery.Select(pair => $"{pair.Key}={pair.Value}")); - return $"{_search}|{_page}|{extra}"; + return $"{_search}|{_page}|{_sort}|{extra}"; } } diff --git a/src/BlazorApp/Components/Inventory/Pages/Albums.razor b/src/BlazorApp/Components/Inventory/Pages/Albums.razor index 5e0f6c10..999b0944 100644 --- a/src/BlazorApp/Components/Inventory/Pages/Albums.razor +++ b/src/BlazorApp/Components/Inventory/Pages/Albums.razor @@ -26,7 +26,10 @@ OnCancelForm="@CancelForm" OnSave="@SaveAsync" OnDelete="@DeleteAsync" - OnSearchChanged="@OnSearchChanged"> + OnSearchChanged="@OnSearchChanged" + Sort="@_sort" + OnSortChanged="@SetSort" + HasRatingSort="true"> diff --git a/src/BlazorApp/Components/Inventory/Pages/Books.razor b/src/BlazorApp/Components/Inventory/Pages/Books.razor index 7c3a369d..e6baa4b4 100644 --- a/src/BlazorApp/Components/Inventory/Pages/Books.razor +++ b/src/BlazorApp/Components/Inventory/Pages/Books.razor @@ -25,7 +25,10 @@ OnCancelForm="@CancelForm" OnSave="@SaveAsync" OnDelete="@DeleteAsync" - OnSearchChanged="@OnSearchChanged"> + OnSearchChanged="@OnSearchChanged" + Sort="@_sort" + OnSortChanged="@SetSort" + HasRatingSort="true"> diff --git a/src/BlazorApp/Components/Inventory/Pages/Cars.razor b/src/BlazorApp/Components/Inventory/Pages/Cars.razor index 15f18ec6..63a118ad 100644 --- a/src/BlazorApp/Components/Inventory/Pages/Cars.razor +++ b/src/BlazorApp/Components/Inventory/Pages/Cars.razor @@ -24,7 +24,9 @@ OnCancelForm="@CancelForm" OnSave="@SaveAsync" OnDelete="@DeleteAsync" - OnSearchChanged="@OnSearchChanged"> + OnSearchChanged="@OnSearchChanged" + Sort="@_sort" + OnSortChanged="@SetSort"> @if (!string.IsNullOrEmpty(car.Manufacturer) || !string.IsNullOrEmpty(car.Model)) { diff --git a/src/BlazorApp/Components/Inventory/Pages/HealthProfileDetail.razor b/src/BlazorApp/Components/Inventory/Pages/HealthProfileDetail.razor index b2722c2d..7184a4a1 100644 --- a/src/BlazorApp/Components/Inventory/Pages/HealthProfileDetail.razor +++ b/src/BlazorApp/Components/Inventory/Pages/HealthProfileDetail.razor @@ -154,7 +154,7 @@ else title="What the public health system (assurance maladie / ameli) officially reimburses for this care."/>
- +
diff --git a/src/BlazorApp/Components/Inventory/Pages/HealthProfiles.razor b/src/BlazorApp/Components/Inventory/Pages/HealthProfiles.razor index f1ebfeda..ac4b2902 100644 --- a/src/BlazorApp/Components/Inventory/Pages/HealthProfiles.razor +++ b/src/BlazorApp/Components/Inventory/Pages/HealthProfiles.razor @@ -24,7 +24,9 @@ OnCancelForm="@CancelForm" OnSave="@SaveAsync" OnDelete="@DeleteAsync" - OnSearchChanged="@OnSearchChanged"> + OnSearchChanged="@OnSearchChanged" + Sort="@_sort" + OnSortChanged="@SetSort"> @if (!string.IsNullOrEmpty(profile.Notes)) { diff --git a/src/BlazorApp/Components/Inventory/Pages/Houses.razor b/src/BlazorApp/Components/Inventory/Pages/Houses.razor index cbf1329b..d4c31f1a 100644 --- a/src/BlazorApp/Components/Inventory/Pages/Houses.razor +++ b/src/BlazorApp/Components/Inventory/Pages/Houses.razor @@ -24,7 +24,9 @@ OnCancelForm="@CancelForm" OnSave="@SaveAsync" OnDelete="@DeleteAsync" - OnSearchChanged="@OnSearchChanged"> + OnSearchChanged="@OnSearchChanged" + Sort="@_sort" + OnSortChanged="@SetSort"> @if (!string.IsNullOrEmpty(house.Address)) { diff --git a/src/BlazorApp/Components/Inventory/Pages/Movies.razor b/src/BlazorApp/Components/Inventory/Pages/Movies.razor index 1939fea3..46cbe8a2 100644 --- a/src/BlazorApp/Components/Inventory/Pages/Movies.razor +++ b/src/BlazorApp/Components/Inventory/Pages/Movies.razor @@ -25,7 +25,10 @@ OnCancelForm="@CancelForm" OnSave="@SaveAsync" OnDelete="@DeleteAsync" - OnSearchChanged="@OnSearchChanged"> + OnSearchChanged="@OnSearchChanged" + Sort="@_sort" + OnSortChanged="@SetSort" + HasRatingSort="true"> diff --git a/src/BlazorApp/Components/Inventory/Pages/Playlists.razor b/src/BlazorApp/Components/Inventory/Pages/Playlists.razor index 9bfca186..0c46be5d 100644 --- a/src/BlazorApp/Components/Inventory/Pages/Playlists.razor +++ b/src/BlazorApp/Components/Inventory/Pages/Playlists.razor @@ -24,7 +24,9 @@ OnCancelForm="@CancelForm" OnSave="@SaveAsync" OnDelete="@DeleteAsync" - OnSearchChanged="@OnSearchChanged"> + OnSearchChanged="@OnSearchChanged" + Sort="@_sort" + OnSortChanged="@SetSort"> @playlist.SongIds.Count song@(playlist.SongIds.Count == 1 ? "" : "s") diff --git a/src/BlazorApp/Components/Inventory/Pages/TvShows.razor b/src/BlazorApp/Components/Inventory/Pages/TvShows.razor index 9eaeeeac..081da056 100644 --- a/src/BlazorApp/Components/Inventory/Pages/TvShows.razor +++ b/src/BlazorApp/Components/Inventory/Pages/TvShows.razor @@ -25,7 +25,10 @@ OnCancelForm="@CancelForm" OnSave="@SaveAsync" OnDelete="@DeleteAsync" - OnSearchChanged="@OnSearchChanged"> + OnSearchChanged="@OnSearchChanged" + Sort="@_sort" + OnSortChanged="@SetSort" + HasRatingSort="true"> diff --git a/src/BlazorApp/Components/Inventory/Pages/VideoGames.razor b/src/BlazorApp/Components/Inventory/Pages/VideoGames.razor index 8db56aef..797ee8e3 100644 --- a/src/BlazorApp/Components/Inventory/Pages/VideoGames.razor +++ b/src/BlazorApp/Components/Inventory/Pages/VideoGames.razor @@ -26,7 +26,10 @@ OnCancelForm="@CancelForm" OnSave="@SaveAsync" OnDelete="@DeleteAsync" - OnSearchChanged="@OnSearchChanged"> + OnSearchChanged="@OnSearchChanged" + Sort="@_sort" + OnSortChanged="@SetSort" + HasRatingSort="true"> @foreach (var state in VideoGameStates) diff --git a/src/BlazorApp/Components/Inventory/Shared/InventoryList.razor b/src/BlazorApp/Components/Inventory/Shared/InventoryList.razor index 80df3981..b9413e11 100644 --- a/src/BlazorApp/Components/Inventory/Shared/InventoryList.razor +++ b/src/BlazorApp/Components/Inventory/Shared/InventoryList.razor @@ -52,6 +52,15 @@ else { } + @if (Filters is not null) {
@Filters
@@ -73,19 +82,9 @@ else @ItemTitle(item)
@MetaTemplate(item)
- @* a monochrome inline SVG bin: there is no bin codepoint with a reliable text - presentation (U+1F5D1 renders as a color emoji on several platforms - see the - theme section's emoji gotcha in CLAUDE.md) *@ } @@ -203,6 +202,12 @@ else [Parameter] public required bool ShowForm { get; set; } [Parameter] public required string? Error { get; set; } [Parameter] public required string Search { get; set; } + + /// Current sort key ("" = the newest-first default) - see . + [Parameter] public required string Sort { get; set; } + + /// Offers the "Rating" sort option - only for types that carry a rating field. + [Parameter] public bool HasRatingSort { get; set; } [Parameter] public required int Page { get; set; } [Parameter] public required int TotalPages { get; set; } [Parameter] public required long TotalCount { get; set; } @@ -237,4 +242,5 @@ else [Parameter] public required EventCallback OnGoToPage { get; set; } [Parameter] public required EventCallback OnDelete { get; set; } [Parameter] public required EventCallback OnSearchChanged { get; set; } + [Parameter] public required EventCallback OnSortChanged { get; set; } } diff --git a/src/BlazorApp/Components/Inventory/Shared/OwnedVersionsEditor.razor b/src/BlazorApp/Components/Inventory/Shared/OwnedVersionsEditor.razor index 68cdb27d..14e9cec9 100644 --- a/src/BlazorApp/Components/Inventory/Shared/OwnedVersionsEditor.razor +++ b/src/BlazorApp/Components/Inventory/Shared/OwnedVersionsEditor.razor @@ -2,24 +2,42 @@ @* Shared ownership section for movie/TV show/book/album detail pages: one card per owned copy. An item is owned exactly when this list is non-empty - there is no separate owned flag. Video games - don't use this component; their per-platform entries already are their copies. *@ + don't use this component; their per-platform entries already are their copies. + + Creating vs. editing follow the app's two conventions: a NEW copy is a draft card with explicit + Save/Cancel (the list pages' Add-form pattern - nothing persists, and the item doesn't become owned, + until Save), while an already-saved copy's fields auto-save on change like every other detail-page + field. Removing a saved copy asks for confirmation (it discards price/date/vendor data), except when + the copy is entirely empty - then there is nothing to lose and the removal stays one click. *@

Ownership

-@if (Versions.Count == 0) + + +@if (Versions.Count == 0 && _draft is null) {

No owned copy recorded - add one below if you own this.

} -@foreach (var version in Versions) +@foreach (var version in DisplayedVersions) { + var isDraft = ReferenceEquals(version, _draft);
- + @if (!isDraft) + { + + }
@@ -44,10 +62,20 @@ value="@version.Reference" @onchange="e => SetReferenceAsync(version, e)"/>
+ @if (isDraft) + { +
+ + +
+ }
} - +@if (_draft is null) +{ + +} @code { /// The item's owned-versions list, mutated in place - the parent owns persistence. @@ -56,46 +84,100 @@ /// Raised after every mutation; the parent saves the whole DTO (the detail pages' usual shape). [Parameter] public required EventCallback OnChanged { get; set; } - private async Task AddAsync() + private OwnedVersionDto? _draft; + + private OwnedVersionDto? _pendingRemove; + + /// The saved copies plus the in-progress draft (rendered as one more card, never persisted). + private IEnumerable DisplayedVersions => _draft is null ? Versions : Versions.Append(_draft); + + private void StartDraft() => _draft = new OwnedVersionDto(); + + private void CancelDraft() => _draft = null; + + private async Task SaveDraftAsync() { - Versions.Add(new OwnedVersionDto()); + if (_draft is null) + { + return; + } + + Versions.Add(_draft); + _draft = null; await OnChanged.InvokeAsync(); } + private async Task RequestRemoveAsync(OwnedVersionDto version) + { + // an entirely empty copy has no details to lose - remove it without the confirmation detour + if (IsEmpty(version)) + { + await RemoveAsync(version); + return; + } + + _pendingRemove = version; + } + + private async Task ConfirmRemoveAsync() + { + var version = _pendingRemove; + _pendingRemove = null; + if (version is not null) + { + await RemoveAsync(version); + } + } + private async Task RemoveAsync(OwnedVersionDto version) { Versions.Remove(version); await OnChanged.InvokeAsync(); } + private static bool IsEmpty(OwnedVersionDto version) => + version.Price is null + && version.AcquiredAt is null + && string.IsNullOrWhiteSpace(version.Vendor) + && string.IsNullOrWhiteSpace(version.Reference); + + /// Saves after a mutation on a saved copy; a draft's edits stay local until its Save button. + private async Task NotifyChangedAsync(OwnedVersionDto version) + { + if (!ReferenceEquals(version, _draft)) + { + await OnChanged.InvokeAsync(); + } + } + private async Task SetCopyTypeAsync(OwnedVersionDto version, CopyType copyType) { version.CopyType = copyType; - await OnChanged.InvokeAsync(); + await NotifyChangedAsync(version); } // number inputs report invariant-formatted values regardless of UI culture, so parse them the same way private async Task SetPriceAsync(OwnedVersionDto version, ChangeEventArgs e) { version.Price = decimal.TryParse(e.Value?.ToString(), NumberStyles.Number, CultureInfo.InvariantCulture, out var price) ? price : null; - await OnChanged.InvokeAsync(); + await NotifyChangedAsync(version); } private async Task SetVendorAsync(OwnedVersionDto version, ChangeEventArgs e) { version.Vendor = e.Value?.ToString(); - await OnChanged.InvokeAsync(); + await NotifyChangedAsync(version); } private async Task SetAcquiredAtAsync(OwnedVersionDto version, ChangeEventArgs e) { version.AcquiredAt = DateOnly.TryParse(e.Value?.ToString(), out var date) ? date : null; - await OnChanged.InvokeAsync(); + await NotifyChangedAsync(version); } private async Task SetReferenceAsync(OwnedVersionDto version, ChangeEventArgs e) { version.Reference = e.Value?.ToString(); - await OnChanged.InvokeAsync(); + await NotifyChangedAsync(version); } } diff --git a/src/BlazorApp/Components/Shared/TrashIcon.razor b/src/BlazorApp/Components/Shared/TrashIcon.razor new file mode 100644 index 00000000..81d285a3 --- /dev/null +++ b/src/BlazorApp/Components/Shared/TrashIcon.razor @@ -0,0 +1,16 @@ +@* A monochrome inline SVG bin, shared by every delete affordance: there is no bin codepoint with a + reliable text presentation (U+1F5D1 renders as a color emoji on several platforms - see the theme + section's emoji gotcha in CLAUDE.md). Wrap it in the action's own button; this is just the glyph. *@ + + + +@code { + [Parameter] public int Size { get; set; } = 15; +} diff --git a/src/BlazorApp/appsettings.json b/src/BlazorApp/appsettings.json index 6de501d1..975e1f73 100644 --- a/src/BlazorApp/appsettings.json +++ b/src/BlazorApp/appsettings.json @@ -11,6 +11,11 @@ }, "ServiceAccount": "" }, + "DataProtection": { + "MongoDb": { + "ConnectionString": "" + } + }, "Logging": { "LogLevel": { "Default": "Information", diff --git a/src/BlazorApp/wwwroot/app.css b/src/BlazorApp/wwwroot/app.css index f083debc..0d508f03 100644 --- a/src/BlazorApp/wwwroot/app.css +++ b/src/BlazorApp/wwwroot/app.css @@ -999,6 +999,11 @@ a.kt-item-row { color: inherit; text-decoration: none; } /* pushed to the far right of the search bar row via margin-left:auto - the search input/Clear button stay left-aligned, filter toggles (status, favorites, ...) sit on the opposite end of the same line instead of a separate row above the table */ +.kt-sort-select { + width: 80px; + flex: 0 0 auto; +} + .kt-search-filters { display: flex; align-items: center; diff --git a/src/Common.System/ListSort.cs b/src/Common.System/ListSort.cs new file mode 100644 index 00000000..2013a558 --- /dev/null +++ b/src/Common.System/ListSort.cs @@ -0,0 +1,16 @@ +namespace Keeptrack.Common.System; + +/// +/// List sort keys shared end-to-end: the Blazor list pages' sort picker values, the REST query contract +/// () and the repositories' sort translation. No key (null/empty) means the +/// default order, newest first; a collection that doesn't support a given key falls back to that default +/// rather than erroring. +/// +public static class ListSort +{ + /// Alphabetical by the item's title/name, case- and diacritic-insensitive. + public const string Title = "title"; + + /// Best rated first; unrated items last. + public const string Rating = "rating"; +} diff --git a/src/Common.System/PagedRequest.cs b/src/Common.System/PagedRequest.cs index 50a9e57a..a8fccc08 100644 --- a/src/Common.System/PagedRequest.cs +++ b/src/Common.System/PagedRequest.cs @@ -23,6 +23,12 @@ public class PagedRequest ///
public int PageSize { get; set; } = 20; + /// + /// Sort key (see : "title", "rating"). Null or empty means the default order, + /// newest first. An unsupported key for the requested collection also falls back to the default. + /// + public string? Sort { get; set; } + /// /// Elements to skip. /// diff --git a/src/Domain/Repositories/IDataRepository.cs b/src/Domain/Repositories/IDataRepository.cs index 2766b10a..de6e0cc5 100644 --- a/src/Domain/Repositories/IDataRepository.cs +++ b/src/Domain/Repositories/IDataRepository.cs @@ -8,7 +8,12 @@ public interface IDataRepository { Task FindOneAsync(string id, string ownerId); - Task> FindAllAsync(string ownerId, int page, int pageSize, string? search, TModel input); + /// + /// Owner-scoped paged read. is a key; null/empty (or a + /// key the collection doesn't support) means the default order, newest first. Every page read is + /// deterministically ordered - an unsorted skip/limit page could duplicate or drop items across pages. + /// + Task> FindAllAsync(string ownerId, int page, int pageSize, string? search, TModel input, string? sort = null); /// How many items this owner has in total - backs the Home page's collection overview. Task CountAsync(string ownerId); diff --git a/src/Infrastructure.MongoDb/Repositories/AlbumRepository.cs b/src/Infrastructure.MongoDb/Repositories/AlbumRepository.cs index c46dfd75..8d3ef373 100644 --- a/src/Infrastructure.MongoDb/Repositories/AlbumRepository.cs +++ b/src/Infrastructure.MongoDb/Repositories/AlbumRepository.cs @@ -1,5 +1,7 @@ +using System; using System.Collections.Generic; using System.Linq; +using System.Linq.Expressions; using System.Text.RegularExpressions; using System.Threading.Tasks; using Keeptrack.Domain.Models; @@ -17,6 +19,10 @@ public class AlbumRepository(IMongoDatabase mongoDatabase, ILogger "album"; + protected override Expression> SortTitleField => x => x.Title; + + protected override Expression> SortRatingField => x => x.Rating!; + protected override FilterDefinition GetFilter(string ownerId, string? search, AlbumModel input) { var builder = Builders.Filter; diff --git a/src/Infrastructure.MongoDb/Repositories/BookRepository.cs b/src/Infrastructure.MongoDb/Repositories/BookRepository.cs index 154b263c..41cfae80 100644 --- a/src/Infrastructure.MongoDb/Repositories/BookRepository.cs +++ b/src/Infrastructure.MongoDb/Repositories/BookRepository.cs @@ -1,5 +1,7 @@ +using System; using System.Collections.Generic; using System.Linq; +using System.Linq.Expressions; using System.Text.RegularExpressions; using System.Threading.Tasks; using Keeptrack.Domain.Models; @@ -17,6 +19,10 @@ public class BookRepository(IMongoDatabase mongoDatabase, ILogger "book"; + protected override Expression> SortTitleField => x => x.Title; + + protected override Expression> SortRatingField => x => x.Rating!; + protected override FilterDefinition GetFilter(string ownerId, string? search, BookModel input) { var builder = Builders.Filter; diff --git a/src/Infrastructure.MongoDb/Repositories/CarRepository.cs b/src/Infrastructure.MongoDb/Repositories/CarRepository.cs index 037431ba..28d72385 100644 --- a/src/Infrastructure.MongoDb/Repositories/CarRepository.cs +++ b/src/Infrastructure.MongoDb/Repositories/CarRepository.cs @@ -1,4 +1,6 @@ -using Keeptrack.Domain.Models; +using System; +using System.Linq.Expressions; +using Keeptrack.Domain.Models; using Keeptrack.Domain.Repositories; using Keeptrack.Infrastructure.MongoDb.Entities; using Keeptrack.Infrastructure.MongoDb.Mappers; @@ -12,6 +14,8 @@ public class CarRepository(IMongoDatabase mongoDatabase, ILogger { protected override string CollectionName => "car"; + protected override Expression> SortTitleField => x => x.Name; + protected override FilterDefinition GetFilter(string ownerId, string? search, CarModel input) { var builder = Builders.Filter; diff --git a/src/Infrastructure.MongoDb/Repositories/HealthProfileRepository.cs b/src/Infrastructure.MongoDb/Repositories/HealthProfileRepository.cs index ffae544c..bfa0f1c1 100644 --- a/src/Infrastructure.MongoDb/Repositories/HealthProfileRepository.cs +++ b/src/Infrastructure.MongoDb/Repositories/HealthProfileRepository.cs @@ -1,3 +1,5 @@ +using System; +using System.Linq.Expressions; using Keeptrack.Domain.Models; using Keeptrack.Domain.Repositories; using Keeptrack.Infrastructure.MongoDb.Entities; @@ -12,6 +14,8 @@ public class HealthProfileRepository(IMongoDatabase mongoDatabase, ILogger "health_profile"; + protected override Expression> SortTitleField => x => x.Name; + protected override FilterDefinition GetFilter(string ownerId, string? search, HealthProfileModel input) { var builder = Builders.Filter; diff --git a/src/Infrastructure.MongoDb/Repositories/HouseRepository.cs b/src/Infrastructure.MongoDb/Repositories/HouseRepository.cs index 0e3e614b..fa3fbff0 100644 --- a/src/Infrastructure.MongoDb/Repositories/HouseRepository.cs +++ b/src/Infrastructure.MongoDb/Repositories/HouseRepository.cs @@ -1,3 +1,5 @@ +using System; +using System.Linq.Expressions; using Keeptrack.Domain.Models; using Keeptrack.Domain.Repositories; using Keeptrack.Infrastructure.MongoDb.Entities; @@ -12,6 +14,8 @@ public class HouseRepository(IMongoDatabase mongoDatabase, ILogger "house"; + protected override Expression> SortTitleField => x => x.Name; + protected override FilterDefinition GetFilter(string ownerId, string? search, HouseModel input) { var builder = Builders.Filter; diff --git a/src/Infrastructure.MongoDb/Repositories/MongoDbRepositoryBase.cs b/src/Infrastructure.MongoDb/Repositories/MongoDbRepositoryBase.cs index 13bab957..edb1e125 100644 --- a/src/Infrastructure.MongoDb/Repositories/MongoDbRepositoryBase.cs +++ b/src/Infrastructure.MongoDb/Repositories/MongoDbRepositoryBase.cs @@ -1,4 +1,6 @@ -using System.Threading.Tasks; +using System; +using System.Linq.Expressions; +using System.Threading.Tasks; using Keeptrack.Common.System; using Keeptrack.Infrastructure.MongoDb.Mappers; using Microsoft.Extensions.Logging; @@ -35,15 +37,29 @@ public abstract class MongoDbRepositoryBase( return entity is null ? default : Mapper.ToModel(entity); } - public async Task> FindAllAsync(string ownerId, int page, int pageSize, string? search, TModel input) + /// + /// The title sort's per-query collation: strength 2 orders case- and diacritic-insensitively + /// ("Apple" between "ant" and "bee", "é" next to "e") without a normalized shadow field or a + /// collated index. MongoDB rejects a collation combined with a $text filter, which is fine here: + /// every repository's GetFilter searches via a regex Contains, never $text (the base default + /// below is legacy - see the index script's car_text removal note). + /// + private static readonly Collation s_titleCollation = new("en", strength: CollationStrength.Secondary); + + public async Task> FindAllAsync(string ownerId, int page, int pageSize, string? search, TModel input, string? sort = null) { var collection = GetCollection(); var filter = GetFilter(ownerId, search, input); var totalCount = await collection.CountDocumentsAsync(filter); + var options = sort == ListSort.Title && SortTitleField is not null + ? new FindOptions { Collation = s_titleCollation } + : null; + var entities = await collection - .Find(filter) + .Find(filter, options) + .Sort(GetSort(sort)) .Skip((page - 1) * pageSize) .Limit(pageSize) .ToListAsync(); @@ -56,6 +72,32 @@ public async Task> FindAllAsync(string ownerId, int page, in ); } + /// + /// Field behind the sort key; null (the default) means this collection + /// doesn't offer that sort and the key falls back to newest-first. An expression rather than an + /// element-name string, so the BSON name mapping stays with the entity class. + /// + protected virtual Expression>? SortTitleField => null; + + /// Field behind the sort key (descending, unrated items last) - same contract as . + protected virtual Expression>? SortRatingField => null; + + /// + /// "_id" descending doubles as the "recently added" default (ObjectIds embed their creation + /// timestamp, so no separate created-at field is needed) and as the deterministic tie-break + /// appended to every other sort. + /// + private SortDefinition GetSort(string? sort) + { + var builder = Builders.Sort; + return sort switch + { + ListSort.Title when SortTitleField is not null => builder.Ascending(SortTitleField).Descending("_id"), + ListSort.Rating when SortRatingField is not null => builder.Descending(SortRatingField).Descending("_id"), + _ => builder.Descending("_id"), + }; + } + public async Task CountAsync(string ownerId) => await GetCollection().CountDocumentsAsync(Builders.Filter.Eq(f => f.OwnerId, ownerId)); diff --git a/src/Infrastructure.MongoDb/Repositories/MovieRepository.cs b/src/Infrastructure.MongoDb/Repositories/MovieRepository.cs index c6ae34f2..8da5cb76 100644 --- a/src/Infrastructure.MongoDb/Repositories/MovieRepository.cs +++ b/src/Infrastructure.MongoDb/Repositories/MovieRepository.cs @@ -1,5 +1,7 @@ +using System; using System.Collections.Generic; using System.Linq; +using System.Linq.Expressions; using System.Text.RegularExpressions; using System.Threading.Tasks; using Keeptrack.Domain.Models; @@ -17,6 +19,10 @@ public class MovieRepository(IMongoDatabase mongoDatabase, ILogger "movie"; + protected override Expression> SortTitleField => x => x.Title; + + protected override Expression> SortRatingField => x => x.Rating!; + protected override FilterDefinition GetFilter(string ownerId, string? search, MovieModel input) { var builder = Builders.Filter; diff --git a/src/Infrastructure.MongoDb/Repositories/PlaylistRepository.cs b/src/Infrastructure.MongoDb/Repositories/PlaylistRepository.cs index e0852a66..6b4f0526 100644 --- a/src/Infrastructure.MongoDb/Repositories/PlaylistRepository.cs +++ b/src/Infrastructure.MongoDb/Repositories/PlaylistRepository.cs @@ -1,3 +1,5 @@ +using System; +using System.Linq.Expressions; using Keeptrack.Domain.Models; using Keeptrack.Domain.Repositories; using Keeptrack.Infrastructure.MongoDb.Entities; @@ -12,6 +14,8 @@ public class PlaylistRepository(IMongoDatabase mongoDatabase, ILogger "playlist"; + protected override Expression> SortTitleField => x => x.Title; + protected override FilterDefinition GetFilter(string ownerId, string? search, PlaylistModel input) { var builder = Builders.Filter; diff --git a/src/Infrastructure.MongoDb/Repositories/TvShowRepository.cs b/src/Infrastructure.MongoDb/Repositories/TvShowRepository.cs index 608423d2..3641e5f0 100644 --- a/src/Infrastructure.MongoDb/Repositories/TvShowRepository.cs +++ b/src/Infrastructure.MongoDb/Repositories/TvShowRepository.cs @@ -1,5 +1,7 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; using System.Linq; +using System.Linq.Expressions; using System.Text.RegularExpressions; using System.Threading.Tasks; using Keeptrack.Domain.Models; @@ -17,6 +19,10 @@ public class TvShowRepository(IMongoDatabase mongoDatabase, ILogger "tvshow"; + protected override Expression> SortTitleField => x => x.Title; + + protected override Expression> SortRatingField => x => x.Rating!; + protected override FilterDefinition GetFilter(string ownerId, string? search, TvShowModel input) { var builder = Builders.Filter; diff --git a/src/Infrastructure.MongoDb/Repositories/VideoGameRepository.cs b/src/Infrastructure.MongoDb/Repositories/VideoGameRepository.cs index f46e1e86..7ac669c5 100644 --- a/src/Infrastructure.MongoDb/Repositories/VideoGameRepository.cs +++ b/src/Infrastructure.MongoDb/Repositories/VideoGameRepository.cs @@ -1,5 +1,7 @@ +using System; using System.Collections.Generic; using System.Linq; +using System.Linq.Expressions; using System.Text.RegularExpressions; using System.Threading.Tasks; using Keeptrack.Domain.Models; @@ -17,6 +19,10 @@ public class VideoGameRepository(IMongoDatabase mongoDatabase, ILogger "videogame"; + protected override Expression> SortTitleField => x => x.Title; + + protected override Expression> SortRatingField => x => x.Rating!; + protected override FilterDefinition GetFilter(string ownerId, string? search, VideoGameModel input) { var builder = Builders.Filter; diff --git a/src/WebApi/Controllers/DataCrudControllerBase.cs b/src/WebApi/Controllers/DataCrudControllerBase.cs index a90dba70..81480ca4 100644 --- a/src/WebApi/Controllers/DataCrudControllerBase.cs +++ b/src/WebApi/Controllers/DataCrudControllerBase.cs @@ -40,7 +40,8 @@ public async Task>> Get([FromQuery] PagedRequest pagedRequest.Page, pagedRequest.PageSize, pagedRequest.Search, - mapper.ToModel(input)); + mapper.ToModel(input), + pagedRequest.Sort); var page = models.Map(mapper.ToDto); await OnListMappedAsync(page.Items); return Ok(page); diff --git a/test/BlazorApp.PlaywrightTests/Smoke/OwnershipSmokeTest.cs b/test/BlazorApp.PlaywrightTests/Smoke/OwnershipSmokeTest.cs index 13ae0fb8..dc65938e 100644 --- a/test/BlazorApp.PlaywrightTests/Smoke/OwnershipSmokeTest.cs +++ b/test/BlazorApp.PlaywrightTests/Smoke/OwnershipSmokeTest.cs @@ -34,12 +34,18 @@ public async Task AddingAndRemovingAnOwnedVersion_DrivesTheOwnedState() var detail = new BookDetailPage(Page); await detail.WaitForReadyAsync(); - // add a version (defaults to Physical) and fill in its purchase details + // a new version is a draft until its Save button - cancelling discards it without owning the item + await Page.GetByRole(AriaRole.Button, new PageGetByRoleOptions { Name = "+ Add version" }).ClickAsync(); + await Page.GetByRole(AriaRole.Button, new PageGetByRoleOptions { Name = "Cancel" }).ClickAsync(); + await Assertions.Expect(Page.GetByRole(AriaRole.Button, new PageGetByRoleOptions { Name = "+ Add version" })).ToBeVisibleAsync(); + + // add a version (defaults to Physical), fill in its purchase details, and save the draft await Page.GetByRole(AriaRole.Button, new PageGetByRoleOptions { Name = "+ Add version" }).ClickAsync(); await BookDetailPage.SetFieldAsync(Page.GetByTestId("version-price-input"), "12.50"); await BookDetailPage.SetFieldAsync(Page.GetByTestId("version-acquired-input"), "2024-05-17"); await BookDetailPage.SetFieldAsync(Page.GetByTestId("version-vendor-input"), "E2e Bookshop"); await BookDetailPage.SetFieldAsync(Page.GetByTestId("version-reference-input"), "Paperback 2nd edition"); + await Page.GetByTestId("version-save-button").ClickAsync(); // round-trip via the list: the row must show the derived Owned badge, and reopening the // detail page must show the persisted version fields @@ -54,8 +60,9 @@ public async Task AddingAndRemovingAnOwnedVersion_DrivesTheOwnedState() await Assertions.Expect(Page.GetByTestId("version-vendor-input")).ToHaveValueAsync("E2e Bookshop"); await Assertions.Expect(Page.GetByTestId("version-reference-input")).ToHaveValueAsync("Paperback 2nd edition"); - // removing the only version un-owns the item - await Page.GetByRole(AriaRole.Button, new PageGetByRoleOptions { Name = "Remove this version" }).ClickAsync(); + // removing the only version un-owns the item - the copy has details, so a confirmation is asked + await Page.GetByRole(AriaRole.Button, new PageGetByRoleOptions { Name = "Remove this copy" }).ClickAsync(); + await Page.GetByRole(AriaRole.Button, new PageGetByRoleOptions { Name = "Remove", Exact = true }).ClickAsync(); list = await detail.OpenBooksAsync(); await list.SearchAsync(title); await Assertions.Expect(list.Row(title)).ToBeVisibleAsync(); diff --git a/test/WebApi.IntegrationTests/Resources/ListSortingRepositoryTest.cs b/test/WebApi.IntegrationTests/Resources/ListSortingRepositoryTest.cs new file mode 100644 index 00000000..76cad37f --- /dev/null +++ b/test/WebApi.IntegrationTests/Resources/ListSortingRepositoryTest.cs @@ -0,0 +1,107 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using AwesomeAssertions; +using Keeptrack.Common.System; +using Keeptrack.Domain.Models; +using Keeptrack.Domain.Repositories; +using Keeptrack.WebApi.IntegrationTests.Hosting; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace Keeptrack.WebApi.IntegrationTests.Resources; + +/// +/// Exercises MongoDbRepositoryBase.FindAllAsync's sort translation against real MongoDB - the +/// interesting behaviors (the title sort's case-insensitive collation, descending-sort null placement, +/// the newest-first default from _id ordering) are server-side semantics a mocked repository can never +/// prove. One entity type suffices: the sort switch and collation live once in the shared base, and +/// Book covers both overridable sort fields (title and rating). Each test run uses its own random +/// owner id, so parallel runs and other tests' data can't interfere with the ordering assertions. +/// +public class ListSortingRepositoryTest(KestrelWebAppFactory factory) : IClassFixture> +{ + [Fact] + public async Task FindAllAsync_SortsByDefault_TitleAndRating() + { + using var scope = factory.Services.CreateScope(); + var repository = scope.ServiceProvider.GetRequiredService(); + var ownerId = $"sort-test-{Guid.NewGuid():N}"; + + // creation order deliberately differs from every sorted order; "apple" (lowercase) between + // "Banana" and "Cherry" proves the title collation, the null rating proves nulls sort last + var created = new[] + { + await repository.CreateAsync(NewBook(ownerId, "Banana", rating: 2f)), + await repository.CreateAsync(NewBook(ownerId, "apple", rating: null)), + await repository.CreateAsync(NewBook(ownerId, "Cherry", rating: 4.5f)), + }; + + try + { + var byDefault = await repository.FindAllAsync(ownerId, 1, 10, null, NewBook(ownerId, "")); + byDefault.Items.Select(b => b.Title).Should().Equal(["Cherry", "apple", "Banana"], + "the default order is newest first"); + + var byTitle = await repository.FindAllAsync(ownerId, 1, 10, null, NewBook(ownerId, ""), ListSort.Title); + byTitle.Items.Select(b => b.Title).Should().Equal(["apple", "Banana", "Cherry"], + "the title sort is case-insensitive, not byte order (which would put every uppercase title first)"); + + var byRating = await repository.FindAllAsync(ownerId, 1, 10, null, NewBook(ownerId, ""), ListSort.Rating); + byRating.Items.Select(b => b.Title).Should().Equal(["Cherry", "Banana", "apple"], + "the rating sort is best-first with unrated items last"); + + var byUnknownKey = await repository.FindAllAsync(ownerId, 1, 10, null, NewBook(ownerId, ""), "nonsense"); + byUnknownKey.Items.Select(b => b.Title).Should().Equal(["Cherry", "apple", "Banana"], + "an unknown sort key falls back to the newest-first default rather than erroring"); + } + finally + { + foreach (var book in created) + { + await repository.DeleteAsync(book.Id!, ownerId); + } + } + } + + [Fact] + public async Task FindAllAsync_KeepsTheSortStable_AcrossPages() + { + using var scope = factory.Services.CreateScope(); + var repository = scope.ServiceProvider.GetRequiredService(); + var ownerId = $"sort-test-{Guid.NewGuid():N}"; + + var created = new BookModel[5]; + for (var i = 0; i < created.Length; i++) + { + created[i] = await repository.CreateAsync(NewBook(ownerId, $"Book {i:D2}")); + } + + try + { + // paging through a sorted list must partition it exactly: no duplicates, no drops - the + // guarantee an unsorted skip/limit read never had + var page1 = await repository.FindAllAsync(ownerId, 1, 2, null, NewBook(ownerId, ""), ListSort.Title); + var page2 = await repository.FindAllAsync(ownerId, 2, 2, null, NewBook(ownerId, ""), ListSort.Title); + var page3 = await repository.FindAllAsync(ownerId, 3, 2, null, NewBook(ownerId, ""), ListSort.Title); + + page1.Items.Concat(page2.Items).Concat(page3.Items).Select(b => b.Title) + .Should().Equal("Book 00", "Book 01", "Book 02", "Book 03", "Book 04"); + } + finally + { + foreach (var book in created) + { + await repository.DeleteAsync(book.Id!, ownerId); + } + } + } + + private static BookModel NewBook(string ownerId, string title, float? rating = null) => new() + { + OwnerId = ownerId, + Title = title, + Author = "Sort Test Author", + Rating = rating, + }; +} diff --git a/test/WebApi.UnitTests/Controllers/FreeTierTest.cs b/test/WebApi.UnitTests/Controllers/FreeTierTest.cs index 07281504..e9750def 100644 --- a/test/WebApi.UnitTests/Controllers/FreeTierTest.cs +++ b/test/WebApi.UnitTests/Controllers/FreeTierTest.cs @@ -62,7 +62,7 @@ public Task CreateAsync(TestModel model) public Task FindOneAsync(string id, string ownerId) => Task.FromResult(null); - public Task> FindAllAsync(string ownerId, int page, int pageSize, string? search, TestModel input) => + public Task> FindAllAsync(string ownerId, int page, int pageSize, string? search, TestModel input, string? sort = null) => Task.FromResult(new PagedResult([], 0, page, pageSize)); public Task UpdateAsync(string id, TestModel model, string ownerId) => Task.FromResult(1L); diff --git a/test/WebApi.UnitTests/Import/HealthImportServiceTest.cs b/test/WebApi.UnitTests/Import/HealthImportServiceTest.cs index 97dacabc..232608a5 100644 --- a/test/WebApi.UnitTests/Import/HealthImportServiceTest.cs +++ b/test/WebApi.UnitTests/Import/HealthImportServiceTest.cs @@ -141,7 +141,7 @@ private class InMemoryRepository public Task CountAsync(string ownerId) => Task.FromResult((long)Items.Count(x => x.OwnerId == ownerId)); - public Task> FindAllAsync(string ownerId, int page, int pageSize, string? search, TModel input) + public Task> FindAllAsync(string ownerId, int page, int pageSize, string? search, TModel input, string? sort = null) { var items = Items.Where(x => x.OwnerId == ownerId).ToList(); return Task.FromResult(new PagedResult(items, items.Count, page, pageSize)); diff --git a/test/WebApi.UnitTests/Import/TvTimeImportServiceIdempotencyTest.cs b/test/WebApi.UnitTests/Import/TvTimeImportServiceIdempotencyTest.cs index 535067a2..c252b434 100644 --- a/test/WebApi.UnitTests/Import/TvTimeImportServiceIdempotencyTest.cs +++ b/test/WebApi.UnitTests/Import/TvTimeImportServiceIdempotencyTest.cs @@ -146,7 +146,7 @@ private class InMemoryRepository(Func? matchesInpu public Task CountAsync(string ownerId) => Task.FromResult((long)Items.Count(x => x.OwnerId == ownerId)); - public Task> FindAllAsync(string ownerId, int page, int pageSize, string? search, TModel input) + public Task> FindAllAsync(string ownerId, int page, int pageSize, string? search, TModel input, string? sort = null) { var items = Items.Where(x => x.OwnerId == ownerId && _matchesInput(x, input)).ToList(); return Task.FromResult(new PagedResult(items, items.Count, page, pageSize)); From 9ebce72925f31f53171295639eb8ca15593e4029 Mon Sep 17 00:00:00 2001 From: Bertrand THOMAS Date: Fri, 17 Jul 2026 23:27:37 +0200 Subject: [PATCH 05/10] Small code rework --- .../Components/Import/ImportPage.razor | 19 +++-- src/Domain/Services/HealthMetricsService.cs | 73 +++++++++---------- .../Repositories/MongoDbRepositoryBase.cs | 40 ++++------ .../Controllers/HealthProfileController.cs | 13 ++-- .../Import/CarHistoryImportController.cs | 3 + src/WebApi/Import/CarHistoryImportService.cs | 32 ++++---- src/WebApi/Import/ExcelCellParser.cs | 14 ++-- src/WebApi/Import/HealthImportController.cs | 10 ++- src/WebApi/Import/HealthImportService.cs | 43 +++++++---- .../Import/HealthImportServiceTest.cs | 25 +++++-- .../Services/HealthMetricsServiceTest.cs | 18 ++--- 11 files changed, 150 insertions(+), 140 deletions(-) diff --git a/src/BlazorApp/Components/Import/ImportPage.razor b/src/BlazorApp/Components/Import/ImportPage.razor index a5855bbf..5892bae2 100644 --- a/src/BlazorApp/Components/Import/ImportPage.razor +++ b/src/BlazorApp/Components/Import/ImportPage.razor @@ -103,10 +103,9 @@

- Upload the personal "Journal_sante.xlsx" spreadsheet (one sheet, every family member mixed in one - "Personne" column - health profiles are created or matched by name). - The "Reste à charge" column isn't imported: the app recomputes the balance itself, so rows that - don't balance land in each profile's "Reimbursements to check" list for review. + Upload the personal "Journal_sante.xlsx" spreadsheet (one sheet, every family member mixed in one "Personne" column - health profiles are created or matched by name). + The "Reste à charge" column isn't imported: the app recomputes the balance itself, + so rows that don't balance land in each profile's "Reimbursements to check" list for review. Only run this once - re-uploading the same file will duplicate the journal entries.

@@ -167,8 +166,6 @@ private string? _healthError; private HealthImportResultDto? _healthResult; - [System.Diagnostics.CodeAnalysis.SuppressMessage("Security", "S5693:Make sure the content length limit is safe here", - Justification = "The limit IS set (MaxFileSize, 50 MB, matching the API's own RequestSizeLimit).")] private async Task OnFileSelectedAsync(InputFileChangeEventArgs e) { _importing = true; @@ -179,10 +176,12 @@ try { Guid jobId; +#pragma warning disable S5693 await using (var stream = e.File.OpenReadStream(MaxFileSize)) { jobId = await ImportApi.StartImportAsync(stream, e.File.Name); } +#pragma warning restore S5693 while (true) { @@ -215,8 +214,6 @@ } } - [System.Diagnostics.CodeAnalysis.SuppressMessage("Security", "S5693:Make sure the content length limit is safe here", - Justification = "The limit IS set (MaxFileSize, 50 MB, matching the API's own RequestSizeLimit).")] private async Task OnCarFileSelectedAsync(InputFileChangeEventArgs e) { _carImporting = true; @@ -225,7 +222,9 @@ try { +#pragma warning disable S5693 await using var stream = e.File.OpenReadStream(MaxFileSize); +#pragma warning restore S5693 _carResult = await CarImportApi.ImportAsync(stream, e.File.Name); } catch (Exception ex) @@ -238,8 +237,6 @@ } } - [System.Diagnostics.CodeAnalysis.SuppressMessage("Security", "S5693:Make sure the content length limit is safe here", - Justification = "The limit IS set (MaxFileSize, 50 MB, matching the API's own RequestSizeLimit).")] private async Task OnHealthFileSelectedAsync(InputFileChangeEventArgs e) { _healthImporting = true; @@ -248,7 +245,9 @@ try { +#pragma warning disable S5693 await using var stream = e.File.OpenReadStream(MaxFileSize); +#pragma warning restore S5693 _healthResult = await HealthImportApi.ImportAsync(stream, e.File.Name); } catch (Exception ex) diff --git a/src/Domain/Services/HealthMetricsService.cs b/src/Domain/Services/HealthMetricsService.cs index c760aa33..fa60159d 100644 --- a/src/Domain/Services/HealthMetricsService.cs +++ b/src/Domain/Services/HealthMetricsService.cs @@ -6,41 +6,41 @@ namespace Keeptrack.Domain.Services; /// -/// Pure, stateless computation over a health profile's journal - no persistence of its own, same shape -/// as /. Three views the raw journal -/// can't answer at a glance: what health costs per year after reimbursements, when each practitioner was -/// last seen, and which paid records are still waiting on a reimbursement. +/// Pure, stateless computation over a health profile's journal - +/// no persistence of its own, same shape as /. +/// Three views the raw journal can't answer at a glance: +/// what health costs per year after reimbursements, when each practitioner was last seen, and which paid records are still waiting on a reimbursement. /// public class HealthMetricsService { /// - /// Two amounts closer than this are "equal" - reimbursement arithmetic runs on doubles, and a - /// sub-cent residue must never flag a genuinely settled record. + /// Two amounts closer than this are "equal" - reimbursement arithmetic runs on doubles, and a sub-cent residue must never flag a genuinely settled record. /// private const double BalanceTolerance = 0.005; - public HealthMetricsModel ComputeMetrics(IEnumerable records) + public static HealthMetricsModel ComputeMetrics(IEnumerable records) { var list = records.ToList(); - return new HealthMetricsModel - { - CostHistory = ComputeAnnualCostHistory(list), - LastVisits = ComputeLastVisits(list), - UnbalancedRecords = ComputeUnbalancedRecords(list) - }; + return new HealthMetricsModel { CostHistory = ComputeAnnualCostHistory(list), LastVisits = ComputeLastVisits(list), UnbalancedRecords = ComputeUnbalancedRecords(list) }; } /// - /// What's still missing once everything entered is accounted for: price minus both reimbursements - /// minus the accepted not-covered part (reste à charge). Zero = settled. + /// What's still missing once everything entered is accounted for: price minus both reimbursements minus the accepted not-covered part (reste à charge). + /// Zero = settled. /// - public static double ComputeMissingAmount(HealthRecordModel record) => - (record.Price ?? 0) - (record.PublicReimbursement ?? 0) - (record.InsuranceReimbursement ?? 0) - (record.NotCovered ?? 0); + private static double ComputeMissingAmount(HealthRecordModel record) + { + return (record.Price ?? 0) - (record.PublicReimbursement ?? 0) - (record.InsuranceReimbursement ?? 0) - (record.NotCovered ?? 0); + } - public static bool IsBalanced(HealthRecordModel record) => Math.Abs(ComputeMissingAmount(record)) <= BalanceTolerance; + private static bool IsBalanced(HealthRecordModel record) + { + return Math.Abs(ComputeMissingAmount(record)) <= BalanceTolerance; + } - private static List ComputeAnnualCostHistory(IEnumerable records) => - records + private static List ComputeAnnualCostHistory(IEnumerable records) + { + return records .Where(r => r.Price is not null || r.PublicReimbursement is not null || r.InsuranceReimbursement is not null) .GroupBy(r => r.HistoryDate.Year) .OrderBy(g => g.Key) @@ -48,15 +48,10 @@ private static List ComputeAnnualCostHistory(IEnume { var paid = g.Sum(r => r.Price ?? 0); var reimbursed = g.Sum(r => (r.PublicReimbursement ?? 0) + (r.InsuranceReimbursement ?? 0)); - return new HealthCostHistoryPointModel - { - Year = g.Key, - TotalPaid = paid, - TotalReimbursed = reimbursed, - OutOfPocket = paid - reimbursed - }; + return new HealthCostHistoryPointModel { Year = g.Key, TotalPaid = paid, TotalReimbursed = reimbursed, OutOfPocket = paid - reimbursed }; }) .ToList(); + } /// /// One line per specialty across every appointment - most recently seen first, so "when did I last @@ -64,17 +59,15 @@ private static List ComputeAnnualCostHistory(IEnume /// practitioner name (owner's call): the question is about the kind of care, and the journal itself /// carries the names. /// - private static List ComputeLastVisits(IEnumerable records) => - records + private static List ComputeLastVisits(IEnumerable records) + { + return records .Where(r => r.EventType == HealthEventType.Appointment && !string.IsNullOrWhiteSpace(r.Specialty)) .GroupBy(r => r.Specialty!.Trim()) - .Select(g => new HealthLastVisitModel - { - Specialty = g.Key, - LastVisitDate = g.Max(r => r.HistoryDate) - }) + .Select(g => new HealthLastVisitModel { Specialty = g.Key, LastVisitDate = g.Max(r => r.HistoryDate) }) .OrderByDescending(v => v.LastVisitDate) .ToList(); + } /// /// Every paid record whose money doesn't balance to zero, oldest first (the longest-waiting claim is @@ -82,8 +75,9 @@ private static List ComputeLastVisits(IEnumerable - private static List ComputeUnbalancedRecords(IEnumerable records) => - records + private static List ComputeUnbalancedRecords(IEnumerable records) + { + return records .Where(r => r.Price is > 0 && r.Id is not null && !IsBalanced(r)) .OrderBy(r => r.HistoryDate) .Select(r => new HealthUnbalancedRecordModel @@ -95,7 +89,10 @@ private static List ComputeUnbalancedRecords(IEnume MissingAmount = ComputeMissingAmount(r) }) .ToList(); + } - private static string? FirstNonEmpty(params string?[] values) => - values.FirstOrDefault(v => !string.IsNullOrWhiteSpace(v)); + private static string? FirstNonEmpty(params string?[] values) + { + return values.FirstOrDefault(v => !string.IsNullOrWhiteSpace(v)); + } } diff --git a/src/Infrastructure.MongoDb/Repositories/MongoDbRepositoryBase.cs b/src/Infrastructure.MongoDb/Repositories/MongoDbRepositoryBase.cs index edb1e125..20d29727 100644 --- a/src/Infrastructure.MongoDb/Repositories/MongoDbRepositoryBase.cs +++ b/src/Infrastructure.MongoDb/Repositories/MongoDbRepositoryBase.cs @@ -25,27 +25,12 @@ public abstract class MongoDbRepositoryBase( private IStorageMapper Mapper { get; } = mapper; - /// - /// "Not found" must stay a real null, not a mapped default instance: Mapperly throws on a null - /// source rather than substituting a default instance, so the missing-document check has to happen - /// before mapping regardless - this guard is what makes that "not found" case behave as null instead - /// of propagating an exception. - /// public async Task FindOneAsync(string id, string ownerId) { var entity = await GetCollection().Find(x => x.Id == id && x.OwnerId == ownerId).FirstOrDefaultAsync(); return entity is null ? default : Mapper.ToModel(entity); } - /// - /// The title sort's per-query collation: strength 2 orders case- and diacritic-insensitively - /// ("Apple" between "ant" and "bee", "é" next to "e") without a normalized shadow field or a - /// collated index. MongoDB rejects a collation combined with a $text filter, which is fine here: - /// every repository's GetFilter searches via a regex Contains, never $text (the base default - /// below is legacy - see the index script's car_text removal note). - /// - private static readonly Collation s_titleCollation = new("en", strength: CollationStrength.Secondary); - public async Task> FindAllAsync(string ownerId, int page, int pageSize, string? search, TModel input, string? sort = null) { var collection = GetCollection(); @@ -54,7 +39,7 @@ public async Task> FindAllAsync(string ownerId, int page, in var totalCount = await collection.CountDocumentsAsync(filter); var options = sort == ListSort.Title && SortTitleField is not null - ? new FindOptions { Collation = s_titleCollation } + ? new FindOptions { Collation = new Collation("en", strength: CollationStrength.Secondary) } : null; var entities = await collection @@ -73,19 +58,20 @@ public async Task> FindAllAsync(string ownerId, int page, in } /// - /// Field behind the sort key; null (the default) means this collection - /// doesn't offer that sort and the key falls back to newest-first. An expression rather than an - /// element-name string, so the BSON name mapping stays with the entity class. + /// Field behind the sort key; + /// null (the default) means this collection doesn't offer that sort and the key falls back to newest-first. + /// An expression rather than an element-name string, so the BSON name mapping stays with the entity class. /// protected virtual Expression>? SortTitleField => null; - /// Field behind the sort key (descending, unrated items last) - same contract as . + /// + /// Field behind the sort key (descending, unrated items last) - same contract as . + /// protected virtual Expression>? SortRatingField => null; /// - /// "_id" descending doubles as the "recently added" default (ObjectIds embed their creation - /// timestamp, so no separate created-at field is needed) and as the deterministic tie-break - /// appended to every other sort. + /// "_id" descending doubles as the "recently added" default (ObjectIds embed their creation timestamp, so no separate created-at field is needed) + /// and as the deterministic tie-break appended to every other sort. /// private SortDefinition GetSort(string? sort) { @@ -94,12 +80,14 @@ private SortDefinition GetSort(string? sort) { ListSort.Title when SortTitleField is not null => builder.Ascending(SortTitleField).Descending("_id"), ListSort.Rating when SortRatingField is not null => builder.Descending(SortRatingField).Descending("_id"), - _ => builder.Descending("_id"), + _ => builder.Descending("_id") }; } - public async Task CountAsync(string ownerId) => - await GetCollection().CountDocumentsAsync(Builders.Filter.Eq(f => f.OwnerId, ownerId)); + public async Task CountAsync(string ownerId) + { + return await GetCollection().CountDocumentsAsync(Builders.Filter.Eq(f => f.OwnerId, ownerId)); + } public async Task CreateAsync(TModel model) { diff --git a/src/WebApi/Controllers/HealthProfileController.cs b/src/WebApi/Controllers/HealthProfileController.cs index 2b5e8a65..f550d14a 100644 --- a/src/WebApi/Controllers/HealthProfileController.cs +++ b/src/WebApi/Controllers/HealthProfileController.cs @@ -14,7 +14,6 @@ public class HealthProfileController( IDtoMapper mapper, IHealthProfileRepository dataRepository, IHealthRecordRepository healthRecordRepository, - HealthMetricsService metricsService, HealthMetricsDtoMapper metricsMapper) : DataCrudControllerBase(mapper, dataRepository) { @@ -35,13 +34,15 @@ public async Task> GetMetrics(string id) var records = await healthRecordRepository.FindAllAsync(ownerId, 1, int.MaxValue, null, new HealthRecordModel { OwnerId = ownerId, HealthProfileId = id, EventType = default, HistoryDate = default }); - return Ok(metricsMapper.ToDto(metricsService.ComputeMetrics(records.Items))); + return Ok(metricsMapper.ToDto(HealthMetricsService.ComputeMetrics(records.Items))); } /// - /// HealthRecord is a separate top-level collection referencing its profile by id, not an embedded - /// array (see CLAUDE.md's "Child entities" section) - without this, deleting a profile would leave - /// its journal orphaned in MongoDB forever, since it's only ever reachable via the profile's own id. + /// HealthRecord is a separate top-level collection referencing its profile by id, not an embedded array (see CLAUDE.md's "Child entities" section) - + /// without this, deleting a profile would leave its journal orphaned in MongoDB forever, since it's only ever reachable via the profile's own id. /// - protected override async Task OnDeletedAsync(string id, string ownerId) => await healthRecordRepository.DeleteAllForProfileAsync(id, ownerId); + protected override async Task OnDeletedAsync(string id, string ownerId) + { + await healthRecordRepository.DeleteAllForProfileAsync(id, ownerId); + } } diff --git a/src/WebApi/Import/CarHistoryImportController.cs b/src/WebApi/Import/CarHistoryImportController.cs index 726d630d..954fea2b 100644 --- a/src/WebApi/Import/CarHistoryImportController.cs +++ b/src/WebApi/Import/CarHistoryImportController.cs @@ -1,3 +1,4 @@ +using System.Diagnostics.CodeAnalysis; using Keeptrack.WebApi.Controllers; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; @@ -20,6 +21,8 @@ public class CarHistoryImportController(CarHistoryImportService importService) : [Consumes("multipart/form-data")] [ProducesResponseType(200)] [ProducesResponseType(400)] + [SuppressMessage("Security", "S5693:Make sure the content length limit is safe here", + Justification = "The limit IS set (10 MB), deliberately above Sonar's 8 MB default.")] public async Task> ImportCarHistory(IFormFile file) { if (file.Length == 0) diff --git a/src/WebApi/Import/CarHistoryImportService.cs b/src/WebApi/Import/CarHistoryImportService.cs index 56ea928a..ff1ff380 100644 --- a/src/WebApi/Import/CarHistoryImportService.cs +++ b/src/WebApi/Import/CarHistoryImportService.cs @@ -8,13 +8,12 @@ namespace Keeptrack.WebApi.Import; /// -/// One-off personal import of the "Voitures.xlsx" spreadsheet: one fuel-log sheet and (optionally) one -/// maintenance-log sheet per car, hand-tracked in French. This is a single-owner spreadsheet with a fixed, -/// known shape (not a generic user-facing file format like the TV Time GDPR export), so the sheet names and -/// column headers below are hardcoded rather than auto-detected. Not idempotent by design - matches an -/// existing car by name so re-running doesn't duplicate the cars themselves, but re-uploading the same -/// file will duplicate history entries. This is meant to be run once per car; unlike , -/// there is no per-entry natural key in the source data to de-duplicate against. +/// One-off personal import of the "Voitures.xlsx" spreadsheet: +/// one fuel-log sheet and (optionally) one maintenance-log sheet per car, hand-tracked in French. +/// This is a single-owner spreadsheet with a fixed, known shape (not a generic user-facing file format like the TV Time GDPR export), +/// so the sheet names and column headers below are hardcoded rather than auto-detected. Not idempotent by design - +/// matches an existing car by name so re-running doesn't duplicate the cars themselves, but re-uploading the same file will duplicate history entries. +/// This is meant to be run once per car; unlike , there is no per-entry natural key in the source data to de-duplicate against. /// public class CarHistoryImportService(ICarRepository carRepository, ICarHistoryRepository carHistoryRepository) { @@ -24,9 +23,9 @@ private sealed record CarSheetGroup(string CarName, string Manufacturer, string private static readonly CarSheetGroup[] CarGroups = [ - new("Renault Scenic", "Renault", "Scenic", "Carburant S", null), - new("Renault Modus", "Renault", "Modus", "Carburant M", "Interventions M"), - new("Peugeot 306", "Peugeot", "306", "Carburant 306", "Intervention 306") + new("First car", "First", "First", "Carburant F", null), + new("Second car", "Second", "Second", "Carburant S", "Interventions S"), + new("Third car", "Third", "Third", "Carburant T", "Intervention T") ]; public async Task ImportAsync(Stream xlsxStream, string ownerId) @@ -175,8 +174,8 @@ private static Dictionary ReadHeaders(IXLWorksheet sheet) var headers = new Dictionary(); foreach (var cell in headerRow.CellsUsed()) { - // Some headers (e.g. "Autoroute\nEloigné") span two lines within a single cell; normalize - // whitespace so a lookup doesn't have to guess the exact line-break character used in the file. + // Some headers (e.g. "Autoroute\nEloigné") span two lines within a single cell; + // normalize whitespace so a lookup doesn't have to guess the exact line-break character used in the file. var text = string.Join(' ', cell.GetString().Split([' ', '\r', '\n', '\t'], StringSplitOptions.RemoveEmptyEntries)); if (text.Length > 0) headers.TryAdd(text, cell.Address.ColumnNumber); } @@ -190,10 +189,10 @@ private static Dictionary ReadHeaders(IXLWorksheet sheet) headers.TryGetValue(header, out var column) ? row.Cell(column) : null; /// - /// Reads a date, falling back to a manual French (d/M/yyyy) text parse for the one row that wasn't - /// stored as a real Excel date. One entry in "Carburant S" has the corrupted literal text "11/06/0207", - /// confirmed (from the surrounding, chronologically-sorted rows: 2017-08-13 above, 2017-05-26 below) to - /// be a mistyped 11/06/2017 - fixed here rather than skipped, per a manual review of the source file. + /// Reads a date, falling back to a manual French (d/M/yyyy) text parse for the one row that wasn't stored as a real Excel date. + /// One entry in "Carburant X" has the corrupted literal text "11/06/0207", + /// confirmed (from the surrounding, chronologically-sorted rows: 2017-08-13 above, 2017-05-26 below) to be a mistyped 11/06/2017 - + /// fixed here rather than skipped, per a manual review of the source file. /// private static DateOnly? ParseDate(IXLCell cell) { @@ -210,5 +209,4 @@ private static Dictionary ReadHeaders(IXLWorksheet sheet) if (cell is null || cell.IsEmpty()) return null; return string.Equals(cell.GetString().Trim(), "O", StringComparison.OrdinalIgnoreCase); } - } diff --git a/src/WebApi/Import/ExcelCellParser.cs b/src/WebApi/Import/ExcelCellParser.cs index 9a8c74a7..646e791d 100644 --- a/src/WebApi/Import/ExcelCellParser.cs +++ b/src/WebApi/Import/ExcelCellParser.cs @@ -4,15 +4,14 @@ namespace Keeptrack.WebApi.Import; /// -/// Cell-parsing helpers shared by the one-off Excel importers (, -/// ) - extracted rather than duplicated per importer. +/// Cell-parsing helpers shared by the one-off Excel importers (, ) - +/// extracted rather than duplicated per importer. /// internal static class ExcelCellParser { /// - /// "Heure" columns are inconsistent in these personal files - some cells are real Excel time values - /// (a fraction of a day), others plain text like "11:57" - but GetFormattedString() renders - /// both the same way, so a single text parse handles both. + /// "Heure" columns are inconsistent in these personal files - some cells are real Excel time values (a fraction of a day), others plain text like "11:57" - + /// but GetFormattedString() renders both the same way, so a single text parse handles both. /// internal static TimeOnly? ParseTime(IXLCell? cell) { @@ -31,9 +30,8 @@ internal static class ExcelCellParser internal static double? DoubleOrNull(IXLCell? cell) => cell is not null && !cell.IsEmpty() && cell.TryGetValue(out double value) ? value : null; /// - /// Euro-amount columns are often Excel formulas (sums of several acts), which routinely produce - /// floating-point noise like 36.049999999999997 - rounded to the cent so every imported price is a - /// real, displayable amount. + /// Euro-amount columns are often Excel formulas (sums of several acts), which routinely produce floating-point noise like 36.049999999999997 - + /// rounded to the cent so every imported price is a real, displayable amount. /// internal static double? PriceOrNull(IXLCell? cell) => DoubleOrNull(cell) is { } value ? Math.Round(value, 2) : null; diff --git a/src/WebApi/Import/HealthImportController.cs b/src/WebApi/Import/HealthImportController.cs index c3a089c7..f90eebb1 100644 --- a/src/WebApi/Import/HealthImportController.cs +++ b/src/WebApi/Import/HealthImportController.cs @@ -1,3 +1,4 @@ +using System.Diagnostics.CodeAnalysis; using Keeptrack.WebApi.Controllers; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; @@ -10,16 +11,17 @@ namespace Keeptrack.WebApi.Import; public class HealthImportController(HealthImportService importService) : ControllerBase { /// - /// Imports the personal "Journal_sante.xlsx" spreadsheet (one sheet of health events, every family - /// member mixed in one "Personne" column - profiles are created/matched by name). Runs synchronously, - /// same as the car import: no external API call in the loop, so a few hundred rows complete well - /// within a normal request. + /// Imports the personal "Journal_sante.xlsx" spreadsheet + /// (one sheet of health events, every family member mixed in one "Personne" column - profiles are created/matched by name). + /// Runs synchronously, same as the car import: no external API call in the loop, so a few hundred rows complete well within a normal request. /// [HttpPost("health")] [RequestSizeLimit(10_000_000)] [Consumes("multipart/form-data")] [ProducesResponseType(200)] [ProducesResponseType(400)] + [SuppressMessage("Security", "S5693:Make sure the content length limit is safe here", + Justification = "The limit IS set (10 MB), deliberately above Sonar's 8 MB default.")] public async Task> ImportHealth(IFormFile file) { if (file.Length == 0) diff --git a/src/WebApi/Import/HealthImportService.cs b/src/WebApi/Import/HealthImportService.cs index 7bdb8dfa..de5aef38 100644 --- a/src/WebApi/Import/HealthImportService.cs +++ b/src/WebApi/Import/HealthImportService.cs @@ -6,42 +6,55 @@ namespace Keeptrack.WebApi.Import; /// -/// One-off personal import of the "Journal_sante.xlsx" spreadsheet: a single sheet of hand-tracked -/// health events in French, mixing every family member in one "Personne" column - so unlike the car -/// import, rows are dispatched to (and, when needed, create) one per -/// distinct person. Same non-idempotence trade-off as : profiles -/// are matched by name so re-running doesn't duplicate the people, but re-uploading the same file will -/// duplicate journal entries - there is no per-row natural key to de-duplicate against. -/// The "Reste à charge" column is deliberately NOT imported: it's a formula in the source file -/// (paid - ameli - mutuelle), i.e. derived data the app recomputes itself (see -/// ) - importing its cached value as -/// would silently mark every historical row as settled. +/// One-off personal import of the "Journal_sante.xlsx" spreadsheet: +/// a single sheet of hand-tracked health events in French, mixing every family member in one "Personne" column - +/// so unlike the car import, rows are dispatched to (and, when needed, create) one per distinct person. +/// Same non-idempotence trade-off as : +/// profiles are matched by name so re-running doesn't duplicate the people, but re-uploading the same file will duplicate journal entries - +/// there is no per-row natural key to de-duplicate against. +/// The "Reste à charge" column is deliberately NOT imported: +/// it's a formula in the source file (paid - ameli - mutuelle), i.e. derived data the app recomputes itself (see ) - +/// importing its cached value as would silently mark every historical row as settled. /// Rows the balance check then flags are exactly the ones the owner wants to re-verify. /// public class HealthImportService(IHealthProfileRepository healthProfileRepository, IHealthRecordRepository healthRecordRepository) { /// - /// Column positions resolved from the header row. The file has TWO columns titled "Personne" - the - /// first is who was treated (the profile), the second the practitioner - so a plain - /// header-name-to-column dictionary (the car importer's approach) can't represent this sheet. + /// Column positions resolved from the header row. + /// The file has TWO columns titled "Personne" - the first is who was treated (the profile), the second the practitioner - + /// so a plain header-name-to-column dictionary (the car importer's approach) can't represent this sheet. /// "Jour" (derived day-of-week) and "Reste à charge" (derived, see the class remarks) are ignored. /// private sealed class SheetColumns { public int? Date { get; set; } + public int? Time { get; set; } + public int? Person { get; set; } + public int? Practitioner { get; set; } + public int? Specialty { get; set; } + public int? Location { get; set; } + public int? Description { get; set; } + public int? Notes { get; set; } + public int? Paid { get; set; } + public int? PublicReimbursement { get; set; } + public int? PublicTransfer { get; set; } + public int? PublicDate { get; set; } + public int? InsuranceReimbursement { get; set; } + public int? InsuranceDate { get; set; } + public int? Comment { get; set; } } @@ -102,8 +115,8 @@ await healthRecordRepository.CreateAsync(new HealthRecordModel Price = ExcelCellParser.PriceOrNull(Cell(row, columns.Paid)), PublicReimbursement = ExcelCellParser.PriceOrNull(Cell(row, columns.PublicReimbursement)), InsuranceReimbursement = ExcelCellParser.PriceOrNull(Cell(row, columns.InsuranceReimbursement)), - // the sparse bookkeeping columns (place, actual ameli bank transfer, payment dates, - // comment) have no dedicated fields - preserved as labeled notes instead of dropped + // the sparse bookkeeping columns (place, actual ameli bank transfer, payment dates, comment) have no dedicated fields - + // preserved as labeled notes instead of dropped Notes = ExcelCellParser.JoinNonEmpty("; ", ExcelCellParser.StringOrNull(Cell(row, columns.Notes)), ExcelCellParser.StringOrNull(Cell(row, columns.Location)) is { } location ? $"Lieu : {location}" : null, diff --git a/test/WebApi.UnitTests/Import/HealthImportServiceTest.cs b/test/WebApi.UnitTests/Import/HealthImportServiceTest.cs index 232608a5..23ab8594 100644 --- a/test/WebApi.UnitTests/Import/HealthImportServiceTest.cs +++ b/test/WebApi.UnitTests/Import/HealthImportServiceTest.cs @@ -14,11 +14,9 @@ namespace Keeptrack.WebApi.UnitTests.Import; /// -/// Exercises against an in-memory workbook shaped exactly like the -/// real "Journal_sante.xlsx" (verified against the actual file): the duplicate "Personne" header (first = -/// who was treated, second = practitioner), two-line headers like "Rbrst\nAmeli", Excel-native dates and -/// time fractions, formula-backed amounts, and the derived "Reste à charge" column that must NOT be -/// imported. +/// Exercises against an in-memory workbook shaped exactly like the real "Journal_sante.xlsx" (verified against the actual file): +/// the duplicate "Personne" header (first = who was treated, second = practitioner), two-line headers like "Rbrst\nAmeli", Excel-native dates +/// and time fractions, formula-backed amounts, and the derived "Reste à charge" column that must NOT be imported. /// [Trait("Category", "UnitTests")] public class HealthImportServiceTest @@ -33,7 +31,11 @@ private static byte[] BuildWorkbook(Action? mutate = null) using var workbook = new XLWorkbook(); var sheet = workbook.AddWorksheet("Journal"); - string[] headers = ["Jour", "Date", "Heure", "Personne", "Spécialité", "Personne", "Lieu", "Fait", "Notes", "Paiement", "Rbrst\nAmeli", "Virt Ameli", "Date Ameli", "Rbrst\nMutuelle", "Date\nmutuelle", "Reste\nà charge", "Commentaire"]; + string[] headers = + [ + "Jour", "Date", "Heure", "Personne", "Spécialité", "Personne", "Lieu", "Fait", "Notes", "Paiement", "Rbrst\nAmeli", "Virt Ameli", "Date Ameli", "Rbrst\nMutuelle", + "Date\nmutuelle", "Reste\nà charge", "Commentaire" + ]; for (var i = 0; i < headers.Length; i++) sheet.Cell(1, i + 1).Value = headers[i]; // a settled consultation for Bertrand, with a time of day @@ -154,7 +156,16 @@ public Task CreateAsync(TModel model) return Task.FromResult(model); } - public Task UpdateAsync(string id, TModel model, string ownerId) => Task.FromResult(1L); + public Task UpdateAsync(string id, TModel model, string ownerId) + { + var existing = Items.FirstOrDefault(x => x.Id == id && x.OwnerId == ownerId); + if (existing != null) + { + Items.Remove(existing); + Items.Add(model); + } + return Task.FromResult(1L); + } public Task DeleteAsync(string id, string ownerId) => Task.FromResult((long)Items.RemoveAll(x => x.Id == id && x.OwnerId == ownerId)); diff --git a/test/WebApi.UnitTests/Services/HealthMetricsServiceTest.cs b/test/WebApi.UnitTests/Services/HealthMetricsServiceTest.cs index 5224102a..ce56a37d 100644 --- a/test/WebApi.UnitTests/Services/HealthMetricsServiceTest.cs +++ b/test/WebApi.UnitTests/Services/HealthMetricsServiceTest.cs @@ -41,7 +41,7 @@ private static HealthRecordModel Record( [Fact] public void ComputeMetrics_IsAllEmpty_WhenThereAreNoRecords() { - var result = _service.ComputeMetrics([]); + var result = HealthMetricsService.ComputeMetrics([]); result.CostHistory.Should().BeEmpty(); result.LastVisits.Should().BeEmpty(); @@ -58,7 +58,7 @@ public void ComputeMetrics_CostHistory_GroupsByYear_AndComputesOutOfPocketAfterB Record("r3", new DateTime(2026, 1, 15, 10, 0, 0), price: 26.5, publicReimbursement: 16.5) }; - var result = _service.ComputeMetrics(records); + var result = HealthMetricsService.ComputeMetrics(records); result.CostHistory.Should().HaveCount(2); var year2025 = result.CostHistory[0]; @@ -80,7 +80,7 @@ public void ComputeMetrics_CostHistory_ExcludesRecordsWithNoMoneyAtAll() // a sickness entry carries no money - it must not fabricate a zero-cost year var records = new[] { Record("r1", new DateTime(2025, 3, 10, 8, 0, 0), HealthEventType.Sickness, description: "Fever") }; - _service.ComputeMetrics(records).CostHistory.Should().BeEmpty(); + HealthMetricsService.ComputeMetrics(records).CostHistory.Should().BeEmpty(); } [Fact] @@ -93,7 +93,7 @@ public void ComputeMetrics_LastVisits_GroupsBySpecialty_MostRecentFirst() Record("r3", new DateTime(2025, 6, 1, 15, 30, 0), practitioner: "Dr Diaz", specialty: "généraliste") }; - var result = _service.ComputeMetrics(records); + var result = HealthMetricsService.ComputeMetrics(records); result.LastVisits.Should().HaveCount(2); result.LastVisits[0].Specialty.Should().Be("dentiste"); @@ -110,7 +110,7 @@ public void ComputeMetrics_LastVisits_IgnoreSicknessEntriesAndAppointmentsWithou Record("r2", new DateTime(2025, 2, 20, 11, 0, 0), practitioner: "Dr Diaz") }; - _service.ComputeMetrics(records).LastVisits.Should().BeEmpty(); + HealthMetricsService.ComputeMetrics(records).LastVisits.Should().BeEmpty(); } [Fact] @@ -128,7 +128,7 @@ public void ComputeMetrics_UnbalancedRecords_FlagsEverythingThatDoesNotSumToZero Record("r4", new DateTime(2026, 6, 1, 9, 0, 0), practitioner: "Dr Diaz") }; - var result = _service.ComputeMetrics(records); + var result = HealthMetricsService.ComputeMetrics(records); result.UnbalancedRecords.Should().HaveCount(3); result.UnbalancedRecords[0].RecordId.Should().Be("r2"); @@ -149,7 +149,7 @@ public void ComputeMetrics_UnbalancedRecords_ConsidersARecordSettled_WhenPriceRe Record("r1", new DateTime(2026, 2, 3, 9, 0, 0), price: 26.5, publicReimbursement: 16.5, insuranceReimbursement: 8, leftover: 2) }; - _service.ComputeMetrics(records).UnbalancedRecords.Should().BeEmpty(); + HealthMetricsService.ComputeMetrics(records).UnbalancedRecords.Should().BeEmpty(); } [Fact] @@ -161,7 +161,7 @@ public void ComputeMetrics_UnbalancedRecords_ToleratesSubCentFloatingPointResidu Record("r1", new DateTime(2026, 2, 3, 9, 0, 0), price: 0.3, publicReimbursement: 0.1, insuranceReimbursement: 0.2) }; - _service.ComputeMetrics(records).UnbalancedRecords.Should().BeEmpty(); + HealthMetricsService.ComputeMetrics(records).UnbalancedRecords.Should().BeEmpty(); } [Fact] @@ -172,7 +172,7 @@ public void ComputeMetrics_UnbalancedRecords_FlagsOverPayments_WithANegativeMiss Record("r1", new DateTime(2026, 2, 3, 9, 0, 0), price: 30, publicReimbursement: 20, insuranceReimbursement: 15) }; - var result = _service.ComputeMetrics(records); + var result = HealthMetricsService.ComputeMetrics(records); var unbalanced = result.UnbalancedRecords.Should().ContainSingle().Subject; unbalanced.MissingAmount.Should().Be(-5); From cffaaa8d1888f507cdacede03784d3fa20760da5 Mon Sep 17 00:00:00 2001 From: Bertrand THOMAS Date: Sat, 18 Jul 2026 00:08:57 +0200 Subject: [PATCH 06/10] Add tabs for health, car, house and mobile display MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - HealthProfileDetail.razor: journal is now split into per-year tabs (kt-season-picker/kt-page-btn, same header used by TvShowDetail's season picker), newest year first and selected first; the table shows only the selected year's entries. Tab selection persists across reloads when the year still exists, falling back to the newest year otherwise (mirrors TvShowDetail's season-selection logic). - CarDetail.razor / CarHistoryRow.razor: history table now carries kt-table-grid, so on mobile it stays a real grid instead of stacking into labeled cards. City/Description are desktop-only (d-none d-md-table-cell); Edit/Del text buttons became kt-icon-btn ✎/✕ icons, matching HealthRecordRow. Now-unused data-label attributes were dropped. - HouseDetail.razor / HouseHistoryRow.razor: same treatment — kt-table-grid on the history table, Provider/Description hidden on mobile, icon-based edit/delete. The yearly cost-review summary tables (Car's charts, House's per-category breakdown) were left untouched — the request was about the record list, not those summaries. - CarDetail.razor / HouseDetail.razor: history now split into per-year tabs (same kt-season-picker/kt-page-btn pattern used by TvShowDetail's seasons and HealthProfileDetail's journal), newest year selected by default and preserved across reloads when still valid. - Ordering: Car's history now loads newest-first (OrderByDescending, previously ascending) to match Health/House; House was already descending, comment updated to reflect all three now share the same convention. - Mileage-warning computation for Car is unaffected — it comes from the server-side CarMetricsDto, not from the local _history ordering. --- .../Inventory/Pages/CarDetail.razor | 95 +++++++++++++------ .../Inventory/Pages/CarHistoryRow.razor | 20 ++-- .../Inventory/Pages/HealthProfileDetail.razor | 59 +++++++++--- .../Inventory/Pages/HouseDetail.razor | 85 +++++++++++------ .../Inventory/Pages/HouseHistoryRow.razor | 17 ++-- 5 files changed, 186 insertions(+), 90 deletions(-) diff --git a/src/BlazorApp/Components/Inventory/Pages/CarDetail.razor b/src/BlazorApp/Components/Inventory/Pages/CarDetail.razor index 8ebb348d..b4e713cf 100644 --- a/src/BlazorApp/Components/Inventory/Pages/CarDetail.razor +++ b/src/BlazorApp/Components/Inventory/Pages/CarDetail.razor @@ -126,37 +126,59 @@ else

History

-
-
-
Year
@DateText@Entry.EventType - @Entry.Practitioner - @if (!string.IsNullOrEmpty(Entry.Specialty)) - { - @* ms-1, not a text space: Razor collapses the leading whitespace inside the span *@ - @Entry.Specialty - } - - @Entry.Price?.ToString("F2") + @Entry.Specialty@Entry.Practitioner@Entry.Description@Entry.Price?.ToString("F2") @if (IsUnbalanced) { - ⚠ to check + } @Entry.Description -
- - +
+ +
- - - - - - - - - - - - - - @if (_history.Count == 0) - { - - } - @foreach (var entry in _history) - { - - } - -
DateMileageEventCostCityDescription
No history yet - add the first entry above.
+ @* kt-table-grid opts this table out of the mobile stacked-cards treatment (see HealthProfileDetail.razor's + journal for the same pattern) - a real spreadsheet-like grid with only the most important columns + (City/Description) stay desktop-only via d-none d-md-table-cell. + One tab per year (same kt-season-picker/kt-page-btn header as TvShowDetail.razor's season tabs and + HealthProfileDetail.razor's journal), newest year first. *@ + @if (_history.Count == 0) + { +
+
+ + + + +
No history yet - add the first entry above.
+
-
+ } + else + { +
+ @foreach (var year in _years) + { + + } +
+
+
+ + + + + + + + + + + + + + + @foreach (var entry in _historyByYear.GetValueOrDefault(_selectedYear, [])) + { + + } + +
DateMileageEventCostCityDescription
+
+
+ } @if (_showModal) { @@ -286,6 +308,9 @@ else private bool _loading = true; private CarDto? _car; private List _history = []; + private Dictionary> _historyByYear = new(); + private List _years = []; + private int _selectedYear; private CarMetricsDto? _metrics; private CarHistoryDto _modalEntry = NewEntry(""); private bool _showModal; @@ -350,7 +375,13 @@ else if (_car is not null) { var result = await CarHistoryApi.GetAsync("", 1, int.MaxValue, new Dictionary { ["CarId"] = Id }); - _history = result.Items.OrderBy(h => h.HistoryDate).ToList(); + // most recent first - same journal convention as HealthProfileDetail/HouseDetail + _history = result.Items.OrderByDescending(h => h.HistoryDate).ToList(); + _historyByYear = _history.GroupBy(h => h.HistoryDate.Year).ToDictionary(g => g.Key, g => g.ToList()); + _years = _historyByYear.Keys.OrderByDescending(y => y).ToList(); + // same "keep the current tab if it still exists, else default to the newest" rule as + // TvShowDetail.razor's own season selection + if (!_years.Contains(_selectedYear)) _selectedYear = _years.Count > 0 ? _years[0] : 0; _metrics = await CarApi.GetMetricsAsync(Id); _warningsByEntryId = _metrics.MileageWarnings .GroupBy(w => w.CarHistoryId) @@ -359,6 +390,8 @@ else _loading = false; } + private void SelectYear(int year) => _selectedYear = year; + private async Task SaveCarAsync() { if (_car is null) return; diff --git a/src/BlazorApp/Components/Inventory/Pages/CarHistoryRow.razor b/src/BlazorApp/Components/Inventory/Pages/CarHistoryRow.razor index 40084640..90823177 100644 --- a/src/BlazorApp/Components/Inventory/Pages/CarHistoryRow.razor +++ b/src/BlazorApp/Components/Inventory/Pages/CarHistoryRow.razor @@ -1,13 +1,15 @@ @* One row of the car history sheet - a compact summary of the most important fields. Editing happens - through the same modal used to add a new entry (CarDetail.razor), opened via the Edit button below. *@ + through the same modal used to add a new entry (CarDetail.razor), opened via the Edit button below. + ⚠ (U+26A0) and the edit/delete icons default to text presentation (no FE0F) - same convention as + HealthRecordRow.razor. *@ @Entry.HistoryDate.ToString(Entry.HistoryDate.TimeOfDay == TimeSpan.Zero ? "yyyy-MM-dd" : "yyyy-MM-dd HH:mm") - @Entry.Mileage - @Entry.EventType - @Entry.Cost?.ToString("F2") - @Entry.City - @Entry.Description + @Entry.Mileage + @Entry.EventType + @Entry.Cost?.ToString("F2") + @Entry.City + @Entry.Description @if (HasWarning) { @@ -15,9 +17,9 @@ } -
- - +
+ +
diff --git a/src/BlazorApp/Components/Inventory/Pages/HealthProfileDetail.razor b/src/BlazorApp/Components/Inventory/Pages/HealthProfileDetail.razor index 7184a4a1..18436f5e 100644 --- a/src/BlazorApp/Components/Inventory/Pages/HealthProfileDetail.razor +++ b/src/BlazorApp/Components/Inventory/Pages/HealthProfileDetail.razor @@ -55,23 +55,42 @@ else
@* Excel-like on purpose: no header row, no per-field labels, aligned columns at every width (kt-table-grid opts out of the mobile stacked-cards treatment) - date, specialty, name, a warning - sign when money remains unaccounted, and edit/delete icons. Everything else lives in the modal. *@ -
-
- - - @if (_records.Count == 0) - { + sign when money remains unaccounted, and edit/delete icons. Everything else lives in the modal. + One tab per year (same kt-season-picker/kt-page-btn header as TvShowDetail.razor's season tabs), + newest year first, so a long-running journal doesn't render every entry on one page. *@ + @if (_records.Count == 0) + { +
+
+
+ - } - @foreach (var entry in _records) - { - - } - -
No entries yet - add the first one above.
+ + +
-
+ } + else + { +
+ @foreach (var year in _years) + { + + } +
+
+
+ + + @foreach (var entry in _recordsByYear.GetValueOrDefault(_selectedYear, [])) + { + + } + +
+
+
+ } @if (_metrics is { CostHistory.Count: > 0 }) { @@ -208,6 +227,9 @@ else private bool _loading = true; private HealthProfileDto? _profile; private List _records = []; + private Dictionary> _recordsByYear = new(); + private List _years = []; + private int _selectedYear; private HealthMetricsDto? _metrics; private HealthRecordDto _modalEntry = NewEntry(""); private bool _showModal; @@ -268,11 +290,18 @@ else var result = await HealthRecordApi.GetAsync("", 1, int.MaxValue, new Dictionary { ["HealthProfileId"] = Id }); // most recent first - a journal reads with the newest entry on top, same call as HouseDetail _records = result.Items.OrderByDescending(r => r.HistoryDate).ToList(); + _recordsByYear = _records.GroupBy(r => r.HistoryDate.Year).ToDictionary(g => g.Key, g => g.ToList()); + _years = _recordsByYear.Keys.OrderByDescending(y => y).ToList(); + // same "keep the current tab if it still exists, else default to the newest" rule as + // TvShowDetail.razor's own season selection + if (!_years.Contains(_selectedYear)) _selectedYear = _years.Count > 0 ? _years[0] : 0; _metrics = await HealthProfileApi.GetMetricsAsync(Id); } _loading = false; } + private void SelectYear(int year) => _selectedYear = year; + private async Task SaveProfileAsync() { if (_profile is null) return; diff --git a/src/BlazorApp/Components/Inventory/Pages/HouseDetail.razor b/src/BlazorApp/Components/Inventory/Pages/HouseDetail.razor index e2db48b0..60a3df00 100644 --- a/src/BlazorApp/Components/Inventory/Pages/HouseDetail.razor +++ b/src/BlazorApp/Components/Inventory/Pages/HouseDetail.razor @@ -93,32 +93,54 @@ else

History

-
-
- - - - - - - - - - - - - @if (_history.Count == 0) - { - - } - @foreach (var entry in _history) - { - - } - -
DateEventCostProviderDescription
No history yet - add the first entry above.
+ @* kt-table-grid opts this table out of the mobile stacked-cards treatment (see HealthProfileDetail.razor's + journal for the same pattern) - a real spreadsheet-like grid with only the most important columns + (Provider/Description) stay desktop-only via d-none d-md-table-cell. + One tab per year (same kt-season-picker/kt-page-btn header as TvShowDetail.razor's season tabs and + HealthProfileDetail.razor's journal), newest year first. *@ + @if (_history.Count == 0) + { +
+
+ + + + +
No history yet - add the first entry above.
+
-
+ } + else + { +
+ @foreach (var year in _years) + { + + } +
+
+
+ + + + + + + + + + + + + @foreach (var entry in _historyByYear.GetValueOrDefault(_selectedYear, [])) + { + + } + +
DateEventCostProviderDescription
+
+
+ } @if (_showModal) { @@ -177,6 +199,9 @@ else private bool _loading = true; private HouseDto? _house; private List _history = []; + private Dictionary> _historyByYear = new(); + private List _years = []; + private int _selectedYear; private HouseMetricsDto? _metrics; private HouseHistoryDto _modalEntry = NewEntry(""); private bool _showModal; @@ -204,14 +229,20 @@ else if (_house is not null) { var result = await HouseHistoryApi.GetAsync("", 1, int.MaxValue, new Dictionary { ["HouseId"] = Id }); - // Most recent first - unlike CarDetail's chronological (ascending) order, House has no mileage-style - // ordering dependency, and a browsable insurance/maintenance log reads better with the newest entry on top. + // most recent first - same journal convention as HealthProfileDetail/CarDetail _history = result.Items.OrderByDescending(h => h.HistoryDate).ToList(); + _historyByYear = _history.GroupBy(h => h.HistoryDate.Year).ToDictionary(g => g.Key, g => g.ToList()); + _years = _historyByYear.Keys.OrderByDescending(y => y).ToList(); + // same "keep the current tab if it still exists, else default to the newest" rule as + // TvShowDetail.razor's own season selection + if (!_years.Contains(_selectedYear)) _selectedYear = _years.Count > 0 ? _years[0] : 0; _metrics = await HouseApi.GetMetricsAsync(Id); } _loading = false; } + private void SelectYear(int year) => _selectedYear = year; + private async Task SaveHouseAsync() { if (_house is null) return; diff --git a/src/BlazorApp/Components/Inventory/Pages/HouseHistoryRow.razor b/src/BlazorApp/Components/Inventory/Pages/HouseHistoryRow.razor index fafebb8d..028f1be7 100644 --- a/src/BlazorApp/Components/Inventory/Pages/HouseHistoryRow.razor +++ b/src/BlazorApp/Components/Inventory/Pages/HouseHistoryRow.razor @@ -1,16 +1,17 @@ @* One row of the house history sheet - a compact summary of the most important fields. Editing happens - through the same modal used to add a new entry (HouseDetail.razor), opened via the Edit button below. *@ + through the same modal used to add a new entry (HouseDetail.razor), opened via the Edit button below. + Edit/delete icons default to text presentation (no FE0F) - same convention as HealthRecordRow.razor. *@ @Entry.HistoryDate.ToString("yyyy-MM-dd") - @Entry.EventType - @Entry.Cost?.ToString("F2") - @Entry.Provider - @Entry.Description + @Entry.EventType + @Entry.Cost?.ToString("F2") + @Entry.Provider + @Entry.Description -
- - +
+ +
From 31a3301594d662453a635a65621b82a4185265b1 Mon Sep 17 00:00:00 2001 From: Bertrand THOMAS Date: Sat, 18 Jul 2026 00:14:58 +0200 Subject: [PATCH 07/10] Small code rework --- src/Domain/Services/CarMetricsService.cs | 9 ++--- src/Domain/Services/HealthMetricsService.cs | 4 +- src/Domain/Services/HouseMetricsService.cs | 22 ++++++----- src/WebApi/Controllers/CarController.cs | 16 ++++---- src/WebApi/Controllers/HouseController.cs | 13 ++++--- src/WebApi/Program.cs | 31 ++++++---------- .../Services/CarMetricsServiceTest.cs | 37 +++++++++++-------- .../Services/HealthMetricsServiceTest.cs | 2 - .../Services/HouseMetricsServiceTest.cs | 10 ++--- 9 files changed, 71 insertions(+), 73 deletions(-) diff --git a/src/Domain/Services/CarMetricsService.cs b/src/Domain/Services/CarMetricsService.cs index b8a27c32..c25923db 100644 --- a/src/Domain/Services/CarMetricsService.cs +++ b/src/Domain/Services/CarMetricsService.cs @@ -6,15 +6,14 @@ namespace Keeptrack.Domain.Services; /// -/// Computes fuel/electric consumption, cost history, mileage-consistency warnings and a next-maintenance-due -/// estimate from a car's full intervention history. Pure computation over an in-memory list, same shape as -/// WatchNextService - nothing here is persisted, so a car's metrics are always derived fresh from its history. +/// Computes fuel/electric consumption, cost history, mileage-consistency warnings and a next-maintenance-due estimate from a car's full intervention history. +/// Pure computation over an in-memory list, same shape as WatchNextService - nothing here is persisted, so a car's metrics are always derived fresh from its history. /// -public class CarMetricsService +public static class CarMetricsService { private const double MileageToleranceKm = 1; - public CarMetricsModel ComputeMetrics(IEnumerable history) + public static CarMetricsModel ComputeMetrics(IEnumerable history) { var historyList = history.ToList(); diff --git a/src/Domain/Services/HealthMetricsService.cs b/src/Domain/Services/HealthMetricsService.cs index fa60159d..7e85b979 100644 --- a/src/Domain/Services/HealthMetricsService.cs +++ b/src/Domain/Services/HealthMetricsService.cs @@ -11,12 +11,12 @@ namespace Keeptrack.Domain.Services; /// Three views the raw journal can't answer at a glance: /// what health costs per year after reimbursements, when each practitioner was last seen, and which paid records are still waiting on a reimbursement. /// -public class HealthMetricsService +public static class HealthMetricsService { /// /// Two amounts closer than this are "equal" - reimbursement arithmetic runs on doubles, and a sub-cent residue must never flag a genuinely settled record. /// - private const double BalanceTolerance = 0.005; + private const double BalanceTolerance = 0.05; public static HealthMetricsModel ComputeMetrics(IEnumerable records) { diff --git a/src/Domain/Services/HouseMetricsService.cs b/src/Domain/Services/HouseMetricsService.cs index 70f87d8c..49cdd3aa 100644 --- a/src/Domain/Services/HouseMetricsService.cs +++ b/src/Domain/Services/HouseMetricsService.cs @@ -5,19 +5,20 @@ namespace Keeptrack.Domain.Services; /// -/// Pure, stateless computation over a house's history - no persistence of its own, same shape as -/// /. Deliberately limited to a yearly cost -/// breakdown: House has no reminders/due-date engine (unlike Car's ComputeNextMaintenanceDue) by design - -/// the owner tracks recurring bills/maintenance elsewhere and only wants an exhaustive record plus a yearly -/// cost review here. +/// Pure, stateless computation over a house's history - no persistence of its own, same shape as /. +/// Deliberately limited to a yearly cost breakdown: House has no reminders/due-date engine (unlike Car's ComputeNextMaintenanceDue) by design - +/// the owner tracks recurring bills/maintenance elsewhere and only wants an exhaustive record plus a yearly cost review here. /// -public class HouseMetricsService +public static class HouseMetricsService { - public HouseMetricsModel ComputeMetrics(IEnumerable history) => - new() { CostHistory = ComputeAnnualCostHistory(history) }; + public static HouseMetricsModel ComputeMetrics(IEnumerable history) + { + return new HouseMetricsModel { CostHistory = ComputeAnnualCostHistory(history) }; + } - private static List ComputeAnnualCostHistory(IEnumerable history) => - history + private static List ComputeAnnualCostHistory(IEnumerable history) + { + return history .Where(h => h.Cost is not null) .GroupBy(h => h.HistoryDate.Year) .OrderBy(g => g.Key) @@ -31,4 +32,5 @@ private static List ComputeAnnualCostHistory(IEnumer .ToList() }) .ToList(); + } } diff --git a/src/WebApi/Controllers/CarController.cs b/src/WebApi/Controllers/CarController.cs index f268156b..19d0a05a 100644 --- a/src/WebApi/Controllers/CarController.cs +++ b/src/WebApi/Controllers/CarController.cs @@ -14,13 +14,11 @@ public class CarController( IDtoMapper mapper, ICarRepository dataRepository, ICarHistoryRepository carHistoryRepository, - CarMetricsService metricsService, CarMetricsDtoMapper metricsMapper) : DataCrudControllerBase(mapper, dataRepository) { /// - /// Computed fuel/electric consumption, cost history, mileage warnings and next-maintenance estimate for - /// this car - see . + /// Computed fuel/electric consumption, cost history, mileage warnings and next-maintenance estimate for this car - see . /// [HttpGet("{id}/metrics")] [ProducesResponseType(200)] @@ -35,13 +33,15 @@ public async Task> GetMetrics(string id) var history = await carHistoryRepository.FindAllAsync(ownerId, 1, int.MaxValue, null, new CarHistoryModel { OwnerId = ownerId, CarId = id, EventType = default, HistoryDate = default }); - return Ok(metricsMapper.ToDto(metricsService.ComputeMetrics(history.Items))); + return Ok(metricsMapper.ToDto(CarMetricsService.ComputeMetrics(history.Items))); } /// - /// CarHistory is a separate top-level collection referencing its car by id, not an embedded array - /// (see CLAUDE.md's "Child entities" section) - without this, deleting a car would leave its fuel/ - /// maintenance history orphaned in MongoDB forever, since it's only ever reachable via the car's own id. + /// CarHistory is a separate top-level collection referencing its car by id - + /// without this, deleting a car would leave its fuel/maintenance history orphaned in MongoDB forever, since it's only ever reachable via the car's own id. /// - protected override async Task OnDeletedAsync(string id, string ownerId) => await carHistoryRepository.DeleteAllForCarAsync(id, ownerId); + protected override async Task OnDeletedAsync(string id, string ownerId) + { + await carHistoryRepository.DeleteAllForCarAsync(id, ownerId); + } } diff --git a/src/WebApi/Controllers/HouseController.cs b/src/WebApi/Controllers/HouseController.cs index 8c2f7282..5dccd499 100644 --- a/src/WebApi/Controllers/HouseController.cs +++ b/src/WebApi/Controllers/HouseController.cs @@ -14,7 +14,6 @@ public class HouseController( IDtoMapper mapper, IHouseRepository dataRepository, IHouseHistoryRepository houseHistoryRepository, - HouseMetricsService metricsService, HouseMetricsDtoMapper metricsMapper) : DataCrudControllerBase(mapper, dataRepository) { @@ -34,13 +33,15 @@ public async Task> GetMetrics(string id) var history = await houseHistoryRepository.FindAllAsync(ownerId, 1, int.MaxValue, null, new HouseHistoryModel { OwnerId = ownerId, HouseId = id, EventType = default, HistoryDate = default }); - return Ok(metricsMapper.ToDto(metricsService.ComputeMetrics(history.Items))); + return Ok(metricsMapper.ToDto(HouseMetricsService.ComputeMetrics(history.Items))); } /// - /// HouseHistory is a separate top-level collection referencing its house by id, not an embedded array - /// (see CLAUDE.md's "Child entities" section) - without this, deleting a house would leave its history - /// orphaned in MongoDB forever, since it's only ever reachable via the house's own id. + /// HouseHistory is a separate top-level collection referencing its house by id - + /// without this, deleting a house would leave its history orphaned in MongoDB forever, since it's only ever reachable via the house's own id. /// - protected override async Task OnDeletedAsync(string id, string ownerId) => await houseHistoryRepository.DeleteAllForHouseAsync(id, ownerId); + protected override async Task OnDeletedAsync(string id, string ownerId) + { + await houseHistoryRepository.DeleteAllForHouseAsync(id, ownerId); + } } diff --git a/src/WebApi/Program.cs b/src/WebApi/Program.cs index 82ef5702..cfeb8d60 100644 --- a/src/WebApi/Program.cs +++ b/src/WebApi/Program.cs @@ -11,11 +11,7 @@ // a hosted BackgroundService (e.g. ReferenceSyncBackgroundService) already catches and logs every exception it can anticipate, // but this is a systemic safety net for whatever it doesn't: by default, an unhandled exception escaping a BackgroundService.ExecuteAsync stops the whole host, // taking every other endpoint down with it - "Ignore" logs it instead and lets the rest of the app keep serving requests. -builder.Services.Configure(opts => - opts.BackgroundServiceExceptionBehavior = BackgroundServiceExceptionBehavior.Ignore); -builder.Services.AddSingleton(); -builder.Services.AddSingleton(); -builder.Services.AddSingleton(); +builder.Services.Configure(opts => opts.BackgroundServiceExceptionBehavior = BackgroundServiceExceptionBehavior.Ignore); builder.Services.AddSingleton, Keeptrack.WebApi.Mappers.BookDtoMapper>(); builder.Services.AddSingleton, Keeptrack.WebApi.Mappers.CarDtoMapper>(); builder.Services.AddSingleton, Keeptrack.WebApi.Mappers.CarHistoryDtoMapper>(); @@ -49,12 +45,11 @@ builder.Services.AddHttpClient(client => { client.BaseAddress = new Uri("https://api.themoviedb.org/3/"); - // AddStandardResilienceHandler's own TotalRequestTimeout (30s default) is meant to be the real bound - // on a call - but HttpClient's own Timeout (100s default, never otherwise touched here) wraps the - // whole pipeline including every retry, and silently wins whenever it's shorter than however long the - // resilience pipeline actually takes to give up. Disabling it lets the resilience handler's own - // timeout be authoritative instead of a stuck call hanging for a full 100s - see - // https://github.com/dotnet/extensions/issues/4770 (confirmed against this exact symptom on Discogs). + // AddStandardResilienceHandler's own TotalRequestTimeout (30s default) is meant to be the real bound on a call - + // but HttpClient's own Timeout (100s default, never otherwise touched here) wraps the whole pipeline including every retry, + // and silently wins whenever it's shorter than however long the resilience pipeline actually takes to give up. + // Disabling it lets the resilience handler's own timeout be authoritative instead of a stuck call hanging for a full 100s - + // see https://github.com/dotnet/extensions/issues/4770 (confirmed against this exact symptom on Discogs). client.Timeout = Timeout.InfiniteTimeSpan; }).AddProviderResilienceHandler(); // which IBookReferenceClient implementation is registered is a deployment-time choice @@ -93,10 +88,9 @@ builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) .AddJwtBearer(options => { - // Firebase's own claim names ("user_id", "role", ...) must stay exactly as issued - without this, - // some claim types (short JWT claim names like "role") get silently renamed to legacy ClaimTypes.* - // URIs by the token handler's inbound claim mapping, breaking RequireClaim("role", ...) checks - // that look for the literal type "role". + // Firebase's own claim names ("user_id", "role", ...) must stay exactly as issued - + // without this, some claim types (short JWT claim names like "role") get silently renamed to legacy ClaimTypes. + // URIs by the token handler's inbound claim mapping, breaking RequireClaim("role", ...) checks that look for the literal type "role". options.MapInboundClaims = false; options.Authority = configuration.JwtBearerSettings.Authority; options.TokenValidationParameters = new TokenValidationParameters @@ -111,10 +105,9 @@ builder.Services.AddAuthorization(options => { options.AddPolicy("AdminOnly", policy => policy.RequireClaim("role", "admin")); - // members (and admins - a membership must never be *less* than the owner's own account) get the - // full app; authenticated users without the role are the free preview tier (movies + TV shows, - // capped - see DataCrudControllerBase). Granted the same way as admin: a Firebase custom claim - // role=member via the Admin SDK (see CONTRIBUTING.md). + // members (and admins - a membership must never be *less* than the owner's own account) get the full app; + // authenticated users without the role are the free preview tier (movies + TV shows, capped - see DataCrudControllerBase). + // Granted the same way as admin: a Firebase custom claim role=member via the Admin SDK (see CONTRIBUTING.md). options.AddPolicy("MemberOnly", policy => policy.RequireClaim("role", "member", "admin")); }); if (configuration.CorsAllowedOrigin.Count != 0) diff --git a/test/WebApi.UnitTests/Services/CarMetricsServiceTest.cs b/test/WebApi.UnitTests/Services/CarMetricsServiceTest.cs index 6b8a6ac4..6d738509 100644 --- a/test/WebApi.UnitTests/Services/CarMetricsServiceTest.cs +++ b/test/WebApi.UnitTests/Services/CarMetricsServiceTest.cs @@ -9,8 +9,6 @@ namespace Keeptrack.WebApi.UnitTests.Services; [Trait("Category", "UnitTests")] public class CarMetricsServiceTest { - private readonly CarMetricsService _service = new(); - private static CarHistoryModel Refuel( string id, DateTime date, int mileage, double? fuelVolume = null, double? electricVolume = null, bool isFullRefill = true, @@ -31,7 +29,16 @@ private static CarHistoryModel Refuel( }; private static CarHistoryModel Maintenance(string id, DateTime date, int? mileage = null, double? cost = null) => - new() { Id = id, OwnerId = "owner", CarId = "car-1", HistoryDate = date, Mileage = mileage, EventType = CarHistoryType.Maintenance, Cost = cost }; + new() + { + Id = id, + OwnerId = "owner", + CarId = "car-1", + HistoryDate = date, + Mileage = mileage, + EventType = CarHistoryType.Maintenance, + Cost = cost + }; [Fact] public void ComputeMetrics_FuelConsumption_OnlyEmitsAPointAcrossAFullRefill() @@ -43,7 +50,7 @@ public void ComputeMetrics_FuelConsumption_OnlyEmitsAPointAcrossAFullRefill() Refuel("h3", new DateTime(2024, 2, 1), 1400, fuelVolume: 25, isFullRefill: true) }; - var result = _service.ComputeMetrics(history); + var result = CarMetricsService.ComputeMetrics(history); result.FuelConsumption.Should().ContainSingle(); result.FuelConsumption[0].ValuePer100Km.Should().BeApproximately(11.25, 0.001); @@ -61,7 +68,7 @@ public void ComputeMetrics_FuelAndElectricConsumption_AreComputedIndependentlyFo Refuel("e2", new DateTime(2024, 1, 20), 1300, electricVolume: 30, isFullRefill: true) }; - var result = _service.ComputeMetrics(history); + var result = CarMetricsService.ComputeMetrics(history); result.FuelConsumption.Should().ContainSingle(); result.FuelConsumption[0].ValuePer100Km.Should().BeApproximately(35.0 / 500 * 100, 0.001); @@ -80,7 +87,7 @@ public void ComputeMetrics_CostHistory_GroupsByMonthAndSplitsFuelFromMaintenance Refuel("h3", new DateTime(2024, 2, 5), 1500, fuelVolume: 40, cost: 65) }; - var result = _service.ComputeMetrics(history); + var result = CarMetricsService.ComputeMetrics(history); result.CostHistory.Should().HaveCount(2); var january = result.CostHistory[0]; @@ -97,7 +104,7 @@ public void ComputeMetrics_NextMaintenance_IsNullWhenNoMaintenanceHistoryExists( { var history = new[] { Refuel("h1", new DateTime(2024, 1, 1), 1000, fuelVolume: 40) }; - var result = _service.ComputeMetrics(history); + var result = CarMetricsService.ComputeMetrics(history); result.NextMaintenance.Should().BeNull(); } @@ -108,7 +115,7 @@ public void ComputeMetrics_NextMaintenance_IsOneYearAfterTheLastMaintenanceEvent var lastMaintenance = DateOnly.FromDateTime(DateTime.Today).AddMonths(-10); var history = new[] { Maintenance("h1", lastMaintenance.ToDateTime(TimeOnly.MinValue)) }; - var result = _service.ComputeMetrics(history); + var result = CarMetricsService.ComputeMetrics(history); result.NextMaintenance.Should().NotBeNull(); result.NextMaintenance!.LastMaintenanceDate.Should().Be(lastMaintenance); @@ -122,7 +129,7 @@ public void ComputeMetrics_NextMaintenance_ReportsNegativeMonthsWhenOverdue() var lastMaintenance = DateOnly.FromDateTime(DateTime.Today).AddMonths(-14); var history = new[] { Maintenance("h1", lastMaintenance.ToDateTime(TimeOnly.MinValue)) }; - var result = _service.ComputeMetrics(history); + var result = CarMetricsService.ComputeMetrics(history); result.NextMaintenance!.MonthsRemaining.Should().Be(-2); } @@ -136,7 +143,7 @@ public void ComputeMetrics_MileageWarnings_FlagsAnOdometerRegression() Refuel("h2", new DateTime(2024, 2, 1), 4800, fuelVolume: 40) }; - var result = _service.ComputeMetrics(history); + var result = CarMetricsService.ComputeMetrics(history); result.MileageWarnings.Should().ContainSingle(w => w.CarHistoryId == "h2"); } @@ -144,15 +151,15 @@ public void ComputeMetrics_MileageWarnings_FlagsAnOdometerRegression() [Fact] public void ComputeMetrics_MileageWarnings_FlagsADeltaMismatchAndSuggestsAMissingEntry() { - // the trip computer says 300 km since the last refuel, but the odometer jumped 900 km since the - // previous entry in the app - a refuel was very likely never logged in between + // the trip computer says 300 km since the last refuel, but the odometer jumped 900 km since the previous entry in the app - + // a refuel was very likely never logged in between var history = new[] { Refuel("h1", new DateTime(2024, 1, 1), 1000, fuelVolume: 40), Refuel("h2", new DateTime(2024, 2, 1), 1900, fuelVolume: 40, deltaMileage: 300) }; - var result = _service.ComputeMetrics(history); + var result = CarMetricsService.ComputeMetrics(history); result.MileageWarnings.Should().ContainSingle(w => w.CarHistoryId == "h2"); result.MileageWarnings[0].Message.Should().Contain("missing"); @@ -167,7 +174,7 @@ public void ComputeMetrics_MileageWarnings_FlagsADeltaMismatchWithoutSuggestingA Refuel("h2", new DateTime(2024, 2, 1), 1300, fuelVolume: 40, deltaMileage: 500) }; - var result = _service.ComputeMetrics(history); + var result = CarMetricsService.ComputeMetrics(history); result.MileageWarnings.Should().ContainSingle(w => w.CarHistoryId == "h2"); result.MileageWarnings[0].Message.Should().NotContain("missing"); @@ -182,7 +189,7 @@ public void ComputeMetrics_MileageWarnings_IsSilentWhenDeltaMileageMatchesWithin Refuel("h2", new DateTime(2024, 2, 1), 1500, fuelVolume: 40, deltaMileage: 500.4) }; - var result = _service.ComputeMetrics(history); + var result = CarMetricsService.ComputeMetrics(history); result.MileageWarnings.Should().BeEmpty(); } diff --git a/test/WebApi.UnitTests/Services/HealthMetricsServiceTest.cs b/test/WebApi.UnitTests/Services/HealthMetricsServiceTest.cs index ce56a37d..12efa795 100644 --- a/test/WebApi.UnitTests/Services/HealthMetricsServiceTest.cs +++ b/test/WebApi.UnitTests/Services/HealthMetricsServiceTest.cs @@ -9,8 +9,6 @@ namespace Keeptrack.WebApi.UnitTests.Services; [Trait("Category", "UnitTests")] public class HealthMetricsServiceTest { - private readonly HealthMetricsService _service = new(); - private static HealthRecordModel Record( string id, DateTime date, diff --git a/test/WebApi.UnitTests/Services/HouseMetricsServiceTest.cs b/test/WebApi.UnitTests/Services/HouseMetricsServiceTest.cs index fe1d3597..9ce51883 100644 --- a/test/WebApi.UnitTests/Services/HouseMetricsServiceTest.cs +++ b/test/WebApi.UnitTests/Services/HouseMetricsServiceTest.cs @@ -9,15 +9,13 @@ namespace Keeptrack.WebApi.UnitTests.Services; [Trait("Category", "UnitTests")] public class HouseMetricsServiceTest { - private readonly HouseMetricsService _service = new(); - private static HouseHistoryModel Entry(string id, DateOnly date, HouseEventType eventType, double? cost = null) => new() { Id = id, OwnerId = "owner", HouseId = "house-1", HistoryDate = date, EventType = eventType, Cost = cost }; [Fact] public void ComputeMetrics_CostHistory_IsEmpty_WhenThereIsNoHistory() { - var result = _service.ComputeMetrics([]); + var result = HouseMetricsService.ComputeMetrics([]); result.CostHistory.Should().BeEmpty(); } @@ -27,7 +25,7 @@ public void ComputeMetrics_CostHistory_ExcludesEntriesWithNoCost() { var history = new[] { Entry("h1", new DateOnly(2024, 3, 1), HouseEventType.Maintenance, cost: null) }; - var result = _service.ComputeMetrics(history); + var result = HouseMetricsService.ComputeMetrics(history); result.CostHistory.Should().BeEmpty(); } @@ -42,7 +40,7 @@ public void ComputeMetrics_CostHistory_GroupsByYearAndSumsCost() Entry("h3", new DateOnly(2025, 2, 1), HouseEventType.Purchase, cost: 400) }; - var result = _service.ComputeMetrics(history); + var result = HouseMetricsService.ComputeMetrics(history); result.CostHistory.Should().HaveCount(2); var year2024 = result.CostHistory[0]; @@ -64,7 +62,7 @@ public void ComputeMetrics_CostHistory_BreaksDownByCategoryWithinAYear() Entry("h3", new DateOnly(2024, 6, 1), HouseEventType.Maintenance, cost: 150) }; - var result = _service.ComputeMetrics(history); + var result = HouseMetricsService.ComputeMetrics(history); var year2024 = result.CostHistory.Should().ContainSingle().Subject; year2024.CostByCategory.Should().HaveCount(2); From 157d4af0193a0eb93ea326e2cf983649bb19f96b Mon Sep 17 00:00:00 2001 From: Bertrand THOMAS Date: Sat, 18 Jul 2026 01:06:48 +0200 Subject: [PATCH 08/10] Redesign ownership for video games MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both verification passes succeeded — the mobile/desktop screenshots show the redesigned Platforms section matches the movies/books ownership UI, and the new Playwright smoke test confirms the interactive flow: draft-card with Save/Cancel, empty entries delete without prompting, and entries with data (state set) trigger the "Remove this platform?" confirmation. Summary of changes to VideoGameDetail.razor: - "Add a platform" is now a draft card (Platform select + copy-type buttons + Save/Cancel) opened via a "+ Add platform" button, instead of an always-visible form that persisted immediately. - The remove action uses the shared TrashIcon instead of a plain "✕", and asks for confirmation via ConfirmModal — skipped only when the platform has no state/completion/playthroughs set (mirroring OwnedVersionsEditor's empty-copy shortcut). - All existing fields/logic (Platform, CopyType, State, IsFullyCompleted/FullyCompletedAt, Playthroughs) and the data persisted to the DB are unchanged — this is UI/UX only. Also, per your requests mid-task, I kept two test additions: - MobileScreenshotTest.cs: added video-game-detail (mobile) and video-game-detail-desktop captures. - New VideoGamePlatformSmokeTest.cs: an end-to-end Playwright test for the draft/save/cancel and confirm-before-remove flow, mirroring OwnershipSmokeTest. --- .../Inventory/Pages/VideoGameDetail.razor | 96 ++++++++++++++----- .../Smoke/MobileScreenshotTest.cs | 4 + .../Smoke/VideoGamePlatformSmokeTest.cs | 64 +++++++++++++ 3 files changed, 141 insertions(+), 23 deletions(-) create mode 100644 test/BlazorApp.PlaywrightTests/Smoke/VideoGamePlatformSmokeTest.cs diff --git a/src/BlazorApp/Components/Inventory/Pages/VideoGameDetail.razor b/src/BlazorApp/Components/Inventory/Pages/VideoGameDetail.razor index 1b83d7c9..2291a731 100644 --- a/src/BlazorApp/Components/Inventory/Pages/VideoGameDetail.razor +++ b/src/BlazorApp/Components/Inventory/Pages/VideoGameDetail.razor @@ -90,7 +90,13 @@ else

Platforms

- @if (_game.Platforms.Count == 0) + + + @if (_game.Platforms.Count == 0 && _draftPlatform is null) {

No platforms tracked yet - add one below.

} @@ -106,7 +112,9 @@ else
- +
@@ -144,21 +152,31 @@ else
} -
- -
- - - - + @if (_draftPlatform is not null) + { +
+ +
+ + + +
+
+ + +
-
+ } + else + { + + } } @code { @@ -177,8 +195,8 @@ else private string? _refreshMessage; private string _refreshMessageStyle = "neutral"; private object? _refreshMessageToken; - private string _newPlatform = ""; - private CopyType _newCopyType; + private VideoGamePlatformDto? _draftPlatform; + private VideoGamePlatformDto? _pendingRemovePlatform; protected override async Task OnParametersSetAsync() => await LoadAsync(); @@ -235,15 +253,41 @@ else await SaveGameAsync(); } - private async Task AddPlatformAsync() + private void StartDraftPlatform() => _draftPlatform = new VideoGamePlatformDto(); + + private void CancelDraftPlatform() => _draftPlatform = null; + + private async Task SaveDraftPlatformAsync() { - if (_game is null || string.IsNullOrEmpty(_newPlatform)) return; - _game.Platforms.Add(new VideoGamePlatformDto { Platform = _newPlatform, CopyType = _newCopyType }); - _newPlatform = ""; - _newCopyType = CopyType.Physical; + if (_game is null || _draftPlatform is null || string.IsNullOrEmpty(_draftPlatform.Platform)) return; + _game.Platforms.Add(_draftPlatform); + _draftPlatform = null; await SaveGameAsync(); } + private async Task RequestRemovePlatformAsync(VideoGamePlatformDto entry) + { + // a platform entry with no state/completion/playthroughs set has no details to lose - + // remove it without the confirmation detour, same as OwnedVersionsEditor's empty-copy case + if (IsEmpty(entry)) + { + await RemovePlatformAsync(entry); + return; + } + + _pendingRemovePlatform = entry; + } + + private async Task ConfirmRemovePlatformAsync() + { + var entry = _pendingRemovePlatform; + _pendingRemovePlatform = null; + if (entry is not null) + { + await RemovePlatformAsync(entry); + } + } + private async Task RemovePlatformAsync(VideoGamePlatformDto entry) { if (_game is null) return; @@ -251,6 +295,12 @@ else await SaveGameAsync(); } + private static bool IsEmpty(VideoGamePlatformDto entry) => + string.IsNullOrWhiteSpace(entry.State) + && !entry.IsFullyCompleted + && entry.FullyCompletedAt is null + && entry.Playthroughs.Count == 0; + private async Task SetCopyTypeAsync(VideoGamePlatformDto entry, CopyType copyType) { entry.CopyType = copyType; diff --git a/test/BlazorApp.PlaywrightTests/Smoke/MobileScreenshotTest.cs b/test/BlazorApp.PlaywrightTests/Smoke/MobileScreenshotTest.cs index b0fcbe8f..366d325c 100644 --- a/test/BlazorApp.PlaywrightTests/Smoke/MobileScreenshotTest.cs +++ b/test/BlazorApp.PlaywrightTests/Smoke/MobileScreenshotTest.cs @@ -87,6 +87,7 @@ public async Task CaptureAllPagesAtPhoneViewport() await CaptureFirstDetailAsync("/cars", "car-detail"); await CaptureFirstDetailAsync("/books", "book-detail"); await CaptureFirstDetailAsync("/health", "health-detail"); + await CaptureFirstDetailAsync("/video-games", "video-game-detail"); // The Add form modal on a list page. await Page.GotoAsync("/movies"); @@ -141,6 +142,9 @@ public async Task CaptureAllPagesAtPhoneViewport() await Page.ScreenshotAsync(new PageScreenshotOptions { Path = Path.Combine(ShotsDirectory, "admin-album-expanded.png"), FullPage = true }); } + await Page.SetViewportSizeAsync(1280, 900); + await CaptureFirstDetailAsync("/video-games", "video-game-detail-desktop"); + // A dark-theme sample of the densest pages. await Page.EmulateMediaAsync(new PageEmulateMediaOptions { ColorScheme = ColorScheme.Dark }); await CaptureAsync("/movies", "movies-dark"); diff --git a/test/BlazorApp.PlaywrightTests/Smoke/VideoGamePlatformSmokeTest.cs b/test/BlazorApp.PlaywrightTests/Smoke/VideoGamePlatformSmokeTest.cs new file mode 100644 index 00000000..474109f1 --- /dev/null +++ b/test/BlazorApp.PlaywrightTests/Smoke/VideoGamePlatformSmokeTest.cs @@ -0,0 +1,64 @@ +using System; +using System.Threading.Tasks; +using Keeptrack.BlazorApp.PlaywrightTests.Hosting; +using Keeptrack.BlazorApp.PlaywrightTests.Pages; +using Microsoft.Playwright; +using Xunit; + +namespace Keeptrack.BlazorApp.PlaywrightTests.Smoke; + +/// +/// Proves the video game platform editor's draft-card + trash-icon flow end-to-end - the same +/// draft-until-Save and confirm-before-remove UX as 's owned-versions +/// flow, applied here to VideoGamePlatformDto's own field set (state, not price/vendor/reference). +/// +[Trait("Category", "E2eTests")] +[Trait("Mode", "Mutating")] +public class VideoGamePlatformSmokeTest(End2EndFixture fixture) : SmokeTestBase(fixture) +{ + [Fact] + public async Task AddingAndRemovingAPlatform_DrivesTheDraftAndConfirmFlow() + { + SkipIfReadOnly(); + + var title = $"E2e Platform Game {Guid.NewGuid():N}"; + + var home = await new HomePage(Page).OpenAsync(); + var list = await home.OpenVideoGamesAsync(); + await list.ClickAddAsync(); + await list.FillByPlaceholderAsync("Title", title); + await list.SaveNewAsync(); + + var detail = new VideoGameDetailPage(Page); + await detail.WaitForReadyAsync(); + + // a new platform is a draft until its Save button - cancelling discards it + await Page.GetByRole(AriaRole.Button, new PageGetByRoleOptions { Name = "+ Add platform" }).ClickAsync(); + await Page.GetByRole(AriaRole.Button, new PageGetByRoleOptions { Name = "Cancel" }).ClickAsync(); + await Assertions.Expect(Page.GetByRole(AriaRole.Button, new PageGetByRoleOptions { Name = "+ Add platform" })).ToBeVisibleAsync(); + + // add a platform (defaults to Physical) and save the draft - an untouched platform has no + // details to lose, so removing it needs no confirmation + await Page.GetByRole(AriaRole.Button, new PageGetByRoleOptions { Name = "+ Add platform" }).ClickAsync(); + await Page.Locator("select.form-select-sm").SelectOptionAsync("PC"); + await Page.GetByRole(AriaRole.Button, new PageGetByRoleOptions { Name = "Save", Exact = true }).ClickAsync(); + await Assertions.Expect(Page.GetByRole(AriaRole.Heading, new PageGetByRoleOptions { Name = "PC" })).ToBeVisibleAsync(); + + await Page.GetByRole(AriaRole.Button, new PageGetByRoleOptions { Name = "Remove this platform" }).ClickAsync(); + await Assertions.Expect(Page.GetByRole(AriaRole.Heading, new PageGetByRoleOptions { Name = "PC" })).Not.ToBeVisibleAsync(); + + // re-add and set a state this time - the platform now has a detail to lose, so removing it asks first + await Page.GetByRole(AriaRole.Button, new PageGetByRoleOptions { Name = "+ Add platform" }).ClickAsync(); + await Page.Locator("select.form-select-sm").SelectOptionAsync("PC"); + await Page.GetByRole(AriaRole.Button, new PageGetByRoleOptions { Name = "Save", Exact = true }).ClickAsync(); + await Page.GetByRole(AriaRole.Button, new PageGetByRoleOptions { Name = "Current" }).ClickAsync(); + + await Page.GetByRole(AriaRole.Button, new PageGetByRoleOptions { Name = "Remove this platform" }).ClickAsync(); + await Assertions.Expect(Page.GetByText("Remove this platform?")).ToBeVisibleAsync(); + await Page.GetByRole(AriaRole.Button, new PageGetByRoleOptions { Name = "Remove", Exact = true }).ClickAsync(); + await Assertions.Expect(Page.GetByRole(AriaRole.Heading, new PageGetByRoleOptions { Name = "PC" })).Not.ToBeVisibleAsync(); + + list = await detail.OpenVideoGamesAsync(); + await list.DeleteAsync(title); + } +} From 29845f442e074f53f495c4d2475c50f85d8ed065 Mon Sep 17 00:00:00 2001 From: Bertrand THOMAS Date: Sat, 18 Jul 2026 01:11:37 +0200 Subject: [PATCH 09/10] Update favicon --- src/BlazorApp/wwwroot/favicon.png | Bin 1148 -> 942 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/src/BlazorApp/wwwroot/favicon.png b/src/BlazorApp/wwwroot/favicon.png index 8422b59695935d180d11d5dbe99653e711097819..ee3398687b5cd78e6683915ddc9b334a9dcaa220 100644 GIT binary patch literal 942 zcmV;f15x~mP)My?zf55B?fy0G21_@FG2Iwcbz{)XH3n@v7iO+;f$V%o zxeYtFH@-Q!|CP!YE4S7mpWRveG|KGSxt)8Ek5u|-_HG}|Soz?j{+^_rt)D?!+1lA= zwssoVf-c<{#&u)Z)Qut1jRDF*8Uy5jO9PK)>SriRQadGSXKTH@AMluN47Y9Y$in0F zPm!ZQ?FTFvUjPN4~k1j)ncMFgV-Yp><_^CxAKvH;@ zgb?6u0g}Tz5`HmF{mcSC85+FakO9CT!EFe1Y*ChmlpIUfj3y2cDBf$&| zZwZJZ{7Aw>GY~u$5KVZpc8CNY@TrB)6#)^0cS-O8?-meIct^rd&+w@V5#Vu#2NN9G z*5UbIbZ5Qm2|iUhK$_nHrc4t2!n*|cncl(#C(7_60YOIBA|(WcPgPzbjm6x4SAYZ- z@QwfmhZZ~~u!2vPK>))O8yI6n2|ihV0Rq?_SHbWiD)3hXusf>@LqZtvlmPYzl`JKM z1)nTG2La)o(!zJU%<##15D@Yabr1<*!&?G)oX`7_zym%x{|p51xt!od0x$T4O8|%S zc^G5K1n&socsT*aShBz;N+3YM&5WHf5=6i!N>6D4d~asZ5=6lh0d3y!WPS%@ERpb* z09-TS4o?P{BH+~6uj2~CTsXaao^N%!AM1T6ff?#G-y{T3XkH&J^RYM-L6s& z-i#ObVpu>guNXXJ%heje)iPD!ou^M0^y+Z6Y|`N&g|W>s=+!9zzxm(pAoS{RwM;#D zO2Qme%O>odFL>?{L$wUrRT2NI+q(9H=Uv2M0SZRYv5CU=7It{B$+-#mKRh={V}z4w QKmY&$07*qoM6N<$f^>1JB>(^b literal 1148 zcmV-?1cUpDP)9h26h2-Cs%i*@Moc3?#6qJID|D#|3|2Hn7gTIYEkr|%Xjp);YgvFmB&0#2E2b=| zkVr)lMv9=KqwN&%obTp-$<51T%rx*NCwceh-E+=&e(oLO`@Z~7gybJ#U|^tB2Pai} zRN@5%1qsZ1e@R(XC8n~)nU1S0QdzEYlWPdUpH{wJ2Pd4V8kI3BM=)sG^IkUXF2-j{ zrPTYA6sxpQ`Q1c6mtar~gG~#;lt=s^6_OccmRd>o{*=>)KS=lM zZ!)iG|8G0-9s3VLm`bsa6e ze*TlRxAjXtm^F8V`M1%s5d@tYS>&+_ga#xKGb|!oUBx3uc@mj1%=MaH4GR0tPBG_& z9OZE;->dO@`Q)nr<%dHAsEZRKl zedN6+3+uGHejJp;Q==pskSAcRcyh@6mjm2z-uG;s%dM-u0*u##7OxI7wwyCGpS?4U zBFAr(%GBv5j$jS@@t@iI8?ZqE36I^4t+P^J9D^ELbS5KMtZ z{Qn#JnSd$15nJ$ggkF%I4yUQC+BjDF^}AtB7w348EL>7#sAsLWs}ndp8^DsAcOIL9 zTOO!!0!k2`9BLk25)NeZp7ev>I1Mn={cWI3Yhx2Q#DnAo4IphoV~R^c0x&nw*MoIV zPthX?{6{u}sMS(MxD*dmd5rU(YazQE59b|TsB5Tm)I4a!VaN@HYOR)DwH1U5y(E)z zQqQU*B%MwtRQ$%x&;1p%ANmc|PkoFJZ%<-uq%PX&C!c-7ypis=eP+FCeuv+B@h#{4 zGx1m0PjS~FJt}3mdt4c!lel`1;4W|03kcZRG+DzkTy|7-F~eDsV2Tx!73dM0H0CTh zl)F-YUkE1zEzEW(;JXc|KR5{ox%YTh{$%F$a36JP6Nb<0%#NbSh$dMYF-{ z1_x(Vx)}fs?5_|!5xBTWiiIQHG<%)*e=45Fhjw_tlnmlixq;mUdC$R8v#j( zhQ$9YR-o%i5Uc`S?6EC51!bTRK=Xkyb<18FkCKnS2;o*qlij1YA@-nRpq#OMTX&RbL<^2q@0qja!uIvI;j$6>~k@IMwD42=8$$!+R^@5o6HX(*n~ Date: Sat, 18 Jul 2026 01:35:15 +0200 Subject: [PATCH 10/10] Update house fields MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Domain/schema (HouseModel, House entity, HouseDto): - Removed Address, PostalCode, Country. - City is now required on the model/entity; stays nullable on the DTO (forced by the InventoryPageBase bare new() constraint — same as BookDto.Title), with the existing ToRequiredString fallback covering the gap. - Added PropertyType enum (House/Apartment/Other) — a real discriminated enum, duplicated in WebApi.Contracts per the CarEnergyType/HouseEventType convention, required on the model, nullable on the DTO with a hand-written null→default fallback (mirroring CarDtoMapper.EnergyType). - Added optional MovedInAt/MovedOutAt (DateOnly?, DateTime? in Mongo), wired through the shared CommonStorageMappings DateOnly↔DateTime conversion. UI: Houses.razor list badges/add-form now show City + PropertyType instead of Address; HouseDetail.razor gets a PropertyType button row (matching TvShowDetail/VideoGameDetail's per-item selector pattern) plus City/Moved-in/Moved-out fields, replacing the old address block. Migration: since the Mongo driver enforces C#'s required on read, I added scripts/migrate-house-schema.js to backfill city/property_type on existing documents (reporting affected houses for manual review) and drop the retired fields — run it once against any environment with existing house data before deploying. Verified: full solution build succeeds (including Mapperly's escalated-to-error unmapped-member diagnostics), WebApi and BlazorApp unit test suites both pass. I didn't run the MongoDB-backed integration test for HouseResourceTest since I couldn't confirm a local MongoDB instance in this session — worth running dotnet test test/WebApi.IntegrationTests --filter-method "*HouseResourceTest*" yourself to confirm the full CRUD round-trip. --- scripts/migrate-house-schema.js | 34 +++++++++++++++++ .../Inventory/Pages/HouseDetail.razor | 37 ++++++++++--------- .../Components/Inventory/Pages/Houses.razor | 11 ++---- src/Domain/Models/HouseModel.cs | 11 ++++-- src/Domain/Models/PropertyType.cs | 8 ++++ src/Infrastructure.MongoDb/Entities/House.cs | 14 ++++--- .../Mappers/HouseStorageMapper.cs | 1 + src/WebApi.Contracts/Dto/HouseDto.cs | 17 +++++---- src/WebApi.Contracts/Dto/PropertyType.cs | 14 +++++++ src/WebApi/Mappers/HouseDtoMapper.cs | 15 +++++++- .../Resources/HouseResourceTest.cs | 6 +-- 11 files changed, 123 insertions(+), 45 deletions(-) create mode 100644 scripts/migrate-house-schema.js create mode 100644 src/Domain/Models/PropertyType.cs create mode 100644 src/WebApi.Contracts/Dto/PropertyType.cs diff --git a/scripts/migrate-house-schema.js b/scripts/migrate-house-schema.js new file mode 100644 index 00000000..d5ffb94c --- /dev/null +++ b/scripts/migrate-house-schema.js @@ -0,0 +1,34 @@ +// One-off data migration: `House` dropped `address`/`postal_code`/`country` and made `city` mandatory, +// plus added a new mandatory `property_type` field (see CLAUDE.md's House section). The MongoDB C# driver +// enforces C#'s `required` keyword on deserialization, so any pre-existing `house` document missing +// `city` or `property_type` would fail to load once this ships - this script backfills both before that +// happens, then drops the three retired fields. +// +// `city` can't be reliably derived from the old free-text `address` field, and `property_type` has no +// prior signal at all, so both get a neutral placeholder ("" / "Other") rather than a guess - same +// "don't guess when you don't have the info" principle as the rest of the reference-data code. Affected +// houses are printed below so the owner can fill in the real values by hand. +// +// Idempotent: the $set only touches documents still missing `city`/`property_type`, and the $unset only +// matches documents still carrying the retired fields. +// +// Run once per environment with house data older than this change, e.g.: +// mongosh "mongodb://localhost:27017/keeptrack_dev" scripts/migrate-house-schema.js + +db.house + .find({ $or: [{ city: { $exists: false } }, { property_type: { $exists: false } }] }, { name: 1, address: 1, city: 1 }) + .forEach(h => print(`house "${h.name}" (was: ${h.address ?? "no address"}) needs its city/property type reviewed`)); + +// two separate updates - a blanket $set on the combined $or filter would clobber a document that already +// has one of the two fields with the placeholder for the other +const cityBackfilled = db.house.updateMany({ city: { $exists: false } }, { $set: { city: "" } }); +print(`house: backfilled city on ${cityBackfilled.modifiedCount} document(s)`); + +const propertyTypeBackfilled = db.house.updateMany({ property_type: { $exists: false } }, { $set: { property_type: "Other" } }); +print(`house: backfilled property_type on ${propertyTypeBackfilled.modifiedCount} document(s)`); + +const cleaned = db.house.updateMany( + { $or: [{ address: { $exists: true } }, { postal_code: { $exists: true } }, { country: { $exists: true } }] }, + { $unset: { address: "", postal_code: "", country: "" } } +); +print(`house: removed retired address/postal_code/country fields from ${cleaned.modifiedCount} document(s)`); diff --git a/src/BlazorApp/Components/Inventory/Pages/HouseDetail.razor b/src/BlazorApp/Components/Inventory/Pages/HouseDetail.razor index 60a3df00..2466751f 100644 --- a/src/BlazorApp/Components/Inventory/Pages/HouseDetail.razor +++ b/src/BlazorApp/Components/Inventory/Pages/HouseDetail.razor @@ -26,23 +26,26 @@ else
+
+ @foreach (var propertyType in Enum.GetValues()) + { + + } +
+
-
- - -
-
+
-
- - +
+ +
-
- - +
+ +
@@ -258,10 +261,10 @@ else await SaveHouseAsync(); } - private async Task SaveAddressAsync(ChangeEventArgs e) + private async Task SetPropertyTypeAsync(PropertyType propertyType) { if (_house is null) return; - _house.Address = e.Value?.ToString(); + _house.PropertyType = propertyType; await SaveHouseAsync(); } @@ -272,17 +275,17 @@ else await SaveHouseAsync(); } - private async Task SavePostalCodeAsync(ChangeEventArgs e) + private async Task SaveMovedInAtAsync(ChangeEventArgs e) { if (_house is null) return; - _house.PostalCode = e.Value?.ToString(); + _house.MovedInAt = DateOnly.TryParse(e.Value?.ToString(), out var date) ? date : null; await SaveHouseAsync(); } - private async Task SaveCountryAsync(ChangeEventArgs e) + private async Task SaveMovedOutAtAsync(ChangeEventArgs e) { if (_house is null) return; - _house.Country = e.Value?.ToString(); + _house.MovedOutAt = DateOnly.TryParse(e.Value?.ToString(), out var date) ? date : null; await SaveHouseAsync(); } diff --git a/src/BlazorApp/Components/Inventory/Pages/Houses.razor b/src/BlazorApp/Components/Inventory/Pages/Houses.razor index d4c31f1a..65276afc 100644 --- a/src/BlazorApp/Components/Inventory/Pages/Houses.razor +++ b/src/BlazorApp/Components/Inventory/Pages/Houses.razor @@ -28,23 +28,20 @@ Sort="@_sort" OnSortChanged="@SetSort"> - @if (!string.IsNullOrEmpty(house.Address)) - { - @house.Address - } @if (!string.IsNullOrEmpty(house.City)) { @house.City } + @if (house.PropertyType is not null) + { + @house.PropertyType + }
- -
-
diff --git a/src/Domain/Models/HouseModel.cs b/src/Domain/Models/HouseModel.cs index 3b20f0fc..a35ab155 100644 --- a/src/Domain/Models/HouseModel.cs +++ b/src/Domain/Models/HouseModel.cs @@ -1,3 +1,4 @@ +using System; using Keeptrack.Common.System; namespace Keeptrack.Domain.Models; @@ -10,13 +11,15 @@ public class HouseModel : IHasIdAndOwnerId public required string Name { get; set; } - public string? Address { get; set; } + public required string City { get; set; } - public string? City { get; set; } + public required PropertyType PropertyType { get; set; } - public string? PostalCode { get; set; } + /// When the owner moved into this property, if recorded. + public DateOnly? MovedInAt { get; set; } - public string? Country { get; set; } + /// When the owner moved out of this property, if recorded (unset while still occupied). + public DateOnly? MovedOutAt { get; set; } public string? Notes { get; set; } } diff --git a/src/Domain/Models/PropertyType.cs b/src/Domain/Models/PropertyType.cs new file mode 100644 index 00000000..88609387 --- /dev/null +++ b/src/Domain/Models/PropertyType.cs @@ -0,0 +1,8 @@ +namespace Keeptrack.Domain.Models; + +public enum PropertyType +{ + House, + Apartment, + Other +} diff --git a/src/Infrastructure.MongoDb/Entities/House.cs b/src/Infrastructure.MongoDb/Entities/House.cs index 0bc6e1d2..9f0322f1 100644 --- a/src/Infrastructure.MongoDb/Entities/House.cs +++ b/src/Infrastructure.MongoDb/Entities/House.cs @@ -1,4 +1,6 @@ +using System; using Keeptrack.Common.System; +using Keeptrack.Domain.Models; using MongoDB.Bson; using MongoDB.Bson.Serialization.Attributes; @@ -15,14 +17,16 @@ public class House : IHasIdAndOwnerId public required string Name { get; set; } - public string? Address { get; set; } + public required string City { get; set; } - public string? City { get; set; } + [BsonElement("property_type")] + public required PropertyType PropertyType { get; set; } - [BsonElement("postal_code")] - public string? PostalCode { get; set; } + [BsonElement("moved_in_at")] + public DateTime? MovedInAt { get; set; } - public string? Country { get; set; } + [BsonElement("moved_out_at")] + public DateTime? MovedOutAt { get; set; } public string? Notes { get; set; } } diff --git a/src/Infrastructure.MongoDb/Mappers/HouseStorageMapper.cs b/src/Infrastructure.MongoDb/Mappers/HouseStorageMapper.cs index 9ed7544e..af865bc4 100644 --- a/src/Infrastructure.MongoDb/Mappers/HouseStorageMapper.cs +++ b/src/Infrastructure.MongoDb/Mappers/HouseStorageMapper.cs @@ -6,6 +6,7 @@ namespace Keeptrack.Infrastructure.MongoDb.Mappers; [Mapper] +[UseStaticMapper(typeof(CommonStorageMappings))] public partial class HouseStorageMapper : IStorageMapper { public partial House ToEntity(HouseModel model); diff --git a/src/WebApi.Contracts/Dto/HouseDto.cs b/src/WebApi.Contracts/Dto/HouseDto.cs index 4da05df6..683583f0 100644 --- a/src/WebApi.Contracts/Dto/HouseDto.cs +++ b/src/WebApi.Contracts/Dto/HouseDto.cs @@ -1,3 +1,4 @@ +using System; using Keeptrack.Common.System; namespace Keeptrack.WebApi.Contracts.Dto; @@ -18,24 +19,24 @@ public class HouseDto : IHasId public string? Name { get; set; } /// - /// Street address. + /// City. /// - public string? Address { get; set; } + public string? City { get; set; } /// - /// City. + /// Type of property (house, apartment, or other). /// - public string? City { get; set; } + public PropertyType? PropertyType { get; set; } /// - /// Postal code. + /// When the owner moved into this property, if recorded. /// - public string? PostalCode { get; set; } + public DateOnly? MovedInAt { get; set; } /// - /// Country. + /// When the owner moved out of this property, if recorded (unset while still occupied). /// - public string? Country { get; set; } + public DateOnly? MovedOutAt { get; set; } /// /// Free-text notes. diff --git a/src/WebApi.Contracts/Dto/PropertyType.cs b/src/WebApi.Contracts/Dto/PropertyType.cs new file mode 100644 index 00000000..bdf085e0 --- /dev/null +++ b/src/WebApi.Contracts/Dto/PropertyType.cs @@ -0,0 +1,14 @@ +namespace Keeptrack.WebApi.Contracts.Dto; + +/// +/// Type of property (house, apartment, or other). Kept as a separate definition from +/// Keeptrack.Domain.Models.PropertyType since WebApi.Contracts doesn't depend on Domain - member +/// names must stay identical so the generated Mapperly mapper (EnumMappingStrategy.ByName) can +/// map enum-to-enum by name. +/// +public enum PropertyType +{ + House, + Apartment, + Other +} diff --git a/src/WebApi/Mappers/HouseDtoMapper.cs b/src/WebApi/Mappers/HouseDtoMapper.cs index 2f9e3274..af354c6e 100644 --- a/src/WebApi/Mappers/HouseDtoMapper.cs +++ b/src/WebApi/Mappers/HouseDtoMapper.cs @@ -3,7 +3,7 @@ namespace Keeptrack.WebApi.Mappers; -[Mapper] +[Mapper(EnumMappingStrategy = EnumMappingStrategy.ByName)] [UseStaticMapper(typeof(CommonDtoMappings))] public partial class HouseDtoMapper : IDtoMapper { @@ -13,4 +13,17 @@ public partial class HouseDtoMapper : IDtoMapper [MapperIgnoreSource(nameof(HouseModel.OwnerId))] public partial HouseDto ToDto(HouseModel model); + + /// + /// HouseDto.PropertyType is nullable (PropertyType?) while HouseModel.PropertyType is required - the + /// same "nullable DTO member mapping to a required model member" gotcha as CarDtoMapper.EnergyType, + /// so it needs the same hand-written null -> default fallback wrapping the generated ByName conversion. + /// + [UserMapping] + private Domain.Models.PropertyType ToRequiredPropertyType(Contracts.Dto.PropertyType? value) + { + return value.HasValue ? MapPropertyType(value.Value) : default; + } + + private partial Domain.Models.PropertyType MapPropertyType(Contracts.Dto.PropertyType value); } diff --git a/test/WebApi.IntegrationTests/Resources/HouseResourceTest.cs b/test/WebApi.IntegrationTests/Resources/HouseResourceTest.cs index 87b1a801..230a99ca 100644 --- a/test/WebApi.IntegrationTests/Resources/HouseResourceTest.cs +++ b/test/WebApi.IntegrationTests/Resources/HouseResourceTest.cs @@ -29,10 +29,10 @@ public async Task HouseResourceFullCycle_IsOk() .Rules((f, o) => { o.Name = f.Random.AlphaNumeric(14); - o.Address = f.Address.StreetAddress(); o.City = f.Address.City(); - o.PostalCode = f.Address.ZipCode(); - o.Country = f.Address.Country(); + o.PropertyType = f.PickRandom(); + o.MovedInAt = System.DateOnly.FromDateTime(f.Date.Past()); + o.MovedOutAt = System.DateOnly.FromDateTime(f.Date.Recent()); }) .Generate(); var created = await PostAsync($"/{ResourceEndpoint}", input);