diff --git a/CLAUDE.md b/CLAUDE.md
index 759c1c3a..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).
@@ -186,6 +191,25 @@ 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.
+The detail page is deliberately journal-first and badge-only (owner feedback after seeing a first version with summary panels): NO "to check" list above the table (the per-row ⚠ badge is the whole warning surface),
+no chart, no per-row reimbursement column (the edit modal holds the money detail), and the compact yearly Paid/Reimbursed/OutOfPocket table sits *after* the journal - this table grows large, so rows carry only the absolute essentials.
+`HealthMetricsService` also computes `LastVisits` ("when did I last see this practitioner" - appointments grouped by practitioner+specialty, most recent first; only practitioner/count/date are displayed).
+Both controllers are `MemberOnly` (health data is never part of the free preview tier).
+`HealthImportService` (`POST /api/import/health`, `MemberOnly` like all imports - CarHistoryImportController was fixed to match, since imports create data through repositories and would otherwise bypass controller policies)
+is the CarHistoryImportService-style one-off Excel import of the personal "Journal_sante.xlsx": one sheet, every family member mixed in one "Personne" column (profiles created/matched by name, case-insensitive),
+a SECOND "Personne" column meaning the practitioner (so header lookup is position-aware, not a plain name dictionary), and the derived "Reste à charge" formula column deliberately NOT imported - the app recomputes the balance,
+so unsettled historical rows surface with the ⚠ badge for the owner's own review. Shared cell parsing lives in `ExcelCellParser` (extracted from the car importer rather than duplicated).
+Verified against the real sample file end-to-end (3 profiles, 13 rows, zero warnings), and `HealthImportServiceTest` pins the file's quirks with an in-memory ClosedXML workbook.
+
`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.
@@ -704,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/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/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/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/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/Import/HealthImportApiClient.cs b/src/BlazorApp/Components/Import/HealthImportApiClient.cs
new file mode 100644
index 00000000..0002ab01
--- /dev/null
+++ b/src/BlazorApp/Components/Import/HealthImportApiClient.cs
@@ -0,0 +1,20 @@
+using System.Net.Http.Headers;
+using Keeptrack.WebApi.Contracts.Dto;
+
+namespace Keeptrack.BlazorApp.Components.Import;
+
+public sealed class HealthImportApiClient(HttpClient http)
+{
+ public async Task ImportAsync(Stream xlsxStream, string fileName)
+ {
+ using var content = new MultipartFormDataContent();
+ using var fileContent = new StreamContent(xlsxStream);
+ fileContent.Headers.ContentType = new MediaTypeHeaderValue("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
+ content.Add(fileContent, "file", fileName);
+
+ var response = await http.PostAsync("/api/import/health", content);
+ response.EnsureSuccessStatusCode();
+
+ return (await response.Content.ReadFromJsonAsync())!;
+ }
+}
diff --git a/src/BlazorApp/Components/Import/ImportPage.razor b/src/BlazorApp/Components/Import/ImportPage.razor
index 26532109..5892bae2 100644
--- a/src/BlazorApp/Components/Import/ImportPage.razor
+++ b/src/BlazorApp/Components/Import/ImportPage.razor
@@ -97,6 +97,53 @@
}
+
+
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.
+
+ @* 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)
+ {
+
+ }
@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. *@
+
+ @* No summary panels before the journal on purpose (owner feedback): the journal is the page's
+ 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)
+ {
+
+ @* 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.
+ 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)
+ {
+
+
+ @* 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 Dictionary> _recordsByYear = new();
+ private List _years = [];
+ private int _selectedYear;
+ 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();
+ _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;
+ 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;
+ }
+
+ 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 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()
+ {
+ 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();
+ }
+}
diff --git a/src/BlazorApp/Components/Inventory/Pages/HealthProfiles.razor b/src/BlazorApp/Components/Inventory/Pages/HealthProfiles.razor
new file mode 100644
index 00000000..ac4b2902
--- /dev/null
+++ b/src/BlazorApp/Components/Inventory/Pages/HealthProfiles.razor
@@ -0,0 +1,41 @@
+@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..e6e6e362
--- /dev/null
+++ b/src/BlazorApp/Components/Inventory/Pages/HealthRecordRow.razor
@@ -0,0 +1,42 @@
+@* 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). *@
+
+
+
@DateText
+
@Entry.Specialty
+
@Entry.Practitioner
+ @* wider screens have room for more than the mobile essentials (d-md-table-cell = hidden below 768px) *@
+
@Entry.Description
+
@Entry.Price?.ToString("F2")
+
+ @if (IsUnbalanced)
+ {
+ ⚠
+ }
+
+
+
+
+
+
+
+
+
+@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.
+ [Parameter] public bool IsUnbalanced { get; set; }
+
+ [Parameter] public EventCallback OnEdit { get; set; }
+
+ [Parameter] public EventCallback OnDelete { get; set; }
+
+ /// 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/Components/Inventory/Pages/HouseDetail.razor b/src/BlazorApp/Components/Inventory/Pages/HouseDetail.razor
index e2db48b0..2466751f 100644
--- a/src/BlazorApp/Components/Inventory/Pages/HouseDetail.razor
+++ b/src/BlazorApp/Components/Inventory/Pages/HouseDetail.razor
@@ -26,23 +26,26 @@ else
+ @* 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)
+ {
+
+ }
@if (_showModal)
{
@@ -177,6 +202,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 +232,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;
@@ -227,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();
}
@@ -241,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/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. *@
- @* 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. *@
}
-
+@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/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
+
+ @* U+271A Heavy Greek Cross: default text presentation (not in Unicode's
+ emoji-data), unlike the medical-cross emoji - see the theme section's
+ emoji gotcha in CLAUDE.md *@
+
+ ✚ Health
+
+
⬆ Import
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/DependencyInjection/InfrastructureServiceCollectionExtensions.cs b/src/BlazorApp/DependencyInjection/InfrastructureServiceCollectionExtensions.cs
index 664eab06..53dff133 100644
--- a/src/BlazorApp/DependencyInjection/InfrastructureServiceCollectionExtensions.cs
+++ b/src/BlazorApp/DependencyInjection/InfrastructureServiceCollectionExtensions.cs
@@ -17,6 +17,10 @@ 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)
.AddHttpMessageHandler();
services.AddHttpClient(client => client.BaseAddress = webApiUri)
@@ -37,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/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 35d530e5..0d508f03 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) {
@@ -988,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/BlazorApp/wwwroot/favicon.png b/src/BlazorApp/wwwroot/favicon.png
index 8422b596..ee339868 100644
Binary files a/src/BlazorApp/wwwroot/favicon.png and b/src/BlazorApp/wwwroot/favicon.png differ
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/Models/HealthEventType.cs b/src/Domain/Models/HealthEventType.cs
new file mode 100644
index 00000000..86ca9813
--- /dev/null
+++ b/src/Domain/Models/HealthEventType.cs
@@ -0,0 +1,14 @@
+namespace Keeptrack.Domain.Models;
+
+///
+/// What kind of health-journal entry a is - a real discriminated enum
+/// (never a bare free-text "type"), same rule as /.
+/// Appointment carries the specialty/practitioner/price/reimbursement fields; Sickness is "I was sick,
+/// here's why" with no money attached; Other covers everything else (vaccination, test results, ...).
+///
+public enum HealthEventType
+{
+ Appointment,
+ Sickness,
+ Other
+}
diff --git a/src/Domain/Models/HealthMetricsModel.cs b/src/Domain/Models/HealthMetricsModel.cs
new file mode 100644
index 00000000..167594da
--- /dev/null
+++ b/src/Domain/Models/HealthMetricsModel.cs
@@ -0,0 +1,63 @@
+using System;
+using System.Collections.Generic;
+
+namespace Keeptrack.Domain.Models;
+
+///
+/// Computed metrics for one health profile - see .
+///
+public class HealthMetricsModel
+{
+ public required List CostHistory { get; set; }
+
+ public required List LastVisits { get; set; }
+
+ public required List UnbalancedRecords { get; set; }
+}
+
+///
+/// One year of health spending: what was paid, what came back, what it really cost.
+///
+public class HealthCostHistoryPointModel
+{
+ public required int Year { get; set; }
+
+ public required double TotalPaid { get; set; }
+
+ public required double TotalReimbursed { get; set; }
+
+ /// What the year really cost after every reimbursement: paid minus reimbursed.
+ public required double OutOfPocket { get; set; }
+}
+
+///
+/// "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 Specialty { get; set; }
+
+ public required DateTime LastVisitDate { get; set; }
+}
+
+///
+/// A paid record whose money doesn't balance: price - public - insurance - leftover isn't zero.
+/// Positive = money still expected (chase the mutuelle, check the bank
+/// account); negative = more was received than the price accounts for, worth a look too. A record with
+/// nothing entered yet is simply missing its full price - the same list covers "not started" and
+/// "partially settled" alike.
+///
+public class HealthUnbalancedRecordModel
+{
+ public required string RecordId { get; set; }
+
+ public required DateTime HistoryDate { get; set; }
+
+ /// Whatever identifies the record best: practitioner, else description, else specialty.
+ public required string Label { get; set; }
+
+ public required double Price { get; set; }
+
+ public required double MissingAmount { get; set; }
+}
diff --git a/src/Domain/Models/HealthProfileModel.cs b/src/Domain/Models/HealthProfileModel.cs
new file mode 100644
index 00000000..4299689d
--- /dev/null
+++ b/src/Domain/Models/HealthProfileModel.cs
@@ -0,0 +1,19 @@
+using Keeptrack.Common.System;
+
+namespace Keeptrack.Domain.Models;
+
+///
+/// Whose health journal this is - the Car/House-style parent of .
+/// One per person: typically just the account owner, but a household tracking a child's or partner's
+/// appointments creates one profile each, exactly like owning two cars.
+///
+public class HealthProfileModel : IHasIdAndOwnerId
+{
+ public string? Id { get; set; }
+
+ public required string OwnerId { get; set; }
+
+ public required string Name { get; set; }
+
+ public string? Notes { get; set; }
+}
diff --git a/src/Domain/Models/HealthRecordModel.cs b/src/Domain/Models/HealthRecordModel.cs
new file mode 100644
index 00000000..83f2fbf2
--- /dev/null
+++ b/src/Domain/Models/HealthRecordModel.cs
@@ -0,0 +1,51 @@
+using System;
+using Keeptrack.Common.System;
+
+namespace Keeptrack.Domain.Models;
+
+///
+/// One health-journal entry (an appointment, a sickness, anything else dated), owned by a
+/// - a separate top-level collection referencing its parent by id, same
+/// deliberate schema choice as CarHistory/Episode (unbounded per-parent growth over years; see CLAUDE.md's
+/// "Child entities" section). is a full (Car's pattern,
+/// not House's DateOnly): an appointment's time of day is real information ("was I there at 9:00 or 17:30").
+///
+public class HealthRecordModel : IHasIdAndOwnerId
+{
+ public string? Id { get; set; }
+
+ public required string OwnerId { get; set; }
+
+ public required string HealthProfileId { get; set; }
+
+ public required DateTime HistoryDate { get; set; }
+
+ public required HealthEventType EventType { get; set; }
+
+ /// Medical specialty ("généraliste", "dentiste", "dermatologue", ...) - free text.
+ public string? Specialty { get; set; }
+
+ /// The practitioner's name - what "when did I last see Dr X" is answered from.
+ public string? Practitioner { get; set; }
+
+ public string? Description { get; set; }
+
+ public string? Notes { get; set; }
+
+ /// What was paid at the time, before any reimbursement.
+ public double? Price { get; set; }
+
+ /// Reimbursement from the public health system (assurance maladie), filled in later.
+ public double? PublicReimbursement { get; set; }
+
+ /// Reimbursement from the private/complementary insurer (mutuelle), filled in later.
+ public double? InsuranceReimbursement { get; set; }
+
+ ///
+ /// The accepted remainder (reste à charge): the part of the price no one will ever reimburse.
+ /// Recording it is what lets the balance check close: a record is settled exactly when
+ /// price - public - insurance - leftover == 0, and anything else is flagged for the owner to chase
+ /// (see ).
+ ///
+ public double? NotCovered { get; set; }
+}
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/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/Domain/Repositories/IHealthProfileRepository.cs b/src/Domain/Repositories/IHealthProfileRepository.cs
new file mode 100644
index 00000000..aac1b524
--- /dev/null
+++ b/src/Domain/Repositories/IHealthProfileRepository.cs
@@ -0,0 +1,7 @@
+using Keeptrack.Domain.Models;
+
+namespace Keeptrack.Domain.Repositories;
+
+public interface IHealthProfileRepository : IDataRepository
+{
+}
diff --git a/src/Domain/Repositories/IHealthRecordRepository.cs b/src/Domain/Repositories/IHealthRecordRepository.cs
new file mode 100644
index 00000000..dda17a6b
--- /dev/null
+++ b/src/Domain/Repositories/IHealthRecordRepository.cs
@@ -0,0 +1,14 @@
+using System.Threading.Tasks;
+using Keeptrack.Domain.Models;
+
+namespace Keeptrack.Domain.Repositories;
+
+public interface IHealthRecordRepository : IDataRepository
+{
+ ///
+ /// Deletes every record owned by for the given profile - used to cascade
+ /// a profile deletion, since HealthRecord is a separate top-level collection referencing its parent by
+ /// id rather than an embedded array (see CLAUDE.md's "Child entities" section).
+ ///
+ Task DeleteAllForProfileAsync(string healthProfileId, string ownerId);
+}
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
new file mode 100644
index 00000000..7e85b979
--- /dev/null
+++ b/src/Domain/Services/HealthMetricsService.cs
@@ -0,0 +1,98 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using Keeptrack.Domain.Models;
+
+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.
+///
+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.05;
+
+ public static HealthMetricsModel ComputeMetrics(IEnumerable records)
+ {
+ var list = records.ToList();
+ 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.
+ ///
+ private static double ComputeMissingAmount(HealthRecordModel record)
+ {
+ return (record.Price ?? 0) - (record.PublicReimbursement ?? 0) - (record.InsuranceReimbursement ?? 0) - (record.NotCovered ?? 0);
+ }
+
+ private static bool IsBalanced(HealthRecordModel record)
+ {
+ return Math.Abs(ComputeMissingAmount(record)) <= BalanceTolerance;
+ }
+
+ 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)
+ .Select(g =>
+ {
+ 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 };
+ })
+ .ToList();
+ }
+
+ ///
+ /// 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)
+ {
+ 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) })
+ .OrderByDescending(v => v.LastVisitDate)
+ .ToList();
+ }
+
+ ///
+ /// Every paid record whose money doesn't balance to zero, oldest first (the longest-waiting claim is
+ /// the one to chase). This deliberately flags partial settlements too: a record with only the ameli
+ /// payment entered is exactly the "did the mutuelle ever pay?" case the check exists for, and a
+ /// negative missing amount (more received than the price accounts for) is just as worth a look.
+ ///
+ 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
+ {
+ RecordId = r.Id!,
+ HistoryDate = r.HistoryDate,
+ Label = FirstNonEmpty(r.Practitioner, r.Description, r.Specialty) ?? r.EventType.ToString(),
+ Price = r.Price!.Value,
+ MissingAmount = ComputeMissingAmount(r)
+ })
+ .ToList();
+ }
+
+ private static string? FirstNonEmpty(params string?[] values)
+ {
+ return values.FirstOrDefault(v => !string.IsNullOrWhiteSpace(v));
+ }
+}
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/Infrastructure.MongoDb/Entities/HealthProfile.cs b/src/Infrastructure.MongoDb/Entities/HealthProfile.cs
new file mode 100644
index 00000000..ac47fcfc
--- /dev/null
+++ b/src/Infrastructure.MongoDb/Entities/HealthProfile.cs
@@ -0,0 +1,19 @@
+using Keeptrack.Common.System;
+using MongoDB.Bson;
+using MongoDB.Bson.Serialization.Attributes;
+
+namespace Keeptrack.Infrastructure.MongoDb.Entities;
+
+public class HealthProfile : IHasIdAndOwnerId
+{
+ [BsonId]
+ [BsonRepresentation(BsonType.ObjectId)]
+ public string? Id { get; set; }
+
+ [BsonElement("owner_id")]
+ public required string OwnerId { get; set; }
+
+ public required string Name { get; set; }
+
+ public string? Notes { get; set; }
+}
diff --git a/src/Infrastructure.MongoDb/Entities/HealthRecord.cs b/src/Infrastructure.MongoDb/Entities/HealthRecord.cs
new file mode 100644
index 00000000..4470fd96
--- /dev/null
+++ b/src/Infrastructure.MongoDb/Entities/HealthRecord.cs
@@ -0,0 +1,45 @@
+using System;
+using Keeptrack.Common.System;
+using Keeptrack.Domain.Models;
+using MongoDB.Bson;
+using MongoDB.Bson.Serialization.Attributes;
+
+namespace Keeptrack.Infrastructure.MongoDb.Entities;
+
+public class HealthRecord : IHasIdAndOwnerId
+{
+ [BsonId]
+ [BsonRepresentation(BsonType.ObjectId)]
+ public string? Id { get; set; }
+
+ [BsonElement("owner_id")]
+ public required string OwnerId { get; set; }
+
+ [BsonElement("health_profile_id")]
+ public required string HealthProfileId { get; set; }
+
+ [BsonElement("history_date")]
+ public required DateTime HistoryDate { get; set; }
+
+ [BsonElement("event_type")]
+ public required HealthEventType EventType { get; set; }
+
+ public string? Specialty { get; set; }
+
+ public string? Practitioner { get; set; }
+
+ public string? Description { get; set; }
+
+ public string? Notes { get; set; }
+
+ public double? Price { get; set; }
+
+ [BsonElement("public_reimbursement")]
+ public double? PublicReimbursement { get; set; }
+
+ [BsonElement("insurance_reimbursement")]
+ public double? InsuranceReimbursement { get; set; }
+
+ [BsonElement("not_covered")]
+ public double? NotCovered { get; set; }
+}
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/HealthProfileStorageMapper.cs b/src/Infrastructure.MongoDb/Mappers/HealthProfileStorageMapper.cs
new file mode 100644
index 00000000..57c9ceb7
--- /dev/null
+++ b/src/Infrastructure.MongoDb/Mappers/HealthProfileStorageMapper.cs
@@ -0,0 +1,16 @@
+using System.Collections.Generic;
+using Keeptrack.Domain.Models;
+using Keeptrack.Infrastructure.MongoDb.Entities;
+using Riok.Mapperly.Abstractions;
+
+namespace Keeptrack.Infrastructure.MongoDb.Mappers;
+
+[Mapper]
+public partial class HealthProfileStorageMapper : IStorageMapper
+{
+ public partial HealthProfile ToEntity(HealthProfileModel model);
+
+ public partial HealthProfileModel ToModel(HealthProfile entity);
+
+ public partial List ToModels(List entities);
+}
diff --git a/src/Infrastructure.MongoDb/Mappers/HealthRecordStorageMapper.cs b/src/Infrastructure.MongoDb/Mappers/HealthRecordStorageMapper.cs
new file mode 100644
index 00000000..8d149bd4
--- /dev/null
+++ b/src/Infrastructure.MongoDb/Mappers/HealthRecordStorageMapper.cs
@@ -0,0 +1,24 @@
+using System;
+using System.Collections.Generic;
+using Keeptrack.Domain.Models;
+using Keeptrack.Infrastructure.MongoDb.Entities;
+using Riok.Mapperly.Abstractions;
+
+namespace Keeptrack.Infrastructure.MongoDb.Mappers;
+
+[Mapper]
+public partial class HealthRecordStorageMapper : IStorageMapper
+{
+ // HistoryDate is a plain DateTime on both sides (an appointment's time of day is real data, same as
+ // CarHistory - the shared DateOnly<->DateTime CommonStorageMappings deliberately doesn't apply), so
+ // nothing upstream stamps DateTimeKind.Utc and the Mongo driver's DateTimeSerializer requires it.
+ [MapProperty(nameof(HealthRecordModel.HistoryDate), nameof(HealthRecord.HistoryDate), Use = nameof(ToUtcDateTime))]
+ public partial HealthRecord ToEntity(HealthRecordModel model);
+
+ public partial HealthRecordModel ToModel(HealthRecord entity);
+
+ public partial List ToModels(List entities);
+
+ [UserMapping(Default = false)]
+ private static DateTime ToUtcDateTime(DateTime dateTime) => DateTime.SpecifyKind(dateTime, DateTimeKind.Utc);
+}
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/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
new file mode 100644
index 00000000..bfa0f1c1
--- /dev/null
+++ b/src/Infrastructure.MongoDb/Repositories/HealthProfileRepository.cs
@@ -0,0 +1,26 @@
+using System;
+using System.Linq.Expressions;
+using Keeptrack.Domain.Models;
+using Keeptrack.Domain.Repositories;
+using Keeptrack.Infrastructure.MongoDb.Entities;
+using Keeptrack.Infrastructure.MongoDb.Mappers;
+using Microsoft.Extensions.Logging;
+using MongoDB.Driver;
+
+namespace Keeptrack.Infrastructure.MongoDb.Repositories;
+
+public class HealthProfileRepository(IMongoDatabase mongoDatabase, ILogger logger, IStorageMapper mapper)
+ : MongoDbRepositoryBase(mongoDatabase, logger, mapper), IHealthProfileRepository
+{
+ protected override string CollectionName => "health_profile";
+
+ protected override Expression> SortTitleField => x => x.Name;
+
+ protected override FilterDefinition GetFilter(string ownerId, string? search, HealthProfileModel input)
+ {
+ var builder = Builders.Filter;
+ var filter = builder.Eq(f => f.OwnerId, ownerId);
+ if (!string.IsNullOrEmpty(search)) filter &= builder.Where(f => f.Name.Contains(search, System.StringComparison.CurrentCultureIgnoreCase));
+ return filter;
+ }
+}
diff --git a/src/Infrastructure.MongoDb/Repositories/HealthRecordRepository.cs b/src/Infrastructure.MongoDb/Repositories/HealthRecordRepository.cs
new file mode 100644
index 00000000..03593ec7
--- /dev/null
+++ b/src/Infrastructure.MongoDb/Repositories/HealthRecordRepository.cs
@@ -0,0 +1,39 @@
+using System.Threading.Tasks;
+using Keeptrack.Domain.Models;
+using Keeptrack.Domain.Repositories;
+using Keeptrack.Infrastructure.MongoDb.Entities;
+using Keeptrack.Infrastructure.MongoDb.Mappers;
+using Microsoft.Extensions.Logging;
+using MongoDB.Driver;
+
+namespace Keeptrack.Infrastructure.MongoDb.Repositories;
+
+public class HealthRecordRepository(IMongoDatabase mongoDatabase, ILogger logger, IStorageMapper mapper)
+ : MongoDbRepositoryBase(mongoDatabase, logger, mapper), IHealthRecordRepository
+{
+ protected override string CollectionName => "health_record";
+
+ public async Task DeleteAllForProfileAsync(string healthProfileId, string ownerId)
+ {
+ var filter = Builders.Filter.Eq(f => f.OwnerId, ownerId) & Builders.Filter.Eq(f => f.HealthProfileId, healthProfileId);
+ var result = await GetCollection().DeleteManyAsync(filter);
+ return result.DeletedCount;
+ }
+
+ protected override FilterDefinition GetFilter(string ownerId, string? search, HealthRecordModel input)
+ {
+ var builder = Builders.Filter;
+ var filter = builder.Eq(f => f.OwnerId, ownerId);
+ // parent-id filter with Eq, never Text - see CLAUDE.md's CarHistory gotcha
+ if (!string.IsNullOrEmpty(input.HealthProfileId)) filter &= builder.Eq(f => f.HealthProfileId, input.HealthProfileId);
+ if (!string.IsNullOrEmpty(search))
+ {
+ // "who/what was that" searches span the three identifying free-text fields, like Book's
+ // title/series/author triple
+ filter &= builder.Where(f => (f.Description != null && f.Description.Contains(search, System.StringComparison.CurrentCultureIgnoreCase))
+ || (f.Practitioner != null && f.Practitioner.Contains(search, System.StringComparison.CurrentCultureIgnoreCase))
+ || (f.Specialty != null && f.Specialty.Contains(search, System.StringComparison.CurrentCultureIgnoreCase)));
+ }
+ return 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..20d29727 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;
@@ -23,27 +25,26 @@ 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);
}
- public async Task> FindAllAsync(string ownerId, int page, int pageSize, string? search, TModel input)
+ 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 = new Collation("en", strength: CollationStrength.Secondary) }
+ : null;
+
var entities = await collection
- .Find(filter)
+ .Find(filter, options)
+ .Sort(GetSort(sort))
.Skip((page - 1) * pageSize)
.Limit(pageSize)
.ToListAsync();
@@ -56,8 +57,37 @@ public async Task> FindAllAsync(string ownerId, int page, in
);
}
- public async Task CountAsync(string ownerId) =>
- await GetCollection().CountDocumentsAsync(Builders.Filter.Eq(f => f.OwnerId, ownerId));
+ ///
+ /// 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)
+ {
+ return await GetCollection().CountDocumentsAsync(Builders.Filter.Eq(f => f.OwnerId, ownerId));
+ }
public async Task CreateAsync(TModel model)
{
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.Contracts/Dto/HealthEventType.cs b/src/WebApi.Contracts/Dto/HealthEventType.cs
new file mode 100644
index 00000000..091508ee
--- /dev/null
+++ b/src/WebApi.Contracts/Dto/HealthEventType.cs
@@ -0,0 +1,12 @@
+namespace Keeptrack.WebApi.Contracts.Dto;
+
+///
+/// What kind of health-journal entry a record is. Member names must stay identical to the Domain enum
+/// (mapped ByName - see CLAUDE.md's enum convention).
+///
+public enum HealthEventType
+{
+ Appointment,
+ Sickness,
+ Other
+}
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.Contracts/Dto/HealthMetricsDto.cs b/src/WebApi.Contracts/Dto/HealthMetricsDto.cs
new file mode 100644
index 00000000..a5c71a3f
--- /dev/null
+++ b/src/WebApi.Contracts/Dto/HealthMetricsDto.cs
@@ -0,0 +1,101 @@
+using System;
+using System.Collections.Generic;
+
+namespace Keeptrack.WebApi.Contracts.Dto;
+
+///
+/// Computed metrics for one health profile: yearly costs after reimbursements, last visit per
+/// practitioner, and paid records still waiting on a reimbursement.
+///
+public class HealthMetricsDto
+{
+ ///
+ /// Health spending by year.
+ ///
+ public required List CostHistory { get; set; }
+
+ ///
+ /// When each practitioner was last seen, most recent first.
+ ///
+ public required List LastVisits { get; set; }
+
+ ///
+ /// Paid records whose money doesn't balance to zero (price - public - insurance - leftover), oldest
+ /// first - money is still expected, or more arrived than the price accounts for.
+ ///
+ public required List UnbalancedRecords { get; set; }
+}
+
+///
+/// One year of health spending.
+///
+public class HealthCostHistoryPointDto
+{
+ ///
+ /// Year.
+ ///
+ public required int Year { get; set; }
+
+ ///
+ /// Total paid over the year, before reimbursements.
+ ///
+ public required double TotalPaid { get; set; }
+
+ ///
+ /// Total reimbursed over the year (public + insurance).
+ ///
+ public required double TotalReimbursed { get; set; }
+
+ ///
+ /// What the year really cost: paid minus reimbursed.
+ ///
+ public required double OutOfPocket { get; set; }
+}
+
+///
+/// When a specialty was last seen.
+///
+public class HealthLastVisitDto
+{
+ ///
+ /// The medical specialty.
+ ///
+ public required string Specialty { get; set; }
+
+ ///
+ /// The most recent appointment date for that specialty.
+ ///
+ public required DateTime LastVisitDate { get; set; }
+}
+
+///
+/// A paid record whose money doesn't balance to zero.
+///
+public class HealthUnbalancedRecordDto
+{
+ ///
+ /// The record's id.
+ ///
+ public required string RecordId { get; set; }
+
+ ///
+ /// When the expense happened.
+ ///
+ public required DateTime HistoryDate { get; set; }
+
+ ///
+ /// Practitioner, description or specialty - whatever identifies the record best.
+ ///
+ public required string Label { get; set; }
+
+ ///
+ /// The amount paid.
+ ///
+ public required double Price { get; set; }
+
+ ///
+ /// Price minus reimbursements minus leftover: positive = money still expected, negative = more was
+ /// received than the price accounts for.
+ ///
+ public required double MissingAmount { get; set; }
+}
diff --git a/src/WebApi.Contracts/Dto/HealthProfileDto.cs b/src/WebApi.Contracts/Dto/HealthProfileDto.cs
new file mode 100644
index 00000000..3a8e86b5
--- /dev/null
+++ b/src/WebApi.Contracts/Dto/HealthProfileDto.cs
@@ -0,0 +1,24 @@
+using Keeptrack.Common.System;
+
+namespace Keeptrack.WebApi.Contracts.Dto;
+
+///
+/// Health profile data transfer object - whose health journal this is.
+///
+public class HealthProfileDto : IHasId
+{
+ ///
+ /// Health profile ID.
+ ///
+ public string? Id { get; set; }
+
+ ///
+ /// The person's name (e.g. "Me", a child's first name).
+ ///
+ public string? Name { get; set; }
+
+ ///
+ /// Free-text notes (blood type, allergies, anything worth keeping at hand).
+ ///
+ public string? Notes { get; set; }
+}
diff --git a/src/WebApi.Contracts/Dto/HealthRecordDto.cs b/src/WebApi.Contracts/Dto/HealthRecordDto.cs
new file mode 100644
index 00000000..b277a2a2
--- /dev/null
+++ b/src/WebApi.Contracts/Dto/HealthRecordDto.cs
@@ -0,0 +1,72 @@
+using System;
+using Keeptrack.Common.System;
+
+namespace Keeptrack.WebApi.Contracts.Dto;
+
+///
+/// One health-journal entry: an appointment, a sickness, or any other dated health event.
+///
+public class HealthRecordDto : IHasId
+{
+ ///
+ /// Record ID.
+ ///
+ public string? Id { get; set; }
+
+ ///
+ /// Health profile ID (whose journal this entry belongs to).
+ ///
+ public required string HealthProfileId { get; set; }
+
+ ///
+ /// When it happened - date and time (an appointment's time of day is kept).
+ ///
+ public required DateTime HistoryDate { get; set; }
+
+ ///
+ /// Event type (Appointment, Sickness, Other).
+ ///
+ public required HealthEventType EventType { get; set; }
+
+ ///
+ /// Medical specialty for appointments ("généraliste", "dentiste", ...).
+ ///
+ public string? Specialty { get; set; }
+
+ ///
+ /// The practitioner's name.
+ ///
+ public string? Practitioner { get; set; }
+
+ ///
+ /// Free-text description (reason for the visit, the sickness, ...).
+ ///
+ public string? Description { get; set; }
+
+ ///
+ /// Free-text notes (outcome, prescriptions, follow-up, ...).
+ ///
+ public string? Notes { get; set; }
+
+ ///
+ /// What was paid at the time, before any reimbursement.
+ ///
+ public double? Price { get; set; }
+
+ ///
+ /// Reimbursement from the public health system (assurance maladie), usually filled in later.
+ ///
+ public double? PublicReimbursement { get; set; }
+
+ ///
+ /// Reimbursement from the private/complementary insurer (mutuelle), usually filled in later.
+ ///
+ public double? InsuranceReimbursement { get; set; }
+
+ ///
+ /// The accepted remainder (reste à charge) that no one will reimburse. A record is settled exactly
+ /// when price - public - insurance - leftover equals zero; anything else appears in the metrics'
+ /// to-check list with the missing amount.
+ ///
+ public double? NotCovered { get; set; }
+}
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/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/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/src/WebApi/Controllers/HealthProfileController.cs b/src/WebApi/Controllers/HealthProfileController.cs
new file mode 100644
index 00000000..f550d14a
--- /dev/null
+++ b/src/WebApi/Controllers/HealthProfileController.cs
@@ -0,0 +1,48 @@
+using Keeptrack.Domain.Models;
+using Keeptrack.Domain.Repositories;
+using Keeptrack.Domain.Services;
+using Keeptrack.WebApi.Mappers;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Mvc;
+
+namespace Keeptrack.WebApi.Controllers;
+
+[ApiController]
+[Authorize(Policy = "MemberOnly")]
+[Route("api/health-profiles")]
+public class HealthProfileController(
+ IDtoMapper mapper,
+ IHealthProfileRepository dataRepository,
+ IHealthRecordRepository healthRecordRepository,
+ HealthMetricsDtoMapper metricsMapper)
+ : DataCrudControllerBase(mapper, dataRepository)
+{
+ ///
+ /// Computed metrics for this profile: yearly costs after reimbursements, last visit per practitioner,
+ /// and paid records still waiting on a reimbursement - see .
+ ///
+ [HttpGet("{id}/metrics")]
+ [ProducesResponseType(200)]
+ [ProducesResponseType(404)]
+ public async Task> GetMetrics(string id)
+ {
+ var ownerId = this.GetUserId();
+
+ var profile = await dataRepository.FindOneAsync(id, ownerId);
+ if (profile is null) return NotFound();
+
+ var records = await healthRecordRepository.FindAllAsync(ownerId, 1, int.MaxValue, null,
+ new HealthRecordModel { OwnerId = ownerId, HealthProfileId = id, EventType = default, HistoryDate = default });
+
+ 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.
+ ///
+ protected override async Task OnDeletedAsync(string id, string ownerId)
+ {
+ await healthRecordRepository.DeleteAllForProfileAsync(id, ownerId);
+ }
+}
diff --git a/src/WebApi/Controllers/HealthRecordController.cs b/src/WebApi/Controllers/HealthRecordController.cs
new file mode 100644
index 00000000..59eda010
--- /dev/null
+++ b/src/WebApi/Controllers/HealthRecordController.cs
@@ -0,0 +1,13 @@
+using Keeptrack.Domain.Models;
+using Keeptrack.Domain.Repositories;
+using Keeptrack.WebApi.Mappers;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Mvc;
+
+namespace Keeptrack.WebApi.Controllers;
+
+[ApiController]
+[Authorize(Policy = "MemberOnly")]
+[Route("api/health-records")]
+public class HealthRecordController(IDtoMapper mapper, IHealthRecordRepository dataRepository)
+ : DataCrudControllerBase(mapper, dataRepository);
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/DependencyInjection/InfrastructureServiceCollectionExtensions.cs b/src/WebApi/DependencyInjection/InfrastructureServiceCollectionExtensions.cs
index ea050c0a..133c8efe 100644
--- a/src/WebApi/DependencyInjection/InfrastructureServiceCollectionExtensions.cs
+++ b/src/WebApi/DependencyInjection/InfrastructureServiceCollectionExtensions.cs
@@ -39,6 +39,8 @@ internal static void AddMongoDbInfrastructure(this IServiceCollection services,
services.AddSingleton, CarHistoryStorageMapper>();
services.AddSingleton, HouseStorageMapper>();
services.AddSingleton, HouseHistoryStorageMapper>();
+ services.AddSingleton, HealthProfileStorageMapper>();
+ services.AddSingleton, HealthRecordStorageMapper>();
services.AddSingleton();
services.AddSingleton();
@@ -60,6 +62,8 @@ internal static void AddMongoDbInfrastructure(this IServiceCollection services,
services.TryAddScoped();
services.TryAddScoped();
services.TryAddScoped();
+ services.TryAddScoped();
+ services.TryAddScoped();
services.TryAddScoped();
services.TryAddScoped();
services.TryAddScoped();
diff --git a/src/WebApi/Import/CarHistoryImportController.cs b/src/WebApi/Import/CarHistoryImportController.cs
index b2bc6680..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;
@@ -5,7 +6,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
{
@@ -18,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 d85cad2c..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)
@@ -87,7 +86,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 +94,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 +136,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 +147,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)
};
@@ -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)
{
@@ -205,45 +204,9 @@ 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..646e791d
--- /dev/null
+++ b/src/WebApi/Import/ExcelCellParser.cs
@@ -0,0 +1,45 @@
+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..f90eebb1
--- /dev/null
+++ b/src/WebApi/Import/HealthImportController.cs
@@ -0,0 +1,36 @@
+using System.Diagnostics.CodeAnalysis;
+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)]
+ [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)
+ {
+ 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..de5aef38
--- /dev/null
+++ b/src/WebApi/Import/HealthImportService.cs
@@ -0,0 +1,173 @@
+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/Mappers/HealthMetricsDtoMapper.cs b/src/WebApi/Mappers/HealthMetricsDtoMapper.cs
new file mode 100644
index 00000000..9abd2545
--- /dev/null
+++ b/src/WebApi/Mappers/HealthMetricsDtoMapper.cs
@@ -0,0 +1,15 @@
+using Keeptrack.Domain.Models;
+using Riok.Mapperly.Abstractions;
+
+namespace Keeptrack.WebApi.Mappers;
+
+///
+/// One-directional (Model -> Dto): is a pure
+/// Domain-level computation with no Dto dependency, so
+/// maps its result here - same shape as .
+///
+[Mapper(EnumMappingStrategy = EnumMappingStrategy.ByName)]
+public partial class HealthMetricsDtoMapper
+{
+ public partial HealthMetricsDto ToDto(HealthMetricsModel model);
+}
diff --git a/src/WebApi/Mappers/HealthProfileDtoMapper.cs b/src/WebApi/Mappers/HealthProfileDtoMapper.cs
new file mode 100644
index 00000000..19a48891
--- /dev/null
+++ b/src/WebApi/Mappers/HealthProfileDtoMapper.cs
@@ -0,0 +1,16 @@
+using Keeptrack.Domain.Models;
+using Riok.Mapperly.Abstractions;
+
+namespace Keeptrack.WebApi.Mappers;
+
+[Mapper]
+[UseStaticMapper(typeof(CommonDtoMappings))]
+public partial class HealthProfileDtoMapper : IDtoMapper
+{
+ // see BookDtoMapper.ToModel for why MapValue (not MapperIgnoreTarget) is required here
+ [MapValue(nameof(HealthProfileModel.OwnerId), "")]
+ public partial HealthProfileModel ToModel(HealthProfileDto dto);
+
+ [MapperIgnoreSource(nameof(HealthProfileModel.OwnerId))]
+ public partial HealthProfileDto ToDto(HealthProfileModel model);
+}
diff --git a/src/WebApi/Mappers/HealthRecordDtoMapper.cs b/src/WebApi/Mappers/HealthRecordDtoMapper.cs
new file mode 100644
index 00000000..cbf348ae
--- /dev/null
+++ b/src/WebApi/Mappers/HealthRecordDtoMapper.cs
@@ -0,0 +1,15 @@
+using Keeptrack.Domain.Models;
+using Riok.Mapperly.Abstractions;
+
+namespace Keeptrack.WebApi.Mappers;
+
+[Mapper(EnumMappingStrategy = EnumMappingStrategy.ByName)]
+public partial class HealthRecordDtoMapper : IDtoMapper
+{
+ // see BookDtoMapper.ToModel for why MapValue (not MapperIgnoreTarget) is required here
+ [MapValue(nameof(HealthRecordModel.OwnerId), "")]
+ public partial HealthRecordModel ToModel(HealthRecordDto dto);
+
+ [MapperIgnoreSource(nameof(HealthRecordModel.OwnerId))]
+ public partial HealthRecordDto ToDto(HealthRecordModel model);
+}
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/src/WebApi/Program.cs b/src/WebApi/Program.cs
index 26811b8d..cfeb8d60 100644
--- a/src/WebApi/Program.cs
+++ b/src/WebApi/Program.cs
@@ -11,15 +11,14 @@
// 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.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>();
builder.Services.AddSingleton, Keeptrack.WebApi.Mappers.HouseDtoMapper>();
builder.Services.AddSingleton, Keeptrack.WebApi.Mappers.HouseHistoryDtoMapper>();
+builder.Services.AddSingleton, Keeptrack.WebApi.Mappers.HealthProfileDtoMapper>();
+builder.Services.AddSingleton, Keeptrack.WebApi.Mappers.HealthRecordDtoMapper>();
builder.Services.AddSingleton, Keeptrack.WebApi.Mappers.EpisodeDtoMapper>();
builder.Services.AddSingleton, Keeptrack.WebApi.Mappers.MovieDtoMapper>();
builder.Services.AddSingleton, Keeptrack.WebApi.Mappers.AlbumDtoMapper>();
@@ -30,6 +29,7 @@
builder.Services.AddSingleton();
builder.Services.AddSingleton();
builder.Services.AddSingleton();
+builder.Services.AddSingleton();
builder.Services.AddSingleton();
builder.Services.AddSingleton();
builder.Services.AddSingleton();
@@ -40,16 +40,16 @@
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 =>
{
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
@@ -88,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
@@ -106,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/BlazorApp.PlaywrightTests/Pages/HealthProfileDetailPage.cs b/test/BlazorApp.PlaywrightTests/Pages/HealthProfileDetailPage.cs
new file mode 100644
index 00000000..f1f00311
--- /dev/null
+++ b/test/BlazorApp.PlaywrightTests/Pages/HealthProfileDetailPage.cs
@@ -0,0 +1,5 @@
+using Microsoft.Playwright;
+
+namespace Keeptrack.BlazorApp.PlaywrightTests.Pages;
+
+public class HealthProfileDetailPage(IPage page) : DetailPageBase(page);
diff --git a/test/BlazorApp.PlaywrightTests/Pages/PageBase.cs b/test/BlazorApp.PlaywrightTests/Pages/PageBase.cs
index 57793c92..4de1db3a 100644
--- a/test/BlazorApp.PlaywrightTests/Pages/PageBase.cs
+++ b/test/BlazorApp.PlaywrightTests/Pages/PageBase.cs
@@ -92,6 +92,8 @@ private async Task NavigateAsync(string linkName, TPage next) wher
public Task OpenHousesAsync() => NavigateAsync("Houses", new ListPage(Page, "/houses", "Houses"));
+ public Task OpenHealthAsync() => NavigateAsync("Health", new ListPage(Page, "/health", "Health"));
+
///
/// AuthenticationController.Logout redirects to the Home page.
///
diff --git a/test/BlazorApp.PlaywrightTests/Smoke/HealthSmokeTest.cs b/test/BlazorApp.PlaywrightTests/Smoke/HealthSmokeTest.cs
new file mode 100644
index 00000000..d3ab9ffc
--- /dev/null
+++ b/test/BlazorApp.PlaywrightTests/Smoke/HealthSmokeTest.cs
@@ -0,0 +1,52 @@
+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;
+
+///
+/// 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 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")]
+public class HealthSmokeTest(End2EndFixture fixture) : SmokeTestBase(fixture)
+{
+ [Fact]
+ public async Task AddProfileAndUnbalancedAppointment_ThenDelete()
+ {
+ SkipIfReadOnly();
+
+ var name = $"E2e Smoke Health {Guid.NewGuid():N}";
+
+ var home = await new HomePage(Page).OpenAsync();
+ var list = await home.OpenHealthAsync();
+ await list.ClickAddAsync();
+ await list.FillAsync("name-input", name);
+ await list.SaveNewAsync();
+
+ var detail = new HealthProfileDetailPage(Page);
+ await detail.WaitForReadyAsync();
+ await Assertions.Expect(detail.TitleInput).ToHaveValueAsync(name);
+
+ // add an appointment paid 60 with nothing reimbursed yet - it must come back flagged
+ await Page.GetByRole(AriaRole.Button, new PageGetByRoleOptions { Name = "+ Add entry" }).ClickAsync();
+ // data-testid, not GetByLabel: the modal's label/input pairs have no for/id association (the
+ // documented inventory-form gotcha - see CLAUDE.md's Playwright section)
+ await Page.GetByTestId("practitioner-input").FillAsync("Dr E2e");
+ await Page.GetByTestId("price-input").FillAsync("60");
+ await Page.Locator(".kt-modal").GetByRole(AriaRole.Button, new LocatorGetByRoleOptions { Name = "Save" }).ClickAsync();
+
+ // 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();
+ await list.DeleteAsync(name);
+ await Assertions.Expect(list.Row(name)).Not.ToBeVisibleAsync();
+ }
+}
diff --git a/test/BlazorApp.PlaywrightTests/Smoke/MobileScreenshotTest.cs b/test/BlazorApp.PlaywrightTests/Smoke/MobileScreenshotTest.cs
index 9b9b44a4..366d325c 100644
--- a/test/BlazorApp.PlaywrightTests/Smoke/MobileScreenshotTest.cs
+++ b/test/BlazorApp.PlaywrightTests/Smoke/MobileScreenshotTest.cs
@@ -39,6 +39,7 @@ private static readonly (string Route, string Name)[] s_routes =
("/video-games", "video-games"),
("/cars", "cars"),
("/houses", "houses"),
+ ("/health", "health"),
("/import", "import"),
("/admin/reference-data", "admin-reference-data")
];
@@ -85,6 +86,8 @@ public async Task CaptureAllPagesAtPhoneViewport()
await CaptureFirstDetailAsync("/tv-shows", "tvshow-detail");
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");
@@ -139,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");
@@ -241,6 +247,40 @@ private static async Task SeedAsync(HttpClient api, List created)
await LinkFirstCandidateAsync(api, ReferenceItemType.Album, "Nevermind", 1991, "Nirvana");
await LinkFirstCandidateAsync(api, ReferenceItemType.VideoGame, "Hades", 2020, null);
+ // a health journal with a settled appointment, an unbalanced one (drives the "to check" panel)
+ // and a sickness entry, so the detail shot shows every section
+ var healthProfileId = await CreateAsync(api, created, "api/health-profiles", new HealthProfileDto { Name = "Bertrand" });
+ await CreateAsync(api, created, "api/health-records", new HealthRecordDto
+ {
+ HealthProfileId = healthProfileId,
+ HistoryDate = new DateTime(2026, 2, 3, 9, 30, 0),
+ EventType = HealthEventType.Appointment,
+ Specialty = "généraliste",
+ Practitioner = "Dr Martin",
+ Description = "Annual check-up",
+ Price = 30,
+ PublicReimbursement = 20,
+ InsuranceReimbursement = 8.5,
+ NotCovered = 1.5
+ });
+ await CreateAsync(api, created, "api/health-records", new HealthRecordDto
+ {
+ HealthProfileId = healthProfileId,
+ HistoryDate = new DateTime(2026, 5, 12, 14, 0, 0),
+ EventType = HealthEventType.Appointment,
+ Specialty = "dentiste",
+ Practitioner = "Dr Diaz",
+ Description = "Descaling",
+ Price = 120
+ });
+ await CreateAsync(api, created, "api/health-records", new HealthRecordDto
+ {
+ HealthProfileId = healthProfileId,
+ HistoryDate = new DateTime(2026, 7, 1, 8, 0, 0),
+ EventType = HealthEventType.Sickness,
+ Description = "Fever, stayed home"
+ });
+
// stays unresolved (no provider can match it) - proves the admin queue prefills the saved artist
await CreateAsync(api, created, "api/albums", new AlbumDto { Title = "Zzq Unfindable Album", Artist = "Zzq Test Artist", Year = 1999 });
await CreateAsync(api, created, "api/books", new BookDto
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/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);
+ }
+}
diff --git a/test/WebApi.IntegrationTests/Resources/HealthProfileResourceTest.cs b/test/WebApi.IntegrationTests/Resources/HealthProfileResourceTest.cs
new file mode 100644
index 00000000..78e8272f
--- /dev/null
+++ b/test/WebApi.IntegrationTests/Resources/HealthProfileResourceTest.cs
@@ -0,0 +1,128 @@
+using System;
+using System.Net;
+using System.Threading.Tasks;
+using AwesomeAssertions;
+using Keeptrack.Common.System;
+using Keeptrack.WebApi.Contracts.Dto;
+using Keeptrack.WebApi.IntegrationTests.Hosting;
+using Xunit;
+
+namespace Keeptrack.WebApi.IntegrationTests.Resources;
+
+///
+/// Full-cycle CRUD coverage for HealthProfile plus the two behaviors specific to the health
+/// feature: the metrics endpoint (yearly costs, last visits, pending reimbursements over real MongoDB
+/// data) and the cascade delete of the profile's journal - same shape as .
+///
+public class HealthProfileResourceTest(KestrelWebAppFactory factory)
+ : ResourceTestBase(factory)
+{
+ private const string ResourceEndpoint = "api/health-profiles";
+ private const string RecordEndpoint = "api/health-records";
+
+ [Fact]
+ public async Task HealthProfileResourceFullCycle_IsOk()
+ {
+ await GetAsync($"/{ResourceEndpoint}", HttpStatusCode.Unauthorized);
+
+ await Authenticate();
+
+ var created = await PostAsync($"/{ResourceEndpoint}", new HealthProfileDto { Name = $"Profile-{Guid.NewGuid():N}" });
+ created.Id.Should().NotBeNullOrEmpty();
+
+ try
+ {
+ created.Notes = "Allergic to penicillin";
+ await PutAsync($"/{ResourceEndpoint}/{created.Id}", created);
+
+ var updated = await GetAsync($"/{ResourceEndpoint}/{created.Id}");
+ updated.Should().BeEquivalentTo(created);
+ }
+ finally
+ {
+ await DeleteAsync($"/{ResourceEndpoint}/{created.Id}");
+ }
+ }
+
+ [Fact]
+ public async Task HealthProfileMetrics_ComputeCostsLastVisitsAndUnbalanced_FromRealRecords()
+ {
+ await Authenticate();
+
+ var profile = await PostAsync($"/{ResourceEndpoint}", new HealthProfileDto { Name = $"Profile-{Guid.NewGuid():N}" });
+
+ try
+ {
+ // a fully settled appointment (price = ameli + mutuelle + leftover), an unsettled one, and a
+ // sickness entry with no money
+ await PostAsync($"/{RecordEndpoint}", new HealthRecordDto
+ {
+ HealthProfileId = profile.Id!,
+ HistoryDate = new DateTime(2026, 2, 3, 9, 30, 0),
+ EventType = HealthEventType.Appointment,
+ Specialty = "généraliste",
+ Practitioner = "Dr Martin",
+ Price = 30,
+ PublicReimbursement = 20,
+ InsuranceReimbursement = 8.5,
+ NotCovered = 1.5
+ });
+ await PostAsync($"/{RecordEndpoint}", new HealthRecordDto
+ {
+ HealthProfileId = profile.Id!,
+ HistoryDate = new DateTime(2026, 5, 10, 14, 0, 0),
+ EventType = HealthEventType.Appointment,
+ Specialty = "dentiste",
+ Practitioner = "Dr Diaz",
+ Price = 120
+ });
+ await PostAsync($"/{RecordEndpoint}", new HealthRecordDto
+ {
+ HealthProfileId = profile.Id!,
+ HistoryDate = new DateTime(2026, 7, 1, 8, 0, 0),
+ EventType = HealthEventType.Sickness,
+ Description = "Fever"
+ });
+
+ var metrics = await GetAsync($"/{ResourceEndpoint}/{profile.Id}/metrics");
+
+ var year = metrics.CostHistory.Should().ContainSingle().Subject;
+ year.Year.Should().Be(2026);
+ year.TotalPaid.Should().Be(150);
+ year.TotalReimbursed.Should().Be(28.5);
+ year.OutOfPocket.Should().Be(121.5);
+
+ metrics.LastVisits.Should().HaveCount(2);
+ metrics.LastVisits[0].Specialty.Should().Be("dentiste");
+
+ var unbalanced = metrics.UnbalancedRecords.Should().ContainSingle().Subject;
+ unbalanced.Label.Should().Be("Dr Diaz");
+ unbalanced.Price.Should().Be(120);
+ unbalanced.MissingAmount.Should().Be(120);
+ }
+ finally
+ {
+ // deleting the profile cascades to its records (verified below), so no per-record cleanup here
+ await DeleteAsync($"/{ResourceEndpoint}/{profile.Id}");
+ }
+ }
+
+ [Fact]
+ public async Task DeletingAProfile_CascadesToItsJournal()
+ {
+ await Authenticate();
+
+ var profile = await PostAsync($"/{ResourceEndpoint}", new HealthProfileDto { Name = $"Profile-{Guid.NewGuid():N}" });
+ var record = await PostAsync($"/{RecordEndpoint}", new HealthRecordDto
+ {
+ HealthProfileId = profile.Id!,
+ HistoryDate = DateTime.Today,
+ EventType = HealthEventType.Other,
+ Description = "Cascade target"
+ });
+
+ await DeleteAsync($"/{ResourceEndpoint}/{profile.Id}");
+
+ await GetAsync($"/{RecordEndpoint}/{record.Id}", HttpStatusCode.NotFound);
+ }
+}
diff --git a/test/WebApi.IntegrationTests/Resources/HealthRecordResourceTest.cs b/test/WebApi.IntegrationTests/Resources/HealthRecordResourceTest.cs
new file mode 100644
index 00000000..03951426
--- /dev/null
+++ b/test/WebApi.IntegrationTests/Resources/HealthRecordResourceTest.cs
@@ -0,0 +1,91 @@
+using System;
+using System.Net;
+using System.Threading.Tasks;
+using AwesomeAssertions;
+using Keeptrack.Common.System;
+using Keeptrack.WebApi.Contracts.Dto;
+using Keeptrack.WebApi.IntegrationTests.Hosting;
+using Xunit;
+
+namespace Keeptrack.WebApi.IntegrationTests.Resources;
+
+///
+/// Basic full-cycle CRUD coverage for HealthRecord, same shape as
+/// - including the date+time round trip (HistoryDate is a full
+/// DateTime here, the Car pattern, so the time of day must survive MongoDB's UTC stamping).
+///
+public class HealthRecordResourceTest(KestrelWebAppFactory factory)
+ : ResourceTestBase(factory)
+{
+ private const string ResourceEndpoint = "api/health-records";
+
+ private static HealthRecordDto NewEntry(string profileId) => new()
+ {
+ HealthProfileId = profileId,
+ HistoryDate = new DateTime(2026, 3, 12, 16, 45, 0),
+ EventType = HealthEventType.Appointment,
+ Specialty = "dermatologue",
+ Practitioner = "Dr Test",
+ Description = "Test entry",
+ Price = 51.7
+ };
+
+ [Fact]
+ public async Task HealthRecordResourceFullCycle_IsOk_AndKeepsTheTimeOfDay()
+ {
+ await GetAsync($"/{ResourceEndpoint}", HttpStatusCode.Unauthorized);
+
+ await Authenticate();
+
+ var profileId = Guid.NewGuid().ToString();
+ var created = await PostAsync($"/{ResourceEndpoint}", NewEntry(profileId));
+ created.Id.Should().NotBeNullOrEmpty();
+
+ try
+ {
+ created.PublicReimbursement = 30;
+ created.InsuranceReimbursement = 15.5;
+ await PutAsync($"/{ResourceEndpoint}/{created.Id}", created);
+
+ var updated = await GetAsync($"/{ResourceEndpoint}/{created.Id}");
+ updated.Should().BeEquivalentTo(created);
+ // the appointment's time of day is real data and must survive the BSON round trip
+ updated.HistoryDate.Hour.Should().Be(16);
+ updated.HistoryDate.Minute.Should().Be(45);
+ }
+ finally
+ {
+ await DeleteAsync($"/{ResourceEndpoint}/{created.Id}");
+ }
+ }
+
+ [Fact]
+ public async Task HealthRecordFilter_ByProfileIdAndSearch_OnlyReturnsMatchingEntries_IsOk()
+ {
+ await Authenticate();
+
+ var profileId = Guid.NewGuid().ToString();
+ var otherProfileId = Guid.NewGuid().ToString();
+ var practitioner = $"Dr {Guid.NewGuid():N}";
+ var entry = NewEntry(profileId);
+ entry.Practitioner = practitioner;
+ var created = await PostAsync($"/{ResourceEndpoint}", entry);
+ var otherCreated = await PostAsync($"/{ResourceEndpoint}", NewEntry(otherProfileId));
+
+ try
+ {
+ var byProfile = await GetAsync>($"/{ResourceEndpoint}?HealthProfileId={profileId}");
+ byProfile.Items.Should().ContainSingle(x => x.Id == created.Id);
+ byProfile.Items.Should().NotContain(x => x.Id == otherCreated.Id);
+
+ // search spans practitioner (and specialty/description) - "when did I last see Dr X"
+ var bySearch = await GetAsync>($"/{ResourceEndpoint}?HealthProfileId={profileId}&search={practitioner}");
+ bySearch.Items.Should().ContainSingle(x => x.Id == created.Id);
+ }
+ finally
+ {
+ await DeleteAsync($"/{ResourceEndpoint}/{created.Id}");
+ await DeleteAsync($"/{ResourceEndpoint}/{otherCreated.Id}");
+ }
+ }
+}
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);
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 e1b40ff8..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);
@@ -153,7 +153,11 @@ public async Task Post_NeverCapsAnAdmin()
[InlineData(typeof(CarHistoryController), "MemberOnly")]
[InlineData(typeof(HouseController), "MemberOnly")]
[InlineData(typeof(HouseHistoryController), "MemberOnly")]
+ [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..23ab8594
--- /dev/null
+++ b/test/WebApi.UnitTests/Import/HealthImportServiceTest.cs
@@ -0,0 +1,181 @@
+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, string? sort = null)
+ {
+ 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)
+ {
+ 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));
+ }
+
+ private sealed class FakeHealthProfileRepository : InMemoryRepository, IHealthProfileRepository;
+
+ private sealed class FakeHealthRecordRepository : InMemoryRepository, IHealthRecordRepository
+ {
+ public Task