Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion Directory.Build.props
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@

<PropertyGroup>
<!-- edit this value to change the current MAJOR.MINOR.PATCH version -->
<VersionPrefix>2.1.0</VersionPrefix>
<VersionPrefix>2.2.0</VersionPrefix>
</PropertyGroup>

<Choose>
Expand Down
6 changes: 4 additions & 2 deletions docs/code-quality-findings.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`

Expand All @@ -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<TvShow>.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.
Expand Down
34 changes: 34 additions & 0 deletions scripts/migrate-house-schema.js
Original file line number Diff line number Diff line change
@@ -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)`);
20 changes: 8 additions & 12 deletions scripts/migrate-is-owned-to-owned-versions.js
Original file line number Diff line number Diff line change
@@ -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(
Expand Down
2 changes: 2 additions & 0 deletions scripts/mongodb-create-index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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" });
Expand Down
20 changes: 20 additions & 0 deletions src/BlazorApp/Components/Import/HealthImportApiClient.cs
Original file line number Diff line number Diff line change
@@ -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<HealthImportResultDto> 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<HealthImportResultDto>())!;
}
}
Loading
Loading