diff --git a/.azure/pipelines/ci.yml b/.azure/pipelines/ci.yml
index 02c4fd20..d0e5ac37 100644
--- a/.azure/pipelines/ci.yml
+++ b/.azure/pipelines/ci.yml
@@ -59,66 +59,66 @@ stages:
DOTNET_CLI_TELEMETRY_OPTOUT: 1
DOTNET_NOLOGO: 1
- - stage: "integration_testing"
- displayName: "Integration testing"
- dependsOn: []
- condition: eq(variables['RunIntegrationTestingStage'], true)
- jobs:
- - job: "windows_integrationtests"
- displayName: "Windows CI"
- pool:
- vmImage: "windows-latest"
- steps:
- - task: UseDotNet@2
- displayName: "Use .NET Core SDK"
- inputs:
- packageType: "sdk"
- version: $(dotnet.sdk.version)
- - task: PowerShell@2
- displayName: "Start local MongoDB"
- inputs:
- targetType: "inline"
- script: |
- Write-Host "Starting MongoDB"
- md log
- md data
- Start-Process -FilePath "mongod" -ArgumentList "--logpath log/mongod.log", "--dbpath data", "--port 27017" -WindowStyle Hidden
- - task: DotNetCoreCLI@2
- displayName: "Restore .NET packages"
- inputs:
- command: "restore"
- projects: "$(SolutionFile)"
- feedsToUse: "select"
- noCache: $(dotnet.cache.disable)
- env:
- DOTNET_CLI_TELEMETRY_OPTOUT: 1
- DOTNET_NOLOGO: 1
- - task: DotNetCoreCLI@2
- displayName: "Build .NET solution"
- inputs:
- command: "build"
- projects: "$(SolutionFile)"
- arguments: "-c $(BuildConfiguration)"
- env:
- DOTNET_CLI_TELEMETRY_OPTOUT: 1
- DOTNET_NOLOGO: 1
- - task: DotNetCoreCLI@2
- displayName: "Run .NET test projects"
- inputs:
- command: "test"
- projects: "$(TestProjects)"
- arguments: '--configuration $(BuildConfiguration) --report-xunit-trx --coverage --coverage-output-format cobertura -- --filter-query "/[(Category=UnitTests)|(Mode=Readonly)]"'
- env:
- AllowedOrigins__0: "5207"
- Features__IsScalarEnabled: true
- Features__IsHttpsRedirectionEnabled: false
- ASPNETCORE_ENVIRONMENT: Development
- DOTNET_CLI_TELEMETRY_OPTOUT: 1
- DOTNET_NOLOGO: 1
- - task: PublishBuildArtifacts@1
- displayName: "Publish artifacts"
- condition: failed()
- inputs:
- PathtoPublish: "$(Build.ArtifactStagingDirectory)"
- ArtifactName: "drop"
- publishLocation: "Container"
+# - stage: "integration_testing"
+# displayName: "Integration testing"
+# dependsOn: []
+# condition: eq(variables['RunIntegrationTestingStage'], true)
+# jobs:
+# - job: "windows_integrationtests"
+# displayName: "Windows CI"
+# pool:
+# vmImage: "windows-latest"
+# steps:
+# - task: UseDotNet@2
+# displayName: "Use .NET Core SDK"
+# inputs:
+# packageType: "sdk"
+# version: $(dotnet.sdk.version)
+# - task: PowerShell@2
+# displayName: "Start local MongoDB"
+# inputs:
+# targetType: "inline"
+# script: |
+# Write-Host "Starting MongoDB"
+# md log
+# md data
+# Start-Process -FilePath "mongod" -ArgumentList "--logpath log/mongod.log", "--dbpath data", "--port 27017" -WindowStyle Hidden
+# - task: DotNetCoreCLI@2
+# displayName: "Restore .NET packages"
+# inputs:
+# command: "restore"
+# projects: "$(SolutionFile)"
+# feedsToUse: "select"
+# noCache: $(dotnet.cache.disable)
+# env:
+# DOTNET_CLI_TELEMETRY_OPTOUT: 1
+# DOTNET_NOLOGO: 1
+# - task: DotNetCoreCLI@2
+# displayName: "Build .NET solution"
+# inputs:
+# command: "build"
+# projects: "$(SolutionFile)"
+# arguments: "-c $(BuildConfiguration)"
+# env:
+# DOTNET_CLI_TELEMETRY_OPTOUT: 1
+# DOTNET_NOLOGO: 1
+# - task: DotNetCoreCLI@2
+# displayName: "Run .NET test projects"
+# inputs:
+# command: "test"
+# projects: "$(TestProjects)"
+# arguments: '--configuration $(BuildConfiguration) --report-xunit-trx --coverage --coverage-output-format cobertura -- --filter-query "/[(Category=UnitTests)|(Mode=Readonly)]"'
+# env:
+# AllowedOrigins__0: "5207"
+# Features__IsScalarEnabled: true
+# Features__IsHttpsRedirectionEnabled: false
+# ASPNETCORE_ENVIRONMENT: Development
+# DOTNET_CLI_TELEMETRY_OPTOUT: 1
+# DOTNET_NOLOGO: 1
+# - task: PublishBuildArtifacts@1
+# displayName: "Publish artifacts"
+# condition: failed()
+# inputs:
+# PathtoPublish: "$(Build.ArtifactStagingDirectory)"
+# ArtifactName: "drop"
+# publishLocation: "Container"
diff --git a/.editorconfig b/.editorconfig
index 0a60a2c0..742fee64 100644
--- a/.editorconfig
+++ b/.editorconfig
@@ -6,7 +6,7 @@ root = true
end_of_line = lf
insert_final_newline = true
indent_style = space
-max_line_length = 150
+max_line_length = 180
[*.{cs,razor,sln}]
indent_size = 4
@@ -19,6 +19,7 @@ trim_trailing_whitespace = true
[*.md]
indent_size = 2
trim_trailing_whitespace = false
+max_line_length = 240
[*.cs]
dotnet_sort_system_directives_first = true
@@ -152,3 +153,7 @@ csharp_space_between_square_brackets = false
csharp_prefer_braces = true:silent
csharp_preserve_single_line_blocks = true
csharp_preserve_single_line_statements = true
+# Mapperly configuration drift (an unmapped source or target member on a [Mapper] class) must fail the build, the way AutoMapperConfigurationTest's AssertConfigurationIsValid() used to fail the test run
+# before it was deleted - see docs/automapper-removal-plan.md.
+dotnet_diagnostic.RMG012.severity = error
+dotnet_diagnostic.RMG020.severity = error
diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml
index b827e193..cbe55619 100644
--- a/.github/workflows/ci.yaml
+++ b/.github/workflows/ci.yaml
@@ -4,9 +4,11 @@
on:
push:
- branches: [ main ]
+ branches:
+ - main
pull_request:
- branches: [ main ]
+ branches:
+ - main
workflow_dispatch:
inputs:
run-code-quality:
@@ -37,13 +39,13 @@ jobs:
markup-lint:
name: Markup
- uses: devpro/github-workflow-parts/.github/workflows/reusable-markup-lint.yml@c90ba46a93ca90123f57744b47e49c1a73962cea
+ uses: devpro/github-workflow-parts/.github/workflows/reusable-markup-lint.yml@f9d8c00db1ed723e39cfff6c4c7addc77a23c54d
code-quality:
name: Code
needs: git-check
if: needs.git-check.outputs.app_changed == 'true' || (github.event_name == 'workflow_dispatch' && inputs.run-code-quality)
- uses: devpro/github-workflow-parts/.github/workflows/reusable-dotnet-quality.yml@c90ba46a93ca90123f57744b47e49c1a73962cea
+ uses: devpro/github-workflow-parts/.github/workflows/reusable-dotnet-quality.yml@f9d8c00db1ed723e39cfff6c4c7addc77a23c54d
with:
custom-commands: |
./scripts/mongodb-install.sh
@@ -58,11 +60,11 @@ jobs:
Infrastructure__MongoDB__DatabaseName=keeptrack_ci
fossa-enabled: true
sonar-enabled: true
- sonar-exclusions: "samples/**"
+ sonar-exclusions: "samples/**,**/*.g.cs,**/*.generated.cs"
sonar-organization: ${{ vars.SONAR_ORG }}
sonar-project-key: ${{ vars.SONAR_PROJECT_KEY }}
sonar-project-name: Keeptrack
- workflow-parts-version: c90ba46a93ca90123f57744b47e49c1a73962cea
+ workflow-parts-version: f9d8c00db1ed723e39cfff6c4c7addc77a23c54d
secrets:
fossa-api-key: ${{ secrets.FOSSA_API_KEY }}
sonar-token: ${{ secrets.SONAR_TOKEN }}
@@ -70,6 +72,9 @@ jobs:
Authentication__JwtBearer__Authority=https://securetoken.google.com/${{ secrets.FIREBASE_PROJECTID }}
Authentication__JwtBearer__TokenValidation__Issuer=https://securetoken.google.com/${{ secrets.FIREBASE_PROJECTID }}
Authentication__JwtBearer__TokenValidation__Audience=${{ secrets.FIREBASE_PROJECTID }}
+ Tmdb__ApiKey=${{ secrets.TMDB_APIKEY }}
+ Rawg__ApiKey=${{ secrets.RAWG_APIKEY }}
+ Discogs__Token=${{ secrets.DISCOGS_TOKEN }}
FIREBASE_APIKEY=${{ secrets.FIREBASE_APIKEY }}
FIREBASE_USERNAME=${{ secrets.FIREBASE_TESTUSERNAME }}
FIREBASE_PASSWORD=${{ secrets.FIREBASE_TESTPASSWORD }}
@@ -90,7 +95,7 @@ jobs:
- name: "Web Api"
image-name: "keeptrack-webapi"
image-definition: "src/WebApi/Dockerfile"
- uses: devpro/github-workflow-parts/.github/workflows/reusable-container-scan.yml@c90ba46a93ca90123f57744b47e49c1a73962cea
+ uses: devpro/github-workflow-parts/.github/workflows/reusable-container-scan.yml@f9d8c00db1ed723e39cfff6c4c7addc77a23c54d
with:
image-definition: ${{ matrix.image-definition }}
image-name: ${{ matrix.image-name }}
diff --git a/.github/workflows/pkg.yaml b/.github/workflows/pkg.yaml
index 82d0668c..caed832a 100644
--- a/.github/workflows/pkg.yaml
+++ b/.github/workflows/pkg.yaml
@@ -28,7 +28,7 @@ jobs:
permissions:
id-token: write
contents: read
- uses: devpro/github-workflow-parts/.github/workflows/reusable-container-publication.yml@c90ba46a93ca90123f57744b47e49c1a73962cea
+ uses: devpro/github-workflow-parts/.github/workflows/reusable-container-publication.yml@f9d8c00db1ed723e39cfff6c4c7addc77a23c54d
with:
create-latest: ${{ github.ref_name == 'main' }}
image-definition: ${{ matrix.image-definition }}
diff --git a/.gitignore b/.gitignore
index b10602ac..11e8acdc 100644
--- a/.gitignore
+++ b/.gitignore
@@ -17,3 +17,6 @@ Local.runsettings
# NPM
firebase.config.js
node_modules/
+
+# Logs
+*.log
diff --git a/.markdownlint-cli2.yaml b/.markdownlint-cli2.yaml
index bc94ac6b..5e56cf49 100644
--- a/.markdownlint-cli2.yaml
+++ b/.markdownlint-cli2.yaml
@@ -1,4 +1,4 @@
-gitignore: true
+gitignore: true
ignores:
- "**/node_modules/**"
config:
diff --git a/CLAUDE.md b/CLAUDE.md
new file mode 100644
index 00000000..2d95db74
--- /dev/null
+++ b/CLAUDE.md
@@ -0,0 +1,724 @@
+# CLAUDE.md
+
+This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
+
+## Project overview
+
+Keeptrack is a source-available application (PolyForm Strict 1.0.0, see `LICENSE` - not open source).
+It lets users save and review everything they read, watch, listen to or play (books, movies, TV shows, music albums, video games, cars/car history).
+
+It is a three-tier .NET 10 / C# solution:
+
+- Frontend: `BlazorApp`, a Blazor Server application.
+- Backend: `WebApi`, an ASP.NET Web API (REST).
+- Database: MongoDB.
+
+## Commands
+
+```bash
+# restore and build the whole solution
+dotnet restore
+dotnet build
+
+# run the Web API (https://localhost:5011/)
+dotnet run --project src/WebApi
+
+# run the Blazor Server app (https://localhost:5021/)
+dotnet run --project src/BlazorApp
+
+# run all tests (uses the Microsoft.Testing.Platform runner, xunit v3)
+dotnet test
+
+# run a single test project
+dotnet test test/WebApi.UnitTests/WebApi.UnitTests.csproj
+dotnet test test/WebApi.IntegrationTests/WebApi.IntegrationTests.csproj
+
+# run a single test by fully qualified name
+dotnet test --filter-method "Keeptrack.WebApi.UnitTests.Services.WatchNextServiceTest.ComputeInProgressShows_IncludesShowWithAConfirmedAiredUnwatchedNextEpisode"
+
+# build container images
+docker build . -t devprofr/keeptrack-blazorapp:local -f src/BlazorApp/Dockerfile
+docker build . -t devprofr/keeptrack-webapi:local -f src/WebApi/Dockerfile
+```
+
+A local MongoDB instance is required to run the Web API or the integration tests:
+
+```bash
+docker run --name mongodb -d -p 27017:27017 mongo:8.2
+```
+
+Integration tests also need Firebase test-user credentials and MongoDB connection settings.
+Provide them as environment variables, or in a `Local.runsettings` file at the repository root (see `CONTRIBUTING.md` for the template).
+Never commit this file.
+
+## Architecture
+
+The solution follows a layered / clean-architecture style split across small, single-purpose projects (`src/*`), with `Domain` at the center and no project referencing "outward":
+
+Project | Depends on | Responsibility
+-------------------------|--------------------------------------------------------|---------------
+`Common.System` | — | Cross-cutting primitives shared by every layer: `IHasId`, `IHasIdAndOwnerId`, `PagedRequest`, `PagedResult`.
+`Domain` | `Common.System` | Business models (`*Model` in `Models/`) and repository interfaces (`I*Repository` in `Repositories/`). No persistence or web concerns.
+`Infrastructure.MongoDb` | `Domain` | MongoDB implementation: BSON `Entities/` and `Repositories/` implementing the `Domain` interfaces.
+`WebApi.Contracts` | `Common.System` | Public REST DTOs (`Dto/`), shared between `WebApi` and `BlazorApp` so the Blazor client can deserialize API responses without duplicating classes.
+`WebApi` | `Infrastructure.MongoDb`, `Domain`, `WebApi.Contracts` | ASP.NET Web API: controllers, DTO mappers, DI wiring, JWT authentication, OpenAPI/Scalar docs.
+`BlazorApp` | `Common.System`, `WebApi.Contracts` | Blazor Server UI. Talks to `WebApi` over HTTP using the shared DTOs; it never references `Domain` or `Infrastructure.MongoDb` directly.
+
+### Data model conventions
+
+Every entity that belongs to a user implements `IHasIdAndOwnerId` (`Id` + `OwnerId`) at all three layers (`Domain` model, MongoDB entity, contract DTO), each with its own class.
+Mapping between them is generated at compile time by [Riok.Mapperly](https://github.com/riok/mapperly) (a source generator, no runtime reflection) rather than AutoMapper - see `docs/automapper-removal-plan.md` for the removal history:
+
+- `Infrastructure.MongoDb/Mappers/`: one `[Mapper]` partial class per entity pair (e.g. `BookStorageMapper`), implementing `IStorageMapper` (`Infrastructure.MongoDb/Mappers/IStorageMapper.cs`) -
+ MongoDB entity <-> Domain model.
+ Injected into `MongoDbRepositoryBase` and the six owner-less reference repositories.
+- `WebApi/Mappers/`: one `[Mapper]` partial class per CRUD pair (e.g. `BookDtoMapper`), implementing `IDtoMapper` (`WebApi/Mappers/IDtoMapper.cs`) - DTO <-> Domain model.
+ `OwnerId` is always set via `[MapValue(nameof(Model.OwnerId), "")]` on the DTO -> model direction (not `[MapperIgnoreTarget]` -
+ `OwnerId` is `required` on the model, so a plain ignore would fail to compile the generated object initializer).
+ The placeholder value is immediately overwritten server-side from the authenticated user's claims in `DataCrudControllerBase`, never trusted from client input.
+ Read-only feature controllers (`WatchNextController`, `WishlistController`, `CarController`/`HouseController`'s metrics, `ReferenceDataController`) use a small one-directional Model-to-DTO mapper class instead.
+ It's injected as its concrete type rather than through `IDtoMapper`, since it has no DTO -> Model direction to speak of.
+
+Unmapped members are build errors, not a runtime assertion: `RMG012`/`RMG020` (Mapperly's unmapped-target/unmapped-source diagnostics) are escalated to `error` severity in `.editorconfig`.
+So a new property added to a model/entity/DTO without a matching mapping fails the build immediately.
+This replaced the old `AutoMapperConfigurationTest`'s runtime `AssertConfigurationIsValid()` check, now enforced at compile time instead.
+A source-only member each direction doesn't need (like `OwnerId` on the Model -> Dto direction, or a filter-only DTO/model field with no entity counterpart) needs an explicit `[MapperIgnoreSource]`/`[MapperIgnoreTarget]` attribute.
+This satisfies the diagnostic.
+Don't leave a member unmapped and unignored - the build won't compile.
+
+An enum used in a `Domain` model (e.g. `TvShowModel.State: TvShowStatus?`) needs its own **separate** definition in `WebApi.Contracts` (`TvShowDto.State` uses `Keeptrack.WebApi.Contracts.Dto.TvShowStatus`, not the Domain one).
+`WebApi.Contracts` doesn't depend on `Domain` (see the project table above), so the DTO can't reference the Domain enum directly.
+Keep the member names identical between the two; the DTO mapper sets `EnumMappingStrategy = EnumMappingStrategy.ByName` on its `[Mapper]` attribute so Mapperly maps enum-to-enum by name.
+This generates a build diagnostic (rather than a silent runtime mismatch) if a member name ever drifts between the two definitions.
+The Mongo entity, on the other hand, *can* reuse the Domain enum directly (`Infrastructure.MongoDb` depends on `Domain`).
+Only the Contracts layer needs the duplicate, and storage mappers don't need the `ByName` strategy since there's only one enum type involved.
+
+**Gotcha:** a DTO member that's nullable while its model counterpart is `required` non-nullable (the `InventoryPageBase` `new()`-constraint gotcha two sections down - e.g. `BookDto.Title` vs. `BookModel.Title`).
+This needs an explicit fallback on the DTO -> model direction.
+Mapperly refuses to compile a silent substitution the way AutoMapper used to.
+`CommonDtoMappings.ToRequiredString` (`WebApi/Mappers/`, a `[UserMapping]` static method attached to the affected DTO mappers via `[UseStaticMapper(typeof(CommonDtoMappings))]`) reproduces AutoMapper's old `?? string.Empty` behavior.
+It applies for every such string member across every mapper that needs it.
+This is for exact behavior parity.
+Tightening this to a real 400 validation error for a missing title is a deliberate follow-up, not part of the mechanical migration.
+`CarDto.EnergyType` (nullable) -> `CarModel.EnergyType` (required, non-nullable, and a *different* enum type than the Dto's) needs the same treatment but can't reuse `ToRequiredString` (wrong types).
+See `CarDtoMapper`'s own hand-written `[UserMapping]` for the pattern: a partial method gets the generated, drift-checked `ByName` enum conversion, wrapped by one small hand-written method that only handles the null -> `default` fallback.
+
+### Adding a new trackable item type
+
+Follow the existing types (`Book`, `Movie`, `Album`, `TvShow`, `VideoGame`, `Car`/`CarHistory`) as the template.
+A new type touches every layer:
+
+1. `Domain/Models/Model.cs` and `Domain/Repositories/IRepository.cs` (extends `IDataRepository`).
+2. `Infrastructure.MongoDb/Entities/.cs` (BSON attributes, `snake_case` element names via `[BsonElement]`).
+ `Infrastructure.MongoDb/Repositories/Repository.cs` (extends `MongoDbRepositoryBase`, overrides `CollectionName` and, if searchable, `GetFilter`).
+3. `WebApi.Contracts/Dto/Dto.cs` (XML doc comments drive the generated OpenAPI spec).
+4. `WebApi/Controllers/Controller.cs`: a one-line class extending `DataCrudControllerBase` — CRUD logic is never duplicated per controller.
+5. Register the repository in `WebApi/DependencyInjection/InfrastructureServiceCollectionExtensions.cs`.
+6. Add a storage mapper (`Infrastructure.MongoDb/Mappers/StorageMapper.cs`, implementing `IStorageMapper`, registered in `InfrastructureServiceCollectionExtensions.cs`).
+ Also add a DTO mapper (`WebApi/Mappers/DtoMapper.cs`, implementing `IDtoMapper`, registered in `Program.cs`).
+7. `BlazorApp/Components/Inventory/Clients/ApiClient.cs` extending `InventoryApiClientBase`, plus a `Pages/.razor` / `.razor.cs` pair extending `InventoryPageBase`.
+
+**Gotcha:** `Dto` can never have a `required` member if `Pages/.razor.cs` extends `InventoryPageBase` — that base class is constrained `where TDto : IHasId, new()`.
+A type with *any* `required` member can't satisfy a bare `new()` constraint in C# (`CS9040`).
+This is why `BookDto.Title`, `CarDto.Name`, etc. are all nullable even though their Domain model counterpart (`BookModel.Title`, `CarModel.Name`) is `required`.
+It's a hard language limitation forced by the generic list-page base class, not a design inconsistency to "fix" by making the Dto match the model.
+A Dto with no `InventoryPageBase` usage (e.g. `CarHistoryDto`, owned-by-a-parent types managed from their parent's detail page rather than their own list page) has no such constraint.
+It can mirror the Domain model's `required` members in full.
+
+### Child entities (1-to-many owned by another entity)
+
+`CarHistory` (owned by `Car`) and `Episode` (owned by `TvShow`) are separate top-level collections referencing their parent by id (`car_id`, `tv_show_id`), not embedded arrays.
+This is deliberate MongoDB schema design, not an accident: these child collections can grow unbounded per parent over years of use.
+Features that need to query the child across *all* of a user's parents at once (e.g. Watch Next, below) need a plain indexed query rather than an `$unwind` aggregation.
+Embedding is the right call for genuinely small, always-together, never-queried-alone data; neither condition holds here.
+`EpisodeRepository.GetFilter`/`CarHistoryRepository.GetFilter` both filter their parent-id field (`TvShowId`/`CarId`) with `Eq`, not `Text`.
+An exact-id filter used `Text` in `CarHistory`'s first draft, throwing whenever a free-text `search` was also supplied (MongoDB allows only one `$text` expression per query).
+This was fixed and is covered by `docs/code-quality-findings.md`.
+New multi-word BSON fields get an explicit `[BsonElement("snake_case_name")]`; don't rely on the `CamelCaseElementNameConvention` registered in `AddMongoDbInfrastructure`.
+Every existing multi-word field already overrides it explicitly, so it's effectively dead configuration.
+Indexes for new collections are declared in `scripts/mongodb-create-index.js` (natural-key uniqueness, query-shape support, and partial indexes for sparse boolean flags like `is_favorite`/`want_to_watch`).
+
+`CarHistoryModel.EventType` (`CarHistoryType`: `Refuel`/`Maintenance`/`Other`) is a real discriminated enum, not free text.
+This follows the same "never name a property/field bare `Type`" rule as any other discriminator in this codebase (see `TvShowModel.State`'s own rename history above).
+A bare `Type` reads ambiguously and collides in spirit with `object.GetType()`/`System.Type`.
+`Car.EnergyType` (`CarEnergyType`: `Combustion`/`Hybrid`/`Electric`) and `CarHistoryModel.EventType`/`CarId`/`HistoryDate` are all `required` on both the Domain model and the Mongo entity.
+`CarDto` itself can't mirror `Name`/`EnergyType` as `required`, because `Cars.razor` extends `InventoryPageBase`, which needs a bare `new()` constraint — a type with *any* `required` member can't satisfy `new()` in C#.
+So the Dto layer stays nullable there by hard language necessity, not by choice (`CarHistoryDto` has no such constraint, so it mirrors `required` in full).
+`CarHistoryModel.DeltaMileage` is real user-entered data (typically read off the car's own trip computer at refuel time), not a derived/computed field.
+It's kept specifically so `Domain/Services/CarMetricsService.cs` can cross-check it against consecutive `Mileage` readings and flag a likely typo or a skipped entry.
+This is the automated version of a manual spreadsheet cross-check, and the reason this field was kept rather than dropped as "unused" during review.
+`CarMetricsService` (consumption — only ever computed across a full refill/recharge, never a partial one — cost-of-ownership history, mileage warnings, next-maintenance-due) follows `WatchNextService`'s shape.
+It's a plain, `AddSingleton`-registered, unit-tested pure computation class with no persistence of its own.
+It's exposed via `CarController.GetMetrics` the same way `VideoGameController.RefreshReference` adds an extra per-item action to an entity's own CRUD controller instead of a separate cross-entity controller.
+
+`House`/`HouseHistory` (owned-by-a-parent, same shape as `Car`/`CarHistory` above) is a deliberately smaller sibling, not a second copy of Car's design.
+The priority (confirmed with the owner) is an exhaustive, browsable action log for insurance purposes plus a yearly cost review, not fuel/mileage tracking or reminders.
+The owner tracks recurring bills/maintenance schedules elsewhere and explicitly asked for reminders to stay out of scope ("keep it simple, not important, I manage it elsewhere").
+Concretely: `HouseHistoryModel` has no `Mileage`/`DeltaMileage`/fuel fields, no location sub-fields (a house doesn't move, unlike a car's refuel stops).
+`Domain/Services/HouseMetricsService.cs` has no `ComputeNextMaintenanceDue`-style due-date engine - it only computes `ComputeAnnualCostHistory` (cost per year, broken down by `HouseEventType`).
+`HouseHistoryModel.HistoryDate` stays `DateOnly` (not `DateTime` like `CarHistoryModel.HistoryDate`) - House has no Car-style same-day-multiple-entries ordering need (a road trip's several refuel stops).
+So it reuses `CommonStorageMappings` (`Infrastructure.MongoDb/Mappers/`), the shared `DateOnly<->DateTime` UTC-stamping conversion every other date field in the app attaches via `[UseStaticMapper(typeof(CommonStorageMappings))]`, for free.
+This avoids Car's bespoke hand-written `DateTime.SpecifyKind`/`ModalTimeText` "HH:mm" proxy machinery.
+`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.
+
+`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.
+The CLAUDE.md "no duplicated algorithms" quality bar applies to Razor-hosted chart code the same as any other logic.
+Only the axis/geometry math moved; each page's own series-drawing loop (Car's stacked 2-series cost bars, Car's single-series consumption line, House's single-series year bars) stays local.
+That part differs enough per chart that forcing one shared renderer would have been the over-generalization the quality bar warns against.
+The shared CSS these charts need (`.kt-callout*`, `.kt-chart-axis-text`/`-title`, `.kt-chart-svg`, `.kt-sheet-table`, `.kt-chart-legend`/`.kt-legend-swatch`) moved from `CarDetail.razor.css` into `app.css`.
+It's now plain global rules, for the same reason.
+As a side benefit, they no longer need `::deep` to reach into child `*Row` components' markup, since global (non-isolated) CSS was never scoped to begin with.
+
+### Reference data (shared, owner-less)
+
+`tvshow_reference` and `movie_reference` (`MovieReferenceModel.cs`, `MovieReference.cs`) hold metadata (synopsis, episode titles) fetched from TMDB.
+They are the one deliberate exception to "every collection has `owner_id`": this data is public facts about a real show/movie, not user content.
+So storing it once and pointing every tenant's `TvShowModel.ReferenceId`/`MovieModel.ReferenceId` at it avoids duplicating the same show across every user who tracks it.
+Matching key is normalized title + year (`TitleNormalizer.Normalize`, `Common.System/TitleNormalizer.cs` — shared with `TvTimeImportService` so the two never drift on what counts as "the same title").
+The rest of this section walks through the mechanics using TV shows/movies/TMDB as the concrete example.
+`book_reference`/`videogame_reference`/`album_reference` (Open Library/RAWG/Discogs) follow the identical shape - see "Reference data now covers five domains" further down for what's shared versus what's provider-specific.
+
+These repositories (`ITvShowReferenceRepository`/`IMovieReferenceRepository`) do **not** extend `IDataRepository` or `MongoDbRepositoryBase`.
+Both are hard-constrained to `IHasIdAndOwnerId` and owner-scoped paged CRUD, which doesn't fit a shared lookup table with a different method set (`FindByTitleYearAsync`, `UpsertAsync`).
+Don't force a new owner-less collection through that base; write a small purpose-built repository instead, like these two.
+
+`TvShowReferenceModel.Episodes` is **embedded**, the opposite choice from the per-tenant `Episode` collection.
+This isn't inconsistent - the access pattern is opposite too: a reference show's episode list is bounded to its real runtime, always fetched as a whole (rendering a season picker needs all of it at once), and never queried across shows.
+That's exactly the case CLAUDE.md already says favors embedding.
+The per-tenant `Episode` collection fails all three conditions (unbounded per-user growth, queried across shows for Watch Next, upserted independently), which is why that one is referenced instead.
+
+`WebApi/ReferenceData/ReferenceEnrichmentService.cs` is the single place that resolves a title+year to a TMDB id.
+It propagates the result to every tenant's matching document via `ITvShowRepository.SetReferenceLinkAsync`/`IMovieRepository`'s equivalent.
+The automatic best-effort match is fired from `TvShowController`/`MovieController`'s `OnCreatedAsync` hook on `DataCrudControllerBase`, and from `TvTimeImportService` after each newly-imported show/movie.
+Both it and the admin's manual pick (`ReferenceDataAdminController`) call through this one method.
+This avoids duplicating the "upsert reference doc, then propagate" logic.
+The automatic path only ever acts on a *single, confident* TMDB search result - zero or multiple candidates leaves the item unresolved for the admin queue rather than guessing.
+This is the same "don't guess when you don't have the info" principle as Watch Next, below.
+`SetReferenceLinkAsync` sets `ReferenceId`, `Title` (the TMDB canonical name) and, when the reference has one, `Year` (the TMDB canonical year) in the same update.
+Linking is meant to correct whatever title/year the tenant originally typed/imported, not just attach an id, and pre-populating `Year` from a trustworthy source beats leaving it at whatever the tenant originally guessed.
+`Year` stays freely editable afterward on `MovieDetail.razor`/`TvShowDetail.razor` (same as `Title` - see the paragraph on `TryLinkExistingTvShowReferenceAsync` below for why), so this is a starting value, not a lock.
+
+`TvShowReferenceModel`/`MovieReferenceModel.MatchedAliases` is a `List` of every (title, year) combination ever confirmed (via a TMDB resolution, automatic or admin-picked) to mean this exact show/movie.
+This is not just the document's own canonical `TitleNormalized`/`Year`.
+`ResolveTvShowAsync`/`ResolveMovieAsync` populate it with **both** the (TMDB canonical title, TMDB canonical year) and whatever (title, year) the tenant actually searched with.
+This uses `MergeMatchedAliases`, merged with any aliases a previous resolution already contributed - never overwritten.
+`TvShowReferenceRepository`/`MovieReferenceRepository.UpsertAsync` additionally guarantees the document's own (`TitleNormalized`, `Year`) is always in the list even if a caller forgot.
+Year travels with its specific title variant rather than living as a single top-level scalar, because a title-only alias list isn't enough on its own.
+Two tenants (or a tenant and TMDB's own canonical data) can legitimately record different years for the same real show.
+A French-language tenant's "Le Fil" (2002) and an English tenant's "The Wire" (2002) are one case this handles.
+So is a tenant who recorded 2003 by mistake and TMDB's canonical year of 2002 - both should still resolve to the same document once *either* variant has been confirmed.
+`FindByTitleYearAsync`/`FindByTitleAsync` (on both reference repositories) query `MatchedAliases` with `Builders.Filter.ElemMatch` instead of a plain `Eq` against `TitleNormalized`/`Year`.
+Both the title and year condition must hold on the *same* embedded array element - a plain `AnyEq`-per-field approach would let a title match on one alias and a year match on a completely different one.
+`scripts/mongodb-create-index.js`'s `tvshow_reference_title_year`/`movie_reference_title_year` indexes were changed to a compound multikey index over `matched_aliases.title`/`matched_aliases.year` accordingly.
+A single compound index over two sub-fields of the *same* array is fine, unlike trying to combine two *different* array fields in one compound index, which MongoDB disallows.
+
+`ReferenceEnrichmentService.TryLinkExistingTvShowReferenceAsync`/`TryLinkExistingMovieReferenceAsync` is a second, cheaper resolution path that never calls TMDB.
+It only checks whether a matching reference document *already exists* (title+year, falling back to title-only, against `MatchedAliases`).
+This backs `POST /api/tv-shows/{id}/refresh-reference`/`/api/movies/{id}/refresh-reference`.
+That's a "check for reference match" control shown **unconditionally** on both detail pages (not just when unresolved) to **any** authenticated user, not just admins.
+It can never do anything beyond reusing a fact some other tenant (or an admin) already established.
+It's unconditional deliberately: `Title` and `Year` are both freely editable at any time.
+A tenant who thinks their current link is wrong (two different real movies sharing an identical title is common - year and cast/poster are often the only way to tell them apart) needs to be able to edit either.
+They need to be able to re-check *even though something is already linked*.
+This is to replace a bad match.
+The method does **not** short-circuit on an existing `ReferenceId` for exactly this reason.
+When a match is found it updates only this tenant's own document directly (`ITvShowRepository.UpdateAsync`/`IMovieRepository`'s equivalent).
+It sets `Year` to the reference's own canonical year alongside `Title` and `ReferenceId` (same rationale as `SetReferenceLinkAsync` above).
+This is rather than the broad cross-tenant `SetReferenceLinkAsync` (which refuses to touch already-linked documents by design, so it wouldn't fix a wrong link on this tenant's own item).
+But it still also calls `SetReferenceLinkAsync` with the pre-edit title/year afterward, so any other still-unresolved tenant sharing that text benefits too.
+When **no** match is found for the current title/year and the document *was* linked, the link is cleared (`ReferenceId` set to `""`) rather than left pointing at something the tenant just told us, by editing the title, is wrong.
+Clearing it is also exactly what puts the item back into the admin's unresolved queue (`FindDistinctUnresolvedTitleYearsAsync`) for a manual TMDB search.
+This is deliberately explicit/user-triggered rather than automatic on every page view (no `GetById` side effect), so the behavior stays visible and predictable.
+It's also why `Title`/`Year` are unconditionally editable on both `MovieDetail.razor`/`TvShowDetail.razor` regardless of link status -
+free editing is what makes "fix a typo or a wrong year (or pick a different match), then hit refresh" work.
+`Year` matters here specifically because TMDB search results embedding a year into the title text (e.g. "Dune 2021") is unreliable - the tip that used to suggest doing that was wrong and has been removed.
+The actual matching (both this local lookup and the live TMDB search behind `InlineReferenceLinker`) always takes year as its own separate field/parameter, never parsed out of title text.
+The list edit modal (`Movies.razor`/`TvShows.razor`) still locks `Title` for non-admins once linked - that's a separate, intentionally stricter surface for bulk-editing, not an inconsistency to reconcile.
+
+**Gotcha:** the title-only fallback in `TryLinkExistingTvShowReferenceAsync`/`TryLinkExistingMovieReferenceAsync` must run unconditionally, *including* when the tenant's `Year` is `null`.
+An earlier version only attempted it when `Year is not null`, which is backwards.
+A tenant with no year recorded at all is exactly the case that needs the year-agnostic fallback most, since `FindByTitleYearAsync(title, null)` can only ever match a reference whose own `Year` is also `null`.
+Skipping the fallback there meant any linked item with an unset year would unlink itself the instant "check for reference match" was clicked, since neither query could succeed.
+Confirmed by a real user hitting this on a genuinely valid, already-linked title.
+
+`ResolveTvShowAsync`/`ResolveMovieAsync`'s "does a reference document already exist for this" check now looks up by TMDB id first.
+This uses `ITvShowReferenceRepository`/`IMovieReferenceRepository.FindByExternalIdAsync`, same shape as `IPersonReferenceRepository`'s cast-dedup lookup.
+It falls back to title+year/title-only only when no document has that id yet.
+Title-text matching alone isn't reliable enough to prevent duplicates.
+Two tenants (or an admin resolving the same unresolved queue twice under different title text - a translation, or a typo the admin fixed) can easily resolve the *same* TMDB entry through completely different search strings.
+If the existence check only compares title text, the second resolution creates a second reference document for what's genuinely the same movie/show instead of updating the first.
+The TMDB id is the one signal that's invariant under any title text difference, so it's checked first and is authoritative.
+
+`scripts/mongodb-create-index.js`'s `tvshow_reference_tmdb_id`/`movie_reference_tmdb_id`/`person_reference_tmdb_id` indexes are `unique: true` with a `partialFilterExpression: { "external_ids.tmdb": { $exists: true } }`.
+The application-level id-first dedup check above is what's *supposed* to prevent two documents sharing a TMDB id, but a database constraint is what actually guarantees it can never happen even if a future code path forgets to check.
+The partial filter (rather than a plain unique index, or the older `sparse: true` option) is required because it's only `external_ids.tmdb` that must be unique *when present*.
+A document from before this field existed (or any owner-less collection document that's never linked to that provider) has no such key at all.
+A plain unique index would treat every one of those "missing the field" documents as colliding on the same null key.
+
+Reference data is meant to be portable across environments, not re-earned from TMDB one search at a time per deployment.
+`ReferenceDataAdminController`'s `GET/POST /api/reference-data/export`/`import` round-trip the entire `tvshow_reference`/`movie_reference`/`person_reference` collections as a zip of JSON arrays.
+Idempotency here is free, not new logic - every repository's existing `UpsertAsync` already replaces-by-id when the model carries one, so re-importing the same export twice is a no-op the second time by construction.
+`FindAllAsync()` on each of the three reference repositories exists solely to back this export; it's an unpaged full-collection read, acceptable because this data is small and shared (not per-tenant).
+
+`TvTimeImportService`'s per-show/per-movie reference match (`TryEnrichShowAsync`/`TryEnrichMovieAsync`) fires on its own DI scope via `IServiceScopeFactory`, same shape as `TvShowController`/`MovieController.OnCreatedAsync`.
+It's not awaited inline.
+A bulk import creating dozens of new shows/movies must not block on a sequential chain of TMDB HTTP calls (search + details + credits, per item) before the import job itself can report "Completed".
+If TV Time import feels slow again, check this path before assuming it's a missing Mongo index (already fully audited once - see `docs/code-quality-findings.md`).
+
+**Gotcha (historical - fixed by the AutoMapper -> Mapperly migration, but old data still needs this):** "does this tenant document have no reference link yet" cannot be a plain `Eq(x => x.ReferenceId, null)` filter.
+Under AutoMapper, `AllowNullDestinationValues = false` made mapping a model whose string property is null store an **empty string**, never an actual BSON null; documents written that way still exist.
+`TvShowRepository`/`MovieRepository`'s `UnresolvedFilter()` matches null *or* empty string for exactly this reason.
+Copy that helper's shape (not a bare null check) for any new "is this string field unset" query, even now that new writes store a real null (Mapperly preserves nulls by default).
+This one is easy to get wrong silently: it doesn't throw, it just quietly matches zero documents.
+A real-MongoDB integration test (`TvShowReferenceLinkingTest`) is what caught it originally, not the unit tests (which mock the repository and never see the actual serialization behavior).
+
+**Second instance of the same gotcha (also historical - see `docs/automapper-removal-plan.md`):** AutoMapper's `AllowNullDestinationValues = false` used to also change `mapper.Map(source)`.
+This happened even when `source` itself (not just one of its properties) was `null`.
+Instead of returning `null`, it returned a new, all-default instance of `TDestination`.
+Mapperly (the current mapper) takes the opposite, honest approach: it *throws* on a null source rather than fabricating a default instance, which is exactly why the `entity is null` guard below still matters,
+just for a different reason now.
+Every `Find*Async` method on `TvShowReferenceRepository`/`MovieReferenceRepository`/`PersonReferenceRepository` does `var entity = await Collection.Find(...).FirstOrDefaultAsync();`.
+`entity` can legitimately be null (nothing matched).
+Mapping it directly would either fabricate a fake found-but-empty model (old AutoMapper behavior) or throw (current Mapperly behavior).
+Neither of those is the `null` that `ReferenceDataController`'s `model is null` 404 check (and anything else checking `is null` on a Find result) needs.
+Each of those methods checks `entity is null` before calling the mapper, returning `null` directly instead.
+**This isn't limited to the owner-less reference repositories**: `MongoDbRepositoryBase.FindOneAsync` (the shared base every ordinary owner-scoped repository extends) has the exact same shape and needs the exact same guard.
+Every entity's `GetById` 404 check depends on it.
+Copy this guard for any new Find method that can legitimately return "nothing matched," anywhere in the codebase, not just the owner-less collections.
+A mocked-repository unit test cannot catch a regression here, only a real MongoDB integration test can (a mock never exercises the real mapper/serialization behavior).
+
+The reference layer also now has `Genres`, `Cast` (embedded `CastMemberModel` list, pointing at a third owner-less collection, `person_reference`), and `PosterUrl`/`CastMemberDto.ProfileImageUrl`.
+Actors are deduplicated across every show/movie that credits them, keyed by TMDB person id via `IPersonReferenceRepository.FindByExternalIdAsync`, not by name.
+Images are **hotlinked directly from TMDB's CDN** (`https://image.tmdb.org/t/p/{size}{path}`, built once in `TmdbClient` and stored as a plain URL) rather than downloaded and re-hosted.
+This is confirmed to be TMDB's own sanctioned, standard usage pattern (a separate, unauthenticated static-asset host, not the rate-limited API), so there's no local storage/volume/static-file-serving subsystem to operate.
+`CastMemberDto` is fully hydrated server-side (`ReferenceDataController` joins the embedded cast list against `person_reference`) specifically because that join needs repository access a generated mapper doesn't have.
+`TvShowReferenceDtoMapper`/`MovieReferenceDtoMapper` (`WebApi/Mappers/`) ignore `Cast` via `[MapperIgnoreTarget]` for exactly this reason; don't try to make it a plain mapped member.
+
+`TvShowDetail.razor`'s episode list is a full watch-through checklist (every reference episode, checkbox = watched) once the show has a `ReferenceId`,
+falling back to the original recorded-episodes-only view with a manual add form when it doesn't.
+There's no way to know the full episode count without reference data, so that fallback is a deliberate scope boundary, not a bug.
+Checking a box creates an `Episode` with `WatchedAt = today`; unchecking deletes it - there's no way to set an arbitrary watched date from this view once a show is resolved.
+
+Admin-only endpoints (`ReferenceDataAdminController`) use policy-based authorization (`[Authorize(Policy = "AdminOnly")]`, registered in both `WebApi/Program.cs` and `BlazorApp/Program.cs`).
+The policy is `RequireClaim("role", "admin")`, backed by a Firebase custom claim.
+This is not ASP.NET's built-in `Roles=` support.
+Firebase's claim arrives as a plain `"role"` claim rather than the `ClaimTypes.Role` URI that `[Authorize(Roles=...)]`/`` expect by default.
+`BlazorApp/Components/Account/Controllers/AuthenticationController.cs` copies the `role` claim from the verified Firebase token into the cookie principal at sign-in.
+WebApi validates the bearer token's claims directly and needs no equivalent step.
+There's no in-app way to grant the first admin; it's a one-off `setCustomUserClaims` call via the Firebase Admin SDK (see `CONTRIBUTING.md`).
+
+**Gotcha:** `WebApi/Program.cs`'s `AddJwtBearer` sets `options.MapInboundClaims = false` deliberately.
+Without it, the token handler silently renames certain short JWT claim names to legacy `ClaimTypes.*` URIs before `HttpContext.User` ever sees them.
+`"role"` is one of the remapped names (to `ClaimTypes.Role`), so `RequireClaim("role", "admin")` would never match even though the raw token genuinely has a `role` claim.
+This is exactly why the Blazor side (whose cookie principal is built by hand in `AuthenticationController`, using the literal claim name) could show the admin nav link while the same request's bearer-token call to WebApi still 403'd.
+The two sides were checking different claim types for what was, on the wire, the same claim.
+`user_id` was never affected by this because it isn't one of the handful of short names the legacy map remaps; don't assume a new custom claim is equally safe without checking, or just leave `MapInboundClaims = false` alone.
+
+### Web API request flow
+
+`DataCrudControllerBase` (`WebApi/Controllers/DataCrudControllerBase.cs`) implements the full CRUD surface (`GET`, `GET/{id}`, `POST`, `PUT/{id}`, `DELETE/{id}`) once, generically.
+It calls the shared `ControllerBaseExtensions.GetUserId()` extension (`WebApi/Controllers/ControllerBaseExtensions.cs`) to read the caller's `user_id` claim, scope every query, and stamp `OwnerId` on writes.
+Per-type controllers only need routing and generic type arguments — any new controller (CRUD or not) should call the same extension rather than re-reading the claim.
+Unhandled exceptions are converted to JSON error responses by `ApiExceptionFilterAttribute` (`ArgumentException`/`ArgumentNullException` -> 400, everything else -> 500).
+It also logs every caught exception via `ILogger` before converting it - a 500 leaves a server-side trail to diagnose, not just an opaque error in the browser.
+
+**Resilience against failing external providers.** TMDB/RAWG/Open Library/Discogs (`WebApi/ReferenceData/`'s `TmdbClient`/`RawgClient`/`OpenLibraryClient`/`DiscogsClient`) are third-party APIs outside Keeptrack's control.
+Slow, rate-limited, or briefly down is a "when," not an "if."
+Each of their four `AddHttpClient<...>()` registrations in `Program.cs` chains `.AddStandardResilienceHandler()` (`Microsoft.Extensions.Http.Resilience`).
+This gives every call automatic retry, a per-attempt timeout, a total-request timeout, and a circuit breaker.
+A transient failure is retried and recovered without the caller ever seeing it.
+A provider that's genuinely down fails with a normal exception (mapped to a clean JSON 500 by `ApiExceptionFilterAttribute` above) instead of hanging a request thread indefinitely or degrading the whole app under concurrent load.
+Give any future outbound HTTP client to a third-party service the same treatment.
+`.AddStandardResilienceHandler()` is a one-line addition to the `AddHttpClient<...>()` chain, not something to hand-roll per client.
+Covered by `ExternalProviderResilienceTest` (`test/WebApi.UnitTests/ReferenceData/`), which builds the exact same wiring against a stub `HttpMessageHandler` (no real network).
+It asserts both halves: a transient failure is retried and recovered, and a provider that stays down fails cleanly rather than hanging.
+This was tested once against `RawgClient` as a representative example, since the wiring is identical for all four and duplicating the same test four times would add nothing.
+
+`builder.Services.Configure(opts => opts.BackgroundServiceExceptionBehavior = BackgroundServiceExceptionBehavior.Ignore)` in `Program.cs` is a separate, systemic safety net.
+By default, an unhandled exception escaping any hosted `BackgroundService.ExecuteAsync` (like `ReferenceSyncBackgroundService`) stops the *entire host*, taking every other endpoint down with it, not just the background job.
+`ReferenceSyncBackgroundService`/`ReferenceSyncService` already catch and log everything they can anticipate (see "Keeping reference data fresh" below).
+This setting is the backstop for whatever a future background service's own error handling misses.
+`Ignore` logs the failure and keeps the rest of the app serving requests instead of crashing the process.
+Apply the same reasoning before adding a new `AddHostedService<...>()`: its own `ExecuteAsync` should still catch what it can anticipate (this global setting isn't a substitute for that).
+A bug in it should never be able to take down unrelated endpoints.
+
+Not every endpoint is per-item CRUD.
+`WatchNextController`/`WishlistController` (read-only cross-entity aggregations) live in `WebApi/Controllers/` like every other controller, with a plain `ControllerBase` rather than being force-fit into `DataCrudControllerBase`.
+Their computation lives in `Domain/Services/` (`WatchNextService`, `WishlistService`) since it's pure logic over Domain models with no persistence or web dependency - Domain's stated charter from the project-overview table above.
+`WebApi/Import/` (the TV Time GDPR-export upsert, using `CsvHelper` for parsing) and `WebApi/ReferenceData/` still follow the older feature-folder shape (a `ControllerBase` and service class colocated under `WebApi//`).
+This is now recognized as the same misplacement `WatchNext` had, but migrating them is deliberately deferred to a separate change to limit regression surface and manual-testing burden.
+Don't extend the older feature-folder shape to new code; follow `WatchNextController`/`WishlistController`'s split instead (controller in `Controllers/`, pure computation in `Domain/Services/` when there is any).
+
+`seen_episode_source.csv` alone is a drastically incomplete picture of a user's episode history (it's only written when an episode is marked watched via TV Time's episode-detail screen) - confirmed against real export data, not assumed.
+`TvTimeImportService` also reads `tracking-prod-records.csv` and `tracking-prod-records-v2.csv`, TV Time's generic event logs, which capture episodes marked watched any other way (bulk/season actions).
+All three are merged and de-duplicated per (show, season, episode), earliest date wins, before upserting `Episode`s.
+If a future TV Time export field looks suspiciously sparse, check the raw export files directly (`grep` the show name across every `*.csv`) before assuming the current parser set is complete -
+this is exactly how the tracking files were found.
+
+`followed_tv_show.csv` is not a complete list of shows either - confirmed with a real show ("The Pitt") that has genuine watch history in the tracking files but no row in `followed_tv_show.csv` at all.
+`ImportEpisodesAsync` creates a show on the fly from watch-event data (title, plus the show's TV Time id when the source file has one) rather than skipping shows that aren't already known.
+This applies the same rating/favorite/notes enrichment a normally-followed show gets.
+Don't reintroduce a "skip if not already followed" check here.
+
+The import is idempotent by matching on a stable, enrichment-immutable id, **not** by title.
+Re-running an import used to duplicate every show/movie (and, via the new show id, all their episodes).
+Shows/movies were matched by normalized `Title`, but reference enrichment rewrites `Title` to the provider's canonical name after the first import (`SetReferenceLinkAsync`).
+So on the second import the export's original title no longer matched and a duplicate was created - confirmed by a real re-import.
+The fix stamps every imported show/movie with `TvTimeId` (`IHasTvTimeId`, a real Domain field on `TvShowModel`/`MovieModel` carried through entity/DTO like `ReferenceId`,
+and round-tripped on edits since `UpdateAsync` is a full `ReplaceOneAsync`).
+This is TV Time's own show id for shows (`followed_tv_show.csv`/the tracking logs), and the per-movie tracking `uuid` for movies (every follow/watch/towatch row for one movie shares it).
+Movies do have a stable id after all, contrary to the older "movies have no stable id" note, which was only ever true of the per-vote uuid in the rating files.
+When the export carries no id for a title (a movie known only from the vote files; a show seen only in `seen_episode_source.csv`), `ResolveTvTimeId` synthesizes a fallback.
+It's a deterministic `tvtime_title:` fallback from the **export** title.
+This fallback title is one that enrichment never touches.
+`BuildIdByTitle` first maps every title to its id across the id-bearing files, so the title-only files resolve to the *same* id as their id-bearing counterpart.
+This is why an id-less `seen_episode_source.csv` episode still attaches to the followed show it belongs to, even after that show's stored title was enrichment-renamed.
+`UpsertIndex` matches an incoming item by `TvTimeId` first.
+A title fallback fires only for a pre-existing record that has **no** `TvTimeId` yet (created by an import predating this feature), which is then adopted and back-filled exactly once (`BackfillTvTimeIdAsync`) rather than duplicated.
+A record already carrying a *different* id is left alone, so two genuinely different items sharing a title are never collapsed.
+On a match the record is **left untouched** - not merely honoring the "if it already exists, do nothing" rule.
+This is because a re-import must never clobber edits the user made *in the app* after the initial import (rating, notes, favorite, a corrected title/year).
+Re-applying the export's values on every re-import would silently erase those.
+So don't reintroduce an update-on-existing path here.
+Only genuinely new items are created, and the result counts `*Created`/`*Skipped` (deduped by reference identity so a show touched in both the followed-shows and episodes phases counts once).
+Covered by `TvTimeImportServiceIdempotencyTest` (unit, in-memory repos, re-imports after mutating the stored titles to prove no duplication) and the re-import half of `TvTimeImportResourceTest` (integration).
+Note this fixes *future* imports; pre-existing duplicates from earlier buggy runs need a one-off cleanup (a `scripts/*.js` dedupe in the same run-once style as `migrate-poster-url-to-image-url.js`), not yet written.
+
+When a `SeenEpisodeRecord`-shaped property is only present in *some* of the three source files' headers (like `TvShowId`, absent from `seen_episode_source.csv`), it needs a special attribute.
+It needs `[Optional]` from `CsvHelper.Configuration.Attributes` in addition to not being C#'s `required`.
+CsvHelper's header validation throws on a missing column for any `[Name]`-decorated property regardless of C# nullability unless that attribute is present.
+This only surfaces at runtime against a real file missing the column, not at compile time - a unit test with a realistic fixture (matching `seen_episode_source.csv`'s actual header) is what caught it.
+
+Movies were originally believed to have no watch date anywhere in the export ("import movies without a watch date" was the confirmed scope decision).
+That turned out to be wrong the same way the episode-history gaps were: `tracking-prod-records.csv`'s generic event log carries `entity_type == "movie"` rows too.
+These have `type` "watch" (an individually dated watch event - `Movie.FirstSeenAt`), "towatch" (`Movie.WantToWatch`, only applied when there's no watched event, so a since-watched movie doesn't stay flagged as still-to-watch).
+They also have "follow" (existence only, same role as `followed_tv_show.csv` for shows).
+`MovieTrackingEventsCsvParser` reads these; `tracking-prod-records-v2.csv` (the newer-generation log) carries no movie data at all - confirmed against a real export, not assumed.
+If a future TV Time field looks suspiciously absent, re-check the real export before documenting it as a limitation - this is the second time an "unrecoverable" gap turned out to just be unparsed.
+
+The import runs as a background job rather than a single request/response, so the UI can show real progress.
+`POST /api/import/tv-time` buffers the upload, kicks off the work via `IServiceScopeFactory.CreateScope()` (the request's own DI scope is gone by the time the background work runs), and returns a job id immediately.
+`GET /api/import/tv-time/{jobId}` reports the current `ImportStage`.
+`ImportJobStore` is an in-memory singleton keyed by job id and checks the caller's owner id on every read, so a job is only ever visible to the user who started it.
+Follow this shape (buffer input, background `Task` via a fresh scope, pollable status keyed by owner) for any other future long-running action.
+Don't block a request on multi-second server work just because the current single-endpoint pattern is simpler to write.
+
+Watch Next originally reported only the *last watched* episode per in-progress show, deliberately never guessing a "next" one, because Keeptrack had no episode-guide data to confirm a further episode actually existed.
+That constraint is gone now that `TvShowReferenceModel.Episodes` carries a real TMDB episode list.
+`WatchNextService.ComputeInProgressShows` now takes a third parameter, `referencesByShowId`.
+It only reports a show if (a) its `Status` is `TvShowStatus.Current` (not merely "not Finished/Stopped" as before) and (b) its linked reference's episode list has an entry after the last one watched.
+That entry is compared by `(SeasonNumber, EpisodeNumber)`, not by title or air-date order, and its `AirDate` must have already passed (or be unset).
+A show with no `ReferenceId`, or no matching reference document, is excluded rather than guessed at - the same "don't guess when you don't have the info" principle as before, just enforced by data instead of by omission.
+`WatchNextController` only fetches the (small, bounded) reference document for shows that are `Current` and linked, since those are the only ones that can possibly appear in the result.
+The DTO now also reports the confirmed next episode's season/episode/title (`InProgressShowDto.Next*`).
+This was withheld before purely for lack of data, not by design preference, so surfacing it once the data is trustworthy is not a reintroduction of the old "+1" heuristic that shipped a confirmed-wrong result.
+That heuristic guessed without confirming an episode existed; this checks a real episode-guide.
+
+`WatchNextService.FilterMoviesToWatch` excludes a movie from the "movies to watch" list once it's marked seen (`FirstSeenAt` set), even though it's still flagged `WantToWatch`.
+Toggling that flag on a movie's own detail page doesn't clear it on watch (unlike the TV Time import's "towatch" event handling, which never flags an already-watched movie in the first place).
+So the exclusion has to happen at read time here instead of relying on the flag never going stale.
+
+`TvShowDetail.razor`'s episode checklist filters `_reference.Episodes` to `AirDate is null || AirDate <= today` before grouping into seasons.
+This is the same air-date filter `WatchNextService` already applies for its "next episode" calc.
+An episode TMDB lists with a future air date (a confirmed-but-unaired next season, e.g. a renewal announced months ahead) hasn't happened yet from the viewer's perspective - it shouldn't appear as a checkbox to mark watched.
+An entirely future season simply doesn't appear in the season picker at all once every one of its episodes is filtered out.
+
+### Keeping reference data fresh: periodic + on-demand TMDB sync
+
+TMDB's own data (episode air dates as seasons progress, genres, posters, cast) drifts out of date after the initial resolution - a show resolved months ago needs re-checking, not just a one-time fetch.
+`ReferenceSyncBackgroundService` (`WebApi/ReferenceData/`) is a plain in-process `BackgroundService` running a `PeriodicTimer` (24h interval, does an initial pass immediately on startup too).
+It's deliberately **not** a Kubernetes CronJob or separate worker process.
+A second scheduled workload is real operational overhead (another manifest, another thing that can silently stop running) for a job that's cheap enough to run inside the existing API process.
+`ReferenceSyncService.SyncStaleReferencesAsync(staleAfter, ...)` is shared by both the periodic loop and the admin's on-demand trigger, so there's exactly one sync algorithm.
+It skips any reference document whose `LastEnrichedAt` is more recent than `staleAfter` (3 days for the periodic pass; `TimeSpan.Zero` for the admin's forced "sync now", which therefore re-checks everything regardless of recency).
+It never lets one failing document (a TMDB id that's since been removed, a transient network error) abort the rest of the run - each is caught and logged individually.
+
+`ReferenceEnrichmentService.RefreshTvShowReferenceAsync`/`RefreshMovieReferenceAsync` do the actual per-document work, and lead with a cheap pre-check before the expensive full re-fetch.
+`ITmdbClient.HasTvShowChangedSinceAsync`/`HasMovieChangedSinceAsync` call TMDB's per-id `/{tv,movie}/{id}/changes?start_date=...` endpoint (one call, no season fan-out) to ask "has anything changed since this was last enriched".
+This is the "if TMDB provides the last updated date... check with `last_enriched_at`" idea, implemented via TMDB's changes endpoint since TMDB doesn't expose a plain last-modified field on the show/movie details response itself.
+If nothing changed, only `LastEnrichedAt` is bumped (so the next periodic pass doesn't re-check an already-current document) and the costly `GetTvShowDetailsAsync` + per-season `GetTvShowCastAsync` calls are skipped entirely.
+A reference with no `LastEnrichedAt` yet always does the full fetch (nothing to compare against, and the changes-check would be meaningless anyway).
+
+An admin can force an immediate full re-check via `POST /api/reference-data/sync-now` (`ReferenceDataAdminController`, admin-only) instead of waiting for the next scheduled pass.
+The same "Reference data" admin page that already has export/import gets a "Sync now" button reporting checked/updated counts for both collections.
+
+**Gotcha:** the periodic background service is registered unconditionally in `Program.cs`, but only actually does work when `Features:IsReferenceSyncEnabled` (`AppConfiguration.IsReferenceSyncEnabled`, default `true`) is true.
+This is checked fresh on every tick, not once at startup.
+The integration test host (`KestrelWebAppFactory`) overrides this to `false` via `ConfigureAppConfiguration` (an in-memory source added last, so it wins over `appsettings.json`).
+`UseSetting` was tried first and silently didn't work for a top-level-statement minimal-hosting `Program.cs` like this one - it's loaded too early relative to the rest of configuration and gets overridden back by `appsettings.json`.
+Without this, every integration test run spun up its own in-process Kestrel host (one per `IClassFixture>` test class).
+Each one immediately fired real TMDB HTTP calls against whatever reference data the shared test MongoDB happened to hold - noisy and non-deterministic, not a real regression to chase.
+Any integration test using a bare `WebApplicationFactory` instead of `KestrelWebAppFactory` bypasses this override (this already bit `AuxiliaryResourceTest` once).
+Use `KestrelWebAppFactory` for any new integration test fixture, even one that doesn't need real Kestrel networking, specifically so it inherits this override for free instead of re-solving it.
+
+### Reference data now covers five domains (Book, VideoGame, Album)
+
+The TMDB-backed pattern above (shared `*_reference` collection, admin unresolved queue, user-triggered "check for reference match", periodic + on-demand sync, export/import) is no longer TV-show/movie-only.
+`Book`/`VideoGame`/`Album` (the last renamed from `MusicAlbum` for consistency with `Episode`/`CarHistory`-style short names) each get their own external source and reference collection:
+
+- **Books** → Open Library (`IBookReferenceClient`/`OpenLibraryClient`, `WebApi/ReferenceData/`), no API key, `book_reference` collection with an extra `AuthorReferenceId` field.
+- **Video Games** → RAWG (`IRawgClient`/`RawgClient`), a single API key appended as a query param (same convention as TMDB's `api_key`), `videogame_reference` with an extra `Platforms` list.
+ `VideoGameModel.Platform`/`State` are deliberately never overwritten by a reference link - they describe this tenant's own copy/progress, not the canonical release.
+ This is unlike `Title`/`Year`, which do get set on link the same way Movie/TvShow already do.
+- **Albums** → Discogs (`IDiscogsClient`/`DiscogsClient`), a personal access token appended as a query param plus a required descriptive `User-Agent` header (Discogs API policy), `album_reference` with an extra `ArtistReferenceId` field.
+ Discogs search results title a release as `"Artist - Album Title"` in one string; `DiscogsClient` splits on the first `" - "` to populate `Artist`/`Title` separately.
+
+**Divergence from TMDB:** none of these three APIs expose a TMDB-style per-id `/changes` endpoint, so `RefreshReferenceAsync` for these three domains has no cheap "has this changed" pre-check.
+It always does a full re-fetch once a document is past its staleness cutoff (the cutoff check itself, in `ReferenceSyncService`, is unchanged).
+`ReferenceSyncResultDto`'s `Books/VideoGames/AlbumsUpdated` counts are therefore always equal to their matching `*Checked` counts.
+
+**Author/artist matter for search precision, not just display.** A common book/album title alone (e.g. "Killing Floor") returns many unrelated candidates from Open Library/Discogs.
+`IBookReferenceClient.SearchBooksAsync`/`IDiscogsClient.SearchAlbumsAsync` both take an optional `author`/`artist` parameter (the provider's own query field for it) precisely to narrow this.
+This is threaded through `TryAutoResolveBookAsync`/`TryAutoResolveAlbumAsync` (captured from `BookModel.Author`/`AlbumModel.Artist` in `BookController`/`AlbumController.OnCreatedAsync`) and through the admin's manual search.
+The admin's manual search is `ReferenceDataAdminController.Search`'s `creator` query param, surfaced as a free-text box in `ReferenceDataAdminPage.razor`.
+It's also `InlineReferenceLinker`'s `Creator` parameter, populated from the tenant's own Author/Artist field on `BookDetail.razor`/`AlbumDetail.razor`.
+`ReferenceSearchResultDto.Creator` (book author or album artist; null for TV/movie/game) surfaces this on every search candidate the admin sees.
+Without it, an admin looking at otherwise-identical title+year+cover candidates has no way to tell them apart.
+
+**Gotcha:** `OpenLibraryClient.SearchBooksAsync` deliberately never sends `year` as a server-side query filter (unlike TMDB/RAWG/Discogs, which do).
+Open Library's `first_publish_year` is a book's ORIGINAL publication year, not whatever edition/printing year a tenant happened to record.
+Searching "Killing Floor" (Lee Child, first published 1997) with `first_publish_year=2016` (a tenant's 2016 reprint) returns zero relevant results.
+This is because Open Library filters the *work* by that exact year server-side and no edition of that work was first published in 2016.
+`year` is still returned per candidate for display/tie-breaking, just never used to narrow the query itself.
+This was confirmed against the real API (searching `title=Killing+Floor&first_publish_year=2016` returns an unrelated 2016 book; dropping the year filter and adding `author=Lee+Child` finds the real one).
+This is a book-specific problem (one canonical release date doesn't generalize to "the book was first published once, but every reader owns some edition").
+RAWG/Discogs keep their year filters since no equivalent bug has been found for games/albums.
+
+Book authors and album artists are deduplicated the same way TV/movie cast is: `PersonReferenceModel`/`person_reference` was kept as-is (no rename) and reused, rather than adding two more near-duplicate lookup tables.
+"Person" already meant "a named individual or group identified by an external provider id," not "actor" specifically.
+So extending it to a book's author (Open Library author id) or an album's artist (Discogs artist id, which can be a group/band) needed no rename, just reuse.
+`BookReferenceModel.AuthorReferenceId`/`AlbumReferenceModel.ArtistReferenceId` point at a `PersonReferenceModel` document instead of embedding a plain name string.
+`ReferenceEnrichmentService.ResolvePersonReferenceIdAsync` (shared helper in the core partial-class file) is the single dedup-by-external-id path used by cast resolution (`ResolveCastAsync`, refactored to call it per member)
+and by `ResolveBookAsync`/`ResolveAlbumAsync`.
+`BookReferenceDto.AuthorName`/`AlbumReferenceDto.ArtistName` are hydrated by `ReferenceDataController` joining `person_reference` by id (same `[MapperIgnoreTarget]` + manual join pattern as `Cast`).
+The model only carries the id, never a denormalized name, so there's nothing to keep in sync.
+When linking (`TryLinkExistingBookReferenceAsync`/`TryLinkExistingAlbumReferenceAsync`), the tenant's own plain-string `BookModel.Author`/`AlbumModel.Artist` is still populated.
+This works by resolving the reference's `*ReferenceId` to a name at link time (`ResolvePersonNameAsync`).
+The tenant-facing field stays a free-text string editable like `Title`, only the shared reference document uses the dedup'd id.
+
+`ReferenceEnrichmentService` is a single `partial class` split across `.TvShowsAndMovies.cs`/`.Books.cs`/`.VideoGames.cs`/`.Albums.cs` files (one class, same constructor, same five-method-per-domain template:
+`TryLinkExistingReferenceAsync`/`TryAutoResolveAsync`/`ResolveAsync`/`RefreshReferenceAsync`).
+Splitting by file rather than by class keeps the shared helpers (`MergeMatchedAliases`, `TitleNormalizer.Normalize`) in one place without one file growing to cover six domains' worth of logic.
+`ReferenceMatchModel`/`MatchedAliases` needed no changes at all to support the new domains - it was already generic, not TMDB-specific, per its original naming decision.
+
+`PosterUrl` was renamed to `ImageUrl` on `TvShowReferenceModel`/`MovieReferenceModel` (and their entities/DTOs/Razor bindings) as part of this.
+"Poster" only made sense for movies/TV, and the field is now shared across all five reference types.
+Similarly, the admin search/link DTOs' `TmdbId` field was renamed to `ExternalId` (`ReferenceSearchResultDto`, `LinkReferenceRequestDto`).
+This is because `ReferenceDataAdminController`'s `Search`/`Link`/`GetUnresolved` endpoints now switch over a 5-way `ReferenceItemType` enum instead of a TV-show/movie ternary.
+Hardcoding a TMDB-specific name in a request DTO shared by Open Library/RAWG/Discogs would have been misleading.
+
+`Book`/`VideoGame`/`Album` detail pages (`BookDetail.razor`, `VideoGameDetail.razor`, `AlbumDetail.razor`) follow `MovieDetail.razor`'s exact shape (own route, own load/save methods, no `InventoryPageBase` inheritance).
+Cover art, synopsis and genres render when linked, plus the same refresh-reference icon button and admin `InlineReferenceLinker`.
+None of the three have a cast/credits concept, so there's no `CastGrid` equivalent for them.
+
+**Gotcha:** `OpenLibraryClient.GetBookDetailsAsync` fetches a work's year from `first_publish_date` on `/works/{id}.json`, but that field is routinely absent from the work document itself.
+This was confirmed against the real API for a book as well-known as Lee Child's "Killing Floor" (`/works/OL24477958W.json` has no `first_publish_date` at all),
+while the search index's computed `first_publish_year` for the same work is reliable.
+`GetBookDetailsAsync` falls back to `FindPublishYearViaSearchAsync` (a single-document `q=key:{workKey}` search re-query) whenever the work-level parse comes back null.
+This is rather than silently leaving `BookReferenceModel.Year`/the tenant's own `BookModel.Year` unset after a link.
+
+**No reliable "series" field exists in Open Library.** Checked directly against the real API (work JSON, edition JSON, and a `fields=*` search query for "Killing Floor") - none expose a `series` field.
+The closest available signal is the search index's `person`/`subject_people` facet (a recurring fictional character, e.g. `"person": ["Jack Reacher"]` for "Killing Floor"), but that's a character-name facet, not a series title.
+It doesn't generalize (many series aren't named after - or don't have - a recurring eponymous character, and a standalone book can still feature a named character).
+It's not wired up to `BookModel.Series`/`BookReferenceModel` for that reason - auto-filling `Series` from it would be right by coincidence for character-titled franchises and wrong or absent otherwise.
+
+`Album`/`Book` gained `IsFavorite` (`AlbumModel`/`Album`/`AlbumDto`, `BookModel`/`Book`/`BookDto`), same shape as `Movie`/`TvShow`'s existing flag:
+a partial Mongo index (`album_favorite`/`book_favorite`, `scripts/mongodb-create-index.js`), a `Favorites` filter button in each list page's `Filters` slot,
+and a toggle button on `AlbumDetail.razor`/`BookDetail.razor`'s page header (same `kt-toggle-btn`/`active` pattern as `MovieDetail.razor`).
+
+`VideoGameModel.State` stays a plain free-text string (not an enum like `TvShowModel.State`) deliberately.
+Real tenant data already has values like `"To resume"`/`"On-hold"` that aren't valid C# enum member names, and MongoDB's `EnumRepresentationConvention(BsonType.String)` deserializes by matching the enum member name exactly.
+So converting would require a data migration with real breakage risk for existing documents.
+Filtering by state (`VideoGameRepository.GetFilter` already supported exact-match filtering by `input.State`) is instead exposed as list-page filter buttons (`VideoGames.razor`'s `Filters` slot,
+same visual pattern as `TvShows.razor`'s `TvShowStatus` filter buttons) over the existing string values.
+The Add/Edit forms' State `` was replaced with a button group (`VideoGames.VideoGameStates`, the shared array both the filter and the form buttons iterate over) for the same reason TvShow's own state buttons exist -
+clicking a value is faster than a dropdown for a small fixed set.
+`FinishedAt` is a plain, always-visible date field on `VideoGameDetail.razor`'s card (not a corner-flag toggle like `MovieDetail.razor`'s "Mark as watched"/`BookDetail.razor`'s "Mark as read").
+It was previously only editable once already set elsewhere, with no way to set it from scratch on the detail page.
+Unlike Movie/Book, "finished" already has its own explicit `State` value ("Completed") to toggle, so `FinishedAt` doesn't need a second boolean-flag affordance layered on top.
+
+**`VideoGameDetail.razor`'s own State editor follows `TvShowDetail.razor`'s per-item State pattern exactly, not the list page's filter-button pattern.** These look superficially similar (both are button rows) but behave differently,
+and the two were conflated once already.
+The per-item editor buttons live in their own row directly below the page header (not inside the `kt-form-card` next to Year/Rating).
+Clicking the already-active value clears it back to unset (`SetStateAsync`: `_game.State = _game.State == state ? "" : state`, mirroring `TvShowDetail.razor`'s own `SetStateAsync`).
+The list page's filter buttons are a different control with different semantics (an explicit "All" option to clear, since a filter and a per-item value aren't the same kind of state).
+
+**`TvShowModel.Status` was renamed to `TvShowModel.State`** for naming parity with `VideoGameModel.State`
+(`TvShowDto.State`, `TvShow.State` entity property, `TvShowRepository.GetFilter`'s `input.State`, `TvShows.razor`'s `_stateFilter`/`SetStateFilterAsync`/`ExtraQuery["State"]`, `TvShowDetail.razor`'s `SetStateAsync` all renamed to match).
+The enum type itself keeps its `TvShowStatus` name - only the property that holds it moved to `State`, since `VideoGameModel.State` has no equivalent enum to rename against.
+Unlike the `PosterUrl`→`ImageUrl` rename below, this one needed **no** data migration: `TvShow`'s entity property kept an explicit `[BsonElement("status")]` pointing at the unchanged storage name,
+so existing documents (confirmed directly against the real dev database - `status: 'Finished'` reads back correctly through the renamed `State` property) deserialize with no script required.
+`TvTimeImportService`/`ShowStatusCsvParser`'s `ShowStatusRecord.Status` is a same-named but *entirely unrelated* field - TV Time's own CSV column for favorite/for_later, mapped to `IsFavorite`/`WantToWatch`,never to this enum -
+so the import pipeline needed no changes at all for this rename; verified by tracing every consumer before renaming, not just running the test suite.
+`WatchNextService`/`WatchNextController`'s `Status == TvShowStatus.Current` checks were updated to `State == TvShowStatus.Current` and covered by `WatchNextServiceTest`, which still passes.
+
+**Gotcha:** an optional narrowing parameter on an external search must never be allowed to silently zero out results that a broader search would find -
+this bit both the Open Library year filter (see above) and, separately, `IDiscogsClient.SearchAlbumsAsync`'s `artist` parameter:
+a tenant's own `AlbumModel.Artist` text passed straight through as Discogs' `artist=` query field can fail to match Discogs' own exact indexing (a disambiguation suffix like `"Artist (2)"`, different capitalization/formatting),
+returning zero candidates even though the title alone finds the album - confirmed with a real title ("Born Pink") that returned nothing via `AlbumDetail.razor`'s `InlineReferenceLinker`
+(which always passes the tracked album's own `Artist`) but succeeded via the admin page (whose first search per selected item always passes no creator).
+Both `DiscogsClient.SearchAlbumsAsync` and `OpenLibraryClient.SearchBooksAsync` now retry once without the narrowing author/artist parameter whenever the constrained search comes back empty, rather than reporting a false "not found."
+
+**Gotcha:** `OpenLibraryClient` searched via `search.json?title=...` (a field-scoped exact match against the work's own canonical title), which misses regional title variants entirely -
+confirmed against the real API that "Harry Potter and the Sorcerer's Stone" (the US title) only matches a handful of near-empty, 1-edition work stubs this way,
+because Open Library's actual canonical work for this book is titled "Harry Potter and the Philosopher's Stone" (the UK title) and carries 398 editions.
+Switched to `search.json?q=...` (a general relevance-ranked query across title, alternate titles, etc.),
+which correctly surfaces the well-populated canonical work first in this case while still returning the same top result as before for titles that don't have this regional-variant problem (e.g. "The Return of the King").
+This also explains why some resolved covers can look like a plain, uninteresting library rebinding rather than an illustrated dust jacket even once the *correct* work is matched (confirmed for "The Return of the King", `OL27455W`) -
+Open Library's own `covers` array for a work is whatever has been scanned/contributed, not curated by "which looks best," and the first entry there is already identical to the search index's own `cover_i` in the cases checked;
+there's no metadata signal (short of actual image content analysis, out of scope here) to pick a nicer-looking alternative from the same array.
+
+**Gotcha:** when `PosterUrl` was renamed to `ImageUrl` on `TvShowReferenceModel`/`MovieReferenceModel`,
+existing `tvshow_reference`/`movie_reference` documents created before the rename kept their data under the old `poster_url` BSON field - the new entity class only ever reads `image_url`,
+so every pre-existing reference document silently lost its cover image (confirmed against a real dev database: 72/87 TV show references and 343/353 movie references still had the old field name).
+Fixed with a one-off migration, `scripts/migrate-poster-url-to-image-url.js` (idempotent `$rename`, safe to re-run) - run it once against any environment with reference data older than the rename.
+This is the same class of risk as the earlier `music-album` → `album` collection rename (see "Reference data now covers five domains" above):
+a data-shape rename in code needs an explicit, documented migration step for whatever already exists in Mongo, not just updated `[BsonElement]` attributes.
+
+`ReferenceMatchModel.Creator` (nullable `string`) extends the `MatchedAliases` match key beyond just (title, year) -
+a title+year match alone risks silently linking a tenant's book/album to a *different* tenant's unrelated one that happens to share a common title and year
+(a generic name re-published/re-released the same year is common; TV/movie titles almost never collide this hard, which is why they don't need this).
+`IBookReferenceRepository`/`IAlbumReferenceRepository`'s `FindByTitleYearAsync`/`FindByTitleAsync` now take a required `author`/`artist` parameter and add a `Creator` equality condition to the same `ElemMatch` filter
+(both title and creator normalized via `TitleNormalizer.Normalize`) - `Creator` is always derived from the **canonical** resolved `details.Author`/`details.Artist` (the external API's own response),
+never from whatever text the tenant/admin originally typed, since that let the design avoid adding a new parameter to `ResolveBookAsync`/`ResolveAlbumAsync`, `LinkReferenceRequestDto`, or the admin linking UI.
+TvShow/Movie/VideoGame's `MergeMatchedAliases` calls all pass `null` for `Creator` (no creator dimension in those domains' match key) - the shared helper's third tuple element is `null` for them, not omitted,
+since `ReferenceMatchModel` stays one generic shape across every domain rather than growing a Book/Album-only subtype.
+`BookReferenceRepository`/`AlbumReferenceRepository.UpsertAsync`'s defensive "always include the canonical title/year alias" safety net can't set `Creator` (the model only carries `AuthorReferenceId`/`ArtistReferenceId`, a dedup'd link,
+not denormalized text) - that alias is simply unreachable via the creator-required find methods, which is harmless (the normal Resolve/Refresh path always adds a proper creator-bearing alias first) rather than a false-positive risk;
+a real-MongoDB integration test (`BookReferenceRepositoryTest`/`AlbumReferenceRepositoryTest`) asserts on the stored alias directly for this specific case rather than through the creator-required lookup,
+since that lookup can no longer find it by design.
+
+**Gotcha (historical - structurally fixed by the AutoMapper -> Mapperly migration):** `MergeMatchedAliases`' dedup check compares a freshly-computed `Creator` against an existing alias's `Creator` (`m.Creator == normalizedCreator`),
+which used to silently duplicate aliases on every re-resolve/re-refresh for TV show/movie/video game (the three domains that always pass `null` for `Creator`, having no creator dimension).
+The reason: `AllowNullDestinationValues = false` (a profile-wide AutoMapper default, since removed along with AutoMapper itself) substituted `""` for a null *string* member during model → entity mapping -
+so a freshly-built alias with `Creator = null` got persisted as `Creator = ""`, and on the *next* resolve/refresh the freshly computed `normalizedCreator` (still literally `null`) never equalled that already-persisted `""`,
+so the dedup check saw no existing match and appended an exact duplicate `{title, year, creator: ""}` entry.
+Confirmed against a real video game reference (RAWG's "God of War", resolved/refreshed more than once) that had accumulated a literal duplicate alias this way.
+
+The fix belonged at the mapping/entity layer, not as a comparison workaround in `MergeMatchedAliases` (an `(m.Creator ?? "") == (normalizedCreator ?? "")` patch was tried first and reverted) -
+at the time, `DataStorageMappingProfile`'s `ReferenceMatchModel` → `ReferenceMatch` map opted `Creator` out of the profile-wide default with `.ForMember(x => x.Creator, opt => opt.AllowNull())`,
+so a null `Creator` reached Mongo as an actual null again, and `MergeMatchedAliases` stayed a plain, honest `m.Creator == normalizedCreator`.
+That per-member opt-out is gone now, not just moved: Mapperly (the current mapper) preserves nulls by default, so every storage mapper's `Creator` mapping is a real null with no configuration needed at all -
+this entire class of bug is structurally impossible today, not merely patched.
+From there, the *already-registered*, codebase-wide `IgnoreIfNullConvention(true)` (`InfrastructureServiceCollectionExtensions.AddMongoDbInfrastructure`) does the rest for free -
+it omits any null property from the stored document, which is why `Year` (also nullable on `ReferenceMatch`) was never affected by this bug in the first place and needed no equivalent per-property `[BsonIgnoreIfNull]` fix;
+that attribute doesn't appear anywhere in this codebase, and shouldn't - "is this field omitted when unset" is a driver-convention-level answer here, not a per-entity one.
+Was covered by `RefreshVideoGameReferenceAsync_DoesNotDuplicateAnAliasAlreadyPersistedWithANullCreator` (unit, mocked) and `TvShowReferenceRepositoryTest.UpsertAsync_PersistsANullCreator_AsAnActualBsonNullNotAnEmptyString`
+(integration, real MongoDB - the only way to actually catch a serialization-level regression like this one); both still pass under Mapperly as a regression guard, even though the bug they were written for can no longer occur.
+A scan of the real dev database found only the one "God of War" document actually duplicated, cleaned up with the idempotent `scripts/dedupe-matched-aliases.js`
+(same "run once per environment" pattern as `migrate-poster-url-to-image-url.js`).
+
+`BookModel`/`AlbumModel.Genre` (a single free-text field, not a list -
+it predates the reference-data feature, same as `Author`/`Artist` before `PersonReferenceModel` existed) is now propagated on link the same way `Title`/`Year`/`Author`/`Artist` already are:
+`TryLinkExistingBookReferenceAsync`/`TryLinkExistingAlbumReferenceAsync` and `ResolveBookAsync`/`ResolveAlbumAsync` join the reference's `Genres` list
+(`JoinGenres`, a shared helper in `ReferenceEnrichmentService.cs`) into that single field,
+both on the tenant's own document and via `IBookRepository`/`IAlbumRepository.SetReferenceLinkAsync`'s new `canonicalenre` parameter
+(cross-tenant propagation, same incremental-parameter pattern already used for `canonicalAuthor`/`canonicalArtist`) -
+null (not overwritten) when the reference has no genres, same "don't overwrite with nothing" rule the other propagated fields already follow.
+`BookDetail.razor`/`AlbumDetail.razor` previously displayed the *reference's* raw `Genres` list directly (a read-only comma-joined paragraph) but never touched the tenant's own `Genre` field at all -
+that display was replaced with a plain editable `Genre` input (same shape as `Author`/`Series`/`Artist`), matching how `Author`/`Artist` already work: the reference data flows into the one tenant-owned field on link,
+there's no separate "raw reference value" display once linked.
+
+Books are the one reference domain behind a provider-agnostic interface rather than a provider-named one: `IBookReferenceClient` (`BookSearchResult`/`BookDetails` DTOs,
+an `IBookReferenceClient.ProviderKey` string) instead of `IOpenLibraryClient`.
+TV show/movie/video game/album stay hard-wired to TMDB/RAWG/Discogs directly (their DTOs and hardcoded `"tmdb"`/`"rawg"`/`"discogs"` `ExternalIds` keys are provider-named on purpose -
+swapping any of those would be a bigger redesign, not a config change).
+Which implementation of `IBookReferenceClient` is registered is a deployment-time choice, `ReferenceData:BookProvider` (`ReferenceData__BookProvider` as an environment variable, `Program.cs` switches on it), defaulting to `OpenLibrary` -
+the only implementation that ships today.
+`ReferenceEnrichmentService.Books.cs` never hardcodes a provider name; every `ExternalIds`/person-reference lookup keys off the injected `IBookReferenceClient.ProviderKey` instead,
+so a second implementation only needs its own class (`OpenLibraryClient`-shaped: base address, optional settings class, `ProviderKey`) plus one new `case` in `Program.cs` - no changes to the enrichment service or admin controller.
+
+### Blazor app
+
+`InventoryPageBase` (`BlazorApp/Components/Inventory/InventoryPageBase.cs`) centralizes list/paging/search/inline-edit state and calls into `InventoryApiClientBase`,
+which wraps the typed `HttpClient` calls to the Web API (its `GetAsync` takes an optional extra-query-parameters dictionary, used by features that filter on more than search/page/pageSize).
+Each concrete page (`Books.razor.cs`, `Movies.razor.cs`, ...) only supplies its `Api` instance and `CloneItem`.
+A page that needs its own filter beyond search (e.g. `TvShows.razor.cs`'s state filter) overrides the base's `protected virtual ExtraQuery` property instead of reimplementing paging/search -
+`LoadAsync` is `protected` for exactly this reason, so the page can trigger a reload after changing its own filter state.
+Authentication uses Firebase (cookie auth in the Blazor app, JWT bearer validated against Firebase in the Web API); `AuthenticationTokenHandler` attaches the bearer token to outgoing API calls.
+
+Pages that aren't a generic CRUD list (`TvShowDetail.razor`, `WatchNext/WatchNextPage.razor`, `Import/ImportPage.razor`) don't extend `InventoryPageBase`/`InventoryList` —
+they're free to build their own layout on top of the shared `kt-*` CSS classes in `app.css`.
+Their API clients live next to them in a feature folder rather than in `Inventory/Clients/`.
+
+**Gotcha:** passing a field to a `string`-typed component `[Parameter]` needs the `@` prefix - `Title="_movie.Title"` binds the **literal text** `"_movie.Title"`, not the property's value.
+Razor only auto-detects that an attribute value must be C# code when the parameter's type couldn't otherwise accept a string literal
+(e.g. `Year="_movie.Year"` on an `int?` parameter works unprefixed, because a bare identifier can't type-check as one); a `string` parameter can always accept a literal, so Razor takes it at face value instead.
+This compiles and renders with no error - the bug only shows up in the *data*, not the markup -
+which is exactly what happened when `InlineReferenceLinker`'s `Title="_movie.Title"` sent the literal string `_movie.Title` to TMDB's search instead of the movie's actual title, returning an unrelated result.
+Always write `Title="@_movie.Title"` for string parameters bound to a field/property.
+
+### Theme
+
+`app.css` is a light+dark theme driven by `data-bs-theme` on `` (Bootstrap 5.3's native color-mode support), with Keeptrack's own `--kt-*` tokens layered on top for custom components (sidebar, `kt-table-wrap`, `kt-modal`, etc.).
+`wwwroot/theme.js` sets the initial theme from `localStorage`/`prefers-color-scheme` before first paint (avoiding a flash of the wrong theme) and exposes `ktToggleTheme()`, called directly from a plain button in `NavMenu.razor` —
+no Blazor/JS interop needed for the toggle itself.
+Use system-ui fonts only; no decorative/display webfonts.
+
+Icons throughout the app are plain Unicode symbols with no default emoji presentation (`◈`, `✓`, `✕`, `★`, `▶`, `↻`, `⌂`, `⚙`, `♪`, and the Geometric Shapes block generally: `◼ ▭ ▬ ◆`),
+never a codepoint whose default rendering is a full-color emoji glyph (`⭐`, `👁`, `🔄`, `⏳`, `🏠`, `📚`, `🎬`, ...) - a color emoji reads as an inconsistent,
+slightly unpolished note among otherwise monochrome UI that follows `--kt-text`/`--kt-accent` like everything else.
+**Gotcha:** a codepoint isn't safe just because it "looks like" a plain symbol - `⭐` (U+2B50) and `👁` (U+1F441) were originally used as the "Best of"/"Want to watch" icons on the assumption they were plain stars/eyes,
+but both have `Emoji_Presentation=Yes` by default (Unicode's `emoji-data.txt`) and render as full-color glyphs on every mainstream platform; they were replaced with `★` (U+2605, Black Star, default text presentation)
+and `▶` (U+25B6, default text presentation) respectively.
+Before adding a new symbol, check whether its default presentation is text or emoji - don't assume from how it looks in this file.
+Appending a trailing variation selector (`️`, U+FE0F) forces emoji presentation even on an otherwise-safe codepoint, so never add one; some codepoints (`🌙`, `🚪`, `🔑`, `👤`, `📦`) have no text-presentation form at all
+and were simply dropped rather than replaced with an approximate glyph, since a semantically-forced match is worse than no icon when the row's label text already carries the meaning.
+`.kt-icon-spin` (reuses the same `spin` keyframes as `.kt-spinner`) makes a plain glyph rotate in place for a small inline action's "in progress" state, instead of swapping to an hourglass emoji.
+
+Enhanced navigation re-fetches and diffs the whole document on every in-app link click; anything set on ``/`` by client-side JS
+rather than server-rendered markup (like `data-bs-theme`) gets stripped back out unless it's explicitly re-applied.
+`wwwroot/Keeptrack.BlazorApp.lib.module.js` is a JS initializer (autoloaded by Blazor because its name matches the assembly -
+don't add a manual `
diff --git a/src/BlazorApp/Components/App.razor b/src/BlazorApp/Components/App.razor
index 5d44783d..add500a2 100644
--- a/src/BlazorApp/Components/App.razor
+++ b/src/BlazorApp/Components/App.razor
@@ -6,6 +6,8 @@
+
+
@@ -17,6 +19,7 @@
+
diff --git a/src/BlazorApp/Components/Import/CarHistoryImportApiClient.cs b/src/BlazorApp/Components/Import/CarHistoryImportApiClient.cs
new file mode 100644
index 00000000..7dd605f5
--- /dev/null
+++ b/src/BlazorApp/Components/Import/CarHistoryImportApiClient.cs
@@ -0,0 +1,20 @@
+using System.Net.Http.Headers;
+using Keeptrack.WebApi.Contracts.Dto;
+
+namespace Keeptrack.BlazorApp.Components.Import;
+
+public sealed class CarHistoryImportApiClient(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/car-history", content);
+ response.EnsureSuccessStatusCode();
+
+ return (await response.Content.ReadFromJsonAsync())!;
+ }
+}
diff --git a/src/BlazorApp/Components/Import/ImportPage.razor b/src/BlazorApp/Components/Import/ImportPage.razor
new file mode 100644
index 00000000..2d93acc1
--- /dev/null
+++ b/src/BlazorApp/Components/Import/ImportPage.razor
@@ -0,0 +1,203 @@
+@page "/import"
+@rendermode InteractiveServer
+@attribute [Authorize]
+
+
+
+
+
+
+
+
+
+@code {
+ private const long MaxFileSize = 50_000_000;
+ private static readonly TimeSpan PollInterval = TimeSpan.FromMilliseconds(600);
+
+ [Inject] private TvTimeImportApiClient ImportApi { get; set; } = null!;
+ [Inject] private CarHistoryImportApiClient CarImportApi { get; set; } = null!;
+
+ private bool _importing;
+ private string? _error;
+ private ImportResultDto? _result;
+ private ImportStage _stage;
+
+ private bool _carImporting;
+ private string? _carError;
+ private CarHistoryImportResultDto? _carResult;
+
+ private async Task OnFileSelectedAsync(InputFileChangeEventArgs e)
+ {
+ _importing = true;
+ _error = null;
+ _result = null;
+ _stage = ImportStage.Parsing;
+
+ try
+ {
+ Guid jobId;
+ await using (var stream = e.File.OpenReadStream(MaxFileSize))
+ {
+ jobId = await ImportApi.StartImportAsync(stream, e.File.Name);
+ }
+
+ while (true)
+ {
+ var status = await ImportApi.GetStatusAsync(jobId);
+ _stage = status.Stage;
+
+ if (status.Stage == ImportStage.Completed)
+ {
+ _result = status.Result;
+ break;
+ }
+
+ if (status.Stage == ImportStage.Failed)
+ {
+ _error = status.ErrorMessage ?? "The import failed.";
+ break;
+ }
+
+ StateHasChanged();
+ await Task.Delay(PollInterval);
+ }
+ }
+ catch (Exception ex)
+ {
+ _error = ex.Message;
+ }
+ finally
+ {
+ _importing = false;
+ }
+ }
+
+ private async Task OnCarFileSelectedAsync(InputFileChangeEventArgs e)
+ {
+ _carImporting = true;
+ _carError = null;
+ _carResult = null;
+
+ try
+ {
+ await using var stream = e.File.OpenReadStream(MaxFileSize);
+ _carResult = await CarImportApi.ImportAsync(stream, e.File.Name);
+ }
+ catch (Exception ex)
+ {
+ _carError = ex.Message;
+ }
+ finally
+ {
+ _carImporting = false;
+ }
+ }
+
+ private static int ProgressPercent(ImportStage stage) => stage switch
+ {
+ ImportStage.Parsing => 10,
+ ImportStage.ImportingShows => 35,
+ ImportStage.ImportingEpisodes => 65,
+ ImportStage.ImportingMovies => 90,
+ ImportStage.Completed => 100,
+ _ => 0
+ };
+
+ private static string StageLabel(ImportStage stage) => stage switch
+ {
+ ImportStage.Parsing => "Reading your export…",
+ ImportStage.ImportingShows => "Importing TV shows…",
+ ImportStage.ImportingEpisodes => "Importing episodes…",
+ ImportStage.ImportingMovies => "Importing movies…",
+ ImportStage.Completed => "Done",
+ _ => ""
+ };
+}
diff --git a/src/BlazorApp/Components/Import/TvTimeImportApiClient.cs b/src/BlazorApp/Components/Import/TvTimeImportApiClient.cs
new file mode 100644
index 00000000..fcb70018
--- /dev/null
+++ b/src/BlazorApp/Components/Import/TvTimeImportApiClient.cs
@@ -0,0 +1,27 @@
+using System.Net.Http.Headers;
+using Keeptrack.WebApi.Contracts.Dto;
+
+namespace Keeptrack.BlazorApp.Components.Import;
+
+public sealed class TvTimeImportApiClient(HttpClient http)
+{
+ public async Task StartImportAsync(Stream zipStream, string fileName)
+ {
+ using var content = new MultipartFormDataContent();
+ using var fileContent = new StreamContent(zipStream);
+ fileContent.Headers.ContentType = new MediaTypeHeaderValue("application/zip");
+ content.Add(fileContent, "file", fileName);
+
+ var response = await http.PostAsync("/api/import/tv-time", content);
+ response.EnsureSuccessStatusCode();
+
+ var job = await response.Content.ReadFromJsonAsync();
+ return job!.JobId;
+ }
+
+ public async Task GetStatusAsync(Guid jobId)
+ {
+ var status = await http.GetFromJsonAsync($"/api/import/tv-time/{jobId}");
+ return status ?? new ImportJobStatusDto { Stage = ImportStage.Failed, ErrorMessage = "Lost track of the import job." };
+ }
+}
diff --git a/src/BlazorApp/Components/Inventory/Clients/AlbumApiClient.cs b/src/BlazorApp/Components/Inventory/Clients/AlbumApiClient.cs
new file mode 100644
index 00000000..c86c5b12
--- /dev/null
+++ b/src/BlazorApp/Components/Inventory/Clients/AlbumApiClient.cs
@@ -0,0 +1,16 @@
+using Keeptrack.WebApi.Contracts.Dto;
+
+namespace Keeptrack.BlazorApp.Components.Inventory.Clients;
+
+public sealed class AlbumApiClient(HttpClient http)
+ : InventoryApiClientBase(http)
+{
+ protected override string ApiResourceName => "/api/albums";
+
+ public async Task RefreshReferenceAsync(string id)
+ {
+ var response = await Http.PostAsync($"{ApiResourceName}/{id}/refresh-reference", null);
+ response.EnsureSuccessStatusCode();
+ return (await response.Content.ReadFromJsonAsync())!;
+ }
+}
diff --git a/src/BlazorApp/Components/Inventory/Clients/BookApiClient.cs b/src/BlazorApp/Components/Inventory/Clients/BookApiClient.cs
index d911c62d..6d9a0b45 100644
--- a/src/BlazorApp/Components/Inventory/Clients/BookApiClient.cs
+++ b/src/BlazorApp/Components/Inventory/Clients/BookApiClient.cs
@@ -1,4 +1,4 @@
-using Keeptrack.WebApi.Contracts.Dto;
+using Keeptrack.WebApi.Contracts.Dto;
namespace Keeptrack.BlazorApp.Components.Inventory.Clients;
@@ -6,4 +6,11 @@ public sealed class BookApiClient(HttpClient http)
: InventoryApiClientBase(http)
{
protected override string ApiResourceName => "/api/books";
+
+ public async Task RefreshReferenceAsync(string id)
+ {
+ var response = await Http.PostAsync($"{ApiResourceName}/{id}/refresh-reference", null);
+ response.EnsureSuccessStatusCode();
+ return (await response.Content.ReadFromJsonAsync())!;
+ }
}
diff --git a/src/BlazorApp/Components/Inventory/Clients/CarApiClient.cs b/src/BlazorApp/Components/Inventory/Clients/CarApiClient.cs
new file mode 100644
index 00000000..429865b7
--- /dev/null
+++ b/src/BlazorApp/Components/Inventory/Clients/CarApiClient.cs
@@ -0,0 +1,15 @@
+using Keeptrack.WebApi.Contracts.Dto;
+
+namespace Keeptrack.BlazorApp.Components.Inventory.Clients;
+
+public class CarApiClient(HttpClient http)
+ : InventoryApiClientBase(http)
+{
+ protected override string ApiResourceName => "/api/cars";
+
+ public async Task GetMetricsAsync(string id)
+ {
+ var result = await Http.GetFromJsonAsync($"{ApiResourceName}/{id}/metrics");
+ return result!;
+ }
+}
diff --git a/src/BlazorApp/Components/Inventory/Clients/CarHistoryApiClient.cs b/src/BlazorApp/Components/Inventory/Clients/CarHistoryApiClient.cs
new file mode 100644
index 00000000..8c82793e
--- /dev/null
+++ b/src/BlazorApp/Components/Inventory/Clients/CarHistoryApiClient.cs
@@ -0,0 +1,9 @@
+using Keeptrack.WebApi.Contracts.Dto;
+
+namespace Keeptrack.BlazorApp.Components.Inventory.Clients;
+
+public sealed class CarHistoryApiClient(HttpClient http)
+ : InventoryApiClientBase(http)
+{
+ protected override string ApiResourceName => "/api/car-history";
+}
diff --git a/src/BlazorApp/Components/Inventory/Clients/EpisodeApiClient.cs b/src/BlazorApp/Components/Inventory/Clients/EpisodeApiClient.cs
new file mode 100644
index 00000000..71eee17c
--- /dev/null
+++ b/src/BlazorApp/Components/Inventory/Clients/EpisodeApiClient.cs
@@ -0,0 +1,9 @@
+using Keeptrack.WebApi.Contracts.Dto;
+
+namespace Keeptrack.BlazorApp.Components.Inventory.Clients;
+
+public sealed class EpisodeApiClient(HttpClient http)
+ : InventoryApiClientBase(http)
+{
+ protected override string ApiResourceName => "/api/episodes";
+}
diff --git a/src/BlazorApp/Components/Inventory/Clients/HouseApiClient.cs b/src/BlazorApp/Components/Inventory/Clients/HouseApiClient.cs
new file mode 100644
index 00000000..6024e8f5
--- /dev/null
+++ b/src/BlazorApp/Components/Inventory/Clients/HouseApiClient.cs
@@ -0,0 +1,15 @@
+using Keeptrack.WebApi.Contracts.Dto;
+
+namespace Keeptrack.BlazorApp.Components.Inventory.Clients;
+
+public class HouseApiClient(HttpClient http)
+ : InventoryApiClientBase(http)
+{
+ protected override string ApiResourceName => "/api/houses";
+
+ public async Task GetMetricsAsync(string id)
+ {
+ var result = await Http.GetFromJsonAsync($"{ApiResourceName}/{id}/metrics");
+ return result!;
+ }
+}
diff --git a/src/BlazorApp/Components/Inventory/Clients/HouseHistoryApiClient.cs b/src/BlazorApp/Components/Inventory/Clients/HouseHistoryApiClient.cs
new file mode 100644
index 00000000..62fc4e25
--- /dev/null
+++ b/src/BlazorApp/Components/Inventory/Clients/HouseHistoryApiClient.cs
@@ -0,0 +1,9 @@
+using Keeptrack.WebApi.Contracts.Dto;
+
+namespace Keeptrack.BlazorApp.Components.Inventory.Clients;
+
+public sealed class HouseHistoryApiClient(HttpClient http)
+ : InventoryApiClientBase(http)
+{
+ protected override string ApiResourceName => "/api/house-history";
+}
diff --git a/src/BlazorApp/Components/Inventory/Clients/InventoryApiClientBase.cs b/src/BlazorApp/Components/Inventory/Clients/InventoryApiClientBase.cs
index 52319378..0a42a770 100644
--- a/src/BlazorApp/Components/Inventory/Clients/InventoryApiClientBase.cs
+++ b/src/BlazorApp/Components/Inventory/Clients/InventoryApiClientBase.cs
@@ -7,9 +7,24 @@ public abstract class InventoryApiClientBase(HttpClient http)
{
protected abstract string ApiResourceName { get; }
- public async Task> GetAsync(string search, int page, int pageSize)
+ ///
+ /// Exposes the HttpClient to subclasses that add their own calls (e.g. a refresh-reference endpoint) -
+ /// lets them reuse this instance instead of capturing their own HttpClient primary-constructor
+ /// parameter as a second field holding the same reference.
+ ///
+ protected HttpClient Http => http;
+
+ public async Task> GetAsync(string search, int page, int pageSize, IReadOnlyDictionary? extraQuery = null)
{
var query = $"{ApiResourceName}?search={Uri.EscapeDataString(search)}&page={page}&pageSize={pageSize}";
+ if (extraQuery is not null)
+ {
+ foreach (var (key, value) in extraQuery)
+ {
+ query += $"&{Uri.EscapeDataString(key)}={Uri.EscapeDataString(value)}";
+ }
+ }
+
var result = await http.GetFromJsonAsync>(query);
return result ?? new PagedResult([], 0, 1, 1);
}
@@ -19,9 +34,11 @@ public async Task> GetAsync(string search, int page, int pageS
return await http.GetFromJsonAsync($"{ApiResourceName}/{id}");
}
- public async Task AddAsync(TDto movie)
+ public async Task AddAsync(TDto movie)
{
- (await http.PostAsJsonAsync($"{ApiResourceName}", movie)).EnsureSuccessStatusCode();
+ var response = await http.PostAsJsonAsync($"{ApiResourceName}", movie);
+ response.EnsureSuccessStatusCode();
+ return (await response.Content.ReadFromJsonAsync())!;
}
public async Task UpdateAsync(TDto movie)
diff --git a/src/BlazorApp/Components/Inventory/Clients/MovieApiClient.cs b/src/BlazorApp/Components/Inventory/Clients/MovieApiClient.cs
index 2773d99c..66c9540f 100644
--- a/src/BlazorApp/Components/Inventory/Clients/MovieApiClient.cs
+++ b/src/BlazorApp/Components/Inventory/Clients/MovieApiClient.cs
@@ -6,4 +6,16 @@ public sealed class MovieApiClient(HttpClient http)
: InventoryApiClientBase(http)
{
protected override string ApiResourceName => "/api/movies";
+
+ ///
+ /// User-triggered, exact-match-only re-check against the local reference collection
+ /// (POST api/movies/{id}/refresh-reference on WebApi). Returns the (possibly now-linked) item so the
+ /// caller can tell whether a match was actually found.
+ ///
+ public async Task RefreshReferenceAsync(string id)
+ {
+ var response = await Http.PostAsync($"{ApiResourceName}/{id}/refresh-reference", null);
+ response.EnsureSuccessStatusCode();
+ return (await response.Content.ReadFromJsonAsync())!;
+ }
}
diff --git a/src/BlazorApp/Components/Inventory/Clients/MusicAlbumApiClient.cs b/src/BlazorApp/Components/Inventory/Clients/MusicAlbumApiClient.cs
deleted file mode 100644
index 7f73b4e6..00000000
--- a/src/BlazorApp/Components/Inventory/Clients/MusicAlbumApiClient.cs
+++ /dev/null
@@ -1,9 +0,0 @@
-using Keeptrack.WebApi.Contracts.Dto;
-
-namespace Keeptrack.BlazorApp.Components.Inventory.Clients;
-
-public sealed class MusicAlbumApiClient(HttpClient http)
- : InventoryApiClientBase(http)
-{
- protected override string ApiResourceName => "/api/music-albums";
-}
diff --git a/src/BlazorApp/Components/Inventory/Clients/PlaylistApiClient.cs b/src/BlazorApp/Components/Inventory/Clients/PlaylistApiClient.cs
new file mode 100644
index 00000000..33d00ccc
--- /dev/null
+++ b/src/BlazorApp/Components/Inventory/Clients/PlaylistApiClient.cs
@@ -0,0 +1,9 @@
+using Keeptrack.WebApi.Contracts.Dto;
+
+namespace Keeptrack.BlazorApp.Components.Inventory.Clients;
+
+public sealed class PlaylistApiClient(HttpClient http)
+ : InventoryApiClientBase(http)
+{
+ protected override string ApiResourceName => "/api/playlists";
+}
diff --git a/src/BlazorApp/Components/Inventory/Clients/SongApiClient.cs b/src/BlazorApp/Components/Inventory/Clients/SongApiClient.cs
new file mode 100644
index 00000000..39e7597f
--- /dev/null
+++ b/src/BlazorApp/Components/Inventory/Clients/SongApiClient.cs
@@ -0,0 +1,24 @@
+using Keeptrack.WebApi.Contracts.Dto;
+
+namespace Keeptrack.BlazorApp.Components.Inventory.Clients;
+
+public sealed class SongApiClient(HttpClient http)
+ : InventoryApiClientBase(http)
+{
+ protected override string ApiResourceName => "/api/songs";
+
+ ///
+ /// The single shared "add a real track" operation - reused by both AlbumDetail.razor 's
+ /// per-track "+ Add to playlist" and PlaylistDetail.razor 's "Add from album" picker, so picking
+ /// the same track from two different places (or twice from the same place) reuses one
+ /// instead of creating a duplicate. Relies on SongRepository.GetFilter 's exact-match on
+ /// / - no dedicated WebApi endpoint needed.
+ ///
+ public async Task GetOrCreateForTrackAsync(string albumId, string position, string title, string? duration, string? artist)
+ {
+ var existing = await GetAsync("", 1, 1, new Dictionary { ["AlbumId"] = albumId, ["TrackPosition"] = position });
+ if (existing.Items.Count > 0) return existing.Items[0];
+
+ return await AddAsync(new SongDto { Title = title, Artist = artist, AlbumId = albumId, TrackPosition = position, Duration = duration });
+ }
+}
diff --git a/src/BlazorApp/Components/Inventory/Clients/TvShowApiClient.cs b/src/BlazorApp/Components/Inventory/Clients/TvShowApiClient.cs
index bc524957..6458d356 100644
--- a/src/BlazorApp/Components/Inventory/Clients/TvShowApiClient.cs
+++ b/src/BlazorApp/Components/Inventory/Clients/TvShowApiClient.cs
@@ -6,4 +6,16 @@ public class TvShowApiClient(HttpClient http)
: InventoryApiClientBase(http)
{
protected override string ApiResourceName => "/api/tv-shows";
+
+ ///
+ /// User-triggered, exact-match-only re-check against the local reference collection
+ /// (POST api/tv-shows/{id}/refresh-reference on WebApi). Returns the (possibly now-linked) item so the
+ /// caller can tell whether a match was actually found.
+ ///
+ public async Task RefreshReferenceAsync(string id)
+ {
+ var response = await Http.PostAsync($"{ApiResourceName}/{id}/refresh-reference", null);
+ response.EnsureSuccessStatusCode();
+ return (await response.Content.ReadFromJsonAsync())!;
+ }
}
diff --git a/src/BlazorApp/Components/Inventory/Clients/VideoGameApiClient.cs b/src/BlazorApp/Components/Inventory/Clients/VideoGameApiClient.cs
index 21c2667b..d5e64d3b 100644
--- a/src/BlazorApp/Components/Inventory/Clients/VideoGameApiClient.cs
+++ b/src/BlazorApp/Components/Inventory/Clients/VideoGameApiClient.cs
@@ -1,4 +1,4 @@
-using Keeptrack.WebApi.Contracts.Dto;
+using Keeptrack.WebApi.Contracts.Dto;
namespace Keeptrack.BlazorApp.Components.Inventory.Clients;
@@ -6,4 +6,11 @@ public class VideoGameApiClient(HttpClient http)
: InventoryApiClientBase(http)
{
protected override string ApiResourceName => "/api/video-games";
+
+ public async Task RefreshReferenceAsync(string id)
+ {
+ var response = await Http.PostAsync($"{ApiResourceName}/{id}/refresh-reference", null);
+ response.EnsureSuccessStatusCode();
+ return (await response.Content.ReadFromJsonAsync())!;
+ }
}
diff --git a/src/BlazorApp/Components/Inventory/InventoryPageBase.cs b/src/BlazorApp/Components/Inventory/InventoryPageBase.cs
index ba39c837..aab85d9a 100644
--- a/src/BlazorApp/Components/Inventory/InventoryPageBase.cs
+++ b/src/BlazorApp/Components/Inventory/InventoryPageBase.cs
@@ -35,6 +35,12 @@ public abstract class InventoryPageBase : ComponentBase
protected abstract TDto CloneItem(TDto item);
+ ///
+ /// Extra query parameters beyond search/page/pageSize - null by default. Override in a page that
+ /// needs its own filter (e.g. a status dropdown) instead of reimplementing paging/search from scratch.
+ ///
+ protected virtual IReadOnlyDictionary? ExtraQuery => null;
+
protected override async Task OnInitializedAsync() => await LoadAsync();
protected void OnSearchChanged(string value) => _search = value;
@@ -74,7 +80,7 @@ protected void CancelForm()
_error = null;
}
- protected async Task SaveAsync()
+ protected virtual async Task SaveAsync()
{
try
{
@@ -129,12 +135,12 @@ protected async Task DeleteAsync(string id)
}
}
- private async Task LoadAsync()
+ protected async Task LoadAsync()
{
try
{
_loading = true;
- var result = await Api.GetAsync(_search, _page, PageSize);
+ var result = await Api.GetAsync(_search, _page, PageSize, ExtraQuery);
_items = result.Items;
_totalCount = result.TotalCount;
}
diff --git a/src/BlazorApp/Components/Inventory/Pages/AlbumDetail.razor b/src/BlazorApp/Components/Inventory/Pages/AlbumDetail.razor
new file mode 100644
index 00000000..6374b97d
--- /dev/null
+++ b/src/BlazorApp/Components/Inventory/Pages/AlbumDetail.razor
@@ -0,0 +1,280 @@
+@page "/albums/{Id}"
+@rendermode InteractiveServer
+@attribute [Authorize]
+@using Keeptrack.BlazorApp.Components.ReferenceData
+@using Keeptrack.BlazorApp.Components.ReferenceDataAdmin
+
+@if (_loading)
+{
+
+}
+else if (_album is null)
+{
+
+}
+else
+{
+
+ @if (string.IsNullOrEmpty(_album.ReferenceId))
+ {
+
+
+
+
+
+ }
+
+
+
+ @if (!string.IsNullOrEmpty(_reference?.ArtistImageUrl))
+ {
+ Artist
+
+ }
+
+ @if (_reference?.Tracks.Count > 0)
+ {
+ Tracklist
+
+ }
+}
+
+@code {
+ [Parameter] public required string Id { get; set; }
+
+ [Inject] private AlbumApiClient AlbumApi { get; set; } = null!;
+
+ [Inject] private ReferenceDataApiClient ReferenceDataApi { get; set; } = null!;
+
+ [Inject] private PlaylistApiClient PlaylistApi { get; set; } = null!;
+
+ [Inject] private SongApiClient SongApi { get; set; } = null!;
+
+ private static readonly TimeSpan RefreshMessageDuration = TimeSpan.FromSeconds(4);
+
+ private bool _loading = true;
+ private AlbumDto? _album;
+ private AlbumReferenceDto? _reference;
+ private List _playlists = [];
+ private string? _openTrackMenu;
+ private bool _refreshingReference;
+ private string? _refreshMessage;
+ private string _refreshMessageStyle = "neutral";
+ private object? _refreshMessageToken;
+
+ /// Reuses CastGrid 's "photo + name" card for the single credited artist, instead of
+ /// introducing a one-off component for what's visually the same layout as a TV/movie cast member.
+ private List _artistCard => _reference is null
+ ? []
+ : [new CastMemberDto { Name = _reference.ArtistName ?? _album?.Artist ?? "", CharacterName = "", ProfileImageUrl = _reference.ArtistImageUrl }];
+
+ protected override async Task OnParametersSetAsync() => await LoadAsync();
+
+ private async Task LoadAsync()
+ {
+ _loading = true;
+ _album = await AlbumApi.GetOneAsync(Id);
+ _reference = string.IsNullOrEmpty(_album?.ReferenceId) ? null : await ReferenceDataApi.GetAlbumAsync(_album.ReferenceId);
+ var playlists = await PlaylistApi.GetAsync("", 1, 5000);
+ _playlists = playlists.Items;
+ _loading = false;
+ }
+
+ private void ToggleTrackMenu(string position)
+ {
+ _openTrackMenu = _openTrackMenu == position ? null : position;
+ }
+
+ ///
+ /// The shared "add a real track" operation ( ) -
+ /// reuses the same if this exact track was already added to any playlist,
+ /// instead of creating a duplicate.
+ ///
+ private async Task AddTrackToPlaylistAsync(ReferenceTrackDto track, PlaylistDto playlist)
+ {
+ _openTrackMenu = null;
+ if (_album?.Id is null) return;
+
+ var song = await SongApi.GetOrCreateForTrackAsync(_album.Id, track.Position, track.Title, track.Duration, _album.Artist);
+ if (song.Id is not null && !playlist.SongIds.Contains(song.Id))
+ {
+ playlist.SongIds.Add(song.Id);
+ await PlaylistApi.UpdateAsync(playlist);
+ }
+ }
+
+ private async Task SetRatingAsync(float rating)
+ {
+ if (_album is null) return;
+ _album.Rating = rating;
+ await AlbumApi.UpdateAsync(_album);
+ }
+
+ private async Task ToggleFavoriteAsync()
+ {
+ if (_album is null) return;
+ _album.IsFavorite = !_album.IsFavorite;
+ await AlbumApi.UpdateAsync(_album);
+ }
+
+ private async Task SaveTitleAsync(ChangeEventArgs e)
+ {
+ if (_album is null) return;
+ var title = e.Value?.ToString();
+ if (string.IsNullOrWhiteSpace(title)) return;
+ _album.Title = title;
+ await AlbumApi.UpdateAsync(_album);
+ }
+
+ private async Task SaveArtistAsync(ChangeEventArgs e)
+ {
+ if (_album is null) return;
+ var artist = e.Value?.ToString();
+ if (string.IsNullOrWhiteSpace(artist)) return;
+ _album.Artist = artist;
+ await AlbumApi.UpdateAsync(_album);
+ }
+
+ private async Task SaveGenreAsync(ChangeEventArgs e)
+ {
+ if (_album is null) return;
+ _album.Genre = e.Value?.ToString();
+ await AlbumApi.UpdateAsync(_album);
+ }
+
+ private async Task RefreshReferenceAsync()
+ {
+ if (_album?.Id is null) return;
+
+ var previousReferenceId = _album.ReferenceId;
+ _refreshingReference = true;
+ _refreshMessage = null;
+ try
+ {
+ var refreshed = await AlbumApi.RefreshReferenceAsync(_album.Id);
+ await LoadAsync();
+
+ (_refreshMessage, _refreshMessageStyle) = string.IsNullOrEmpty(refreshed.ReferenceId)
+ ? string.IsNullOrEmpty(previousReferenceId) ? ("No match found", "neutral") : ("Unlinked - no match", "danger")
+ : refreshed.ReferenceId == previousReferenceId ? ("Already linked", "neutral") : ("Linked!", "success");
+ }
+ catch (Exception ex)
+ {
+ _refreshMessage = $"Error: {ex.Message}";
+ _refreshMessageStyle = "danger";
+ }
+ finally
+ {
+ _refreshingReference = false;
+ }
+
+ ScheduleRefreshMessageClear();
+ }
+
+ ///
+ /// The toast is a small, auto-dismissing pill next to the refresh icon, not a permanent line of text -
+ /// a token guards against an in-flight delay from a previous click clearing a newer message.
+ ///
+ private void ScheduleRefreshMessageClear()
+ {
+ var token = new object();
+ _refreshMessageToken = token;
+ _ = ClearRefreshMessageAfterDelayAsync(token);
+ }
+
+ private async Task ClearRefreshMessageAfterDelayAsync(object token)
+ {
+ await Task.Delay(RefreshMessageDuration);
+ if (!ReferenceEquals(_refreshMessageToken, token)) return;
+ _refreshMessage = null;
+ await InvokeAsync(StateHasChanged);
+ }
+
+ private async Task SaveYearAsync(ChangeEventArgs e)
+ {
+ if (_album is null) return;
+ _album.Year = int.TryParse(e.Value?.ToString(), out var year) ? year : null;
+ await AlbumApi.UpdateAsync(_album);
+ }
+}
diff --git a/src/BlazorApp/Components/Inventory/Pages/MusicAlbums.razor b/src/BlazorApp/Components/Inventory/Pages/Albums.razor
similarity index 51%
rename from src/BlazorApp/Components/Inventory/Pages/MusicAlbums.razor
rename to src/BlazorApp/Components/Inventory/Pages/Albums.razor
index 026a0278..58009a70 100644
--- a/src/BlazorApp/Components/Inventory/Pages/MusicAlbums.razor
+++ b/src/BlazorApp/Components/Inventory/Pages/Albums.razor
@@ -1,10 +1,11 @@
-@page "/music-albums"
-@inherits InventoryPageBase
+@page "/albums"
+@inherits InventoryPageBase
@rendermode InteractiveServer
@attribute [Authorize]
-
+
+ ★ Favorites
+
Title
Artist
Rating
+
- @album.Title
+
+ @album.Title
+
@album.Artist
+
+ @if (album.IsFavorite)
+ {
+ ★
+ }
+
-
-
-
+
+ Title
+
+
+
+ Artist
+
+
+
+ Year
+
+
+
+ Rating
+
+
Artist
@@ -53,7 +82,7 @@
Year
-
+
Genre
@@ -61,7 +90,14 @@
Rating
-
+
+
+
+
Lists
+
+
+ Favorites
+
diff --git a/src/BlazorApp/Components/Inventory/Pages/Albums.razor.cs b/src/BlazorApp/Components/Inventory/Pages/Albums.razor.cs
new file mode 100644
index 00000000..a0a15c17
--- /dev/null
+++ b/src/BlazorApp/Components/Inventory/Pages/Albums.razor.cs
@@ -0,0 +1,35 @@
+using Keeptrack.WebApi.Contracts.Dto;
+using Microsoft.AspNetCore.Components;
+
+namespace Keeptrack.BlazorApp.Components.Inventory.Pages;
+
+public partial class Albums : InventoryPageBase
+{
+ [Inject] private AlbumApiClient AlbumApi { get; set; } = null!;
+
+ protected override InventoryApiClientBase Api => AlbumApi;
+
+ private bool _favoriteFilter;
+
+ protected override IReadOnlyDictionary? ExtraQuery =>
+ _favoriteFilter ? new Dictionary { ["IsFavorite"] = "true" } : null;
+
+ private async Task ToggleFavoriteFilterAsync()
+ {
+ _favoriteFilter = !_favoriteFilter;
+ _page = 1;
+ await LoadAsync();
+ }
+
+ protected override AlbumDto CloneItem(AlbumDto item) => new()
+ {
+ Id = item.Id,
+ Title = item.Title,
+ Artist = item.Artist,
+ Genre = item.Genre,
+ Year = item.Year,
+ Rating = item.Rating,
+ ReferenceId = item.ReferenceId,
+ IsFavorite = item.IsFavorite
+ };
+}
diff --git a/src/BlazorApp/Components/Inventory/Pages/BookDetail.razor b/src/BlazorApp/Components/Inventory/Pages/BookDetail.razor
new file mode 100644
index 00000000..2352995b
--- /dev/null
+++ b/src/BlazorApp/Components/Inventory/Pages/BookDetail.razor
@@ -0,0 +1,267 @@
+@page "/books/{Id}"
+@rendermode InteractiveServer
+@attribute [Authorize]
+@using Keeptrack.BlazorApp.Components.ReferenceData
+@using Keeptrack.BlazorApp.Components.ReferenceDataAdmin
+
+@if (_loading)
+{
+
+}
+else if (_book is null)
+{
+
+}
+else
+{
+
+ @if (string.IsNullOrEmpty(_book.ReferenceId))
+ {
+
+
+
+
+
+ }
+
+
+}
+
+@code {
+ [Parameter] public required string Id { get; set; }
+
+ [Inject] private BookApiClient BookApi { get; set; } = null!;
+
+ [Inject] private ReferenceDataApiClient ReferenceDataApi { get; set; } = null!;
+
+ private static readonly TimeSpan RefreshMessageDuration = TimeSpan.FromSeconds(4);
+
+ private bool _loading = true;
+ private BookDto? _book;
+ private BookReferenceDto? _reference;
+ private bool _refreshingReference;
+ private string? _refreshMessage;
+ private string _refreshMessageStyle = "neutral";
+ private object? _refreshMessageToken;
+
+ protected override async Task OnParametersSetAsync() => await LoadAsync();
+
+ private async Task LoadAsync()
+ {
+ _loading = true;
+ _book = await BookApi.GetOneAsync(Id);
+ _reference = string.IsNullOrEmpty(_book?.ReferenceId) ? null : await ReferenceDataApi.GetBookAsync(_book.ReferenceId);
+ _loading = false;
+ }
+
+ private async Task SetRatingAsync(float rating)
+ {
+ if (_book is null) return;
+ _book.Rating = rating;
+ await BookApi.UpdateAsync(_book);
+ }
+
+ private async Task ToggleFavoriteAsync()
+ {
+ if (_book is null) return;
+ _book.IsFavorite = !_book.IsFavorite;
+ await BookApi.UpdateAsync(_book);
+ }
+
+ private async Task ToggleOwnedAsync()
+ {
+ if (_book is null) return;
+ _book.IsOwned = !_book.IsOwned;
+ await BookApi.UpdateAsync(_book);
+ }
+
+ private async Task ToggleWishlistedAsync()
+ {
+ if (_book is null) return;
+ _book.IsWishlisted = !_book.IsWishlisted;
+ await BookApi.UpdateAsync(_book);
+ }
+
+ private async Task ToggleReadAsync()
+ {
+ if (_book is null) return;
+ _book.FirstReadAt = _book.FirstReadAt is null ? DateOnly.FromDateTime(DateTime.Today) : null;
+ await BookApi.UpdateAsync(_book);
+ }
+
+ private async Task SetReadDateAsync(ChangeEventArgs e)
+ {
+ if (_book is null) return;
+ _book.FirstReadAt = DateOnly.TryParse(e.Value?.ToString(), out var date) ? date : null;
+ await BookApi.UpdateAsync(_book);
+ }
+
+ private async Task SaveTitleAsync(ChangeEventArgs e)
+ {
+ if (_book is null) return;
+ var title = e.Value?.ToString();
+ if (string.IsNullOrWhiteSpace(title)) return;
+ _book.Title = title;
+ await BookApi.UpdateAsync(_book);
+ }
+
+ private async Task SaveAuthorAsync(ChangeEventArgs e)
+ {
+ if (_book is null) return;
+ var author = e.Value?.ToString();
+ if (string.IsNullOrWhiteSpace(author)) return;
+ _book.Author = author;
+ await BookApi.UpdateAsync(_book);
+ }
+
+ private async Task SaveSeriesAsync(ChangeEventArgs e)
+ {
+ if (_book is null) return;
+ _book.Series = e.Value?.ToString();
+ await BookApi.UpdateAsync(_book);
+ }
+
+ private async Task SaveGenreAsync(ChangeEventArgs e)
+ {
+ if (_book is null) return;
+ _book.Genre = e.Value?.ToString();
+ await BookApi.UpdateAsync(_book);
+ }
+
+ private async Task RefreshReferenceAsync()
+ {
+ if (_book?.Id is null) return;
+
+ var previousReferenceId = _book.ReferenceId;
+ _refreshingReference = true;
+ _refreshMessage = null;
+ try
+ {
+ var refreshed = await BookApi.RefreshReferenceAsync(_book.Id);
+ await LoadAsync();
+
+ (_refreshMessage, _refreshMessageStyle) = string.IsNullOrEmpty(refreshed.ReferenceId)
+ ? string.IsNullOrEmpty(previousReferenceId) ? ("No match found", "neutral") : ("Unlinked - no match", "danger")
+ : refreshed.ReferenceId == previousReferenceId ? ("Already linked", "neutral") : ("Linked!", "success");
+ }
+ catch (Exception ex)
+ {
+ _refreshMessage = $"Error: {ex.Message}";
+ _refreshMessageStyle = "danger";
+ }
+ finally
+ {
+ _refreshingReference = false;
+ }
+
+ ScheduleRefreshMessageClear();
+ }
+
+ ///
+ /// The toast is a small, auto-dismissing pill next to the refresh icon, not a permanent line of text -
+ /// a token guards against an in-flight delay from a previous click clearing a newer message.
+ ///
+ private void ScheduleRefreshMessageClear()
+ {
+ var token = new object();
+ _refreshMessageToken = token;
+ _ = ClearRefreshMessageAfterDelayAsync(token);
+ }
+
+ private async Task ClearRefreshMessageAfterDelayAsync(object token)
+ {
+ await Task.Delay(RefreshMessageDuration);
+ if (!ReferenceEquals(_refreshMessageToken, token)) return;
+ _refreshMessage = null;
+ await InvokeAsync(StateHasChanged);
+ }
+
+ private async Task SaveYearAsync(ChangeEventArgs e)
+ {
+ if (_book is null) return;
+ _book.Year = int.TryParse(e.Value?.ToString(), out var year) ? year : null;
+ await BookApi.UpdateAsync(_book);
+ }
+
+ private async Task SaveNotesAsync(ChangeEventArgs e)
+ {
+ if (_book is null) return;
+ _book.Notes = e.Value?.ToString();
+ await BookApi.UpdateAsync(_book);
+ }
+}
diff --git a/src/BlazorApp/Components/Inventory/Pages/Books.razor b/src/BlazorApp/Components/Inventory/Pages/Books.razor
index c22b772c..35e39f5f 100644
--- a/src/BlazorApp/Components/Inventory/Pages/Books.razor
+++ b/src/BlazorApp/Components/Inventory/Pages/Books.razor
@@ -5,6 +5,7 @@
+
+ ★ Favorites
+ ● Owned
+ ○ Wishlist
+
Title
Author
Series
Rating
Finished at
+
- @book.Title
+
+ @book.Title
+
@book.Author
@book.Series
@book.FirstReadAt?.ToString("yyyy-MM-dd")
+
+ @if (book.IsFavorite)
+ {
+ ★
+ }
+ @if (book.IsOwned)
+ {
+ ●
+ }
+ @if (book.IsWishlisted)
+ {
+ ○
+ }
+
-
-
-
-
-
+
+ Title
+
+
+
+ Author
+
+
+
+ Series
+
+
+
+ Year
+
+
+
+ Rating
+
+
Author
@@ -61,6 +102,10 @@
Series
+
+ Year
+
+
Genre
@@ -77,5 +122,20 @@
First read at
+
diff --git a/src/BlazorApp/Components/Inventory/Pages/Books.razor.cs b/src/BlazorApp/Components/Inventory/Pages/Books.razor.cs
index 18779d50..70efd008 100644
--- a/src/BlazorApp/Components/Inventory/Pages/Books.razor.cs
+++ b/src/BlazorApp/Components/Inventory/Pages/Books.razor.cs
@@ -1,4 +1,4 @@
-using Keeptrack.WebApi.Contracts.Dto;
+using Keeptrack.WebApi.Contracts.Dto;
using Microsoft.AspNetCore.Components;
namespace Keeptrack.BlazorApp.Components.Inventory.Pages;
@@ -9,15 +9,59 @@ public partial class Books : InventoryPageBase
protected override InventoryApiClientBase Api => BookApi;
+ private bool _favoriteFilter;
+
+ private bool _ownedFilter;
+
+ private bool _wishlistedFilter;
+
+ protected override IReadOnlyDictionary? ExtraQuery
+ {
+ get
+ {
+ var query = new Dictionary();
+ if (_favoriteFilter) query["IsFavorite"] = "true";
+ if (_ownedFilter) query["IsOwned"] = "true";
+ if (_wishlistedFilter) query["IsWishlisted"] = "true";
+ return query.Count > 0 ? query : null;
+ }
+ }
+
+ private async Task ToggleFavoriteFilterAsync()
+ {
+ _favoriteFilter = !_favoriteFilter;
+ _page = 1;
+ await LoadAsync();
+ }
+
+ private async Task ToggleOwnedFilterAsync()
+ {
+ _ownedFilter = !_ownedFilter;
+ _page = 1;
+ await LoadAsync();
+ }
+
+ private async Task ToggleWishlistedFilterAsync()
+ {
+ _wishlistedFilter = !_wishlistedFilter;
+ _page = 1;
+ await LoadAsync();
+ }
+
protected override BookDto CloneItem(BookDto item) => new()
{
Id = item.Id,
Title = item.Title,
Author = item.Author,
Series = item.Series,
+ Year = item.Year,
Genre = item.Genre,
Rating = item.Rating,
Notes = item.Notes,
- FirstReadAt = item.FirstReadAt
+ FirstReadAt = item.FirstReadAt,
+ ReferenceId = item.ReferenceId,
+ IsFavorite = item.IsFavorite,
+ IsOwned = item.IsOwned,
+ IsWishlisted = item.IsWishlisted
};
}
diff --git a/src/BlazorApp/Components/Inventory/Pages/CarDetail.razor b/src/BlazorApp/Components/Inventory/Pages/CarDetail.razor
new file mode 100644
index 00000000..d896520c
--- /dev/null
+++ b/src/BlazorApp/Components/Inventory/Pages/CarDetail.razor
@@ -0,0 +1,599 @@
+@page "/cars/{Id}"
+@rendermode InteractiveServer
+@attribute [Authorize]
+@using System.Globalization
+
+@if (_loading)
+{
+
+}
+else if (_car is null)
+{
+
+}
+else
+{
+
+
+
+
+ @if (HasMetricsToShow)
+ {
+ var metrics = _metrics!;
+ Metrics
+
+ }
+
+
+
History
+ + Add entry
+
+
+
+
+
+
+ Date
+ Mileage
+ Event
+ Cost
+ City
+ Description
+
+
+
+
+
+ @if (_history.Count == 0)
+ {
+ No history yet - add the first entry above.
+ }
+ @foreach (var entry in _history)
+ {
+
+ }
+
+
+
+
+
+ @if (_showModal)
+ {
+
+ }
+}
+
+@code {
+ [Parameter] public required string Id { get; set; }
+
+ [Inject] private CarApiClient CarApi { get; set; } = null!;
+
+ [Inject] private CarHistoryApiClient CarHistoryApi { get; set; } = null!;
+
+ private bool _loading = true;
+ private CarDto? _car;
+ private List _history = [];
+ private CarMetricsDto? _metrics;
+ private CarHistoryDto _modalEntry = NewEntry("");
+ private bool _showModal;
+ private Dictionary _warningsByEntryId = [];
+
+ private bool IsEditMode => !string.IsNullOrEmpty(_modalEntry.Id);
+
+ /// Separate Date/Time proxies over the single _modalEntry.HistoryDate, so editing one doesn't
+ /// reset the other back to midnight - a plain @bind straight to HistoryDate would lose the time
+ /// whenever the date input fires (its built-in converter always parses a bare yyyy-MM-dd).
+ private DateOnly ModalDate
+ {
+ get => DateOnly.FromDateTime(_modalEntry.HistoryDate);
+ set => _modalEntry.HistoryDate = value.ToDateTime(TimeOnly.FromDateTime(_modalEntry.HistoryDate));
+ }
+
+ ///
+ /// A plain text "HH:mm" field rather than <input type="time"> - the native time picker's 12h/24h
+ /// display is decided by the browser from the OS region format, not anything this page controls (Chrome
+ /// and Firefox both ignore the page's own language/culture for it), so it can't be forced to 24h that
+ /// way. A free-text field sidesteps the native widget entirely and always reads/writes 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 bool ShowFuel => _car?.EnergyType != CarEnergyType.Electric;
+
+ private bool ShowElectric => _car?.EnergyType is CarEnergyType.Electric or CarEnergyType.Hybrid;
+
+ /// `ComputeMetrics` always returns a non-null result, even for a brand-new car with no history
+ /// at all (empty lists, null averages/next-maintenance) - the metrics panel should only render once
+ /// there's actually something in it to show, not just because the object itself isn't null.
+ private bool HasMetricsToShow => _metrics is not null && (
+ _metrics.FuelConsumption.Count > 0 ||
+ _metrics.ElectricConsumption.Count > 0 ||
+ _metrics.CostHistory.Count > 0 ||
+ _metrics.MileageWarnings.Count > 0 ||
+ _metrics.NextMaintenance is not null);
+
+ protected override async Task OnParametersSetAsync() => await LoadAsync();
+
+ private static CarHistoryDto NewEntry(string carId) => new()
+ {
+ CarId = carId,
+ HistoryDate = DateTime.Now,
+ EventType = CarHistoryType.Refuel
+ };
+
+ private async Task LoadAsync()
+ {
+ _loading = true;
+ _car = await CarApi.GetOneAsync(Id);
+ 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();
+ _metrics = await CarApi.GetMetricsAsync(Id);
+ _warningsByEntryId = _metrics.MileageWarnings
+ .GroupBy(w => w.CarHistoryId)
+ .ToDictionary(g => g.Key, g => string.Join(" ", g.Select(w => w.Message)));
+ }
+ _loading = false;
+ }
+
+ private async Task SaveCarAsync()
+ {
+ if (_car is null) return;
+ await CarApi.UpdateAsync(_car);
+ }
+
+ private async Task SaveNameAsync(ChangeEventArgs e)
+ {
+ if (_car is null) return;
+ var name = e.Value?.ToString();
+ if (string.IsNullOrWhiteSpace(name)) return;
+ _car.Name = name;
+ await SaveCarAsync();
+ }
+
+ private async Task SaveManufacturerAsync(ChangeEventArgs e)
+ {
+ if (_car is null) return;
+ _car.Manufacturer = e.Value?.ToString();
+ await SaveCarAsync();
+ }
+
+ private async Task SaveModelAsync(ChangeEventArgs e)
+ {
+ if (_car is null) return;
+ _car.Model = e.Value?.ToString();
+ await SaveCarAsync();
+ }
+
+ private async Task SaveYearAsync(ChangeEventArgs e)
+ {
+ if (_car is null) return;
+ _car.Year = int.TryParse(e.Value?.ToString(), out var year) ? year : null;
+ await SaveCarAsync();
+ }
+
+ private async Task SaveLicensePlateAsync(ChangeEventArgs e)
+ {
+ if (_car is null) return;
+ _car.LicensePlate = e.Value?.ToString();
+ await SaveCarAsync();
+ }
+
+ private async Task SetEnergyTypeAsync(CarEnergyType energyType)
+ {
+ if (_car is null) return;
+ _car.EnergyType = energyType;
+ await SaveCarAsync();
+ }
+
+ 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(CarHistoryDto entry)
+ {
+ _modalEntry = CloneEntry(entry);
+ _showModal = true;
+ }
+
+ private void CancelModal() => _showModal = false;
+
+ ///
+ /// Every history mutation reloads the whole car (history + metrics) rather than patching local state -
+ /// an edit can change mileage-warning results and chronological order, so recomputing from the server is
+ /// simpler and safer than trying to keep local derived state in sync by hand.
+ ///
+ private async Task SaveModalEntryAsync()
+ {
+ if (IsEditMode) await CarHistoryApi.UpdateAsync(_modalEntry);
+ else await CarHistoryApi.AddAsync(_modalEntry);
+
+ _showModal = false;
+ await LoadAsync();
+ }
+
+ private static CarHistoryDto CloneEntry(CarHistoryDto entry) => new()
+ {
+ Id = entry.Id,
+ CarId = entry.CarId,
+ HistoryDate = entry.HistoryDate,
+ Mileage = entry.Mileage,
+ EventType = entry.EventType,
+ Description = entry.Description,
+ Cost = entry.Cost,
+ City = entry.City,
+ PostalCode = entry.PostalCode,
+ Country = entry.Country,
+ Longitude = entry.Longitude,
+ Latitude = entry.Latitude,
+ FuelCategory = entry.FuelCategory,
+ FuelVolume = entry.FuelVolume,
+ FuelUnitPrice = entry.FuelUnitPrice,
+ ElectricVolume = entry.ElectricVolume,
+ ElectricUnitPrice = entry.ElectricUnitPrice,
+ IsFullRefill = entry.IsFullRefill,
+ DeltaMileage = entry.DeltaMileage,
+ StationBrandName = entry.StationBrandName,
+ Garage = entry.Garage
+ };
+
+ private async Task DeleteEntryAsync(string id)
+ {
+ await CarHistoryApi.DeleteAsync(id);
+ await LoadAsync();
+ }
+
+ /// Thin hand-rolled SVG line chart - this app has no charting dependency, and one isn't worth
+ /// adding for a couple of small, single-series trend lines. A single series needs no legend (the label
+ /// above the chart already names it); each point carries a native <title> tooltip. Axis drawing
+ /// (arrowheads, ticks, titles) lives in , shared with HouseDetail.razor's
+ /// own chart.
+ private static RenderFragment RenderConsumptionChart(List points, string color, string yLabel, string chartId, SvgChartHelpers.ChartGeometry geometry) => builder =>
+ {
+ var (viewWidth, viewHeight, plotLeft, plotRight, plotTop, plotBottom) = geometry;
+
+ var values = points.Select(p => p.ValuePer100Km).ToList();
+ var min = values.Min();
+ var max = values.Max();
+ var range = max - min == 0 ? 1 : max - min;
+ var stepX = points.Count > 1 ? (plotRight - plotLeft) / (points.Count - 1) : 0;
+
+ double X(int i) => plotLeft + i * stepX;
+ double Y(double v) => plotBottom - (v - min) / range * (plotBottom - plotTop);
+
+ var linePoints = string.Join(" ", points.Select((p, i) => $"{X(i):F1},{Y(p.ValuePer100Km):F1}"));
+
+ var yTicks = Enumerable.Range(0, 4)
+ .Select(i => min + range * i / 3.0)
+ .Select(v => (Y(v), v.ToString("F1")))
+ .ToList();
+ var xTicks = SvgChartHelpers.EvenlySpacedIndices(points.Count, 4)
+ .Select(i => (X(i), points[i].Date.ToString("MMM yy")))
+ .ToList();
+
+ var seq = 0;
+ builder.OpenElement(seq++, "svg");
+ builder.AddAttribute(seq++, "viewBox", $"0 0 {viewWidth} {viewHeight}");
+ builder.AddAttribute(seq++, "class", "kt-chart-svg");
+
+ SvgChartHelpers.RenderAxes(builder, ref seq, geometry, $"kt-chart-arrow-{chartId}", "Date", yLabel, yTicks, xTicks);
+
+ builder.OpenElement(seq++, "polyline");
+ builder.AddAttribute(seq++, "points", linePoints);
+ builder.AddAttribute(seq++, "fill", "none");
+ builder.AddAttribute(seq++, "stroke", color);
+ builder.AddAttribute(seq++, "stroke-width", "2");
+ builder.AddAttribute(seq++, "stroke-linecap", "round");
+ builder.AddAttribute(seq++, "stroke-linejoin", "round");
+ builder.AddAttribute(seq++, "vector-effect", "non-scaling-stroke");
+ builder.CloseElement();
+
+ for (var i = 0; i < points.Count; i++)
+ {
+ builder.OpenElement(seq++, "circle");
+ builder.AddAttribute(seq++, "cx", X(i).ToString("F1"));
+ builder.AddAttribute(seq++, "cy", Y(points[i].ValuePer100Km).ToString("F1"));
+ builder.AddAttribute(seq++, "r", "1.6");
+ builder.AddAttribute(seq++, "fill", color);
+ builder.OpenElement(seq++, "title");
+ builder.AddContent(seq++, $"{points[i].Date:yyyy-MM-dd}: {points[i].ValuePer100Km:F1}");
+ builder.CloseElement();
+ builder.CloseElement();
+ }
+
+ builder.CloseElement();
+ };
+
+ /// Hand-rolled SVG stacked bar chart - fuel vs. maintenance cost per month, each bar segment
+ /// carrying a native <title> tooltip with the exact figure, and a 2px surface gap between the two
+ /// stacked segments so they read as distinct marks rather than one solid bar.
+ private static RenderFragment RenderCostHistoryChart(List points) => builder =>
+ {
+ var geometry = SvgChartHelpers.FullWidthGeometry;
+ var (viewWidth, viewHeight, plotLeft, plotRight, plotTop, plotBottom) = geometry;
+
+ var maxTotal = points.Max(p => p.TotalCost);
+ if (maxTotal <= 0) maxTotal = 1;
+
+ var plotWidth = plotRight - plotLeft;
+ var barWidth = plotWidth / points.Count * 0.7;
+ var gap = plotWidth / points.Count * 0.3;
+
+ double ScaleHeight(double value) => value / maxTotal * (plotBottom - plotTop);
+
+ var yTicks = Enumerable.Range(0, 4)
+ .Select(i => maxTotal * i / 3.0)
+ .Select(v => (plotBottom - ScaleHeight(v), v.ToString("F0")))
+ .ToList();
+ var xTicks = SvgChartHelpers.EvenlySpacedIndices(points.Count, 5)
+ .Select(i => (plotLeft + i * (barWidth + gap) + barWidth / 2, points[i].Period.ToString("MMM yy")))
+ .ToList();
+
+ var seq = 0;
+ builder.OpenElement(seq++, "svg");
+ builder.AddAttribute(seq++, "viewBox", $"0 0 {viewWidth} {viewHeight}");
+ builder.AddAttribute(seq++, "class", "kt-chart-svg");
+
+ SvgChartHelpers.RenderAxes(builder, ref seq, geometry, "kt-chart-arrow-cost", "Month", "€", yTicks, xTicks);
+
+ for (var i = 0; i < points.Count; i++)
+ {
+ var point = points[i];
+ var x = plotLeft + i * (barWidth + gap);
+ var fuelHeight = ScaleHeight(point.FuelCost);
+ var maintenanceHeight = ScaleHeight(point.MaintenanceCost);
+ var stackGap = point.FuelCost > 0 && point.MaintenanceCost > 0 ? 0.6 : 0;
+
+ var maintenanceY = plotBottom - maintenanceHeight;
+ builder.OpenElement(seq++, "rect");
+ builder.AddAttribute(seq++, "x", x.ToString("F1"));
+ builder.AddAttribute(seq++, "y", maintenanceY.ToString("F1"));
+ builder.AddAttribute(seq++, "width", barWidth.ToString("F1"));
+ builder.AddAttribute(seq++, "height", maintenanceHeight.ToString("F1"));
+ builder.AddAttribute(seq++, "fill", "var(--kt-chart-secondary)");
+ builder.OpenElement(seq++, "title");
+ builder.AddContent(seq++, $"{point.Period:yyyy-MM}: maintenance {point.MaintenanceCost:F2}");
+ builder.CloseElement();
+ builder.CloseElement();
+
+ var fuelY = maintenanceY - stackGap - fuelHeight;
+ builder.OpenElement(seq++, "rect");
+ builder.AddAttribute(seq++, "x", x.ToString("F1"));
+ builder.AddAttribute(seq++, "y", fuelY.ToString("F1"));
+ builder.AddAttribute(seq++, "width", barWidth.ToString("F1"));
+ builder.AddAttribute(seq++, "height", fuelHeight.ToString("F1"));
+ builder.AddAttribute(seq++, "fill", "var(--kt-accent)");
+ builder.OpenElement(seq++, "title");
+ builder.AddContent(seq++, $"{point.Period:yyyy-MM}: fuel {point.FuelCost:F2}");
+ builder.CloseElement();
+ builder.CloseElement();
+ }
+
+ builder.CloseElement();
+ };
+}
diff --git a/src/BlazorApp/Components/Inventory/Pages/CarDetail.razor.css b/src/BlazorApp/Components/Inventory/Pages/CarDetail.razor.css
new file mode 100644
index 00000000..9307b294
--- /dev/null
+++ b/src/BlazorApp/Components/Inventory/Pages/CarDetail.razor.css
@@ -0,0 +1,12 @@
+/* Mileage-inconsistency row highlighting - Car-specific (CarMetricsService.ComputeMileageWarnings has no
+ House equivalent), so this stays here rather than in the shared rules in app.css. Rows/cells are rendered
+ by the child CarHistoryRow component, not this file's own markup - CSS isolation scopes stop at a
+ component boundary, so this needs ::deep to reach in. */
+.kt-car-sheet ::deep .kt-history-row-warning td {
+ background: var(--kt-warning-bg);
+}
+
+.kt-car-sheet ::deep .kt-history-warning-icon {
+ color: var(--kt-warning);
+ cursor: help;
+}
diff --git a/src/BlazorApp/Components/Inventory/Pages/CarHistoryRow.razor b/src/BlazorApp/Components/Inventory/Pages/CarHistoryRow.razor
new file mode 100644
index 00000000..112c9c8a
--- /dev/null
+++ b/src/BlazorApp/Components/Inventory/Pages/CarHistoryRow.razor
@@ -0,0 +1,35 @@
+@* 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. *@
+
+
+ @Entry.HistoryDate.ToString(Entry.HistoryDate.TimeOfDay == TimeSpan.Zero ? "yyyy-MM-dd" : "yyyy-MM-dd HH:mm")
+ @Entry.Mileage
+ @Entry.EventType
+ @Entry.Cost?.ToString("F2")
+ @Entry.City
+ @Entry.Description
+
+ @if (HasWarning)
+ {
+ ⚠
+ }
+
+
+
+ OnEdit.InvokeAsync(Entry)">Edit
+ OnDelete.InvokeAsync(Entry.Id!)">Del
+
+
+
+
+@code {
+ [Parameter] public required CarHistoryDto Entry { get; set; }
+
+ [Parameter] public bool HasWarning { get; set; }
+
+ [Parameter] public string? WarningMessage { get; set; }
+
+ [Parameter] public EventCallback OnEdit { get; set; }
+
+ [Parameter] public EventCallback OnDelete { get; set; }
+}
diff --git a/src/BlazorApp/Components/Inventory/Pages/Cars.razor b/src/BlazorApp/Components/Inventory/Pages/Cars.razor
new file mode 100644
index 00000000..91d17f7d
--- /dev/null
+++ b/src/BlazorApp/Components/Inventory/Pages/Cars.razor
@@ -0,0 +1,96 @@
+@page "/cars"
+@inherits InventoryPageBase
+@rendermode InteractiveServer
+@attribute [Authorize]
+
+
+
+ Name
+ Manufacturer
+ Model
+ Year
+ License plate
+ Energy
+
+
+
+
+ @car.Name
+
+ @car.Manufacturer
+ @car.Model
+ @car.Year
+ @car.LicensePlate
+ @car.EnergyType
+
+
+
+
+ Name
+
+
+
+ Manufacturer
+
+
+
+ Model
+
+
+
+ Year
+
+
+
+ License plate
+
+
+
+
+
+ Name
+
+
+
+ Manufacturer
+
+
+
+ Model
+
+
+
+ Year
+
+
+
+ License plate
+
+
+ Manage energy type and history from the car's detail page.
+
+
diff --git a/src/BlazorApp/Components/Inventory/Pages/Cars.razor.cs b/src/BlazorApp/Components/Inventory/Pages/Cars.razor.cs
new file mode 100644
index 00000000..72895cdc
--- /dev/null
+++ b/src/BlazorApp/Components/Inventory/Pages/Cars.razor.cs
@@ -0,0 +1,52 @@
+using Keeptrack.WebApi.Contracts.Dto;
+using Microsoft.AspNetCore.Components;
+
+namespace Keeptrack.BlazorApp.Components.Inventory.Pages;
+
+public partial class Cars : InventoryPageBase
+{
+ [Inject] private CarApiClient CarApi { get; set; } = null!;
+
+ [Inject] private NavigationManager Nav { get; set; } = null!;
+
+ protected override InventoryApiClientBase Api => CarApi;
+
+ ///
+ /// Creating a car only ever captures identity fields here (see FormTemplate ) - energy type and
+ /// history are added on the detail page, so a successful create navigates straight there instead of
+ /// closing the form and staying on the list (same as VideoGames.razor.cs 's SaveAsync ).
+ ///
+ protected override async Task SaveAsync()
+ {
+ try
+ {
+ if (_form.Id is null)
+ {
+ var created = await CarApi.AddAsync(_form);
+ _showForm = false;
+ Nav.NavigateTo($"/cars/{created.Id}");
+ }
+ else
+ {
+ await CarApi.UpdateAsync(_form);
+ _showForm = false;
+ await LoadAsync();
+ }
+ }
+ catch (Exception ex)
+ {
+ _error = ex.Message;
+ }
+ }
+
+ protected override CarDto CloneItem(CarDto item) => new()
+ {
+ Id = item.Id,
+ Name = item.Name,
+ Manufacturer = item.Manufacturer,
+ Model = item.Model,
+ Year = item.Year,
+ LicensePlate = item.LicensePlate,
+ EnergyType = item.EnergyType
+ };
+}
diff --git a/src/BlazorApp/Components/Inventory/Pages/HouseDetail.razor b/src/BlazorApp/Components/Inventory/Pages/HouseDetail.razor
new file mode 100644
index 00000000..311f338f
--- /dev/null
+++ b/src/BlazorApp/Components/Inventory/Pages/HouseDetail.razor
@@ -0,0 +1,361 @@
+@page "/houses/{Id}"
+@rendermode InteractiveServer
+@attribute [Authorize]
+
+@if (_loading)
+{
+
+}
+else if (_house is null)
+{
+
+}
+else
+{
+
+
+
+
+ @if (HasMetricsToShow)
+ {
+ var metrics = _metrics!;
+ Yearly cost review
+
+ }
+
+
+
History
+ + Add entry
+
+
+
+
+
+
+ Date
+ Event
+ Cost
+ Provider
+ Description
+
+
+
+
+ @if (_history.Count == 0)
+ {
+ No history yet - add the first entry above.
+ }
+ @foreach (var entry in _history)
+ {
+
+ }
+
+
+
+
+
+ @if (_showModal)
+ {
+
+
+
+
+
+
+ Date
+
+
+
+ Cost (€)
+
+
+
+
Event type
+
+ @foreach (var eventType in Enum.GetValues())
+ {
+ _modalEntry.EventType = eventType">@eventType
+ }
+
+
+
+ Provider (contractor / technician / utility / store)
+
+
+
+ Description
+
+
+
+
+
+
+
+ }
+}
+
+@code {
+ [Parameter] public required string Id { get; set; }
+
+ [Inject] private HouseApiClient HouseApi { get; set; } = null!;
+
+ [Inject] private HouseHistoryApiClient HouseHistoryApi { get; set; } = null!;
+
+ private bool _loading = true;
+ private HouseDto? _house;
+ private List _history = [];
+ private HouseMetricsDto? _metrics;
+ private HouseHistoryDto _modalEntry = NewEntry("");
+ private bool _showModal;
+
+ private bool IsEditMode => !string.IsNullOrEmpty(_modalEntry.Id);
+
+ /// `ComputeMetrics` always returns a non-null result, even for a brand-new house with no history
+ /// at all (an empty list) - the metrics panel should only render once there's actually something in it
+ /// to show, not just because the object itself isn't null.
+ private bool HasMetricsToShow => _metrics is { CostHistory.Count: > 0 };
+
+ protected override async Task OnParametersSetAsync() => await LoadAsync();
+
+ private static HouseHistoryDto NewEntry(string houseId) => new()
+ {
+ HouseId = houseId,
+ HistoryDate = DateOnly.FromDateTime(DateTime.Now),
+ EventType = HouseEventType.Maintenance
+ };
+
+ private async Task LoadAsync()
+ {
+ _loading = true;
+ _house = await HouseApi.GetOneAsync(Id);
+ 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.
+ _history = result.Items.OrderByDescending(h => h.HistoryDate).ToList();
+ _metrics = await HouseApi.GetMetricsAsync(Id);
+ }
+ _loading = false;
+ }
+
+ private async Task SaveHouseAsync()
+ {
+ if (_house is null) return;
+ await HouseApi.UpdateAsync(_house);
+ }
+
+ private async Task SaveNameAsync(ChangeEventArgs e)
+ {
+ if (_house is null) return;
+ var name = e.Value?.ToString();
+ if (string.IsNullOrWhiteSpace(name)) return;
+ _house.Name = name;
+ await SaveHouseAsync();
+ }
+
+ private async Task SaveAddressAsync(ChangeEventArgs e)
+ {
+ if (_house is null) return;
+ _house.Address = e.Value?.ToString();
+ await SaveHouseAsync();
+ }
+
+ private async Task SaveCityAsync(ChangeEventArgs e)
+ {
+ if (_house is null) return;
+ _house.City = e.Value?.ToString();
+ await SaveHouseAsync();
+ }
+
+ private async Task SavePostalCodeAsync(ChangeEventArgs e)
+ {
+ if (_house is null) return;
+ _house.PostalCode = e.Value?.ToString();
+ await SaveHouseAsync();
+ }
+
+ private async Task SaveCountryAsync(ChangeEventArgs e)
+ {
+ if (_house is null) return;
+ _house.Country = e.Value?.ToString();
+ await SaveHouseAsync();
+ }
+
+ private async Task SaveNotesAsync(ChangeEventArgs e)
+ {
+ if (_house is null) return;
+ _house.Notes = e.Value?.ToString();
+ await SaveHouseAsync();
+ }
+
+ 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(HouseHistoryDto entry)
+ {
+ _modalEntry = CloneEntry(entry);
+ _showModal = true;
+ }
+
+ private void CancelModal() => _showModal = false;
+
+ /// Every history mutation reloads the whole house (history + metrics) rather than patching local
+ /// state - an edit can change the yearly cost breakdown, so recomputing from the server is simpler and
+ /// safer than trying to keep local derived state in sync by hand.
+ private async Task SaveModalEntryAsync()
+ {
+ if (IsEditMode) await HouseHistoryApi.UpdateAsync(_modalEntry);
+ else await HouseHistoryApi.AddAsync(_modalEntry);
+
+ _showModal = false;
+ await LoadAsync();
+ }
+
+ private static HouseHistoryDto CloneEntry(HouseHistoryDto entry) => new()
+ {
+ Id = entry.Id,
+ HouseId = entry.HouseId,
+ HistoryDate = entry.HistoryDate,
+ EventType = entry.EventType,
+ Description = entry.Description,
+ Cost = entry.Cost,
+ Provider = entry.Provider
+ };
+
+ private async Task DeleteEntryAsync(string id)
+ {
+ await HouseHistoryApi.DeleteAsync(id);
+ await LoadAsync();
+ }
+
+ /// Hand-rolled SVG bar chart - total cost per year, one bar each. Simpler than a stacked chart
+ /// (which would need 6 colors for House's event categories): the breakdown table right below already
+ /// carries the per-category precision an insurance review actually needs, so the chart only needs to
+ /// answer "what did this cost, year by year" at a glance. Axis drawing lives in ,
+ /// shared with CarDetail.razor's own charts.
+ private static RenderFragment RenderYearlyCostChart(List points) => builder =>
+ {
+ var geometry = SvgChartHelpers.FullWidthGeometry;
+ var (viewWidth, viewHeight, plotLeft, plotRight, plotTop, plotBottom) = geometry;
+
+ var maxTotal = points.Max(p => p.TotalCost);
+ if (maxTotal <= 0) maxTotal = 1;
+
+ var plotWidth = plotRight - plotLeft;
+ var barWidth = plotWidth / points.Count * 0.6;
+ var gap = plotWidth / points.Count * 0.4;
+
+ double ScaleHeight(double value) => value / maxTotal * (plotBottom - plotTop);
+
+ var yTicks = Enumerable.Range(0, 4)
+ .Select(i => maxTotal * i / 3.0)
+ .Select(v => (plotBottom - ScaleHeight(v), v.ToString("F0")))
+ .ToList();
+ var xTicks = points.Select((p, i) => (plotLeft + i * (barWidth + gap) + barWidth / 2, p.Year.ToString())).ToList();
+
+ var seq = 0;
+ builder.OpenElement(seq++, "svg");
+ builder.AddAttribute(seq++, "viewBox", $"0 0 {viewWidth} {viewHeight}");
+ builder.AddAttribute(seq++, "class", "kt-chart-svg");
+
+ SvgChartHelpers.RenderAxes(builder, ref seq, geometry, "kt-chart-arrow-house-cost", "Year", "€", yTicks, xTicks);
+
+ for (var i = 0; i < points.Count; i++)
+ {
+ var point = points[i];
+ var x = plotLeft + i * (barWidth + gap);
+ var height = ScaleHeight(point.TotalCost);
+ var y = plotBottom - height;
+
+ builder.OpenElement(seq++, "rect");
+ builder.AddAttribute(seq++, "x", x.ToString("F1"));
+ builder.AddAttribute(seq++, "y", y.ToString("F1"));
+ builder.AddAttribute(seq++, "width", barWidth.ToString("F1"));
+ builder.AddAttribute(seq++, "height", height.ToString("F1"));
+ builder.AddAttribute(seq++, "fill", "var(--kt-accent)");
+ builder.OpenElement(seq++, "title");
+ builder.AddContent(seq++, $"{point.Year}: {point.TotalCost:F2}");
+ builder.CloseElement();
+ builder.CloseElement();
+ }
+
+ builder.CloseElement();
+ };
+}
diff --git a/src/BlazorApp/Components/Inventory/Pages/HouseHistoryRow.razor b/src/BlazorApp/Components/Inventory/Pages/HouseHistoryRow.razor
new file mode 100644
index 00000000..7e2809cd
--- /dev/null
+++ b/src/BlazorApp/Components/Inventory/Pages/HouseHistoryRow.razor
@@ -0,0 +1,24 @@
+@* 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. *@
+
+
+ @Entry.HistoryDate.ToString("yyyy-MM-dd")
+ @Entry.EventType
+ @Entry.Cost?.ToString("F2")
+ @Entry.Provider
+ @Entry.Description
+
+
+ OnEdit.InvokeAsync(Entry)">Edit
+ OnDelete.InvokeAsync(Entry.Id!)">Del
+
+
+
+
+@code {
+ [Parameter] public required HouseHistoryDto Entry { get; set; }
+
+ [Parameter] public EventCallback OnEdit { get; set; }
+
+ [Parameter] public EventCallback OnDelete { get; set; }
+}
diff --git a/src/BlazorApp/Components/Inventory/Pages/Houses.razor b/src/BlazorApp/Components/Inventory/Pages/Houses.razor
new file mode 100644
index 00000000..bd44e318
--- /dev/null
+++ b/src/BlazorApp/Components/Inventory/Pages/Houses.razor
@@ -0,0 +1,92 @@
+@page "/houses"
+@inherits InventoryPageBase
+@rendermode InteractiveServer
+@attribute [Authorize]
+
+
+
+ Name
+ Address
+ City
+ Country
+
+
+
+
+ @house.Name
+
+ @house.Address
+ @house.City
+ @house.Country
+
+
+
+
+ Name
+
+
+
+ Address
+
+
+
+ City
+
+
+
+ Postal code
+
+
+
+ Country
+
+
+
+
+
+ Name
+
+
+
+ Address
+
+
+
+ City
+
+
+
+ Postal code
+
+
+
+ Country
+
+
+ Manage notes and history from the house's detail page.
+
+
diff --git a/src/BlazorApp/Components/Inventory/Pages/Houses.razor.cs b/src/BlazorApp/Components/Inventory/Pages/Houses.razor.cs
new file mode 100644
index 00000000..14e33098
--- /dev/null
+++ b/src/BlazorApp/Components/Inventory/Pages/Houses.razor.cs
@@ -0,0 +1,52 @@
+using Keeptrack.WebApi.Contracts.Dto;
+using Microsoft.AspNetCore.Components;
+
+namespace Keeptrack.BlazorApp.Components.Inventory.Pages;
+
+public partial class Houses : InventoryPageBase
+{
+ [Inject] private HouseApiClient HouseApi { get; set; } = null!;
+
+ [Inject] private NavigationManager Nav { get; set; } = null!;
+
+ protected override InventoryApiClientBase Api => HouseApi;
+
+ ///
+ /// Creating a house only ever captures identity fields here (see FormTemplate ) - notes and history
+ /// are added on the detail page, so a successful create navigates straight there instead of closing the
+ /// form and staying on the list (same as Cars.razor.cs 's SaveAsync ).
+ ///
+ protected override async Task SaveAsync()
+ {
+ try
+ {
+ if (_form.Id is null)
+ {
+ var created = await HouseApi.AddAsync(_form);
+ _showForm = false;
+ Nav.NavigateTo($"/houses/{created.Id}");
+ }
+ else
+ {
+ await HouseApi.UpdateAsync(_form);
+ _showForm = false;
+ await LoadAsync();
+ }
+ }
+ catch (Exception ex)
+ {
+ _error = ex.Message;
+ }
+ }
+
+ protected override HouseDto CloneItem(HouseDto item) => new()
+ {
+ Id = item.Id,
+ Name = item.Name,
+ Address = item.Address,
+ City = item.City,
+ PostalCode = item.PostalCode,
+ Country = item.Country,
+ Notes = item.Notes
+ };
+}
diff --git a/src/BlazorApp/Components/Inventory/Pages/MovieDetail.razor b/src/BlazorApp/Components/Inventory/Pages/MovieDetail.razor
new file mode 100644
index 00000000..8a03a895
--- /dev/null
+++ b/src/BlazorApp/Components/Inventory/Pages/MovieDetail.razor
@@ -0,0 +1,250 @@
+@page "/movies/{Id}"
+@rendermode InteractiveServer
+@attribute [Authorize]
+@using Keeptrack.BlazorApp.Components.ReferenceData
+@using Keeptrack.BlazorApp.Components.ReferenceDataAdmin
+
+@if (_loading)
+{
+
+}
+else if (_movie is null)
+{
+
+}
+else
+{
+
+ @if (string.IsNullOrEmpty(_movie.ReferenceId))
+ {
+
+
+
+
+
+ }
+
+
+
+ @if (_reference?.Cast.Count > 0)
+ {
+ Cast
+
+ }
+}
+
+@code {
+ [Parameter] public required string Id { get; set; }
+
+ [Inject] private MovieApiClient MovieApi { get; set; } = null!;
+
+ [Inject] private ReferenceDataApiClient ReferenceDataApi { get; set; } = null!;
+
+ private static readonly TimeSpan RefreshMessageDuration = TimeSpan.FromSeconds(4);
+
+ private bool _loading = true;
+ private MovieDto? _movie;
+ private MovieReferenceDto? _reference;
+ private bool _refreshingReference;
+ private string? _refreshMessage;
+ private string _refreshMessageStyle = "neutral";
+ private object? _refreshMessageToken;
+
+ protected override async Task OnParametersSetAsync() => await LoadAsync();
+
+ private async Task LoadAsync()
+ {
+ _loading = true;
+ _movie = await MovieApi.GetOneAsync(Id);
+ _reference = string.IsNullOrEmpty(_movie?.ReferenceId) ? null : await ReferenceDataApi.GetMovieAsync(_movie.ReferenceId);
+ _loading = false;
+ }
+
+ private async Task ToggleFavoriteAsync()
+ {
+ if (_movie is null) return;
+ _movie.IsFavorite = !_movie.IsFavorite;
+ await MovieApi.UpdateAsync(_movie);
+ }
+
+ private async Task ToggleWantToWatchAsync()
+ {
+ if (_movie is null) return;
+ _movie.WantToWatch = !_movie.WantToWatch;
+ await MovieApi.UpdateAsync(_movie);
+ }
+
+ private async Task ToggleOwnedAsync()
+ {
+ if (_movie is null) return;
+ _movie.IsOwned = !_movie.IsOwned;
+ await MovieApi.UpdateAsync(_movie);
+ }
+
+ private async Task ToggleWishlistedAsync()
+ {
+ if (_movie is null) return;
+ _movie.IsWishlisted = !_movie.IsWishlisted;
+ await MovieApi.UpdateAsync(_movie);
+ }
+
+ private async Task SetRatingAsync(float rating)
+ {
+ if (_movie is null) return;
+ _movie.Rating = rating;
+ await MovieApi.UpdateAsync(_movie);
+ }
+
+ private async Task ToggleWatchedAsync()
+ {
+ if (_movie is null) return;
+ _movie.FirstSeenAt = _movie.FirstSeenAt is null ? DateOnly.FromDateTime(DateTime.Today) : null;
+ await MovieApi.UpdateAsync(_movie);
+ }
+
+ private async Task SetWatchedDateAsync(ChangeEventArgs e)
+ {
+ if (_movie is null) return;
+ _movie.FirstSeenAt = DateOnly.TryParse(e.Value?.ToString(), out var date) ? date : null;
+ await MovieApi.UpdateAsync(_movie);
+ }
+
+ private async Task SaveTitleAsync(ChangeEventArgs e)
+ {
+ if (_movie is null) return;
+ var title = e.Value?.ToString();
+ if (string.IsNullOrWhiteSpace(title)) return;
+ _movie.Title = title;
+ await MovieApi.UpdateAsync(_movie);
+ }
+
+ private async Task RefreshReferenceAsync()
+ {
+ if (_movie?.Id is null) return;
+
+ var previousReferenceId = _movie.ReferenceId;
+ _refreshingReference = true;
+ _refreshMessage = null;
+ try
+ {
+ var refreshed = await MovieApi.RefreshReferenceAsync(_movie.Id);
+ await LoadAsync();
+
+ (_refreshMessage, _refreshMessageStyle) = string.IsNullOrEmpty(refreshed.ReferenceId)
+ ? string.IsNullOrEmpty(previousReferenceId) ? ("No match found", "neutral") : ("Unlinked - no match", "danger")
+ : refreshed.ReferenceId == previousReferenceId ? ("Already linked", "neutral") : ("Linked!", "success");
+ }
+ catch (Exception ex)
+ {
+ _refreshMessage = $"Error: {ex.Message}";
+ _refreshMessageStyle = "danger";
+ }
+ finally
+ {
+ _refreshingReference = false;
+ }
+
+ ScheduleRefreshMessageClear();
+ }
+
+ ///
+ /// The toast is a small, auto-dismissing pill next to the refresh icon, not a permanent line of text -
+ /// a token guards against an in-flight delay from a previous click clearing a newer message.
+ ///
+ private void ScheduleRefreshMessageClear()
+ {
+ var token = new object();
+ _refreshMessageToken = token;
+ _ = ClearRefreshMessageAfterDelayAsync(token);
+ }
+
+ private async Task ClearRefreshMessageAfterDelayAsync(object token)
+ {
+ await Task.Delay(RefreshMessageDuration);
+ if (!ReferenceEquals(_refreshMessageToken, token)) return;
+ _refreshMessage = null;
+ await InvokeAsync(StateHasChanged);
+ }
+
+ private async Task SaveYearAsync(ChangeEventArgs e)
+ {
+ if (_movie is null) return;
+ _movie.Year = int.TryParse(e.Value?.ToString(), out var year) ? year : null;
+ await MovieApi.UpdateAsync(_movie);
+ }
+
+ private async Task SaveNotesAsync(ChangeEventArgs e)
+ {
+ if (_movie is null) return;
+ _movie.Notes = e.Value?.ToString();
+ await MovieApi.UpdateAsync(_movie);
+ }
+}
diff --git a/src/BlazorApp/Components/Inventory/Pages/Movies.razor b/src/BlazorApp/Components/Inventory/Pages/Movies.razor
index 5363faed..6a6cd592 100644
--- a/src/BlazorApp/Components/Inventory/Pages/Movies.razor
+++ b/src/BlazorApp/Components/Inventory/Pages/Movies.razor
@@ -5,6 +5,7 @@
+
+ ★ Favorites
+ ● Owned
+ ○ Wishlist
+
Title
Year
Rating
First seen
+
- @movie.Title
+
+ @movie.Title
+
@(movie.Year > 0 ? movie.Year : "")
@movie.FirstSeenAt?.ToString("yyyy-MM-dd")
+
+ @if (movie.IsFavorite)
+ {
+ ★
+ }
+ @if (movie.WantToWatch)
+ {
+ ▶
+ }
+ @if (movie.IsOwned)
+ {
+ ●
+ }
+ @if (movie.IsWishlisted)
+ {
+ ○
+ }
+
-
-
-
-
-
-
-
Title
-
+
Year
-
+
- Genre
-
+ Rating
+
+
+
+
+
+
+
+ Year
+
+
+ @if (!string.IsNullOrEmpty(movie.ReferenceId))
+ {
+
+
Year is matched to a reference source and can't be edited here.
+
+ }
Rating
-
+
Notes
@@ -71,5 +129,24 @@
First seen at
+
diff --git a/src/BlazorApp/Components/Inventory/Pages/Movies.razor.cs b/src/BlazorApp/Components/Inventory/Pages/Movies.razor.cs
index e60a55aa..554bdf9f 100644
--- a/src/BlazorApp/Components/Inventory/Pages/Movies.razor.cs
+++ b/src/BlazorApp/Components/Inventory/Pages/Movies.razor.cs
@@ -9,16 +9,57 @@ public partial class Movies : InventoryPageBase
protected override InventoryApiClientBase Api => MovieApi;
+ private bool _favoriteFilter;
+
+ private bool _ownedFilter;
+
+ private bool _wishlistedFilter;
+
+ protected override IReadOnlyDictionary? ExtraQuery
+ {
+ get
+ {
+ var query = new Dictionary();
+ if (_favoriteFilter) query["IsFavorite"] = "true";
+ if (_ownedFilter) query["IsOwned"] = "true";
+ if (_wishlistedFilter) query["IsWishlisted"] = "true";
+ return query.Count > 0 ? query : null;
+ }
+ }
+
+ private async Task ToggleFavoriteFilterAsync()
+ {
+ _favoriteFilter = !_favoriteFilter;
+ _page = 1;
+ await LoadAsync();
+ }
+
+ private async Task ToggleOwnedFilterAsync()
+ {
+ _ownedFilter = !_ownedFilter;
+ _page = 1;
+ await LoadAsync();
+ }
+
+ private async Task ToggleWishlistedFilterAsync()
+ {
+ _wishlistedFilter = !_wishlistedFilter;
+ _page = 1;
+ await LoadAsync();
+ }
+
protected override MovieDto CloneItem(MovieDto item) => new()
{
Id = item.Id,
Title = item.Title,
Year = item.Year,
- Genre = item.Genre,
Rating = item.Rating,
Notes = item.Notes,
FirstSeenAt = item.FirstSeenAt,
- AllocineId = item.AllocineId,
- ImdbPageId = item.ImdbPageId
+ ReferenceId = item.ReferenceId,
+ IsFavorite = item.IsFavorite,
+ WantToWatch = item.WantToWatch,
+ IsOwned = item.IsOwned,
+ IsWishlisted = item.IsWishlisted
};
}
diff --git a/src/BlazorApp/Components/Inventory/Pages/MusicAlbums.razor.cs b/src/BlazorApp/Components/Inventory/Pages/MusicAlbums.razor.cs
deleted file mode 100644
index 8ff0ed0b..00000000
--- a/src/BlazorApp/Components/Inventory/Pages/MusicAlbums.razor.cs
+++ /dev/null
@@ -1,21 +0,0 @@
-using Keeptrack.WebApi.Contracts.Dto;
-using Microsoft.AspNetCore.Components;
-
-namespace Keeptrack.BlazorApp.Components.Inventory.Pages;
-
-public partial class MusicAlbums : InventoryPageBase
-{
- [Inject] private MusicAlbumApiClient MusicAlbumApi { get; set; } = null!;
-
- protected override InventoryApiClientBase Api => MusicAlbumApi;
-
- protected override MusicAlbumDto CloneItem(MusicAlbumDto item) => new()
- {
- Id = item.Id,
- Title = item.Title,
- Artist = item.Artist,
- Genre = item.Genre,
- Year = item.Year,
- Rating = item.Rating
- };
-}
diff --git a/src/BlazorApp/Components/Inventory/Pages/PlaylistDetail.razor b/src/BlazorApp/Components/Inventory/Pages/PlaylistDetail.razor
new file mode 100644
index 00000000..db62e007
--- /dev/null
+++ b/src/BlazorApp/Components/Inventory/Pages/PlaylistDetail.razor
@@ -0,0 +1,217 @@
+@page "/playlists/{Id}"
+@rendermode InteractiveServer
+@attribute [Authorize]
+@using Keeptrack.BlazorApp.Components.ReferenceData
+
+@if (_loading)
+{
+
+}
+else if (_playlist is null)
+{
+
+
◈
+
Playlist not found.
+
+}
+else
+{
+
+
+ Songs
+
+ @if (PlaylistSongs.Count == 0)
+ {
+ No songs yet - add one from an album below.
+ }
+ else
+ {
+
+ }
+
+
+}
+
+@code {
+ [Parameter] public required string Id { get; set; }
+
+ [Inject] private PlaylistApiClient PlaylistApi { get; set; } = null!;
+
+ [Inject] private SongApiClient SongApi { get; set; } = null!;
+
+ [Inject] private AlbumApiClient AlbumApi { get; set; } = null!;
+
+ [Inject] private ReferenceDataApiClient ReferenceDataApi { get; set; } = null!;
+
+ private bool _loading = true;
+ private PlaylistDto? _playlist;
+ private List _albums = [];
+ private Dictionary _songsById = [];
+ private Dictionary _albumTitlesById = [];
+ private string _selectedAlbumId = "";
+ private List _selectedAlbumTracks = [];
+
+ ///
+ /// Every song in the playlist paired with its position - a dangling SongId (the song was
+ /// deleted separately) is silently skipped rather than shown as a broken row, the same soft-reference
+ /// tolerance already used for a missing reference-link elsewhere in the app.
+ ///
+ private List<(SongDto Song, int Index)> PlaylistSongs =>
+ _playlist is null
+ ? []
+ : _playlist.SongIds
+ .Select((id, index) => (Song: _songsById.GetValueOrDefault(id), Index: index))
+ .Where(x => x.Song is not null)
+ .Select(x => (x.Song!, x.Index))
+ .ToList();
+
+ protected override async Task OnParametersSetAsync() => await LoadAsync();
+
+ private async Task LoadAsync()
+ {
+ _loading = true;
+ _playlist = await PlaylistApi.GetOneAsync(Id);
+ var songs = await SongApi.GetAsync("", 1, 5000);
+ _songsById = songs.Items.Where(s => s.Id is not null).ToDictionary(s => s.Id!, s => s);
+ var albums = await AlbumApi.GetAsync("", 1, 5000);
+ _albums = albums.Items;
+ _albumTitlesById = _albums.Where(a => a.Id is not null).ToDictionary(a => a.Id!, a => a.Title ?? "");
+ _loading = false;
+ }
+
+ private async Task SavePlaylistAsync()
+ {
+ if (_playlist is null) return;
+ await PlaylistApi.UpdateAsync(_playlist);
+ }
+
+ private async Task SaveTitleAsync(ChangeEventArgs e)
+ {
+ if (_playlist is null) return;
+ var title = e.Value?.ToString();
+ if (string.IsNullOrWhiteSpace(title)) return;
+ _playlist.Title = title;
+ await SavePlaylistAsync();
+ }
+
+ private async Task MoveUpAsync(int index)
+ {
+ if (_playlist is null || index <= 0) return;
+ (_playlist.SongIds[index - 1], _playlist.SongIds[index]) = (_playlist.SongIds[index], _playlist.SongIds[index - 1]);
+ await SavePlaylistAsync();
+ }
+
+ private async Task MoveDownAsync(int index)
+ {
+ if (_playlist is null || index >= _playlist.SongIds.Count - 1) return;
+ (_playlist.SongIds[index], _playlist.SongIds[index + 1]) = (_playlist.SongIds[index + 1], _playlist.SongIds[index]);
+ await SavePlaylistAsync();
+ }
+
+ private async Task RemoveSongAsync(int index)
+ {
+ if (_playlist is null) return;
+ _playlist.SongIds.RemoveAt(index);
+ await SavePlaylistAsync();
+ }
+
+ private async Task SelectAlbumAsync(ChangeEventArgs e)
+ {
+ _selectedAlbumId = e.Value?.ToString() ?? "";
+ await LoadSelectedAlbumTracksAsync();
+ }
+
+ private async Task LoadSelectedAlbumTracksAsync()
+ {
+ _selectedAlbumTracks = [];
+ if (string.IsNullOrEmpty(_selectedAlbumId)) return;
+
+ var album = _albums.FirstOrDefault(a => a.Id == _selectedAlbumId);
+ if (string.IsNullOrEmpty(album?.ReferenceId)) return;
+
+ var reference = await ReferenceDataApi.GetAlbumAsync(album.ReferenceId);
+ _selectedAlbumTracks = reference?.Tracks ?? [];
+ }
+
+ ///
+ /// The shared "add a real track" operation ( ) -
+ /// reuses the same if this exact track was already added elsewhere, instead of
+ /// creating a duplicate.
+ ///
+ private async Task AddTrackAsync(ReferenceTrackDto track)
+ {
+ if (_playlist is null) return;
+ var album = _albums.FirstOrDefault(a => a.Id == _selectedAlbumId);
+ if (album?.Id is null) return;
+
+ var song = await SongApi.GetOrCreateForTrackAsync(album.Id, track.Position, track.Title, track.Duration, album.Artist);
+ if (song.Id is null) return;
+
+ _songsById[song.Id] = song;
+ if (!_playlist.SongIds.Contains(song.Id))
+ {
+ _playlist.SongIds.Add(song.Id);
+ await SavePlaylistAsync();
+ }
+ }
+}
diff --git a/src/BlazorApp/Components/Inventory/Pages/Playlists.razor b/src/BlazorApp/Components/Inventory/Pages/Playlists.razor
new file mode 100644
index 00000000..790cb5fc
--- /dev/null
+++ b/src/BlazorApp/Components/Inventory/Pages/Playlists.razor
@@ -0,0 +1,51 @@
+@page "/playlists"
+@inherits InventoryPageBase
+@rendermode InteractiveServer
+@attribute [Authorize]
+
+
+
+ Title
+ Songs
+
+
+
+ @playlist.Title
+
+ @playlist.SongIds.Count
+
+
+
+
+
+
+ Title
+
+
+ Manage songs from the playlist's detail page.
+
+
diff --git a/src/BlazorApp/Components/Inventory/Pages/Playlists.razor.cs b/src/BlazorApp/Components/Inventory/Pages/Playlists.razor.cs
new file mode 100644
index 00000000..893e98c0
--- /dev/null
+++ b/src/BlazorApp/Components/Inventory/Pages/Playlists.razor.cs
@@ -0,0 +1,48 @@
+using Keeptrack.WebApi.Contracts.Dto;
+using Microsoft.AspNetCore.Components;
+
+namespace Keeptrack.BlazorApp.Components.Inventory.Pages;
+
+public partial class Playlists : InventoryPageBase
+{
+ [Inject] private PlaylistApiClient PlaylistApi { get; set; } = null!;
+
+ [Inject] private NavigationManager Nav { get; set; } = null!;
+
+ protected override InventoryApiClientBase Api => PlaylistApi;
+
+ ///
+ /// Creating a playlist only ever captures Title here (see FormTemplate ) - songs are added on
+ /// the detail page, so a successful create navigates straight there instead of closing the form and
+ /// staying on the list. Same rationale as VideoGames.razor.cs 's override.
+ ///
+ protected override async Task SaveAsync()
+ {
+ try
+ {
+ if (_form.Id is null)
+ {
+ var created = await PlaylistApi.AddAsync(_form);
+ _showForm = false;
+ Nav.NavigateTo($"/playlists/{created.Id}");
+ }
+ else
+ {
+ await PlaylistApi.UpdateAsync(_form);
+ _showForm = false;
+ await LoadAsync();
+ }
+ }
+ catch (Exception ex)
+ {
+ _error = ex.Message;
+ }
+ }
+
+ protected override PlaylistDto CloneItem(PlaylistDto item) => new()
+ {
+ Id = item.Id,
+ Title = item.Title,
+ SongIds = [.. item.SongIds]
+ };
+}
diff --git a/src/BlazorApp/Components/Inventory/Pages/TvShowDetail.razor b/src/BlazorApp/Components/Inventory/Pages/TvShowDetail.razor
new file mode 100644
index 00000000..787a033f
--- /dev/null
+++ b/src/BlazorApp/Components/Inventory/Pages/TvShowDetail.razor
@@ -0,0 +1,470 @@
+@page "/tv-shows/{Id}"
+@rendermode InteractiveServer
+@attribute [Authorize]
+@using Keeptrack.BlazorApp.Components.ReferenceData
+@using Keeptrack.BlazorApp.Components.ReferenceDataAdmin
+
+@if (_loading)
+{
+
+}
+else if (_show is null)
+{
+
+}
+else
+{
+
+
+
+ SetStateAsync(TvShowStatus.Current)'>Current
+ SetStateAsync(TvShowStatus.Finished)'>Finished
+ SetStateAsync(TvShowStatus.Stopped)'>Stopped
+
+
+ @if (string.IsNullOrEmpty(_show.ReferenceId))
+ {
+
+
+
+
+
+ }
+
+
+
+ @if (_reference?.Cast.Count > 0)
+ {
+ Cast
+
+ }
+
+ @if (_reference is not null)
+ {
+ @if (_seasons.Count == 0)
+ {
+
+
◈
+
No episode data available for this show yet.
+
+ }
+ else
+ {
+ Episodes
+
+ @foreach (var season in _seasons)
+ {
+ SelectSeason(season)">S@(season.ToString("00"))
+ }
+
+
+
+
+ @foreach (var episode in _referenceEpisodesBySeason[_selectedSeason])
+ {
+ var watched = _watchedByKey.GetValueOrDefault((episode.SeasonNumber, episode.EpisodeNumber));
+
+ ToggleEpisodeAsync(episode, (bool)e.Value!)"/>
+ E@(episode.EpisodeNumber.ToString("00"))
+ @episode.Title
+ @watched?.WatchedAt?.ToString("yyyy-MM-dd")
+
+ }
+
+ }
+ @if (_error is not null)
+ {
+ @_error
+ }
+ }
+ else
+ {
+ Add episode watched
+
+
+ @if (_unresolvedSeasons.Count == 0)
+ {
+
+
◈
+
No episodes tracked yet. This show hasn't been matched to a reference source yet, so the full episode list isn't available - add episodes manually above.
+
+ }
+ else
+ {
+
+ @foreach (var season in _unresolvedSeasons)
+ {
+ SelectSeason(season)">S@(season.ToString("00"))
+ }
+
+
+
+
+ @foreach (var episode in _unresolvedEpisodesBySeason[_selectedSeason])
+ {
+
+ E@(episode.EpisodeNumber.ToString("00"))
+ @episode.Notes
+ @episode.WatchedAt?.ToString("yyyy-MM-dd")
+
+ }
+
+ }
+ }
+}
+
+@code {
+ [Parameter] public required string Id { get; set; }
+
+ [Inject] private TvShowApiClient TvShowApi { get; set; } = null!;
+
+ [Inject] private EpisodeApiClient EpisodeApi { get; set; } = null!;
+
+ [Inject] private ReferenceDataApiClient ReferenceDataApi { get; set; } = null!;
+
+ private bool _loading = true;
+ private TvShowDto? _show;
+ private TvShowReferenceDto? _reference;
+ private Dictionary<(int Season, int Episode), EpisodeDto> _watchedByKey = new();
+ private Dictionary> _referenceEpisodesBySeason = new();
+ private List _seasons = [];
+ private Dictionary> _unresolvedEpisodesBySeason = new();
+ private List _unresolvedSeasons = [];
+ private int _selectedSeason;
+ private string? _error;
+ private static readonly TimeSpan RefreshMessageDuration = TimeSpan.FromSeconds(4);
+ private bool _refreshingReference;
+ private string? _refreshMessage;
+ private string _refreshMessageStyle = "neutral";
+ private object? _refreshMessageToken;
+
+ private int _newSeason = 1;
+ private int _newEpisode = 1;
+ private DateOnly? _newWatchedAt = DateOnly.FromDateTime(DateTime.Today);
+
+ protected override async Task OnParametersSetAsync() => await LoadAsync();
+
+ private async Task LoadAsync()
+ {
+ _loading = true;
+ _show = await TvShowApi.GetOneAsync(Id);
+ var episodes = await EpisodeApi.GetAsync(string.Empty, 1, 5000, new Dictionary { ["TvShowId"] = Id });
+
+ // reference data (synopsis, real episode titles, cast, poster) is a progressive enhancement: it
+ // may not exist yet (background match still pending, or genuinely unresolved), so the page never
+ // blocks on it - see the two very different rendering branches below.
+ _reference = string.IsNullOrEmpty(_show?.ReferenceId) ? null : await ReferenceDataApi.GetTvShowAsync(_show.ReferenceId);
+ _watchedByKey = episodes.Items.ToDictionary(e => (e.SeasonNumber, e.EpisodeNumber));
+
+ if (_reference is not null)
+ {
+ // full checklist, in natural watch-through order - an episode TMDB lists with a future air date
+ // hasn't aired yet, so it isn't "unseen and ready to watch", it doesn't exist yet from the
+ // viewer's perspective (same air-date filter WatchNextService applies for the "next episode" calc)
+ var today = DateOnly.FromDateTime(DateTime.Today);
+ _referenceEpisodesBySeason = _reference.Episodes
+ .Where(e => e.AirDate is null || e.AirDate <= today)
+ .GroupBy(e => e.SeasonNumber)
+ .ToDictionary(g => g.Key, g => g.OrderBy(e => e.EpisodeNumber).ToList());
+ _seasons = _referenceEpisodesBySeason.Keys.OrderByDescending(s => s).ToList();
+ if (!_seasons.Contains(_selectedSeason)) _selectedSeason = _seasons.Count > 0 ? _seasons[0] : 0;
+ }
+ else
+ {
+ // unresolved: only what's actually been recorded, most recently aired first
+ _unresolvedEpisodesBySeason = episodes.Items
+ .GroupBy(e => e.SeasonNumber)
+ .ToDictionary(g => g.Key, g => g.OrderByDescending(e => e.EpisodeNumber).ToList());
+ _unresolvedSeasons = _unresolvedEpisodesBySeason.Keys.OrderByDescending(s => s).ToList();
+ if (!_unresolvedSeasons.Contains(_selectedSeason)) _selectedSeason = _unresolvedSeasons.Count > 0 ? _unresolvedSeasons[0] : 0;
+ }
+
+ _loading = false;
+ }
+
+ private void SelectSeason(int season) => _selectedSeason = season;
+
+ private async Task ToggleFavoriteAsync()
+ {
+ if (_show is null) return;
+ _show.IsFavorite = !_show.IsFavorite;
+ await TvShowApi.UpdateAsync(_show);
+ }
+
+ private async Task ToggleWantToWatchAsync()
+ {
+ if (_show is null) return;
+ _show.WantToWatch = !_show.WantToWatch;
+ await TvShowApi.UpdateAsync(_show);
+ }
+
+ private async Task ToggleOwnedAsync()
+ {
+ if (_show is null) return;
+ _show.IsOwned = !_show.IsOwned;
+ await TvShowApi.UpdateAsync(_show);
+ }
+
+ private async Task ToggleWishlistedAsync()
+ {
+ if (_show is null) return;
+ _show.IsWishlisted = !_show.IsWishlisted;
+ await TvShowApi.UpdateAsync(_show);
+ }
+
+ private async Task SetRatingAsync(float rating)
+ {
+ if (_show is null) return;
+ _show.Rating = rating;
+ await TvShowApi.UpdateAsync(_show);
+ }
+
+ ///
+ /// Clicking the already-active state button clears it back to unset - otherwise sets it. Explicit
+ /// buttons instead of a click-to-cycle flag: all three options are visible and directly selectable,
+ /// nothing to guess about what a click will do.
+ ///
+ private async Task SetStateAsync(TvShowStatus state)
+ {
+ if (_show is null) return;
+ _show.State = _show.State == state ? null : state;
+ await TvShowApi.UpdateAsync(_show);
+ }
+
+ private async Task SaveTitleAsync(ChangeEventArgs e)
+ {
+ if (_show is null) return;
+ var title = e.Value?.ToString();
+ if (string.IsNullOrWhiteSpace(title)) return;
+ _show.Title = title;
+ await TvShowApi.UpdateAsync(_show);
+ }
+
+ private async Task RefreshReferenceAsync()
+ {
+ if (_show?.Id is null) return;
+
+ var previousReferenceId = _show.ReferenceId;
+ _refreshingReference = true;
+ _refreshMessage = null;
+ try
+ {
+ var refreshed = await TvShowApi.RefreshReferenceAsync(_show.Id);
+ await LoadAsync();
+
+ (_refreshMessage, _refreshMessageStyle) = string.IsNullOrEmpty(refreshed.ReferenceId)
+ ? string.IsNullOrEmpty(previousReferenceId) ? ("No match found", "neutral") : ("Unlinked - no match", "danger")
+ : refreshed.ReferenceId == previousReferenceId ? ("Already linked", "neutral") : ("Linked!", "success");
+ }
+ catch (Exception ex)
+ {
+ _refreshMessage = $"Error: {ex.Message}";
+ _refreshMessageStyle = "danger";
+ }
+ finally
+ {
+ _refreshingReference = false;
+ }
+
+ ScheduleRefreshMessageClear();
+ }
+
+ ///
+ /// The toast is a small, auto-dismissing pill next to the refresh icon, not a permanent line of text -
+ /// a token guards against an in-flight delay from a previous click clearing a newer message.
+ ///
+ private void ScheduleRefreshMessageClear()
+ {
+ var token = new object();
+ _refreshMessageToken = token;
+ _ = ClearRefreshMessageAfterDelayAsync(token);
+ }
+
+ private async Task ClearRefreshMessageAfterDelayAsync(object token)
+ {
+ await Task.Delay(RefreshMessageDuration);
+ if (!ReferenceEquals(_refreshMessageToken, token)) return;
+ _refreshMessage = null;
+ await InvokeAsync(StateHasChanged);
+ }
+
+ private async Task SaveYearAsync(ChangeEventArgs e)
+ {
+ if (_show is null) return;
+ _show.Year = int.TryParse(e.Value?.ToString(), out var year) ? year : null;
+ await TvShowApi.UpdateAsync(_show);
+ }
+
+ private async Task SaveNotesAsync(ChangeEventArgs e)
+ {
+ if (_show is null) return;
+ _show.Notes = e.Value?.ToString();
+ await TvShowApi.UpdateAsync(_show);
+ }
+
+ private bool IsSelectedSeasonFullyWatched() =>
+ _referenceEpisodesBySeason.TryGetValue(_selectedSeason, out var episodes) && episodes.Count > 0
+ && episodes.All(e => _watchedByKey.ContainsKey((e.SeasonNumber, e.EpisodeNumber)));
+
+ private async Task ToggleEpisodeAsync(ReferenceEpisodeDto episode, bool isWatched)
+ {
+ try
+ {
+ await SetEpisodeWatchedAsync(episode, isWatched);
+ _error = null;
+ await LoadAsync();
+ }
+ catch (Exception ex)
+ {
+ _error = ex.Message;
+ }
+ }
+
+ private async Task ToggleSeasonAsync(bool markWatched)
+ {
+ if (!_referenceEpisodesBySeason.TryGetValue(_selectedSeason, out var episodes)) return;
+
+ try
+ {
+ foreach (var episode in episodes)
+ {
+ await SetEpisodeWatchedAsync(episode, markWatched);
+ }
+
+ _error = null;
+ await LoadAsync();
+ }
+ catch (Exception ex)
+ {
+ _error = ex.Message;
+ }
+ }
+
+ private async Task SetEpisodeWatchedAsync(ReferenceEpisodeDto episode, bool isWatched)
+ {
+ var key = (episode.SeasonNumber, episode.EpisodeNumber);
+ var existing = _watchedByKey.GetValueOrDefault(key);
+
+ if (isWatched && existing is null)
+ {
+ await EpisodeApi.AddAsync(new EpisodeDto
+ {
+ TvShowId = Id,
+ SeasonNumber = episode.SeasonNumber,
+ EpisodeNumber = episode.EpisodeNumber,
+ WatchedAt = DateOnly.FromDateTime(DateTime.Today)
+ });
+ }
+ else if (!isWatched && existing?.Id is not null)
+ {
+ await EpisodeApi.DeleteAsync(existing.Id);
+ }
+ }
+
+ private async Task AddEpisodeAsync()
+ {
+ try
+ {
+ await EpisodeApi.AddAsync(new EpisodeDto
+ {
+ TvShowId = Id,
+ SeasonNumber = _newSeason,
+ EpisodeNumber = _newEpisode,
+ WatchedAt = _newWatchedAt
+ });
+ _error = null;
+ await LoadAsync();
+ }
+ catch (Exception ex)
+ {
+ _error = ex.Message;
+ }
+ }
+}
diff --git a/src/BlazorApp/Components/Inventory/Pages/TvShows.razor b/src/BlazorApp/Components/Inventory/Pages/TvShows.razor
index c963dbab..cb68eabe 100644
--- a/src/BlazorApp/Components/Inventory/Pages/TvShows.razor
+++ b/src/BlazorApp/Components/Inventory/Pages/TvShows.razor
@@ -1,10 +1,11 @@
-@page "/tv-shows"
+@page "/tv-shows"
@inherits InventoryPageBase
@rendermode InteractiveServer
@attribute [Authorize]
+
+ SetStateFilterAsync(null)'>All
+ SetStateFilterAsync(TvShowStatus.Current)'>Current
+ SetStateFilterAsync(TvShowStatus.Finished)'>Finished
+ SetStateFilterAsync(TvShowStatus.Stopped)'>Stopped
+ ★ Favorites
+ ● Owned
+ ○ Wishlist
+
Title
- Last episode seen
+ State
Rating
+
- @show.Title
- @show.LastEpisodeSeen
+
+ @show.Title
+
+
+ @if (show.State is not null)
+ {
+ @show.State
+ }
+
+
+ @if (show.IsFavorite)
+ {
+ ★
+ }
+ @if (show.WantToWatch)
+ {
+ ▶
+ }
+ @if (show.IsOwned)
+ {
+ ●
+ }
+ @if (show.IsWishlisted)
+ {
+ ○
+ }
+
-
-
-
+
+ Title
+
+
+
+ Year
+
+
+
+ Rating
+
+
+
Year
-
+
+ @if (!string.IsNullOrEmpty(show.ReferenceId))
+ {
+
+
Year is matched to a reference source and can't be edited here.
+
+ }
Rating
-
+
- Last episode seen
-
+ State
+
+ —
+ Current
+ Finished
+ Stopped
+
- Finished at
-
+ Last episode seen
+
Notes
+
diff --git a/src/BlazorApp/Components/Inventory/Pages/TvShows.razor.cs b/src/BlazorApp/Components/Inventory/Pages/TvShows.razor.cs
index 471c5e09..4e4f9788 100644
--- a/src/BlazorApp/Components/Inventory/Pages/TvShows.razor.cs
+++ b/src/BlazorApp/Components/Inventory/Pages/TvShows.razor.cs
@@ -1,4 +1,4 @@
-using Keeptrack.WebApi.Contracts.Dto;
+using Keeptrack.WebApi.Contracts.Dto;
using Microsoft.AspNetCore.Components;
namespace Keeptrack.BlazorApp.Components.Inventory.Pages;
@@ -9,16 +9,68 @@ public partial class TvShows : InventoryPageBase
protected override InventoryApiClientBase Api => TvShowApi;
+ private TvShowStatus? _stateFilter;
+
+ private bool _favoriteFilter;
+
+ private bool _ownedFilter;
+
+ private bool _wishlistedFilter;
+
+ protected override IReadOnlyDictionary? ExtraQuery
+ {
+ get
+ {
+ var query = new Dictionary();
+ if (_stateFilter is not null) query["State"] = _stateFilter.ToString()!;
+ if (_favoriteFilter) query["IsFavorite"] = "true";
+ if (_ownedFilter) query["IsOwned"] = "true";
+ if (_wishlistedFilter) query["IsWishlisted"] = "true";
+ return query.Count > 0 ? query : null;
+ }
+ }
+
+ private async Task SetStateFilterAsync(TvShowStatus? state)
+ {
+ _stateFilter = state;
+ _page = 1;
+ await LoadAsync();
+ }
+
+ private async Task ToggleFavoriteFilterAsync()
+ {
+ _favoriteFilter = !_favoriteFilter;
+ _page = 1;
+ await LoadAsync();
+ }
+
+ private async Task ToggleOwnedFilterAsync()
+ {
+ _ownedFilter = !_ownedFilter;
+ _page = 1;
+ await LoadAsync();
+ }
+
+ private async Task ToggleWishlistedFilterAsync()
+ {
+ _wishlistedFilter = !_wishlistedFilter;
+ _page = 1;
+ await LoadAsync();
+ }
+
protected override TvShowDto CloneItem(TvShowDto item) => new()
{
Id = item.Id,
Title = item.Title,
Rating = item.Rating,
- AllocineId = item.AllocineId,
- ImdbPageId = item.ImdbPageId,
+ ReferenceId = item.ReferenceId,
Notes = item.Notes,
- FinishedAt = item.FinishedAt,
+ State = item.State,
LastEpisodeSeen = item.LastEpisodeSeen,
- Year = item.Year
+ Year = item.Year,
+ IsFavorite = item.IsFavorite,
+ WantToWatch = item.WantToWatch,
+ IsOwned = item.IsOwned,
+ IsWishlisted = item.IsWishlisted
};
}
diff --git a/src/BlazorApp/Components/Inventory/Pages/VideoGameDetail.razor b/src/BlazorApp/Components/Inventory/Pages/VideoGameDetail.razor
new file mode 100644
index 00000000..4af24c67
--- /dev/null
+++ b/src/BlazorApp/Components/Inventory/Pages/VideoGameDetail.razor
@@ -0,0 +1,360 @@
+@page "/video-games/{Id}"
+@rendermode InteractiveServer
+@attribute [Authorize]
+@using Keeptrack.BlazorApp.Components.ReferenceData
+@using Keeptrack.BlazorApp.Components.ReferenceDataAdmin
+
+@if (_loading)
+{
+
+}
+else if (_game is null)
+{
+
+
◈
+
Video game not found.
+
+}
+else
+{
+
+
+ @if (string.IsNullOrEmpty(_game.ReferenceId))
+ {
+
+
+
+
+
+ }
+
+ @if (!string.IsNullOrEmpty(_reference?.ImageUrl))
+ {
+
+ }
+
+
+
+ Platforms
+
+ @if (_game.Platforms.Count == 0)
+ {
+ No platforms tracked yet - add one below.
+ }
+
+ @foreach (var entry in _game.Platforms)
+ {
+
+ }
+
+
+}
+
+@code {
+ [Parameter] public required string Id { get; set; }
+
+ [Inject] private VideoGameApiClient VideoGameApi { get; set; } = null!;
+
+ [Inject] private ReferenceDataApiClient ReferenceDataApi { get; set; } = null!;
+
+ private static readonly TimeSpan RefreshMessageDuration = TimeSpan.FromSeconds(4);
+
+ private bool _loading = true;
+ private VideoGameDto? _game;
+ private VideoGameReferenceDto? _reference;
+ private bool _refreshingReference;
+ private string? _refreshMessage;
+ private string _refreshMessageStyle = "neutral";
+ private object? _refreshMessageToken;
+ private string _newPlatform = "";
+ private VideoGameCopyType _newCopyType;
+
+ protected override async Task OnParametersSetAsync() => await LoadAsync();
+
+ private async Task LoadAsync()
+ {
+ _loading = true;
+ _game = await VideoGameApi.GetOneAsync(Id);
+ _reference = string.IsNullOrEmpty(_game?.ReferenceId) ? null : await ReferenceDataApi.GetVideoGameAsync(_game.ReferenceId);
+ _loading = false;
+ }
+
+ /// Every mutation on this page follows the same shape: mutate _game locally, then PUT
+ /// the whole DTO - shared here since the per-platform/per-playthrough editors below call it often.
+ private async Task SaveGameAsync()
+ {
+ if (_game is null) return;
+ await VideoGameApi.UpdateAsync(_game);
+ }
+
+ private async Task SetRatingAsync(float rating)
+ {
+ if (_game is null) return;
+ _game.Rating = rating;
+ await SaveGameAsync();
+ }
+
+ private async Task ToggleOwnedAsync()
+ {
+ if (_game is null) return;
+ _game.IsOwned = !_game.IsOwned;
+ await SaveGameAsync();
+ }
+
+ private async Task ToggleWishlistedAsync()
+ {
+ if (_game is null) return;
+ _game.IsWishlisted = !_game.IsWishlisted;
+ await SaveGameAsync();
+ }
+
+ private async Task SaveTitleAsync(ChangeEventArgs e)
+ {
+ if (_game is null) return;
+ var title = e.Value?.ToString();
+ if (string.IsNullOrWhiteSpace(title)) return;
+ _game.Title = title;
+ await SaveGameAsync();
+ }
+
+ private async Task SaveYearAsync(ChangeEventArgs e)
+ {
+ if (_game is null) return;
+ _game.Year = int.TryParse(e.Value?.ToString(), out var year) ? year : null;
+ await SaveGameAsync();
+ }
+
+ private async Task SaveNotesAsync(ChangeEventArgs e)
+ {
+ if (_game is null) return;
+ _game.Notes = e.Value?.ToString();
+ await SaveGameAsync();
+ }
+
+ private async Task AddPlatformAsync()
+ {
+ if (_game is null || string.IsNullOrEmpty(_newPlatform)) return;
+ _game.Platforms.Add(new VideoGamePlatformDto { Platform = _newPlatform, CopyType = _newCopyType });
+ _newPlatform = "";
+ _newCopyType = VideoGameCopyType.Physical;
+ await SaveGameAsync();
+ }
+
+ private async Task RemovePlatformAsync(VideoGamePlatformDto entry)
+ {
+ if (_game is null) return;
+ _game.Platforms.Remove(entry);
+ await SaveGameAsync();
+ }
+
+ private async Task SetCopyTypeAsync(VideoGamePlatformDto entry, VideoGameCopyType copyType)
+ {
+ entry.CopyType = copyType;
+ await SaveGameAsync();
+ }
+
+ ///
+ /// Clicking the already-active state button clears it back to unset - same toggle-off-on-reclick
+ /// pattern as TvShowDetail.razor 's SetStatusAsync , now applied per platform entry.
+ ///
+ private async Task SetPlatformStateAsync(VideoGamePlatformDto entry, string state)
+ {
+ entry.State = entry.State == state ? "" : state;
+ await SaveGameAsync();
+ }
+
+ private async Task ToggleFullyCompletedAsync(VideoGamePlatformDto entry)
+ {
+ entry.IsFullyCompleted = !entry.IsFullyCompleted;
+ entry.FullyCompletedAt = entry.IsFullyCompleted ? entry.FullyCompletedAt ?? DateOnly.FromDateTime(DateTime.Today) : null;
+ await SaveGameAsync();
+ }
+
+ private async Task SetFullyCompletedDateAsync(VideoGamePlatformDto entry, ChangeEventArgs e)
+ {
+ entry.FullyCompletedAt = DateOnly.TryParse(e.Value?.ToString(), out var date) ? date : null;
+ await SaveGameAsync();
+ }
+
+ private async Task AddPlaythroughAsync(VideoGamePlatformDto entry)
+ {
+ entry.Playthroughs.Add(new PlaythroughDto { Label = "" });
+ await SaveGameAsync();
+ }
+
+ private async Task RemovePlaythroughAsync(VideoGamePlatformDto entry, PlaythroughDto playthrough)
+ {
+ entry.Playthroughs.Remove(playthrough);
+ await SaveGameAsync();
+ }
+
+ private async Task UpdatePlaythroughLabelAsync(PlaythroughDto playthrough, ChangeEventArgs e)
+ {
+ playthrough.Label = e.Value?.ToString() ?? "";
+ await SaveGameAsync();
+ }
+
+ private async Task UpdatePlaythroughDateAsync(PlaythroughDto playthrough, ChangeEventArgs e)
+ {
+ playthrough.CompletedAt = DateOnly.TryParse(e.Value?.ToString(), out var date) ? date : null;
+ await SaveGameAsync();
+ }
+
+ private async Task RefreshReferenceAsync()
+ {
+ if (_game?.Id is null) return;
+
+ var previousReferenceId = _game.ReferenceId;
+ _refreshingReference = true;
+ _refreshMessage = null;
+ try
+ {
+ var refreshed = await VideoGameApi.RefreshReferenceAsync(_game.Id);
+ await LoadAsync();
+
+ (_refreshMessage, _refreshMessageStyle) = string.IsNullOrEmpty(refreshed.ReferenceId)
+ ? string.IsNullOrEmpty(previousReferenceId) ? ("No match found", "neutral") : ("Unlinked - no match", "danger")
+ : refreshed.ReferenceId == previousReferenceId ? ("Already linked", "neutral") : ("Linked!", "success");
+ }
+ catch (Exception ex)
+ {
+ _refreshMessage = $"Error: {ex.Message}";
+ _refreshMessageStyle = "danger";
+ }
+ finally
+ {
+ _refreshingReference = false;
+ }
+
+ ScheduleRefreshMessageClear();
+ }
+
+ ///
+ /// The toast is a small, auto-dismissing pill next to the refresh icon, not a permanent line of text -
+ /// a token guards against an in-flight delay from a previous click clearing a newer message.
+ ///
+ private void ScheduleRefreshMessageClear()
+ {
+ var token = new object();
+ _refreshMessageToken = token;
+ _ = ClearRefreshMessageAfterDelayAsync(token);
+ }
+
+ private async Task ClearRefreshMessageAfterDelayAsync(object token)
+ {
+ await Task.Delay(RefreshMessageDuration);
+ if (!ReferenceEquals(_refreshMessageToken, token)) return;
+ _refreshMessage = null;
+ await InvokeAsync(StateHasChanged);
+ }
+}
diff --git a/src/BlazorApp/Components/Inventory/Pages/VideoGames.razor b/src/BlazorApp/Components/Inventory/Pages/VideoGames.razor
index 721abfd2..321ddfef 100644
--- a/src/BlazorApp/Components/Inventory/Pages/VideoGames.razor
+++ b/src/BlazorApp/Components/Inventory/Pages/VideoGames.razor
@@ -5,6 +5,7 @@
+
+ SetStateFilterAsync(null)'>All
+ @foreach (var state in VideoGameStates)
+ {
+ SetStateFilterAsync(state)">@state
+ }
+ ● Owned
+ ○ Wishlist
+
Title
- Platform
- State
+ Platforms
Rating
- Finished
+
- @game.Title
- @game.Platform
- @game.State
+
+ @game.Title
+
+
+ @foreach (var entry in game.Platforms)
+ {
+
+ @entry.Platform@(string.IsNullOrEmpty(entry.State) ? "" : $" ({entry.State})")
+
+ }
+
- @game.FinishedAt?.ToString("yyyy-MM-dd")
+
+ @if (game.IsOwned)
+ {
+ ●
+ }
+ @if (game.IsWishlisted)
+ {
+ ○
+ }
+
-
- — Platform —
- Xbox Series X
- PS5
- PC
- Xbox One X
- PS4
- WII
- Xbox 360
- PS2
- PS1
-
-
- — State —
- Available
- Completed
- To resume
- Current
- On-hold
-
-
-
+
Title
-
-
-
- Platform
-
- —
- Xbox Series X
- PS5
- PC
- Xbox One X
- PS4
- WII
- Xbox 360
- PS2
- PS1
-
-
-
Year
@@ -105,13 +87,21 @@
Rating
-
- Finished at
-
-
-
+
Notes
+
+
Lists
+
+
+ Owned
+
+
+
+ Wishlist
+
+
+
Manage platforms, state and playthroughs from the game's detail page.
diff --git a/src/BlazorApp/Components/Inventory/Pages/VideoGames.razor.cs b/src/BlazorApp/Components/Inventory/Pages/VideoGames.razor.cs
index c5484b3f..fbdd569f 100644
--- a/src/BlazorApp/Components/Inventory/Pages/VideoGames.razor.cs
+++ b/src/BlazorApp/Components/Inventory/Pages/VideoGames.razor.cs
@@ -1,23 +1,117 @@
-using Keeptrack.WebApi.Contracts.Dto;
+using Keeptrack.WebApi.Contracts.Dto;
using Microsoft.AspNetCore.Components;
namespace Keeptrack.BlazorApp.Components.Inventory.Pages;
public partial class VideoGames : InventoryPageBase
{
+ /// Shared by the list's Filters buttons, the detail page's per-platform State buttons, and
+ /// the (now-removed from the list page) Add/Edit forms' old State button group.
+ internal static readonly string[] VideoGameStates = ["Available", "Current", "Completed", "To resume", "On-hold"];
+
+ /// Shared by the detail page's platform picker - previously duplicated as a literal
+ /// <select> in three places (list add form, list edit modal, detail page).
+ internal static readonly string[] VideoGamePlatforms =
+ ["Xbox Series X", "PS5", "PC", "Xbox One X", "PS4", "WII", "Xbox 360", "PS2", "PS1"];
+
+ ///
+ /// The kt-status-badge modifier class for a state value (see app.css) - same badge/color
+ /// pattern as TvShows.razor 's status column, sharing its "current" modifier for the identical
+ /// in-progress meaning and adding the three states with no TV show equivalent.
+ ///
+ internal static string StateBadgeClass(string state) => state.ToLowerInvariant().Replace(" ", "-");
+
[Inject] private VideoGameApiClient VideoGameApi { get; set; } = null!;
+ [Inject] private NavigationManager Nav { get; set; } = null!;
+
protected override InventoryApiClientBase Api => VideoGameApi;
+ private string? _stateFilter;
+
+ private bool _ownedFilter;
+
+ private bool _wishlistedFilter;
+
+ protected override IReadOnlyDictionary? ExtraQuery
+ {
+ get
+ {
+ var query = new Dictionary();
+ if (!string.IsNullOrEmpty(_stateFilter)) query["State"] = _stateFilter;
+ if (_ownedFilter) query["IsOwned"] = "true";
+ if (_wishlistedFilter) query["IsWishlisted"] = "true";
+ return query.Count > 0 ? query : null;
+ }
+ }
+
+ private async Task SetStateFilterAsync(string? state)
+ {
+ _stateFilter = state;
+ _page = 1;
+ await LoadAsync();
+ }
+
+ private async Task ToggleOwnedFilterAsync()
+ {
+ _ownedFilter = !_ownedFilter;
+ _page = 1;
+ await LoadAsync();
+ }
+
+ private async Task ToggleWishlistedFilterAsync()
+ {
+ _wishlistedFilter = !_wishlistedFilter;
+ _page = 1;
+ await LoadAsync();
+ }
+
+ ///
+ /// Creating a game only ever captures Title+Year here (see FormTemplate ) - platforms, state
+ /// and playthroughs are added on the detail page, so a successful create navigates straight there
+ /// instead of closing the form and staying on the list.
+ ///
+ protected override async Task SaveAsync()
+ {
+ try
+ {
+ if (_form.Id is null)
+ {
+ var created = await VideoGameApi.AddAsync(_form);
+ _showForm = false;
+ Nav.NavigateTo($"/video-games/{created.Id}");
+ }
+ else
+ {
+ await VideoGameApi.UpdateAsync(_form);
+ _showForm = false;
+ await LoadAsync();
+ }
+ }
+ catch (Exception ex)
+ {
+ _error = ex.Message;
+ }
+ }
+
protected override VideoGameDto CloneItem(VideoGameDto item) => new()
{
Id = item.Id,
Title = item.Title,
- Platform = item.Platform,
- State = item.State,
- FinishedAt = item.FinishedAt,
+ Platforms = item.Platforms.Select(p => new VideoGamePlatformDto
+ {
+ Platform = p.Platform,
+ CopyType = p.CopyType,
+ State = p.State,
+ Playthroughs = p.Playthroughs.Select(pt => new PlaythroughDto { Label = pt.Label, CompletedAt = pt.CompletedAt }).ToList(),
+ IsFullyCompleted = p.IsFullyCompleted,
+ FullyCompletedAt = p.FullyCompletedAt
+ }).ToList(),
Notes = item.Notes,
Rating = item.Rating,
- Year = item.Year
+ Year = item.Year,
+ ReferenceId = item.ReferenceId,
+ IsOwned = item.IsOwned,
+ IsWishlisted = item.IsWishlisted
};
}
diff --git a/src/BlazorApp/Components/Inventory/Shared/InventoryList.razor b/src/BlazorApp/Components/Inventory/Shared/InventoryList.razor
index 7824411d..ef216aeb 100644
--- a/src/BlazorApp/Components/Inventory/Shared/InventoryList.razor
+++ b/src/BlazorApp/Components/Inventory/Shared/InventoryList.razor
@@ -26,10 +26,15 @@
}
+
+
@if (ShowForm)
{
@if (Items.Count > 0)
{
@@ -86,7 +95,7 @@ else
OpenEditModal(item)">Edit
- OnDelete.InvokeAsync(item.Id!)">Del
+ RequestDelete(item)">Del
@@ -154,6 +163,7 @@ else
@code {
private bool _initialized;
+ private string? _pendingDeleteId;
private string _localSearch = "";
private bool _showModal;
private bool _mouseDownOnBackdrop;
@@ -178,6 +188,17 @@ else
await OnClearSearch.InvokeAsync();
}
+ private void RequestDelete(TDto item) => _pendingDeleteId = item.Id;
+
+ private void CancelDelete() => _pendingDeleteId = null;
+
+ private async Task ConfirmDeleteAsync()
+ {
+ var id = _pendingDeleteId;
+ _pendingDeleteId = null;
+ if (id is not null) await OnDelete.InvokeAsync(id);
+ }
+
private async Task OpenEditModal(TDto item)
{
await OnStartInlineEdit.InvokeAsync(item);
@@ -212,6 +233,7 @@ else
}
[Parameter] public required string Title { get; set; }
+ [Parameter] public string? ItemName { get; set; }
[Parameter] public required List Items { get; set; }
[Parameter] public required TDto Form { get; set; }
[Parameter] public required TDto? EditingInline { get; set; }
@@ -227,6 +249,7 @@ else
[Parameter] public required RenderFragment RowTemplate { get; set; }
[Parameter] public required RenderFragment FormTemplate { get; set; }
[Parameter] public required RenderFragment EditModalTemplate { get; set; }
+ [Parameter] public RenderFragment? Filters { get; set; }
[Parameter] public required EventCallback OnSave { get; set; }
[Parameter] public required EventCallback OnCancelForm { get; set; }
[Parameter] public required EventCallback OnShowAddForm { get; set; }
diff --git a/src/BlazorApp/Components/Inventory/Shared/StarRating.razor b/src/BlazorApp/Components/Inventory/Shared/StarRating.razor
index af9979db..4b6a5963 100644
--- a/src/BlazorApp/Components/Inventory/Shared/StarRating.razor
+++ b/src/BlazorApp/Components/Inventory/Shared/StarRating.razor
@@ -1,15 +1,25 @@
-@for (var i = 1; i <= 5; i++)
+@for (var i = 1; i <= 5; i++)
{
- ★
+ var fillPercent = GetFillPercent(i);
+
+ ★
+ @if (fillPercent > 0)
+ {
+ ★
+ }
+
}
@code {
[Parameter] public float? Rating { get; set; }
- private string GetStarClass(int position)
+ ///
+ /// How much of this star is filled, as a percentage - not just full/half/empty, so a precise
+ /// rating like 4.8 shows visually as the 5th star ~80% filled, not rounded down to a half-star look.
+ ///
+ private int GetFillPercent(int position)
{
- var rating = Rating ?? 0;
- if (rating >= position) return "full";
- return rating >= position - 0.5f ? "half" : "empty";
+ var fill = (Rating ?? 0) - (position - 1);
+ return (int)(Math.Clamp(fill, 0, 1) * 100);
}
}
diff --git a/src/BlazorApp/Components/Inventory/Shared/StarRatingInput.razor b/src/BlazorApp/Components/Inventory/Shared/StarRatingInput.razor
new file mode 100644
index 00000000..2491505d
--- /dev/null
+++ b/src/BlazorApp/Components/Inventory/Shared/StarRatingInput.razor
@@ -0,0 +1,24 @@
+
+
+@code {
+ ///
+ /// Stars are an accurate live preview (see StarRating's percentage fill) - clicking a star can only
+ /// ever express a handful of fixed positions, nowhere near enough precision for an exact rating like
+ /// 4.8, so the number field next to it is the actual editing surface.
+ ///
+ [Parameter] public float? Rating { get; set; }
+
+ [Parameter] public EventCallback RatingChanged { get; set; }
+
+ private async Task HandleChangeAsync(ChangeEventArgs e)
+ {
+ if (float.TryParse(e.Value?.ToString(), out var value))
+ {
+ await RatingChanged.InvokeAsync(MathF.Round(Math.Clamp(value, 0, 5), 1));
+ }
+ }
+}
diff --git a/src/BlazorApp/Components/Layout/MainLayout.razor b/src/BlazorApp/Components/Layout/MainLayout.razor
index e159d4bd..e35c2850 100644
--- a/src/BlazorApp/Components/Layout/MainLayout.razor
+++ b/src/BlazorApp/Components/Layout/MainLayout.razor
@@ -14,5 +14,5 @@
An unhandled error has occurred.
Reload
-
🗙
+
✕
diff --git a/src/BlazorApp/Components/Layout/NavMenu.razor b/src/BlazorApp/Components/Layout/NavMenu.razor
index 66a22a47..81bf3e5b 100644
--- a/src/BlazorApp/Components/Layout/NavMenu.razor
+++ b/src/BlazorApp/Components/Layout/NavMenu.razor
@@ -9,56 +9,107 @@
- 🏠 Home
+ ⌂ Home
+
+
+ ▶ Watch next
+
+
+
+
+ ○ Wishlist
+
+
- 📚 Books
+ ▬ Books
- 🎬 Movies
+ ◼ Movies
-
- 🎵 Music albums
+
+ ♪ Albums
+
+
+
+
+ ♫ Playlists
- 📺 TV shows
+ ▭ TV shows
- 🎮 Video games
+ ◆ Video games
+
+
+ ◉ Cars
+
+
+
+
+ ▲ Houses
+
+
+
+
+ ⬆ Import
+
+
+
+
+
+
+ ⚙ Admin
+
+
+
+
+
+
+ ☀
+ ☾
+ Toggle theme
+
+
+
+@code {
+ private static string GetInitial(string? name) =>
+ string.IsNullOrEmpty(name) ? "" : name[..1].ToUpperInvariant();
+}
diff --git a/src/BlazorApp/Components/Layout/ReconnectModal.razor.css b/src/BlazorApp/Components/Layout/ReconnectModal.razor.css
index 3ad3773f..2e8e5936 100644
--- a/src/BlazorApp/Components/Layout/ReconnectModal.razor.css
+++ b/src/BlazorApp/Components/Layout/ReconnectModal.razor.css
@@ -21,13 +21,15 @@
#components-reconnect-modal {
- background-color: white;
+ background-color: var(--kt-surface);
+ color: var(--kt-text);
width: 20rem;
margin: 20vh auto;
padding: 2rem;
- border: 0;
- border-radius: 0.5rem;
- box-shadow: 0 3px 6px 2px rgba(0, 0, 0, 0.3);
+ border: 1px solid var(--kt-border);
+ border-top: 2px solid var(--kt-accent);
+ border-radius: var(--kt-radius-lg);
+ box-shadow: var(--kt-shadow-lg);
opacity: 0;
transition: display 0.5s allow-discrete, overlay 0.5s allow-discrete;
animation: components-reconnect-modal-fadeOutOpacity 0.5s both;
@@ -41,7 +43,7 @@
}
#components-reconnect-modal::backdrop {
- background-color: rgba(0, 0, 0, 0.4);
+ background-color: rgba(0, 0, 0, 0.5);
animation: components-reconnect-modal-fadeInOpacity 0.5s ease-in-out;
opacity: 1;
}
@@ -86,22 +88,30 @@
#components-reconnect-modal p {
margin: 0;
text-align: center;
+ color: var(--kt-text-muted);
+ font-family: var(--kt-font);
+ font-size: 0.85rem;
}
#components-reconnect-modal button {
- border: 0;
- background-color: #6b9ed2;
- color: white;
- padding: 4px 24px;
- border-radius: 4px;
+ border: 1px solid var(--kt-accent);
+ background-color: var(--kt-accent);
+ color: #fff;
+ font-family: var(--kt-font);
+ font-weight: 500;
+ padding: 0.4rem 1.2rem;
+ border-radius: 8px;
+ cursor: pointer;
}
#components-reconnect-modal button:hover {
- background-color: #3b6ea2;
+ background-color: var(--kt-accent-dim);
+ border-color: var(--kt-accent-dim);
}
#components-reconnect-modal button:active {
- background-color: #6b9ed2;
+ background-color: var(--kt-accent);
+ border-color: var(--kt-accent);
}
.components-rejoining-animation {
@@ -112,7 +122,7 @@
.components-rejoining-animation div {
position: absolute;
- border: 3px solid #0087ff;
+ border: 3px solid var(--kt-accent);
opacity: 1;
border-radius: 50%;
animation: components-rejoining-animation 1.5s cubic-bezier(0, 0.2, 0.8, 1) infinite;
diff --git a/src/BlazorApp/Components/Pages/Home.razor b/src/BlazorApp/Components/Pages/Home.razor
index e3faaff5..c6448923 100644
--- a/src/BlazorApp/Components/Pages/Home.razor
+++ b/src/BlazorApp/Components/Pages/Home.razor
@@ -12,10 +12,10 @@
- 🎬 Movies
- 📺 TV Shows
- 📚 Books
- ⛽ Refueling
+ ◼ Movies
+ ▭ TV Shows
+ ▬ Books
+ ▲ Refueling
+ more
diff --git a/src/BlazorApp/Components/ReferenceData/CastGrid.razor b/src/BlazorApp/Components/ReferenceData/CastGrid.razor
new file mode 100644
index 00000000..c4b2d763
--- /dev/null
+++ b/src/BlazorApp/Components/ReferenceData/CastGrid.razor
@@ -0,0 +1,21 @@
+
+ @foreach (var member in Cast)
+ {
+
+ @if (!string.IsNullOrEmpty(member.ProfileImageUrl))
+ {
+
+ }
+ else
+ {
+
◈
+ }
+
@member.Name
+
@member.CharacterName
+
+ }
+
+
+@code {
+ [Parameter, EditorRequired] public required List Cast { get; set; }
+}
diff --git a/src/BlazorApp/Components/ReferenceData/ReferenceDataApiClient.cs b/src/BlazorApp/Components/ReferenceData/ReferenceDataApiClient.cs
new file mode 100644
index 00000000..a926d32f
--- /dev/null
+++ b/src/BlazorApp/Components/ReferenceData/ReferenceDataApiClient.cs
@@ -0,0 +1,40 @@
+using Keeptrack.WebApi.Contracts.Dto;
+
+namespace Keeptrack.BlazorApp.Components.ReferenceData;
+
+///
+/// Read-only access to the shared reference collection - not per-tenant CRUD, so this doesn't extend
+/// .
+///
+public sealed class ReferenceDataApiClient(HttpClient http)
+{
+ public async Task GetTvShowAsync(string referenceId)
+ {
+ var response = await http.GetAsync($"/api/reference-data/tv-shows/{referenceId}");
+ return response.IsSuccessStatusCode ? await response.Content.ReadFromJsonAsync() : null;
+ }
+
+ public async Task GetMovieAsync(string referenceId)
+ {
+ var response = await http.GetAsync($"/api/reference-data/movies/{referenceId}");
+ return response.IsSuccessStatusCode ? await response.Content.ReadFromJsonAsync() : null;
+ }
+
+ public async Task GetBookAsync(string referenceId)
+ {
+ var response = await http.GetAsync($"/api/reference-data/books/{referenceId}");
+ return response.IsSuccessStatusCode ? await response.Content.ReadFromJsonAsync() : null;
+ }
+
+ public async Task GetVideoGameAsync(string referenceId)
+ {
+ var response = await http.GetAsync($"/api/reference-data/video-games/{referenceId}");
+ return response.IsSuccessStatusCode ? await response.Content.ReadFromJsonAsync() : null;
+ }
+
+ public async Task GetAlbumAsync(string referenceId)
+ {
+ var response = await http.GetAsync($"/api/reference-data/albums/{referenceId}");
+ return response.IsSuccessStatusCode ? await response.Content.ReadFromJsonAsync() : null;
+ }
+}
diff --git a/src/BlazorApp/Components/ReferenceDataAdmin/InlineReferenceLinker.razor b/src/BlazorApp/Components/ReferenceDataAdmin/InlineReferenceLinker.razor
new file mode 100644
index 00000000..aa9d3c7e
--- /dev/null
+++ b/src/BlazorApp/Components/ReferenceDataAdmin/InlineReferenceLinker.razor
@@ -0,0 +1,110 @@
+@inject ReferenceDataAdminApiClient Api
+
+
+
+
+
+
+
+@code {
+ [Parameter, EditorRequired] public required ReferenceItemType Type { get; set; }
+
+ [Parameter, EditorRequired] public required string Title { get; set; }
+
+ [Parameter] public int? Year { get; set; }
+
+ ///
+ /// The book's author or the album's artist, when known - narrows the search considerably (a common
+ /// title alone often returns many unrelated candidates). Ignored for TV shows/movies/video games.
+ ///
+ [Parameter] public string? Creator { get; set; }
+
+ [Parameter] public EventCallback OnLinked { get; set; }
+
+ private bool _searched;
+ private bool _searching;
+ private bool _linking;
+ private List _results = [];
+ private string? _error;
+
+ private string ProviderName => Type switch
+ {
+ ReferenceItemType.TvShow or ReferenceItemType.Movie => "TMDB",
+ ReferenceItemType.Book => "Open Library",
+ ReferenceItemType.VideoGame => "RAWG",
+ ReferenceItemType.Album => "Discogs",
+ _ => "reference"
+ };
+
+ private async Task SearchAsync()
+ {
+ _searched = true;
+ _searching = true;
+ _results = await Api.SearchAsync(Type, Title, Year, Creator);
+ _searching = false;
+ }
+
+ private async Task LinkAsync(ReferenceSearchResultDto candidate)
+ {
+ _linking = true;
+ _error = null;
+ try
+ {
+ await Api.LinkAsync(new LinkReferenceRequestDto { Type = Type, Title = Title, Year = Year, ExternalId = candidate.ExternalId });
+ await OnLinked.InvokeAsync();
+ }
+ catch (Exception ex)
+ {
+ _error = ex.Message;
+ }
+ finally
+ {
+ _linking = false;
+ }
+ }
+}
diff --git a/src/BlazorApp/Components/ReferenceDataAdmin/ReferenceDataAdminApiClient.cs b/src/BlazorApp/Components/ReferenceDataAdmin/ReferenceDataAdminApiClient.cs
new file mode 100644
index 00000000..54fe88b4
--- /dev/null
+++ b/src/BlazorApp/Components/ReferenceDataAdmin/ReferenceDataAdminApiClient.cs
@@ -0,0 +1,75 @@
+using System.Net.Http.Headers;
+using Keeptrack.WebApi.Contracts.Dto;
+
+namespace Keeptrack.BlazorApp.Components.ReferenceDataAdmin;
+
+public sealed class ReferenceDataAdminApiClient(HttpClient http)
+{
+ public async Task> GetUnresolvedAsync(ReferenceItemType type)
+ {
+ var results = await http.GetFromJsonAsync>($"/api/reference-data/unresolved?type={type}");
+ return results ?? [];
+ }
+
+ public async Task> SearchAsync(ReferenceItemType type, string title, int? year, string? creator = null)
+ {
+ var query = $"/api/reference-data/search?type={type}&title={Uri.EscapeDataString(title)}";
+ if (year is not null) query += $"&year={year}";
+ if (!string.IsNullOrEmpty(creator)) query += $"&creator={Uri.EscapeDataString(creator)}";
+
+ var results = await http.GetFromJsonAsync>(query);
+ return results ?? [];
+ }
+
+ public async Task LinkAsync(LinkReferenceRequestDto request)
+ {
+ var response = await http.PostAsJsonAsync("/api/reference-data/link", request);
+ response.EnsureSuccessStatusCode();
+ }
+
+ ///
+ /// The full reference dataset (TV shows, movies, cast) as a zip, for seeding a fresh environment.
+ ///
+ public async Task ExportAsync()
+ {
+ var response = await http.GetAsync("/api/reference-data/export");
+ response.EnsureSuccessStatusCode();
+ return await response.Content.ReadAsByteArrayAsync();
+ }
+
+ ///
+ /// Idempotent (upsert-by-id) re-import of a previously exported zip.
+ ///
+ public async Task ImportAsync(Stream zipStream, string fileName)
+ {
+ using var content = new MultipartFormDataContent();
+ using var streamContent = new StreamContent(zipStream);
+ streamContent.Headers.ContentType = new MediaTypeHeaderValue("application/zip");
+ content.Add(streamContent, "file", fileName);
+
+ var response = await http.PostAsync("/api/reference-data/import", content);
+ response.EnsureSuccessStatusCode();
+ return await response.Content.ReadFromJsonAsync()
+ ?? new ReferenceDataImportResultDto { TvShowCount = 0, MovieCount = 0, PersonCount = 0, BookCount = 0, VideoGameCount = 0, AlbumCount = 0 };
+ }
+
+ ///
+ /// Starts an immediate re-check of every reference document against its provider (see
+ /// ReferenceDataAdminController.SyncNow ), instead of waiting for the periodic background sync.
+ /// Runs in the background; poll with the returned job id for progress.
+ ///
+ public async Task StartSyncAsync()
+ {
+ var response = await http.PostAsync("/api/reference-data/sync-now", null);
+ response.EnsureSuccessStatusCode();
+
+ var job = await response.Content.ReadFromJsonAsync();
+ return job!.JobId;
+ }
+
+ public async Task GetSyncStatusAsync(Guid jobId)
+ {
+ var status = await http.GetFromJsonAsync($"/api/reference-data/sync-now/{jobId}");
+ return status ?? new ReferenceSyncJobStatusDto { Stage = ReferenceSyncStage.Failed, ErrorMessage = "Lost track of the sync job." };
+ }
+}
diff --git a/src/BlazorApp/Components/ReferenceDataAdmin/ReferenceDataAdminPage.razor b/src/BlazorApp/Components/ReferenceDataAdmin/ReferenceDataAdminPage.razor
new file mode 100644
index 00000000..27acb507
--- /dev/null
+++ b/src/BlazorApp/Components/ReferenceDataAdmin/ReferenceDataAdminPage.razor
@@ -0,0 +1,377 @@
+@page "/admin/reference-data"
+@rendermode InteractiveServer
+@attribute [Authorize(Policy = "AdminOnly")]
+
+
+
+
+
+
+
+
+
+
+
+
+
+ @if (_unresolved.Count == 0)
+ {
+
+
◈
+
Nothing waiting on a manual match.
+
+ }
+ else
+ {
+
+
+ @foreach (var item in _unresolved)
+ {
+ SelectItemAsync(item)" style="cursor:pointer">
+ @item.Title
+ @item.Year
+
+ }
+
+
+ }
+
+
+
+
+
+
+@code {
+ private const long MaxImportFileSize = 50_000_000;
+
+ [Inject] private ReferenceDataAdminApiClient Api { get; set; } = null!;
+
+ [Inject] private IJSRuntime JsRuntime { get; set; } = null!;
+
+ private ReferenceItemType _type = ReferenceItemType.TvShow;
+ private List _unresolved = [];
+ private UnresolvedReferenceDto? _selected;
+ private List _searchResults = [];
+ private bool _searching;
+ private bool _linking;
+ private string? _error;
+ private string? _creator;
+
+ private string ProviderName => _type switch
+ {
+ ReferenceItemType.TvShow or ReferenceItemType.Movie => "TMDB",
+ ReferenceItemType.Book => "Open Library",
+ ReferenceItemType.VideoGame => "RAWG",
+ ReferenceItemType.Album => "Discogs",
+ _ => "reference"
+ };
+
+ private bool _exporting;
+ private bool _importing;
+ private ReferenceDataImportResultDto? _importResult;
+ private string? _importExportError;
+
+ private static readonly TimeSpan SyncPollInterval = TimeSpan.FromMilliseconds(600);
+
+ private bool _syncing;
+ private ReferenceSyncResultDto? _syncResult;
+ private string? _syncError;
+ private ReferenceSyncStage _syncStage;
+
+ private async Task SyncNowAsync()
+ {
+ _syncing = true;
+ _syncError = null;
+ _syncResult = null;
+ _syncStage = ReferenceSyncStage.SyncingTvShows;
+
+ try
+ {
+ var jobId = await Api.StartSyncAsync();
+
+ while (true)
+ {
+ var status = await Api.GetSyncStatusAsync(jobId);
+ _syncStage = status.Stage;
+
+ if (status.Stage == ReferenceSyncStage.Completed)
+ {
+ _syncResult = status.Result;
+ break;
+ }
+
+ if (status.Stage == ReferenceSyncStage.Failed)
+ {
+ _syncError = status.ErrorMessage ?? "The sync failed.";
+ break;
+ }
+
+ StateHasChanged();
+ await Task.Delay(SyncPollInterval);
+ }
+ }
+ catch (Exception ex)
+ {
+ _syncError = ex.Message;
+ }
+ finally
+ {
+ _syncing = false;
+ }
+ }
+
+ private static int SyncProgressPercent(ReferenceSyncStage stage) => stage switch
+ {
+ ReferenceSyncStage.SyncingTvShows => 15,
+ ReferenceSyncStage.SyncingMovies => 35,
+ ReferenceSyncStage.SyncingBooks => 55,
+ ReferenceSyncStage.SyncingVideoGames => 75,
+ ReferenceSyncStage.SyncingAlbums => 90,
+ ReferenceSyncStage.Completed => 100,
+ _ => 0
+ };
+
+ private static string SyncStageLabel(ReferenceSyncStage stage) => stage switch
+ {
+ ReferenceSyncStage.SyncingTvShows => "Checking TV shows…",
+ ReferenceSyncStage.SyncingMovies => "Checking movies…",
+ ReferenceSyncStage.SyncingBooks => "Checking books…",
+ ReferenceSyncStage.SyncingVideoGames => "Checking video games…",
+ ReferenceSyncStage.SyncingAlbums => "Checking albums…",
+ ReferenceSyncStage.Completed => "Done",
+ _ => ""
+ };
+
+ private async Task ExportAsync()
+ {
+ _exporting = true;
+ _importExportError = null;
+ try
+ {
+ var bytes = await Api.ExportAsync();
+ await JsRuntime.InvokeVoidAsync("ktDownloadFile", "keeptrack-reference-data.zip", "application/zip", Convert.ToBase64String(bytes));
+ }
+ catch (Exception ex)
+ {
+ _importExportError = ex.Message;
+ }
+ finally
+ {
+ _exporting = false;
+ }
+ }
+
+ private async Task OnImportFileSelectedAsync(InputFileChangeEventArgs e)
+ {
+ _importing = true;
+ _importExportError = null;
+ _importResult = null;
+ try
+ {
+ await using var stream = e.File.OpenReadStream(MaxImportFileSize);
+ _importResult = await Api.ImportAsync(stream, e.File.Name);
+ }
+ catch (Exception ex)
+ {
+ _importExportError = ex.Message;
+ }
+ finally
+ {
+ _importing = false;
+ }
+ }
+
+ protected override async Task OnInitializedAsync() => await LoadUnresolvedAsync();
+
+ private async Task LoadUnresolvedAsync()
+ {
+ _unresolved = await Api.GetUnresolvedAsync(_type);
+ _selected = null;
+ _searchResults = [];
+ _creator = null;
+ }
+
+ private async Task SelectTypeAsync(ReferenceItemType type)
+ {
+ _type = type;
+ await LoadUnresolvedAsync();
+ }
+
+ private async Task SelectItemAsync(UnresolvedReferenceDto item)
+ {
+ _selected = item;
+ _creator = null;
+ await SearchAsync();
+ }
+
+ ///
+ /// Re-run manually (via the "Search again" button) once the admin types an author/artist - the
+ /// unresolved queue itself has no creator field (it's a distinct (title, year) pair across every
+ /// tenant, not any one tenant's own record), so this is the only way to supply one for Book/Album.
+ ///
+ private async Task SearchAsync()
+ {
+ if (_selected is null) return;
+
+ _searching = true;
+ _error = null;
+ _searchResults = await Api.SearchAsync(_type, _selected.Title, _selected.Year, _creator);
+ _searching = false;
+ }
+
+ private async Task LinkAsync(ReferenceSearchResultDto candidate)
+ {
+ if (_selected is null) return;
+
+ _linking = true;
+ _error = null;
+ try
+ {
+ await Api.LinkAsync(new LinkReferenceRequestDto { Type = _type, Title = _selected.Title, Year = _selected.Year, ExternalId = candidate.ExternalId });
+ await LoadUnresolvedAsync();
+ }
+ catch (Exception ex)
+ {
+ _error = ex.Message;
+ }
+ finally
+ {
+ _linking = false;
+ }
+ }
+}
diff --git a/src/BlazorApp/Components/Shared/ConfirmModal.razor b/src/BlazorApp/Components/Shared/ConfirmModal.razor
new file mode 100644
index 00000000..fbcdfb72
--- /dev/null
+++ b/src/BlazorApp/Components/Shared/ConfirmModal.razor
@@ -0,0 +1,41 @@
+@if (Show)
+{
+
+}
+
+@code {
+ private bool _mouseDownOnBackdrop;
+
+ [Parameter] public required bool Show { get; set; }
+ [Parameter] public required string Title { get; set; }
+ [Parameter] public required string Message { get; set; }
+ [Parameter] public string ConfirmLabel { get; set; } = "Delete";
+ [Parameter] public required EventCallback OnConfirm { get; set; }
+ [Parameter] public required EventCallback OnCancel { get; set; }
+
+ private void HandleBackdropMouseDown() => _mouseDownOnBackdrop = true;
+
+ private async Task HandleBackdropClick()
+ {
+ if (_mouseDownOnBackdrop)
+ {
+ _mouseDownOnBackdrop = false;
+ await OnCancel.InvokeAsync();
+ }
+ _mouseDownOnBackdrop = false;
+ }
+}
diff --git a/src/BlazorApp/Components/Shared/SvgChartHelpers.cs b/src/BlazorApp/Components/Shared/SvgChartHelpers.cs
new file mode 100644
index 00000000..27d2a5b2
--- /dev/null
+++ b/src/BlazorApp/Components/Shared/SvgChartHelpers.cs
@@ -0,0 +1,161 @@
+using Microsoft.AspNetCore.Components.Rendering;
+
+namespace Keeptrack.BlazorApp.Components.Shared;
+
+///
+/// Shared SVG axis-drawing primitives for the app's hand-rolled charts (no charting library dependency for a handful of small trend/bar charts).
+/// Extracted from CarDetail.razor so HouseDetail.razor 's own yearly cost chart doesn't duplicate the same axis geometry/arrow-marker/tick-label algorithm.
+/// Deliberately limited to just axis/geometry, not the per-chart series-drawing code (line vs. bar, single vs. stacked series) -
+/// that part differs enough between consumers that it stays with each page rather than being forced into one over-generalized shared renderer.
+///
+public static class SvgChartHelpers
+{
+ ///
+ /// Plot geometry for one chart.
+ /// Not every chart shares a single fixed viewBox: a chart rendered at full row width needs a proportionally wider viewBox than a half-width one -
+ /// matching ViewWidth to actual on-screen width keeps the rendered scale (and therefore axis text/arrow/tick size) the same across every chart instead of the wider ones blowing up.
+ ///
+ public readonly record struct ChartGeometry(
+ double ViewWidth,
+ double ViewHeight,
+ double PlotLeft,
+ double PlotRight,
+ double PlotTop,
+ double PlotBottom);
+
+ public static readonly ChartGeometry HalfWidthGeometry =
+ new(ViewWidth: 300, ViewHeight: 170, PlotLeft: 40, PlotRight: 288, PlotTop: 14, PlotBottom: 132);
+
+ public static readonly ChartGeometry FullWidthGeometry =
+ new(ViewWidth: 600, ViewHeight: 170, PlotLeft: 40, PlotRight: 588, PlotTop: 14, PlotBottom: 132);
+
+ ///
+ /// Draws a graduated X/Y axis pair (arrowhead, tick marks, tick labels, axis title).
+ /// Ticks are computed by the caller, since what counts as an evenly-spaced value differs between a continuous line chart and a per-bar categorical one.
+ /// The Y-axis title is a plain horizontal caption above the axis rather than rotated sideways along it -
+ /// fine for a multi-word label like "L/100km", but a rotated single glyph like "€" reads as a completely different, garbled character, not a sideways euro sign.
+ ///
+#pragma warning disable ASP0006
+ public static void RenderAxes(
+ RenderTreeBuilder builder, ref int seq, ChartGeometry geometry, string markerId, string xAxisLabel, string yAxisLabel,
+ IReadOnlyList<(double Y, string Label)> yTicks, IReadOnlyList<(double X, string Label)> xTicks)
+ {
+ const string AxisColor = "var(--kt-text-muted)";
+ var (_, viewHeight, plotLeft, plotRight, plotTop, plotBottom) = geometry;
+
+ builder.OpenElement(seq++, "defs");
+ builder.OpenElement(seq++, "marker");
+ builder.AddAttribute(seq++, "id", markerId);
+ builder.AddAttribute(seq++, "viewBox", "0 0 8 8");
+ builder.AddAttribute(seq++, "refX", "6");
+ builder.AddAttribute(seq++, "refY", "4");
+ builder.AddAttribute(seq++, "markerWidth", "6");
+ builder.AddAttribute(seq++, "markerHeight", "6");
+ builder.AddAttribute(seq++, "orient", "auto-start-reverse");
+ builder.OpenElement(seq++, "path");
+ builder.AddAttribute(seq++, "d", "M0,0 L8,4 L0,8 Z");
+ builder.AddAttribute(seq++, "fill", AxisColor);
+ builder.CloseElement();
+ builder.CloseElement();
+ builder.CloseElement();
+
+ // Y-axis: drawn bottom-to-top so the arrowhead (marker-end) points up.
+ builder.OpenElement(seq++, "line");
+ builder.AddAttribute(seq++, "x1", plotLeft.ToString("F1"));
+ builder.AddAttribute(seq++, "y1", plotBottom.ToString("F1"));
+ builder.AddAttribute(seq++, "x2", plotLeft.ToString("F1"));
+ builder.AddAttribute(seq++, "y2", plotTop.ToString("F1"));
+ builder.AddAttribute(seq++, "stroke", AxisColor);
+ builder.AddAttribute(seq++, "stroke-width", "1");
+ builder.AddAttribute(seq++, "vector-effect", "non-scaling-stroke");
+ builder.AddAttribute(seq++, "marker-end", $"url(#{markerId})");
+ builder.CloseElement();
+
+ // X-axis: drawn left-to-right so the arrowhead points right.
+ builder.OpenElement(seq++, "line");
+ builder.AddAttribute(seq++, "x1", plotLeft.ToString("F1"));
+ builder.AddAttribute(seq++, "y1", plotBottom.ToString("F1"));
+ builder.AddAttribute(seq++, "x2", plotRight.ToString("F1"));
+ builder.AddAttribute(seq++, "y2", plotBottom.ToString("F1"));
+ builder.AddAttribute(seq++, "stroke", AxisColor);
+ builder.AddAttribute(seq++, "stroke-width", "1");
+ builder.AddAttribute(seq++, "vector-effect", "non-scaling-stroke");
+ builder.AddAttribute(seq++, "marker-end", $"url(#{markerId})");
+ builder.CloseElement();
+
+ foreach (var (y, label) in yTicks)
+ {
+ builder.OpenElement(seq++, "line");
+ builder.AddAttribute(seq++, "x1", (plotLeft - 3).ToString("F1"));
+ builder.AddAttribute(seq++, "y1", y.ToString("F1"));
+ builder.AddAttribute(seq++, "x2", plotLeft.ToString("F1"));
+ builder.AddAttribute(seq++, "y2", y.ToString("F1"));
+ builder.AddAttribute(seq++, "stroke", AxisColor);
+ builder.AddAttribute(seq++, "stroke-width", "1");
+ builder.AddAttribute(seq++, "vector-effect", "non-scaling-stroke");
+ builder.CloseElement();
+
+ builder.OpenElement(seq++, "text");
+ builder.AddAttribute(seq++, "x", (plotLeft - 5).ToString("F1"));
+ builder.AddAttribute(seq++, "y", (y + 2.5).ToString("F1"));
+ builder.AddAttribute(seq++, "text-anchor", "end");
+ builder.AddAttribute(seq++, "class", "kt-chart-axis-text");
+ builder.AddContent(seq++, label);
+ builder.CloseElement();
+ }
+
+ foreach (var (x, label) in xTicks)
+ {
+ builder.OpenElement(seq++, "line");
+ builder.AddAttribute(seq++, "x1", x.ToString("F1"));
+ builder.AddAttribute(seq++, "y1", plotBottom.ToString("F1"));
+ builder.AddAttribute(seq++, "x2", x.ToString("F1"));
+ builder.AddAttribute(seq++, "y2", (plotBottom + 3).ToString("F1"));
+ builder.AddAttribute(seq++, "stroke", AxisColor);
+ builder.AddAttribute(seq++, "stroke-width", "1");
+ builder.AddAttribute(seq++, "vector-effect", "non-scaling-stroke");
+ builder.CloseElement();
+
+ builder.OpenElement(seq++, "text");
+ builder.AddAttribute(seq++, "x", x.ToString("F1"));
+ builder.AddAttribute(seq++, "y", (plotBottom + 12).ToString("F1"));
+ builder.AddAttribute(seq++, "text-anchor", "middle");
+ builder.AddAttribute(seq++, "class", "kt-chart-axis-text");
+ builder.AddContent(seq++, label);
+ builder.CloseElement();
+ }
+
+ // Y-axis title: a plain horizontal caption in the top-left corner, naming the axis unit.
+ builder.OpenElement(seq++, "text");
+ builder.AddAttribute(seq++, "x", "2");
+ builder.AddAttribute(seq++, "y", (plotTop - 4).ToString("F1"));
+ builder.AddAttribute(seq++, "text-anchor", "start");
+ builder.AddAttribute(seq++, "class", "kt-chart-axis-title");
+ builder.AddContent(seq++, yAxisLabel);
+ builder.CloseElement();
+
+ var xTitleCenter = (plotLeft + plotRight) / 2;
+ builder.OpenElement(seq++, "text");
+ builder.AddAttribute(seq++, "x", xTitleCenter.ToString("F1"));
+ builder.AddAttribute(seq++, "y", (viewHeight - 4).ToString("F1"));
+ builder.AddAttribute(seq++, "text-anchor", "middle");
+ builder.AddAttribute(seq++, "class", "kt-chart-axis-title");
+ builder.AddContent(seq++, xAxisLabel);
+ builder.CloseElement();
+ }
+#pragma warning restore ASP0006
+
+ ///
+ /// Picks up to evenly-spaced indices from a 0-based range, always including the first and last -
+ /// shared by every chart's X-axis tick placement.
+ ///
+ public static List EvenlySpacedIndices(int total, int count)
+ {
+ if (total <= 1 || count <= 1) return [0];
+ count = Math.Min(count, total);
+ return Enumerable.Range(0, count)
+ .Select(i => i * (total - 1) / (count - 1))
+ .Distinct()
+ .ToList();
+ }
+}
diff --git a/src/BlazorApp/Components/WatchNext/WatchNextApiClient.cs b/src/BlazorApp/Components/WatchNext/WatchNextApiClient.cs
new file mode 100644
index 00000000..795a530a
--- /dev/null
+++ b/src/BlazorApp/Components/WatchNext/WatchNextApiClient.cs
@@ -0,0 +1,12 @@
+using Keeptrack.WebApi.Contracts.Dto;
+
+namespace Keeptrack.BlazorApp.Components.WatchNext;
+
+public sealed class WatchNextApiClient(HttpClient http)
+{
+ public async Task GetAsync()
+ {
+ var result = await http.GetFromJsonAsync("/api/watch-next");
+ return result ?? new WatchNextDto();
+ }
+}
diff --git a/src/BlazorApp/Components/WatchNext/WatchNextPage.razor b/src/BlazorApp/Components/WatchNext/WatchNextPage.razor
new file mode 100644
index 00000000..65ded692
--- /dev/null
+++ b/src/BlazorApp/Components/WatchNext/WatchNextPage.razor
@@ -0,0 +1,97 @@
+@page "/watch-next"
+@rendermode InteractiveServer
+@attribute [Authorize]
+
+
+
+@if (_loading)
+{
+
+}
+else
+{
+
+ SelectTab(Tab.TvShows)">
+ TV shows@_data.InProgressShows.Count
+
+ SelectTab(Tab.Movies)">
+ Movies@_data.MoviesToWatch.Count
+
+
+
+ @if (_tab == Tab.TvShows)
+ {
+
+ Current shows with a confirmed new episode ready to watch.
+
+ @if (_data.InProgressShows.Count == 0)
+ {
+
+
◈
+
Nothing new to watch. Shows need a "Current" status and a linked reference to appear here.
+
+ }
+ else
+ {
+
+ }
+ }
+ else
+ {
+ @if (_data.MoviesToWatch.Count == 0)
+ {
+
+
◈
+
Your movie watchlist is empty. Add a movie to your watchlist to see it here.
+
+ }
+ else
+ {
+
+ }
+ }
+}
+
+@code {
+ private enum Tab { TvShows, Movies }
+
+ [Inject] private WatchNextApiClient WatchNextApi { get; set; } = null!;
+
+ private bool _loading = true;
+ private WatchNextDto _data = new();
+ private Tab _tab = Tab.TvShows;
+
+ protected override async Task OnInitializedAsync()
+ {
+ _data = await WatchNextApi.GetAsync();
+ _loading = false;
+ }
+
+ private void SelectTab(Tab tab) => _tab = tab;
+}
diff --git a/src/BlazorApp/Components/Wishlist/WishlistApiClient.cs b/src/BlazorApp/Components/Wishlist/WishlistApiClient.cs
new file mode 100644
index 00000000..467e142b
--- /dev/null
+++ b/src/BlazorApp/Components/Wishlist/WishlistApiClient.cs
@@ -0,0 +1,12 @@
+using Keeptrack.WebApi.Contracts.Dto;
+
+namespace Keeptrack.BlazorApp.Components.Wishlist;
+
+public sealed class WishlistApiClient(HttpClient http)
+{
+ public async Task GetAsync()
+ {
+ var result = await http.GetFromJsonAsync("/api/wishlist");
+ return result ?? new WishlistDto();
+ }
+}
diff --git a/src/BlazorApp/Components/Wishlist/WishlistPage.razor b/src/BlazorApp/Components/Wishlist/WishlistPage.razor
new file mode 100644
index 00000000..ff9783d1
--- /dev/null
+++ b/src/BlazorApp/Components/Wishlist/WishlistPage.razor
@@ -0,0 +1,148 @@
+@page "/wishlist"
+@rendermode InteractiveServer
+@attribute [Authorize]
+
+
+
+@if (_loading)
+{
+
+}
+else
+{
+
+ SelectTab(Tab.Movies)">
+ Movies@_data.Movies.Count
+
+ SelectTab(Tab.TvShows)">
+ TV shows@_data.TvShows.Count
+
+ SelectTab(Tab.Books)">
+ Books@_data.Books.Count
+
+ SelectTab(Tab.VideoGames)">
+ Video games@_data.VideoGames.Count
+
+
+
+ @if (_tab == Tab.Movies)
+ {
+ @if (_data.Movies.Count == 0)
+ {
+
+
◈
+
No movies on your wishlist yet.
+
+ }
+ else
+ {
+
+ }
+ }
+ else if (_tab == Tab.TvShows)
+ {
+ @if (_data.TvShows.Count == 0)
+ {
+
+
◈
+
No TV shows on your wishlist yet.
+
+ }
+ else
+ {
+
+ }
+ }
+ else if (_tab == Tab.Books)
+ {
+ @if (_data.Books.Count == 0)
+ {
+
+
◈
+
No books on your wishlist yet.
+
+ }
+ else
+ {
+
+ }
+ }
+ else
+ {
+ @if (_data.VideoGames.Count == 0)
+ {
+
+
◈
+
No video games on your wishlist yet.
+
+ }
+ else
+ {
+
+ }
+ }
+}
+
+@code {
+ private enum Tab { Movies, TvShows, Books, VideoGames }
+
+ [Inject] private WishlistApiClient WishlistApi { get; set; } = null!;
+
+ private bool _loading = true;
+ private WishlistDto _data = new();
+ private Tab _tab = Tab.Movies;
+
+ protected override async Task OnInitializedAsync()
+ {
+ _data = await WishlistApi.GetAsync();
+ _loading = false;
+ }
+
+ private void SelectTab(Tab tab) => _tab = tab;
+}
diff --git a/src/BlazorApp/Components/_Imports.razor b/src/BlazorApp/Components/_Imports.razor
index 7c58a760..03a2303c 100644
--- a/src/BlazorApp/Components/_Imports.razor
+++ b/src/BlazorApp/Components/_Imports.razor
@@ -7,6 +7,7 @@
@using Keeptrack.BlazorApp.Components.Inventory.Clients
@using Keeptrack.BlazorApp.Components.Inventory.Shared
@using Keeptrack.BlazorApp.Components.Layout
+@using Keeptrack.BlazorApp.Components.Shared
@using Keeptrack.Common.System
@using Keeptrack.WebApi.Contracts.Dto
@using Microsoft.AspNetCore.Authorization
diff --git a/src/BlazorApp/DependencyInjection/InfrastructureServiceCollectionExtensions.cs b/src/BlazorApp/DependencyInjection/InfrastructureServiceCollectionExtensions.cs
index dbae8441..524da38a 100644
--- a/src/BlazorApp/DependencyInjection/InfrastructureServiceCollectionExtensions.cs
+++ b/src/BlazorApp/DependencyInjection/InfrastructureServiceCollectionExtensions.cs
@@ -5,15 +5,41 @@ internal static class InfrastructureServiceCollectionExtensions
internal static void AddWebApiHttpClient(this IServiceCollection services, string webApiBaseUrl)
{
var webApiUri = new Uri(webApiBaseUrl);
+ services.AddHttpClient(client => client.BaseAddress = webApiUri)
+ .AddHttpMessageHandler();
services.AddHttpClient(client => client.BaseAddress = webApiUri)
.AddHttpMessageHandler();
- services.AddHttpClient(client => client.BaseAddress = webApiUri)
+ 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)
+ 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)
.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)
+ .AddHttpMessageHandler();
+ services.AddHttpClient(client => client.BaseAddress = webApiUri)
+ .AddHttpMessageHandler();
+ services.AddHttpClient(client => client.BaseAddress = webApiUri)
+ .AddHttpMessageHandler();
+ services.AddHttpClient(client => client.BaseAddress = webApiUri)
+ .AddHttpMessageHandler();
}
}
diff --git a/src/BlazorApp/Program.cs b/src/BlazorApp/Program.cs
index 296b76e6..c387b332 100644
--- a/src/BlazorApp/Program.cs
+++ b/src/BlazorApp/Program.cs
@@ -12,12 +12,16 @@
options.ExpireTimeSpan = TimeSpan.FromHours(8);
options.SlidingExpiration = true;
});
+builder.Services.AddAuthorization(options =>
+{
+ options.AddPolicy("AdminOnly", policy => policy.RequireClaim("role", "admin"));
+});
builder.Services.AddControllers();
builder.Services.AddSingleton(builder.Configuration.TryGetSection("Firebase:WebAppConfiguration"));
if (FirebaseApp.DefaultInstance is null)
{
var firebaseJson = builder.Configuration.TryGetSection("Firebase:ServiceAccount");
- var googleCredential = GoogleCredential.FromJson(firebaseJson);
+ var googleCredential = CredentialFactory.FromJson(firebaseJson).ToGoogleCredential();
FirebaseApp.Create(new AppOptions { Credential = googleCredential });
}
builder.Services.AddHttpContextAccessor();
diff --git a/src/BlazorApp/wwwroot/Keeptrack.BlazorApp.lib.module.js b/src/BlazorApp/wwwroot/Keeptrack.BlazorApp.lib.module.js
new file mode 100644
index 00000000..2022a490
--- /dev/null
+++ b/src/BlazorApp/wwwroot/Keeptrack.BlazorApp.lib.module.js
@@ -0,0 +1,7 @@
+// JS initializer, auto-detected and loaded by Blazor (file name must match the assembly name).
+// Re-applies the theme after enhanced navigation, since data-bs-theme is set by client-side JS
+// (theme.js) rather than server-rendered markup, so Blazor's enhanced-navigation DOM diff would
+// otherwise drop it whenever it merges in a freshly server-rendered page.
+export function afterWebStarted(blazor) {
+ blazor.addEventListener('enhancedload', ktApplyTheme);
+}
diff --git a/src/BlazorApp/wwwroot/app.css b/src/BlazorApp/wwwroot/app.css
index ba460824..2bbca4b0 100644
--- a/src/BlazorApp/wwwroot/app.css
+++ b/src/BlazorApp/wwwroot/app.css
@@ -1,58 +1,92 @@
/* ═══════════════════════════════════════════════════════════════════
- KEEPTRACK — Cinema-grade dark theme v3
- Bootstrap 5.3.3 override layer
+ KEEPTRACK — theme layer (light + dark)
+ Bootstrap 5.3 override layer, driven by [data-bs-theme] on
═══════════════════════════════════════════════════════════════════ */
-@import url('https://fonts.googleapis.com/css2?family=Playfair+Display:ital,wght@0,400;0,600;0,700;1,400;1,600&family=DM+Sans:wght@300;400;500;600&display=swap');
-
-/* ── Variables ─────────────────────────────────────────────────────── */
+/* ── Tokens: light (default) ──────────────────────────────────────── */
:root {
- --kt-bg: #0d0f14;
- --kt-surface: #13161e;
- --kt-surface-2: #1a1e28;
- --kt-surface-3: #222636;
- --kt-border: rgba(255,255,255,0.08);
- --kt-border-hover: rgba(255,255,255,0.16);
- --kt-amber: #e8a045;
- --kt-amber-dim: #c4843a;
- --kt-amber-glow: rgba(232,160,69,0.12);
- --kt-text: #e8e6e1;
- --kt-text-muted: #7a7d8a;
- --kt-text-subtle: #3e414e;
- --kt-danger: #c0485a;
- --kt-success: #3d9970;
+ color-scheme: light;
+
+ --kt-bg: #f6f7f9;
+ --kt-surface: #ffffff;
+ --kt-surface-2: #f1f2f5;
+ --kt-surface-3: #eceef1;
+ --kt-border: rgba(15, 18, 25, 0.10);
+ --kt-border-hover: rgba(15, 18, 25, 0.18);
+ --kt-accent: #2f6fed;
+ --kt-accent-dim: #1f56c4;
+ --kt-accent-glow: rgba(47, 111, 237, 0.10);
+ --kt-text: #1a1d23;
+ --kt-text-muted: #5f6570;
+ --kt-text-subtle: #b1b6bd;
+ --kt-danger: #c73a52;
+ --kt-danger-bg: #f5dde1;
+ --kt-success: #2b8a5e;
+ --kt-success-bg: #dcefe4;
+ --kt-warning: #9a6b00;
+ --kt-warning-bg: #f7ecd2;
+ --kt-chart-secondary: #8a5fd1;
--kt-radius: 10px;
--kt-radius-lg: 14px;
- --kt-shadow: 0 4px 24px rgba(0,0,0,0.5);
- --kt-shadow-lg: 0 12px 48px rgba(0,0,0,0.7);
- --kt-font-display: 'Playfair Display', Georgia, serif;
- --kt-font-ui: 'DM Sans', system-ui, sans-serif;
- --kt-transition: 0.18s cubic-bezier(0.4, 0, 0.2, 1);
-
- /* Bootstrap overrides */
- --bs-body-bg: #0d0f14;
- --bs-body-color: #e8e6e1;
- --bs-border-color: rgba(255,255,255,0.08);
+ --kt-shadow: 0 2px 10px rgba(15, 18, 25, 0.06);
+ --kt-shadow-lg: 0 12px 36px rgba(15, 18, 25, 0.14);
+ --kt-font: system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
+ --kt-transition: 0.15s ease;
+
+ --bs-body-bg: var(--kt-bg);
+ --bs-body-color: var(--kt-text);
+ --bs-border-color: var(--kt-border);
--bs-table-bg: transparent;
- --bs-table-color: #e8e6e1;
- --bs-table-border-color: rgba(255,255,255,0.08);
+ --bs-table-color: var(--kt-text);
+ --bs-table-border-color: var(--kt-border);
--bs-table-striped-bg: transparent;
- --bs-table-hover-bg: #1a1e28;
- --bs-table-active-bg: #1a1e28;
+ --bs-table-hover-bg: var(--kt-surface-2);
+ --bs-table-active-bg: var(--kt-surface-2);
+}
+
+/* ── Tokens: dark ──────────────────────────────────────────────────── */
+:root[data-bs-theme="dark"] {
+ color-scheme: dark;
+
+ --kt-bg: #14161a;
+ --kt-surface: #1b1e24;
+ --kt-surface-2: #23262d;
+ --kt-surface-3: #2b2f37;
+ --kt-border: rgba(255, 255, 255, 0.08);
+ --kt-border-hover: rgba(255, 255, 255, 0.16);
+ --kt-accent: #5b9dff;
+ --kt-accent-dim: #3f7fe0;
+ --kt-accent-glow: rgba(91, 157, 255, 0.14);
+ --kt-text: #e6e8eb;
+ --kt-text-muted: #9aa0aa;
+ --kt-text-subtle: #565c66;
+ --kt-danger: #e0687a;
+ --kt-danger-bg: rgba(224, 104, 122, 0.16);
+ --kt-success: #4cb489;
+ --kt-success-bg: rgba(76, 180, 137, 0.16);
+ --kt-warning: #e0be5b;
+ --kt-warning-bg: rgba(224, 190, 91, 0.16);
+ --kt-chart-secondary: #b39ce8;
+ --kt-shadow: 0 4px 20px rgba(0, 0, 0, 0.35);
+ --kt-shadow-lg: 0 16px 48px rgba(0, 0, 0, 0.5);
+
+ --bs-body-bg: var(--kt-bg);
+ --bs-body-color: var(--kt-text);
+ --bs-border-color: var(--kt-border);
+ --bs-table-hover-bg: var(--kt-surface-2);
+ --bs-table-active-bg: var(--kt-surface-2);
}
/* ── Base ──────────────────────────────────────────────────────────── */
*, *::before, *::after { box-sizing: border-box; }
-html { color-scheme: dark; }
-
html, body {
height: 100%;
margin: 0;
padding: 0;
- background-color: var(--kt-bg) !important;
- color: var(--kt-text) !important;
- font-family: var(--kt-font-ui);
+ background-color: var(--kt-bg);
+ color: var(--kt-text);
+ font-family: var(--kt-font);
font-size: 15px;
font-weight: 400;
line-height: 1.6;
@@ -60,14 +94,18 @@ html, body {
}
h1, h2, h3, h4 {
- font-family: var(--kt-font-display);
+ font-family: var(--kt-font);
font-weight: 600;
- letter-spacing: -0.02em;
+ letter-spacing: -0.01em;
color: var(--kt-text);
}
-a { color: var(--kt-amber); text-decoration: none; transition: color var(--kt-transition); }
-a:hover { color: var(--kt-text); }
+a { color: var(--kt-accent); text-decoration: none; transition: color var(--kt-transition); }
+a:hover { color: var(--kt-accent-dim); }
+
+/* Routes.razor's programmatically focuses the page's h1 after
+ navigation for screen readers; suppress the resulting focus ring since h1 isn't interactive. */
+h1:focus { outline: none; }
/* ── Layout ────────────────────────────────────────────────────────── */
.page {
@@ -76,7 +114,6 @@ a:hover { color: var(--kt-text); }
background: var(--kt-bg);
}
-/* The main column fills all remaining space */
.main {
flex: 1 1 0%;
min-width: 0;
@@ -85,8 +122,6 @@ a:hover { color: var(--kt-text); }
overflow-x: hidden;
}
-/* Content area — covers both article.content (our layout) and
- div.content (Blazor default template). Centered with max-width. */
article.content,
div.content,
.content {
@@ -119,21 +154,19 @@ div.content,
border-bottom: 1px solid var(--kt-border);
}
-/* Hide the default Blazor top-row inside .main (About link etc) */
.main > .top-row { display: none; }
.navbar-brand {
- font-family: var(--kt-font-display);
- font-size: 1.25rem;
+ font-size: 1.2rem;
font-weight: 700;
color: var(--kt-text) !important;
- letter-spacing: -0.02em;
+ letter-spacing: -0.01em;
display: flex;
align-items: center;
gap: 0.5rem;
text-decoration: none !important;
}
-.navbar-brand::before { content: '◈'; color: var(--kt-amber); font-size: 0.95rem; }
+.navbar-brand::before { content: '◈'; color: var(--kt-accent); font-size: 0.95rem; }
.navbar-toggler {
display: none;
@@ -147,12 +180,10 @@ div.content,
line-height: 1;
}
-/* Hide the checkbox input, show only the label */
#nav-toggle {
display: none !important;
}
-/* Hamburger label — visible on mobile */
.navbar-toggler-label {
display: none;
background: none;
@@ -192,24 +223,37 @@ div.content,
line-height: 1.4;
}
.nav-link:hover { color: var(--kt-text) !important; background: var(--kt-surface-2) !important; }
-.nav-link.active { color: var(--kt-amber) !important; background: var(--kt-amber-glow) !important; }
-button.nav-link:hover { color: #e07070 !important; background: rgba(192,72,90,0.08) !important; }
+.nav-link.active { color: var(--kt-accent) !important; background: var(--kt-accent-glow) !important; }
+button.nav-link:hover { color: var(--kt-danger) !important; background: rgba(199, 58, 82, 0.08) !important; }
-/* nav-icon span used instead of pseudo-elements to avoid encoding issues */
.nav-icon {
width: 16px;
text-align: center;
flex-shrink: 0;
font-style: normal;
- opacity: 0.7;
+ opacity: 0.8;
}
-.nav-link-user {
+/* the signed-in user's nav row has no plain-text glyph that reads as "profile" (a person silhouette is a
+ color-emoji-only codepoint - see the Theme section in CLAUDE.md), so it's a small initial-letter avatar
+ instead of an icon - the same pattern Gmail/Slack use, and it doubles as a personalization touch */
+.kt-nav-avatar {
+ width: 20px;
+ height: 20px;
+ border-radius: 50%;
+ background: var(--kt-accent-glow);
+ color: var(--kt-accent);
+ font-size: 0.7rem;
+ font-weight: 700;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ opacity: 1;
}
.nav-link-logout:hover {
- color: #e07070 !important;
- background: rgba(192,72,90,0.08) !important;
+ color: var(--kt-danger) !important;
+ background: rgba(199, 58, 82, 0.08) !important;
}
.nav-username {
@@ -222,27 +266,49 @@ button.nav-link:hover { color: #e07070 !important; background: rgba(192,72,90,0.
.nav-divider { border-top: 1px solid var(--kt-border); margin: 0.75rem 0; }
+/* ── Theme toggle ──────────────────────────────────────────────────── */
+.kt-theme-toggle {
+ display: flex !important;
+ align-items: center;
+ gap: 0.6rem;
+ width: 100%;
+ padding: 0.5rem 0.75rem;
+ border-radius: 7px;
+ border: none;
+ background: none;
+ color: var(--kt-text-muted);
+ font-size: 0.875rem;
+ font-weight: 500;
+ font-family: var(--kt-font);
+ cursor: pointer;
+ text-align: left;
+}
+.kt-theme-toggle:hover { color: var(--kt-text); background: var(--kt-surface-2); }
+.kt-theme-toggle .nav-icon { opacity: 1; }
+[data-bs-theme="dark"] .kt-theme-toggle .kt-theme-icon-dark { display: none; }
+[data-bs-theme="light"] .kt-theme-toggle .kt-theme-icon-light { display: none; }
+:root:not([data-bs-theme]) .kt-theme-toggle .kt-theme-icon-light { display: none; }
+
/* ── Buttons ───────────────────────────────────────────────────────── */
.btn {
- font-family: var(--kt-font-ui) !important;
+ font-family: var(--kt-font) !important;
font-weight: 500 !important;
font-size: 0.85rem !important;
border-radius: 8px !important;
padding: 0.5rem 1.1rem !important;
transition: all var(--kt-transition) !important;
- letter-spacing: 0.01em;
line-height: 1.4;
}
-.btn-primary { background: var(--kt-amber) !important; color: #0d0f14 !important; border-color: var(--kt-amber) !important; }
-.btn-primary:hover { background: #f0b060 !important; border-color: #f0b060 !important; color: #0d0f14 !important; box-shadow: 0 0 0 3px rgba(232,160,69,0.22) !important; }
+.btn-primary { background: var(--kt-accent) !important; color: #fff !important; border-color: var(--kt-accent) !important; }
+.btn-primary:hover { background: var(--kt-accent-dim) !important; border-color: var(--kt-accent-dim) !important; color: #fff !important; box-shadow: 0 0 0 3px var(--kt-accent-glow) !important; }
.btn-secondary { background: var(--kt-surface-3) !important; color: var(--kt-text-muted) !important; border-color: var(--kt-border) !important; }
-.btn-secondary:hover { background: #2a2f40 !important; color: var(--kt-text) !important; border-color: var(--kt-border-hover) !important; box-shadow: none !important; }
+.btn-secondary:hover { background: var(--kt-surface-2) !important; color: var(--kt-text) !important; border-color: var(--kt-border-hover) !important; box-shadow: none !important; }
.btn-success { background: var(--kt-success) !important; color: #fff !important; border-color: var(--kt-success) !important; }
-.btn-success:hover { background: #4aad82 !important; border-color: #4aad82 !important; }
-.btn-outline-danger { background: transparent !important; color: var(--kt-danger) !important; border-color: rgba(192,72,90,0.35) !important; }
-.btn-outline-danger:hover { background: rgba(192,72,90,0.12) !important; color: #d45a6c !important; border-color: var(--kt-danger) !important; box-shadow: none !important; }
-.btn-outline-primary { background: transparent !important; color: var(--kt-amber) !important; border-color: rgba(232,160,69,0.35) !important; }
-.btn-outline-primary:hover { background: var(--kt-amber-glow) !important; color: var(--kt-amber) !important; border-color: rgba(232,160,69,0.6) !important; box-shadow: none !important; }
+.btn-success:hover { filter: brightness(1.08); border-color: var(--kt-success) !important; }
+.btn-outline-danger { background: transparent !important; color: var(--kt-danger) !important; border-color: rgba(199, 58, 82, 0.35) !important; }
+.btn-outline-danger:hover { background: rgba(199, 58, 82, 0.10) !important; color: var(--kt-danger) !important; border-color: var(--kt-danger) !important; box-shadow: none !important; }
+.btn-outline-primary { background: transparent !important; color: var(--kt-accent) !important; border-color: rgba(47, 111, 237, 0.35) !important; }
+.btn-outline-primary:hover { background: var(--kt-accent-glow) !important; color: var(--kt-accent) !important; border-color: var(--kt-accent) !important; box-shadow: none !important; }
.btn-sm { padding: 0.28rem 0.65rem !important; font-size: 0.78rem !important; border-radius: 6px !important; }
/* ── Form controls ─────────────────────────────────────────────────── */
@@ -254,20 +320,21 @@ button.nav-link:hover { color: #e07070 !important; background: rgba(192,72,90,0.
border-radius: 8px !important;
padding: 0.5rem 0.85rem !important;
font-size: 0.875rem !important;
- font-family: var(--kt-font-ui) !important;
+ font-family: var(--kt-font) !important;
transition: border-color var(--kt-transition), box-shadow var(--kt-transition) !important;
}
.form-control:focus, .form-select:focus {
background-color: var(--kt-surface-3) !important;
- border-color: var(--kt-amber) !important;
+ border-color: var(--kt-accent) !important;
color: var(--kt-text) !important;
- box-shadow: 0 0 0 3px rgba(232,160,69,0.12) !important;
+ box-shadow: 0 0 0 3px var(--kt-accent-glow) !important;
outline: none !important;
}
.form-control::placeholder { color: var(--kt-text-subtle) !important; }
.form-select option { background: var(--kt-surface-3); color: var(--kt-text); }
.form-control-sm, .form-select-sm { padding: 0.3rem 0.65rem !important; font-size: 0.82rem !important; border-radius: 6px !important; }
-.form-label { font-size: 0.72rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.07em; color: var(--kt-text-muted); margin-bottom: 0.35rem; }
+.form-label { font-size: 0.72rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.06em; color: var(--kt-text-muted); margin-bottom: 0.35rem; }
+.form-check-input:checked { background-color: var(--kt-accent) !important; border-color: var(--kt-accent) !important; }
/* ── TABLE ─────────────────────────────────────────────────────────── */
.table {
@@ -283,7 +350,6 @@ button.nav-link:hover { color: #e07070 !important; background: rgba(192,72,90,0.
border-spacing: 0 !important;
}
-/* Override Bootstrap's per-cell bg injection */
.table > :not(caption) > * > * {
background-color: transparent !important;
color: var(--kt-text) !important;
@@ -297,7 +363,7 @@ button.nav-link:hover { color: #e07070 !important; background: rgba(192,72,90,0.
font-size: 0.7rem !important;
font-weight: 600 !important;
text-transform: uppercase !important;
- letter-spacing: 0.1em !important;
+ letter-spacing: 0.08em !important;
color: var(--kt-text-muted) !important;
border-bottom: 1px solid var(--kt-border) !important;
white-space: nowrap;
@@ -307,9 +373,9 @@ button.nav-link:hover { color: #e07070 !important; background: rgba(192,72,90,0.
.table tbody tr:hover td { background-color: var(--kt-surface-2) !important; }
.table tbody tr.kt-editing td {
- background-color: rgba(232,160,69,0.04) !important;
- border-top: 1px solid rgba(232,160,69,0.2) !important;
- border-bottom: 1px solid rgba(232,160,69,0.2) !important;
+ background-color: var(--kt-accent-glow) !important;
+ border-top: 1px solid var(--kt-accent) !important;
+ border-bottom: 1px solid var(--kt-accent) !important;
}
.kt-table-wrap {
@@ -321,7 +387,6 @@ button.nav-link:hover { color: #e07070 !important; background: rgba(192,72,90,0.
width: 100%;
}
-/* Header bar inside the table card — title left, button right */
.kt-table-header {
display: flex;
align-items: center;
@@ -331,13 +396,13 @@ button.nav-link:hover { color: #e07070 !important; background: rgba(192,72,90,0.
}
.kt-table-title {
- font-size: 1.75rem;
+ font-size: 1.6rem;
margin: 0;
line-height: 1;
padding-bottom: 1.25rem;
}
-.kt-table-title span { color: var(--kt-amber); }
+.kt-table-title span { color: var(--kt-accent); }
/* ── Page header ───────────────────────────────────────────────────── */
.kt-page-header {
@@ -348,46 +413,244 @@ button.nav-link:hover { color: #e07070 !important; background: rgba(192,72,90,0.
gap: 1rem;
flex-wrap: wrap;
}
-.kt-page-header h1 { font-size: 2.2rem; color: var(--kt-text); margin: 0; line-height: 1; }
-.kt-page-header h1 span { color: var(--kt-amber); }
+.kt-page-header h1 { font-size: 2rem; color: var(--kt-text); margin: 0; line-height: 1; }
+.kt-page-header h1 span { color: var(--kt-accent); }
+
+/* editable "title" input on a detail page, styled to read as a heading until you interact with it.
+ flex/min-width let it fill its row (rather than a fixed browser-default input width), so a button placed
+ right after it (e.g. the reference-refresh icon) sits at a predictable spot instead of trailing right
+ after wherever the visible text happens to end. */
+.kt-title-input {
+ font-size: 2rem;
+ font-weight: 700;
+ color: var(--kt-text);
+ background: transparent;
+ border: 1px solid transparent;
+ border-radius: var(--kt-radius);
+ padding: 0.1rem 0.5rem;
+ margin: 0 0 0 -0.5rem;
+ max-width: 100%;
+ flex: 1 1 auto;
+ min-width: 0;
+ font-family: var(--kt-font);
+}
+.kt-title-input:hover:not(:disabled) { border-color: var(--kt-border); }
+.kt-title-input:focus { border-color: var(--kt-accent); outline: none; background: var(--kt-surface); }
+.kt-title-input:disabled { cursor: not-allowed; }
+
+/* small icon-only button next to the title (e.g. "check for reference match") - borderless/transparent
+ until hover, matching the title input's own minimal look, instead of standing out as a heavy bordered button */
+.kt-icon-btn {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 2.25rem;
+ height: 2.25rem;
+ flex-shrink: 0;
+ border: none;
+ background: transparent;
+ border-radius: var(--kt-radius);
+ color: var(--kt-text-muted);
+ font-size: 1.2rem;
+ cursor: pointer;
+}
+.kt-icon-btn:hover:not(:disabled) { background: var(--kt-surface-3); color: var(--kt-text); }
+.kt-icon-btn:disabled { cursor: not-allowed; opacity: 0.5; }
+
+/* small transient pill next to an inline action button (e.g. the refresh-reference result), instead of a
+ plain unstyled paragraph that reads like leftover debug output. Same pill shape as .kt-status-badge, with
+ generic semantic colors instead of TvShowStatus-specific ones. Auto-dismissed from code-behind after a
+ few seconds - see RefreshReferenceAsync in MovieDetail.razor/TvShowDetail.razor. */
+.kt-inline-toast {
+ display: inline-flex;
+ align-items: center;
+ padding: 0.2rem 0.6rem;
+ font-size: 0.75rem;
+ font-weight: 600;
+ border-radius: 20px;
+ white-space: nowrap;
+ animation: kt-toast-in 0.15s ease-out;
+}
+.kt-inline-toast.success { background: var(--kt-success-bg); color: var(--kt-success); }
+.kt-inline-toast.neutral { background: var(--kt-surface-3); color: var(--kt-text-muted); }
+.kt-inline-toast.danger { background: var(--kt-danger-bg); color: var(--kt-danger); }
+@keyframes kt-toast-in { from { opacity: 0; transform: translateY(-2px); } to { opacity: 1; transform: translateY(0); } }
+
+/* small "more options" popup anchored under a kt-icon-btn (e.g. a track row's "⋮" menu) - the row it's
+ anchored to needs position:relative so this absolute box lands directly under the button, not the page. */
+.kt-menu {
+ position: absolute;
+ right: 0;
+ top: 100%;
+ z-index: 10;
+ background: var(--kt-surface);
+ border: 1px solid var(--kt-border);
+ border-radius: var(--kt-radius-lg);
+ box-shadow: var(--kt-shadow-lg);
+ padding: 0.4rem;
+ min-width: 180px;
+}
+.kt-menu-item {
+ display: block;
+ width: 100%;
+ text-align: left;
+ background: transparent;
+ border: none;
+ padding: 0.4rem 0.6rem;
+ border-radius: var(--kt-radius);
+ color: var(--kt-text);
+ font-size: 0.85rem;
+ cursor: pointer;
+}
+.kt-menu-item:hover { background: var(--kt-surface-3); }
+
+/* Album/Book/VideoGame detail hero: cover + fields side by side. A fixed grid (not flex-wrap) so the
+ layout never depends on how much horizontal space the field values happen to need - relying on
+ flex-wrap's intrinsic-sizing heuristics caused the cover to intermittently land alone on its own row
+ for some albums (long genre text, etc.) while others stayed side-by-side, with no clear pattern from
+ the markup alone. */
+.kt-album-hero {
+ display: grid;
+ grid-template-columns: 300px minmax(0, 480px);
+ gap: 1.5rem;
+ align-items: start;
+}
+.kt-album-hero.kt-album-hero-no-cover { grid-template-columns: minmax(0, 480px); }
+.kt-album-cover {
+ width: 300px;
+ height: 300px;
+ object-fit: cover;
+ border-radius: var(--kt-radius-lg);
+}
+@media (max-width: 767px) {
+ .kt-album-hero, .kt-album-hero.kt-album-hero-no-cover { grid-template-columns: 1fr; }
+ .kt-album-cover { width: 200px; height: 200px; }
+}
+
+/* compact aligned row for a list of tracks/songs inside a .kt-form-card (e.g. a playlist's song list) -
+ a CSS grid so title/artist/album line up in columns across rows without resorting to a full
+ .kt-table-wrap/, which reads as a top-level list page rather than a detail-page sub-section. */
+.kt-track-row {
+ display: grid;
+ grid-template-columns: 1fr 180px 180px auto;
+ align-items: center;
+ gap: 0.75rem;
+ padding: 0.55rem 0;
+ border-top: 1px solid var(--kt-border);
+}
+.kt-track-row:first-of-type { border-top: none; }
/* ── Form card ─────────────────────────────────────────────────────── */
.kt-form-card {
+ position: relative;
background: var(--kt-surface);
border: 1px solid var(--kt-border);
- border-top: 1px solid rgba(232,160,69,0.3);
+ border-top: 2px solid var(--kt-accent);
border-radius: var(--kt-radius-lg);
padding: 1.5rem 1.75rem;
margin-bottom: 1.75rem;
box-shadow: var(--kt-shadow);
}
.kt-form-card h5 {
- font-family: var(--kt-font-display);
- font-style: italic;
- color: var(--kt-amber);
- font-size: 0.9rem;
+ color: var(--kt-accent);
+ font-size: 0.85rem;
font-weight: 600;
margin-bottom: 1.25rem;
text-transform: uppercase;
- letter-spacing: 0.08em;
+ letter-spacing: 0.06em;
+}
+
+/* corner flag: e.g. "watched" status on a movie/show presentation card */
+.kt-corner-flag {
+ position: absolute;
+ top: 0;
+ right: 0;
+ padding: 0.35rem 0.9rem;
+ font-size: 0.75rem;
+ font-weight: 600;
+ border-radius: 0 var(--kt-radius-lg) 0 var(--kt-radius);
+ cursor: pointer;
+ background: var(--kt-surface-3);
+ color: var(--kt-text-muted);
+ transition: background var(--kt-transition), color var(--kt-transition);
+ user-select: none;
+}
+.kt-corner-flag:hover { background: var(--kt-border-hover); }
+.kt-corner-flag.active {
+ background: var(--kt-success-bg);
+ color: var(--kt-success);
+}
+
+/* favorite/watchlist toggle buttons on a detail page: a soft tinted "selected" state instead of the
+ solid btn-primary blue/white block, which read as unnecessarily aggressive, especially in dark mode -
+ same accent-glow treatment already used for .kt-page-btn.active. */
+.kt-toggle-btn {
+ background: var(--kt-surface-3) !important;
+ color: var(--kt-text-muted) !important;
+ border: 1px solid var(--kt-border) !important;
+}
+.kt-toggle-btn:hover { background: var(--kt-surface-2) !important; color: var(--kt-text) !important; }
+.kt-toggle-btn.active {
+ background: var(--kt-accent-glow) !important;
+ color: var(--kt-accent) !important;
+ border-color: rgba(47, 111, 237, 0.35) !important;
}
+.kt-toggle-btn.active:hover { background: var(--kt-accent-glow) !important; }
+
+/* Status/state pill for a list column (TV show status, video game state) - setting the value itself
+ happens via the explicit buttons on the detail page, not by clicking this) */
+.kt-status-badge {
+ display: inline-flex;
+ align-items: center;
+ padding: 0.2rem 0.6rem;
+ font-size: 0.75rem;
+ font-weight: 600;
+ border-radius: 20px;
+}
+.kt-status-badge.current { background: var(--kt-warning-bg); color: var(--kt-warning); }
+.kt-status-badge.finished { background: var(--kt-success-bg); color: var(--kt-success); }
+.kt-status-badge.stopped { background: var(--kt-danger-bg); color: var(--kt-danger); }
+/* video game states share "current" above (same in-progress meaning); the remaining three have no
+ TV show equivalent, so get their own modifiers here. */
+.kt-status-badge.completed { background: var(--kt-success-bg); color: var(--kt-success); }
+.kt-status-badge.to-resume { background: var(--kt-warning-bg); color: var(--kt-warning); }
+.kt-status-badge.on-hold { background: var(--kt-danger-bg); color: var(--kt-danger); }
+.kt-status-badge.available { background: var(--kt-surface-2); color: var(--kt-text-muted); }
+
+/* ── Progress bar ──────────────────────────────────────────────────── */
+.progress { background-color: var(--kt-surface-3) !important; border-radius: 999px !important; }
+.progress-bar { background-color: var(--kt-accent) !important; transition: width 0.3s ease !important; }
/* ── Alerts ────────────────────────────────────────────────────────── */
.alert { border-radius: var(--kt-radius) !important; font-size: 0.875rem; padding: 0.8rem 1.1rem !important; }
-.alert-danger { background: rgba(192,72,90,0.1) !important; border-color: rgba(192,72,90,0.3) !important; color: #e07080 !important; }
-.alert-info { background: rgba(232,160,69,0.06) !important; border-color: rgba(232,160,69,0.18) !important; color: var(--kt-text-muted) !important; }
+.alert-danger { background: rgba(199, 58, 82, 0.10) !important; border-color: rgba(199, 58, 82, 0.3) !important; color: var(--kt-danger) !important; }
+.alert-info { background: var(--kt-accent-glow) !important; border-color: rgba(47, 111, 237, 0.2) !important; color: var(--kt-text-muted) !important; }
/* ── Loading / empty ───────────────────────────────────────────────── */
.kt-loading { display: flex; align-items: center; gap: 0.75rem; color: var(--kt-text-muted); font-size: 0.9rem; padding: 4rem 0; justify-content: center; }
-.kt-spinner { width: 18px; height: 18px; border: 2px solid var(--kt-surface-3); border-top-color: var(--kt-amber); border-radius: 50%; animation: spin 0.75s linear infinite; flex-shrink: 0; }
+.kt-spinner { width: 18px; height: 18px; border: 2px solid var(--kt-surface-3); border-top-color: var(--kt-accent); border-radius: 50%; animation: spin 0.75s linear infinite; flex-shrink: 0; }
@keyframes spin { to { transform: rotate(360deg); } }
+
+/* plain monochrome glyph (not a color emoji) for a small inline action button, e.g. "check for reference
+ match" - spins in place using the same animation as .kt-spinner while the action is running */
+.kt-icon-spin { display: inline-block; animation: spin 0.75s linear infinite; }
.kt-empty { text-align: center; padding: 5rem 2rem; }
.kt-empty-icon { font-size: 2.5rem; color: var(--kt-text-subtle); display: block; margin-bottom: 1rem; }
.kt-empty p { font-size: 0.9rem; color: var(--kt-text-muted); margin: 0; line-height: 1.8; }
/* ── Stars ─────────────────────────────────────────────────────────── */
-.kt-stars { color: var(--kt-amber); letter-spacing: 0; font-size: 0.85rem; }
-.kt-stars .empty { color: var(--kt-text-subtle); }
+.kt-stars { color: var(--kt-accent); letter-spacing: 0; font-size: 0.85rem; }
+
+/* each star is the empty glyph with a precisely-widthed filled glyph overlaid on top, so any decimal
+ rating (e.g. 4.8) renders as an accurate partial fill, not rounded to the nearest half star */
+.star { position: relative; display: inline-block; font-size: 1.2rem; color: var(--kt-text-subtle); }
+.star-fill {
+ position: absolute;
+ left: 0;
+ top: 0;
+ overflow: hidden;
+ color: var(--kt-accent);
+}
/* ── Home ──────────────────────────────────────────────────────────── */
.kt-home {
@@ -400,9 +663,9 @@ button.nav-link:hover { color: #e07070 !important; background: rgba(192,72,90,0.
margin-right: auto;
padding-top: 5vh;
}
-.kt-home-eyebrow { font-size: 0.72rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.2em; color: var(--kt-amber); margin-bottom: 1.1rem; }
-.kt-home h1 { font-size: clamp(2.4rem, 4.5vw, 3.8rem); line-height: 1.12; margin-bottom: 1.25rem; }
-.kt-home h1 em { font-style: italic; color: var(--kt-amber); }
+.kt-home-eyebrow { font-size: 0.72rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.16em; color: var(--kt-accent); margin-bottom: 1.1rem; }
+.kt-home h1 { font-size: clamp(2.2rem, 4.2vw, 3.4rem); line-height: 1.15; margin-bottom: 1.25rem; }
+.kt-home h1 em { font-style: normal; color: var(--kt-accent); }
.kt-home-sub { font-size: 1rem; color: var(--kt-text-muted); line-height: 1.75; margin-bottom: 2.5rem; }
.kt-home-cta { display: flex; gap: 0.75rem; flex-wrap: wrap; align-items: center; }
@@ -422,7 +685,7 @@ button.nav-link:hover { color: #e07070 !important; background: rgba(192,72,90,0.
border: 1px solid var(--kt-border);
border-radius: 20px;
padding: 0.3rem 0.85rem;
- letter-spacing: 0.02em;
+ letter-spacing: 0.01em;
}
/* ── Login ─────────────────────────────────────────────────────────── */
@@ -437,7 +700,7 @@ button.nav-link:hover { color: #e07070 !important; background: rgba(192,72,90,0.
.kt-login-card {
background: var(--kt-surface);
border: 1px solid var(--kt-border);
- border-top: 2px solid var(--kt-amber);
+ border-top: 2px solid var(--kt-accent);
border-radius: var(--kt-radius-lg);
padding: 2.25rem 2rem 2rem;
width: 100%;
@@ -445,8 +708,8 @@ button.nav-link:hover { color: #e07070 !important; background: rgba(192,72,90,0.
box-shadow: var(--kt-shadow-lg);
}
-.kt-login-logo { font-family: var(--kt-font-display); font-size: 1.5rem; font-weight: 700; color: var(--kt-text); margin-bottom: 0.35rem; display: flex; align-items: center; gap: 0.45rem; }
-.kt-login-logo::before { content: '◈'; color: var(--kt-amber); font-size: 1rem; }
+.kt-login-logo { font-size: 1.4rem; font-weight: 700; color: var(--kt-text); margin-bottom: 0.35rem; display: flex; align-items: center; gap: 0.45rem; }
+.kt-login-logo::before { content: '◈'; color: var(--kt-accent); font-size: 1rem; }
.kt-login-tagline { font-size: 0.82rem; color: var(--kt-text-muted); margin-bottom: 1.75rem; }
/* OAuth buttons — flex, icon left, text centered in remaining space */
@@ -461,7 +724,7 @@ button.nav-link:hover { color: #e07070 !important; background: rgba(192,72,90,0.
margin-bottom: 0.65rem;
transition: all var(--kt-transition);
cursor: pointer;
- font-family: var(--kt-font-ui);
+ font-family: var(--kt-font);
border: 1px solid var(--kt-border);
background: var(--kt-surface-2);
color: var(--kt-text);
@@ -470,7 +733,6 @@ button.nav-link:hover { color: #e07070 !important; background: rgba(192,72,90,0.
position: relative;
}
-/* Icon sits at fixed left position */
.btn-oauth svg,
.btn-oauth img {
position: absolute;
@@ -478,7 +740,6 @@ button.nav-link:hover { color: #e07070 !important; background: rgba(192,72,90,0.
flex-shrink: 0;
}
-/* Label fills the button and centers itself */
.btn-oauth span.btn-label {
flex: 1;
text-align: center;
@@ -493,9 +754,10 @@ button.nav-link:hover { color: #e07070 !important; background: rgba(192,72,90,0.
::-webkit-scrollbar-thumb { background: var(--kt-surface-3); border-radius: 10px; }
/* ── Blazor internals ──────────────────────────────────────────────── */
-#blazor-error-ui { background: var(--kt-surface); border-top: 1px solid var(--kt-border); color: var(--kt-text-muted); font-family: var(--kt-font-ui); font-size: 0.82rem; padding: 0.75rem 1.25rem; position: fixed; bottom: 0; width: 100%; z-index: 1000; display: none; }
+#blazor-error-ui { background: var(--kt-surface); border-top: 1px solid var(--kt-border); color: var(--kt-text-muted); font-family: var(--kt-font); font-size: 0.82rem; padding: 0.75rem 1.25rem; position: fixed; bottom: 0; width: 100%; z-index: 1000; display: none; }
#blazor-error-ui .dismiss { cursor: pointer; float: right; font-weight: 600; }
-#components-reconnect-modal { background: var(--kt-surface); border: 1px solid var(--kt-border); border-radius: var(--kt-radius); padding: 0.85rem 1.25rem; position: fixed; bottom: 1.5rem; right: 1.5rem; color: var(--kt-text-muted); font-size: 0.82rem; z-index: 1001; box-shadow: var(--kt-shadow-lg); }
+/* Reconnect modal theming lives in Components/Layout/ReconnectModal.razor.css - it's Blazor CSS
+ isolation, so a scoped selector there always wins the cascade over anything declared here. */
/* ── Responsive ────────────────────────────────────────────────────── */
@media (max-width: 767px) {
@@ -506,7 +768,7 @@ button.nav-link:hover { color: #e07070 !important; background: rgba(192,72,90,0.
.nav-scrollable { display: none; }
#nav-toggle:checked ~ .nav-scrollable { display: block; }
article.content { padding: 1.5rem 1rem; }
- .kt-page-header h1 { font-size: 1.75rem; }
+ .kt-page-header h1 { font-size: 1.6rem; }
.kt-table-wrap { border-radius: var(--kt-radius); }
.table thead { display: none; }
.table, .table tbody, .table tr, .table td { display: block; width: 100%; }
@@ -549,6 +811,17 @@ button.nav-link:hover { color: #e07070 !important; background: rgba(192,72,90,0.
pointer-events: 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-search-filters {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ flex-wrap: wrap;
+ margin-left: auto;
+}
+
/* ── Pagination ────────────────────────────────────────────────────── */
.kt-pagination {
display: flex;
@@ -576,7 +849,7 @@ button.nav-link:hover { color: #e07070 !important; background: rgba(192,72,90,0.
border: 1px solid var(--kt-border);
background: transparent;
color: var(--kt-text-muted);
- font-family: var(--kt-font-ui);
+ font-family: var(--kt-font);
font-size: 0.82rem;
font-weight: 500;
cursor: pointer;
@@ -593,9 +866,9 @@ button.nav-link:hover { color: #e07070 !important; background: rgba(192,72,90,0.
}
.kt-page-btn.active {
- background: var(--kt-amber-glow);
- border-color: rgba(232,160,69,0.4);
- color: var(--kt-amber);
+ background: var(--kt-accent-glow);
+ border-color: rgba(47, 111, 237, 0.4);
+ color: var(--kt-accent);
font-weight: 600;
}
@@ -610,29 +883,11 @@ button.nav-link:hover { color: #e07070 !important; background: rgba(192,72,90,0.
color: var(--kt-text-subtle);
}
-/* ── Stars ────────────────────────────────────────────────────── */
-.star { font-size: 1.2rem; }
-.star.full { color: gold; }
-.star.empty { color: lightgray; }
-.star.half {
- position: relative;
- display: inline-block;
- color: lightgray;
-}
-.star.half::before {
- content: '★';
- position: absolute;
- left: 0;
- width: 50%;
- overflow: hidden;
- color: gold;
-}
-
/* ── Modal ─────────────────────────────────────────────────────────── */
.kt-modal-backdrop {
position: fixed;
inset: 0;
- background: rgba(0, 0, 0, 0.65);
+ background: rgba(10, 12, 16, 0.5);
backdrop-filter: blur(3px);
z-index: 500;
display: flex;
@@ -645,7 +900,7 @@ button.nav-link:hover { color: #e07070 !important; background: rgba(192,72,90,0.
.kt-modal {
background: var(--kt-surface);
border: 1px solid var(--kt-border);
- border-top: 2px solid var(--kt-amber);
+ border-top: 2px solid var(--kt-accent);
border-radius: var(--kt-radius-lg);
box-shadow: var(--kt-shadow-lg);
width: 100%;
@@ -666,14 +921,12 @@ button.nav-link:hover { color: #e07070 !important; background: rgba(192,72,90,0.
}
.kt-modal-header h5 {
- font-family: var(--kt-font-display);
- font-style: italic;
- color: var(--kt-amber);
- font-size: 0.9rem;
+ color: var(--kt-accent);
+ font-size: 0.85rem;
font-weight: 600;
margin: 0;
text-transform: uppercase;
- letter-spacing: 0.08em;
+ letter-spacing: 0.06em;
}
.kt-modal-close {
@@ -707,13 +960,228 @@ button.nav-link:hover { color: #e07070 !important; background: rgba(192,72,90,0.
@keyframes kt-fade-in { from { opacity: 0; } to { opacity: 1; } }
@keyframes kt-slide-up { from { transform: translateY(12px); opacity: 0; } to { transform: translateY(0); opacity: 1; } }
-/* Mobile: slide up from bottom as a sheet */
@media (max-width: 767px) {
.kt-modal-backdrop { align-items: flex-end; padding: 0; }
.kt-modal {
max-width: 100%;
max-height: 95vh;
border-radius: var(--kt-radius-lg) var(--kt-radius-lg) 0 0;
- border-top: 2px solid var(--kt-amber);
+ border-top: 2px solid var(--kt-accent);
}
}
+
+/* ── Dashboard cards (Watch Next) ──────────────────────────────────── */
+.kt-card-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
+ gap: 1rem;
+}
+
+.kt-card {
+ background: var(--kt-surface);
+ border: 1px solid var(--kt-border);
+ border-radius: var(--kt-radius-lg);
+ padding: 1.25rem;
+ box-shadow: var(--kt-shadow);
+ transition: border-color var(--kt-transition), box-shadow var(--kt-transition);
+}
+.kt-card:hover { border-color: var(--kt-border-hover); }
+
+.kt-card-title {
+ font-size: 1rem;
+ font-weight: 600;
+ color: var(--kt-text);
+ margin: 0 0 0.6rem;
+}
+
+.kt-card-badge {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.35rem;
+ font-size: 0.78rem;
+ font-weight: 600;
+ color: var(--kt-accent);
+ background: var(--kt-accent-glow);
+ border-radius: 20px;
+ padding: 0.25rem 0.75rem;
+ margin-bottom: 0.75rem;
+}
+
+.kt-card-meta {
+ font-size: 0.78rem;
+ color: var(--kt-text-muted);
+}
+
+.kt-section-title {
+ font-size: 0.78rem;
+ font-weight: 600;
+ text-transform: uppercase;
+ letter-spacing: 0.08em;
+ color: var(--kt-text-muted);
+ margin: 2rem 0 1rem;
+}
+.kt-section-title:first-child { margin-top: 0; }
+
+/* ── Callouts, sheet tables and hand-rolled SVG charts (shared by CarDetail.razor/HouseDetail.razor) ──
+ Global (not component-scoped) on purpose: a per-item history table/chart is built from a RenderFragment via
+ RenderTreeBuilder or a child *Row component, so its markup never receives the page's own CSS-isolation
+ scope attribute - putting these rules here avoids every consumer having to redeclare them with ::deep. */
+.kt-callout {
+ background: var(--kt-surface-2);
+ border-radius: var(--kt-radius);
+ padding: 0.6rem 0.9rem;
+ margin: 0 0 0.75rem 0;
+ font-size: 0.9rem;
+}
+
+.kt-callout-warning {
+ background: var(--kt-warning-bg);
+ color: var(--kt-warning);
+}
+
+.kt-callout-danger {
+ background: var(--kt-danger-bg);
+ color: var(--kt-danger);
+}
+
+/* Compact history table: a handful of summary columns per row, with the full field set only shown in the
+ add/edit modal - too many always-visible editable columns read as a spreadsheet dump rather than a
+ browsable history. */
+.kt-sheet-table {
+ font-size: 0.85rem;
+}
+
+.kt-sheet-table th {
+ white-space: nowrap;
+ color: var(--kt-text-muted);
+ font-weight: 600;
+}
+
+.kt-sheet-table td {
+ padding: 0.4rem 0.6rem;
+ vertical-align: middle;
+}
+
+/* width: 100%, height: auto (not a fixed px height) lets each SVG's own viewBox aspect ratio drive the
+ rendered height - required since the charts have real axis text, and preserveAspectRatio="none" would
+ stretch that text unreadably. */
+.kt-chart-svg {
+ width: 100%;
+ height: auto;
+ display: block;
+}
+
+.kt-chart-axis-text {
+ font-size: 8px;
+ fill: var(--kt-text-muted);
+}
+
+.kt-chart-axis-title {
+ font-size: 9px;
+ font-weight: 600;
+ fill: var(--kt-text-muted);
+}
+
+.kt-chart-legend {
+ display: flex;
+ gap: 1rem;
+ font-size: 0.8rem;
+ color: var(--kt-text-muted);
+ margin-bottom: 0.4rem;
+}
+
+.kt-legend-swatch {
+ display: inline-block;
+ width: 0.7rem;
+ height: 0.7rem;
+ border-radius: 2px;
+ margin-right: 0.3rem;
+ vertical-align: middle;
+}
+
+/* ── Tabs (page-level section switcher, e.g. Watch Next / Wishlist by type) ── */
+.kt-tabs {
+ display: flex;
+ gap: 1.5rem;
+ border-bottom: 1px solid var(--kt-border);
+ margin-bottom: 1.5rem;
+ flex-wrap: wrap;
+}
+
+.kt-tab {
+ padding: 0.65rem 0.1rem;
+ background: transparent;
+ border: none;
+ border-bottom: 2px solid transparent;
+ color: var(--kt-text-muted);
+ font-family: var(--kt-font);
+ font-size: 0.9rem;
+ font-weight: 500;
+ cursor: pointer;
+ transition: color var(--kt-transition), border-color var(--kt-transition);
+ margin-bottom: -1px;
+}
+
+.kt-tab:hover {
+ color: var(--kt-text);
+}
+
+.kt-tab.active {
+ color: var(--kt-accent);
+ border-bottom-color: var(--kt-accent);
+ font-weight: 600;
+}
+
+.kt-tab-count {
+ color: var(--kt-text-subtle);
+ font-weight: 400;
+ margin-left: 0.3rem;
+}
+
+/* ── Season timeline (TV show detail) ──────────────────────────────── */
+.kt-season-picker {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.35rem;
+ margin-bottom: 1rem;
+}
+
+.kt-season {
+ background: var(--kt-surface);
+ border: 1px solid var(--kt-border);
+ border-radius: var(--kt-radius-lg);
+ margin-bottom: 1rem;
+ overflow: hidden;
+}
+
+.kt-season-header {
+ padding: 0.9rem 1.25rem;
+ font-weight: 600;
+ font-size: 0.9rem;
+ border-bottom: 1px solid var(--kt-border);
+}
+
+.kt-episode-row {
+ display: flex;
+ align-items: center;
+ gap: 0.85rem;
+ padding: 0.65rem 1.25rem;
+ border-top: 1px solid var(--kt-border);
+ font-size: 0.85rem;
+}
+.kt-episode-row:first-of-type { border-top: none; }
+.kt-episode-row.watched { color: var(--kt-text); }
+.kt-episode-row.unwatched { color: var(--kt-text-muted); }
+.kt-episode-number { font-variant-numeric: tabular-nums; color: var(--kt-text-muted); min-width: 2.5rem; }
+.kt-episode-date { margin-left: auto; font-size: 0.78rem; color: var(--kt-text-subtle); }
+
+/* ── Upload dropzone (Import) ──────────────────────────────────────── */
+.kt-dropzone {
+ border: 2px dashed var(--kt-border);
+ border-radius: var(--kt-radius-lg);
+ padding: 3rem 2rem;
+ text-align: center;
+ color: var(--kt-text-muted);
+ background: var(--kt-surface);
+}
+.kt-dropzone-icon { font-size: 2.25rem; display: block; margin-bottom: 1rem; color: var(--kt-text-subtle); }
diff --git a/src/BlazorApp/wwwroot/download.js b/src/BlazorApp/wwwroot/download.js
new file mode 100644
index 00000000..8dc6bb6d
--- /dev/null
+++ b/src/BlazorApp/wwwroot/download.js
@@ -0,0 +1,11 @@
+// Triggers a browser save-as for bytes already fetched by C# (e.g. a zip export) - there's no other way
+// to hand raw bytes from a Blazor Server component to the browser's download mechanism, since the file
+// only exists in the server-side response, not at a public URL the browser could navigate to directly.
+window.ktDownloadFile = (fileName, contentType, base64Data) => {
+ const link = document.createElement('a');
+ link.href = `data:${contentType};base64,${base64Data}`;
+ link.download = fileName;
+ document.body.appendChild(link);
+ link.click();
+ link.remove();
+};
diff --git a/src/BlazorApp/wwwroot/theme.js b/src/BlazorApp/wwwroot/theme.js
new file mode 100644
index 00000000..7adc2442
--- /dev/null
+++ b/src/BlazorApp/wwwroot/theme.js
@@ -0,0 +1,15 @@
+function ktApplyTheme() {
+ var stored = localStorage.getItem('kt-theme');
+ var theme = stored || (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
+ document.documentElement.setAttribute('data-bs-theme', theme);
+}
+
+// runs immediately, before CSS paints, so the very first page load never flashes the wrong theme
+ktApplyTheme();
+
+function ktToggleTheme() {
+ var current = document.documentElement.getAttribute('data-bs-theme');
+ var next = current === 'dark' ? 'light' : 'dark';
+ document.documentElement.setAttribute('data-bs-theme', next);
+ localStorage.setItem('kt-theme', next);
+}
diff --git a/src/Common.System/IHasTvTimeId.cs b/src/Common.System/IHasTvTimeId.cs
new file mode 100644
index 00000000..707059a3
--- /dev/null
+++ b/src/Common.System/IHasTvTimeId.cs
@@ -0,0 +1,11 @@
+namespace Keeptrack.Common.System;
+
+///
+/// A record that can carry the stable id of the TV Time item it was imported from. Lets the import
+/// pipeline match and de-duplicate shows and movies by this immutable id generically, independent of
+/// their (reference-enrichment-mutable) title.
+///
+public interface IHasTvTimeId
+{
+ public string? TvTimeId { get; set; }
+}
diff --git a/src/Common.System/TitleNormalizer.cs b/src/Common.System/TitleNormalizer.cs
new file mode 100644
index 00000000..dae4a5c1
--- /dev/null
+++ b/src/Common.System/TitleNormalizer.cs
@@ -0,0 +1,10 @@
+namespace Keeptrack.Common.System;
+
+///
+/// Shared title normalization for matching by name across the import pipeline and the reference-data
+/// layer - one implementation so both never drift apart on what counts as "the same title".
+///
+public static class TitleNormalizer
+{
+ public static string Normalize(string title) => title.Trim().ToLowerInvariant();
+}
diff --git a/src/Domain/Models/MusicAlbumModel.cs b/src/Domain/Models/AlbumModel.cs
similarity index 67%
rename from src/Domain/Models/MusicAlbumModel.cs
rename to src/Domain/Models/AlbumModel.cs
index 66d15c5b..2132af52 100644
--- a/src/Domain/Models/MusicAlbumModel.cs
+++ b/src/Domain/Models/AlbumModel.cs
@@ -1,8 +1,8 @@
-using Keeptrack.Common.System;
+using Keeptrack.Common.System;
namespace Keeptrack.Domain.Models;
-public class MusicAlbumModel : IHasIdAndOwnerId
+public class AlbumModel : IHasIdAndOwnerId
{
public string? Id { get; set; }
@@ -17,4 +17,8 @@ public class MusicAlbumModel : IHasIdAndOwnerId
public string? Genre { get; set; }
public float? Rating { get; set; }
+
+ public string? ReferenceId { get; set; }
+
+ public bool IsFavorite { get; set; }
}
diff --git a/src/Domain/Models/AlbumReferenceModel.cs b/src/Domain/Models/AlbumReferenceModel.cs
new file mode 100644
index 00000000..f4e74ab3
--- /dev/null
+++ b/src/Domain/Models/AlbumReferenceModel.cs
@@ -0,0 +1,46 @@
+using System;
+using System.Collections.Generic;
+using Keeptrack.Common.System;
+
+namespace Keeptrack.Domain.Models;
+
+///
+/// Shared, tenant-agnostic album metadata sourced from an external provider (Discogs).
+/// See for why this deliberately has no OwnerId .
+///
+public class AlbumReferenceModel : IHasId
+{
+ public string? Id { get; set; }
+
+ public required string Title { get; set; }
+
+ public required string TitleNormalized { get; set; }
+
+ public int? Year { get; set; }
+
+ public string? Synopsis { get; set; }
+
+ ///
+ /// Points at the shared, deduplicated for this album's artist
+ /// (keyed by the artist's Discogs id) - the same dedup mechanism as TV/movie cast, just for a single
+ /// credited individual/group instead of a list. Null when Discogs' response didn't carry an artist id
+ /// to dedupe by.
+ ///
+ public string? ArtistReferenceId { get; set; }
+
+ public required Dictionary ExternalIds { get; set; }
+
+ ///
+ /// Every (title, year) combination that has ever been confirmed to mean this album - see
+ /// for the full rationale.
+ ///
+ public List MatchedAliases { get; set; } = [];
+
+ public List Genres { get; set; } = [];
+
+ public List Tracks { get; set; } = [];
+
+ public string? ImageUrl { get; set; }
+
+ public DateTime? LastEnrichedAt { get; set; }
+}
diff --git a/src/Domain/Models/BookModel.cs b/src/Domain/Models/BookModel.cs
index f710772d..4715e8aa 100644
--- a/src/Domain/Models/BookModel.cs
+++ b/src/Domain/Models/BookModel.cs
@@ -15,6 +15,8 @@ public class BookModel : IHasIdAndOwnerId
public string? Series { get; set; }
+ public int? Year { get; set; }
+
public float? Rating { get; set; }
public string? Genre { get; set; }
@@ -22,4 +24,12 @@ public class BookModel : IHasIdAndOwnerId
public string? Notes { get; set; }
public DateOnly? FirstReadAt { get; set; }
+
+ public string? ReferenceId { get; set; }
+
+ public bool IsFavorite { get; set; }
+
+ public bool IsOwned { get; set; }
+
+ public bool IsWishlisted { get; set; }
}
diff --git a/src/Domain/Models/BookReferenceModel.cs b/src/Domain/Models/BookReferenceModel.cs
new file mode 100644
index 00000000..053b8b3d
--- /dev/null
+++ b/src/Domain/Models/BookReferenceModel.cs
@@ -0,0 +1,44 @@
+using System;
+using System.Collections.Generic;
+using Keeptrack.Common.System;
+
+namespace Keeptrack.Domain.Models;
+
+///
+/// Shared, tenant-agnostic book metadata sourced from an external provider (Open Library).
+/// See for why this deliberately has no OwnerId .
+///
+public class BookReferenceModel : IHasId
+{
+ public string? Id { get; set; }
+
+ public required string Title { get; set; }
+
+ public required string TitleNormalized { get; set; }
+
+ public int? Year { get; set; }
+
+ public string? Synopsis { get; set; }
+
+ ///
+ /// Points at the shared, deduplicated for this book's author
+ /// (keyed by the author's Open Library id) - the same dedup mechanism as TV/movie cast, just for a
+ /// single credited individual instead of a list. Null when Open Library's response didn't carry an
+ /// author id to dedupe by.
+ ///
+ public string? AuthorReferenceId { get; set; }
+
+ public required Dictionary ExternalIds { get; set; }
+
+ ///
+ /// Every (title, year) combination that has ever been confirmed to mean this book - see
+ /// for the full rationale.
+ ///
+ public List MatchedAliases { get; set; } = [];
+
+ public List Genres { get; set; } = [];
+
+ public string? ImageUrl { get; set; }
+
+ public DateTime? LastEnrichedAt { get; set; }
+}
diff --git a/src/Domain/Models/CarCostHistoryPointModel.cs b/src/Domain/Models/CarCostHistoryPointModel.cs
new file mode 100644
index 00000000..6052c791
--- /dev/null
+++ b/src/Domain/Models/CarCostHistoryPointModel.cs
@@ -0,0 +1,18 @@
+using System;
+
+namespace Keeptrack.Domain.Models;
+
+///
+/// Total cost of ownership for one calendar month - fuel/electric spend and maintenance/other spend tracked
+/// separately as well as combined, so the UI can show a breakdown, not just a total.
+///
+public class CarCostHistoryPointModel
+{
+ public required DateOnly Period { get; set; }
+
+ public required double FuelCost { get; set; }
+
+ public required double MaintenanceCost { get; set; }
+
+ public double TotalCost => FuelCost + MaintenanceCost;
+}
diff --git a/src/Domain/Models/CarEnergyType.cs b/src/Domain/Models/CarEnergyType.cs
new file mode 100644
index 00000000..83150a50
--- /dev/null
+++ b/src/Domain/Models/CarEnergyType.cs
@@ -0,0 +1,8 @@
+namespace Keeptrack.Domain.Models;
+
+public enum CarEnergyType
+{
+ Combustion,
+ Hybrid,
+ Electric
+}
diff --git a/src/Domain/Models/CarHistoryModel.cs b/src/Domain/Models/CarHistoryModel.cs
index 0af144f1..4c303a11 100644
--- a/src/Domain/Models/CarHistoryModel.cs
+++ b/src/Domain/Models/CarHistoryModel.cs
@@ -1,4 +1,4 @@
-using System;
+using System;
using Keeptrack.Common.System;
namespace Keeptrack.Domain.Models;
@@ -11,14 +11,27 @@ public class CarHistoryModel : IHasIdAndOwnerId
public required string CarId { get; set; }
- public DateOnly HistoryDate { get; set; }
+ ///
+ /// When this event happened. Carries a real time-of-day, not just a calendar day - several entries
+ /// (e.g. multiple refuels on a long road trip) can share the same date, and only the time lets them
+ /// sort in the order they actually happened. Defaults to midnight when the time isn't known.
+ ///
+ public required DateTime HistoryDate { get; set; }
public int? Mileage { get; set; }
- public required string Action { get; set; }
+ public required CarHistoryType EventType { get; set; }
+
+ public string? Description { get; set; }
+
+ public double? Cost { get; set; }
public string? City { get; set; }
+ public string? PostalCode { get; set; }
+
+ public string? Country { get; set; }
+
public double? Longitude { get; set; }
public double? Latitude { get; set; }
@@ -29,13 +42,26 @@ public class CarHistoryModel : IHasIdAndOwnerId
public double? FuelUnitPrice { get; set; }
- public double? Amount { get; set; }
+ public double? ElectricVolume { get; set; }
- public bool? IsFullTank { get; set; }
+ public double? ElectricUnitPrice { get; set; }
- public double? DeltaMileage { get; set; }
+ public bool? IsFullRefill { get; set; }
- public string? LastRefuelHistoryId { get; set; }
+ ///
+ /// Distance driven since the previous history entry, as recorded by the user directly (typically read off
+ /// the car's own trip computer at refuel time) - independent of the odometer reading in .
+ /// Kept deliberately as real input, not derived: cross-checks
+ /// it against consecutive readings to catch a mistyped entry or a refuel that was never
+ /// logged at all - the same manual check the user used to do by hand in a spreadsheet.
+ ///
+ public double? DeltaMileage { get; set; }
public string? StationBrandName { get; set; }
+
+ ///
+ /// Garage/auto shop name - the Maintenance/Other-event counterpart of
+ /// (which only applies to Refuel events).
+ ///
+ public string? Garage { get; set; }
}
diff --git a/src/Domain/Models/CarHistoryType.cs b/src/Domain/Models/CarHistoryType.cs
new file mode 100644
index 00000000..f2d57b98
--- /dev/null
+++ b/src/Domain/Models/CarHistoryType.cs
@@ -0,0 +1,8 @@
+namespace Keeptrack.Domain.Models;
+
+public enum CarHistoryType
+{
+ Refuel,
+ Maintenance,
+ Other
+}
diff --git a/src/Domain/Models/CarMetricsModel.cs b/src/Domain/Models/CarMetricsModel.cs
new file mode 100644
index 00000000..8d60705d
--- /dev/null
+++ b/src/Domain/Models/CarMetricsModel.cs
@@ -0,0 +1,22 @@
+using System.Collections.Generic;
+
+namespace Keeptrack.Domain.Models;
+
+public class CarMetricsModel
+{
+ public required List FuelConsumption { get; set; }
+
+ public double? AverageFuelConsumptionPer100Km { get; set; }
+
+ public required List ElectricConsumption { get; set; }
+
+ public double? AverageElectricConsumptionPer100Km { get; set; }
+
+ public required List CostHistory { get; set; }
+
+ public required double TotalCost { get; set; }
+
+ public required List MileageWarnings { get; set; }
+
+ public NextMaintenanceModel? NextMaintenance { get; set; }
+}
diff --git a/src/Domain/Models/CarMileageWarningModel.cs b/src/Domain/Models/CarMileageWarningModel.cs
new file mode 100644
index 00000000..2f482c47
--- /dev/null
+++ b/src/Domain/Models/CarMileageWarningModel.cs
@@ -0,0 +1,13 @@
+namespace Keeptrack.Domain.Models;
+
+///
+/// Flags a CarHistory entry whose recorded mileage doesn't reconcile with its neighbors, so the user can fix
+/// the underlying entry - see CarMetricsService. Advisory only: the flagged entry is never excluded from
+/// metrics or corrected automatically.
+///
+public class CarMileageWarningModel
+{
+ public required string CarHistoryId { get; set; }
+
+ public required string Message { get; set; }
+}
diff --git a/src/Domain/Models/CarModel.cs b/src/Domain/Models/CarModel.cs
index 71c133aa..c4451401 100644
--- a/src/Domain/Models/CarModel.cs
+++ b/src/Domain/Models/CarModel.cs
@@ -1,4 +1,4 @@
-using Keeptrack.Common.System;
+using Keeptrack.Common.System;
namespace Keeptrack.Domain.Models;
@@ -9,4 +9,14 @@ public class CarModel : IHasIdAndOwnerId
public required string OwnerId { get; set; }
public required string Name { get; set; }
+
+ public string? Manufacturer { get; set; }
+
+ public string? Model { get; set; }
+
+ public int? Year { get; set; }
+
+ public string? LicensePlate { get; set; }
+
+ public required CarEnergyType EnergyType { get; set; }
}
diff --git a/src/Domain/Models/CastMemberModel.cs b/src/Domain/Models/CastMemberModel.cs
new file mode 100644
index 00000000..06a4d7f6
--- /dev/null
+++ b/src/Domain/Models/CastMemberModel.cs
@@ -0,0 +1,15 @@
+namespace Keeptrack.Domain.Models;
+
+///
+/// Embedded within / - a bounded,
+/// always-fetched-with-the-show top-billed cast list. See for the
+/// same embedding reasoning.
+///
+public class CastMemberModel
+{
+ public required string PersonReferenceId { get; set; }
+
+ public required string CharacterName { get; set; }
+
+ public int Order { get; set; }
+}
diff --git a/src/Domain/Models/ConsumptionPointModel.cs b/src/Domain/Models/ConsumptionPointModel.cs
new file mode 100644
index 00000000..e3e9ada1
--- /dev/null
+++ b/src/Domain/Models/ConsumptionPointModel.cs
@@ -0,0 +1,14 @@
+using System;
+
+namespace Keeptrack.Domain.Models;
+
+///
+/// One consumption data point, always computed across a full refill/recharge (never a partial one) - see
+/// CarMetricsService.
+///
+public class ConsumptionPointModel
+{
+ public required DateOnly Date { get; set; }
+
+ public required double ValuePer100Km { get; set; }
+}
diff --git a/src/Domain/Models/EpisodeModel.cs b/src/Domain/Models/EpisodeModel.cs
new file mode 100644
index 00000000..044825ca
--- /dev/null
+++ b/src/Domain/Models/EpisodeModel.cs
@@ -0,0 +1,21 @@
+using System;
+using Keeptrack.Common.System;
+
+namespace Keeptrack.Domain.Models;
+
+public class EpisodeModel : IHasIdAndOwnerId
+{
+ public string? Id { get; set; }
+
+ public required string OwnerId { get; set; }
+
+ public required string TvShowId { get; set; }
+
+ public required int SeasonNumber { get; set; }
+
+ public required int EpisodeNumber { get; set; }
+
+ public DateOnly? WatchedAt { get; set; }
+
+ public string? Notes { get; set; }
+}
diff --git a/src/Domain/Models/HouseCategoryCostModel.cs b/src/Domain/Models/HouseCategoryCostModel.cs
new file mode 100644
index 00000000..7d53343c
--- /dev/null
+++ b/src/Domain/Models/HouseCategoryCostModel.cs
@@ -0,0 +1,8 @@
+namespace Keeptrack.Domain.Models;
+
+public class HouseCategoryCostModel
+{
+ public required HouseEventType EventType { get; set; }
+
+ public required double Cost { get; set; }
+}
diff --git a/src/Domain/Models/HouseCostHistoryPointModel.cs b/src/Domain/Models/HouseCostHistoryPointModel.cs
new file mode 100644
index 00000000..c3ac202d
--- /dev/null
+++ b/src/Domain/Models/HouseCostHistoryPointModel.cs
@@ -0,0 +1,16 @@
+using System.Collections.Generic;
+
+namespace Keeptrack.Domain.Models;
+
+///
+/// Total cost of ownership for one calendar year, both as a single total and broken down by event category -
+/// see .
+///
+public class HouseCostHistoryPointModel
+{
+ public required int Year { get; set; }
+
+ public required double TotalCost { get; set; }
+
+ public required List CostByCategory { get; set; }
+}
diff --git a/src/Domain/Models/HouseEventType.cs b/src/Domain/Models/HouseEventType.cs
new file mode 100644
index 00000000..0b32cb2a
--- /dev/null
+++ b/src/Domain/Models/HouseEventType.cs
@@ -0,0 +1,11 @@
+namespace Keeptrack.Domain.Models;
+
+public enum HouseEventType
+{
+ Maintenance,
+ Installation,
+ Rework,
+ Purchase,
+ Bill,
+ Other
+}
diff --git a/src/Domain/Models/HouseHistoryModel.cs b/src/Domain/Models/HouseHistoryModel.cs
new file mode 100644
index 00000000..dece2988
--- /dev/null
+++ b/src/Domain/Models/HouseHistoryModel.cs
@@ -0,0 +1,28 @@
+using System;
+using Keeptrack.Common.System;
+
+namespace Keeptrack.Domain.Models;
+
+public class HouseHistoryModel : IHasIdAndOwnerId
+{
+ public string? Id { get; set; }
+
+ public required string OwnerId { get; set; }
+
+ public required string HouseId { get; set; }
+
+ public required DateOnly HistoryDate { get; set; }
+
+ public required HouseEventType EventType { get; set; }
+
+ public string? Description { get; set; }
+
+ public double? Cost { get; set; }
+
+ ///
+ /// Contractor/technician/utility company/store name - a single field covering every category, unlike
+ /// Car's Refuel-only StationBrandName vs Maintenance-only Garage split, since House has no event type
+ /// where "who was involved" doesn't apply.
+ ///
+ public string? Provider { get; set; }
+}
diff --git a/src/Domain/Models/HouseMetricsModel.cs b/src/Domain/Models/HouseMetricsModel.cs
new file mode 100644
index 00000000..e9e7f3b5
--- /dev/null
+++ b/src/Domain/Models/HouseMetricsModel.cs
@@ -0,0 +1,8 @@
+using System.Collections.Generic;
+
+namespace Keeptrack.Domain.Models;
+
+public class HouseMetricsModel
+{
+ public required List CostHistory { get; set; }
+}
diff --git a/src/Domain/Models/HouseModel.cs b/src/Domain/Models/HouseModel.cs
new file mode 100644
index 00000000..3b20f0fc
--- /dev/null
+++ b/src/Domain/Models/HouseModel.cs
@@ -0,0 +1,22 @@
+using Keeptrack.Common.System;
+
+namespace Keeptrack.Domain.Models;
+
+public class HouseModel : IHasIdAndOwnerId
+{
+ public string? Id { get; set; }
+
+ public required string OwnerId { get; set; }
+
+ public required string Name { get; set; }
+
+ public string? Address { get; set; }
+
+ public string? City { get; set; }
+
+ public string? PostalCode { get; set; }
+
+ public string? Country { get; set; }
+
+ public string? Notes { get; set; }
+}
diff --git a/src/Domain/Models/InProgressShowModel.cs b/src/Domain/Models/InProgressShowModel.cs
new file mode 100644
index 00000000..5a391be8
--- /dev/null
+++ b/src/Domain/Models/InProgressShowModel.cs
@@ -0,0 +1,27 @@
+using System;
+
+namespace Keeptrack.Domain.Models;
+
+///
+/// A TV show in progress, with the last episode Keeptrack knows was watched and the next confirmed-unseen
+/// one from its TMDB episode guide. Only shows with a resolved reference and a confirmed aired-but-unwatched
+/// episode are reported at all - see WatchNextService.
+///
+public class InProgressShowModel
+{
+ public required string TvShowId { get; set; }
+
+ public required string TvShowTitle { get; set; }
+
+ public required int LastSeasonNumber { get; set; }
+
+ public required int LastEpisodeNumber { get; set; }
+
+ public DateOnly? LastWatchedAt { get; set; }
+
+ public required int NextSeasonNumber { get; set; }
+
+ public required int NextEpisodeNumber { get; set; }
+
+ public required string NextEpisodeTitle { get; set; }
+}
diff --git a/src/Domain/Models/MovieModel.cs b/src/Domain/Models/MovieModel.cs
index aa84358c..32ef735a 100644
--- a/src/Domain/Models/MovieModel.cs
+++ b/src/Domain/Models/MovieModel.cs
@@ -3,7 +3,7 @@
namespace Keeptrack.Domain.Models;
-public class MovieModel : IHasIdAndOwnerId
+public class MovieModel : IHasIdAndOwnerId, IHasTvTimeId
{
public string? Id { get; set; }
@@ -11,17 +11,29 @@ public class MovieModel : IHasIdAndOwnerId
public required string Title { get; set; }
+ ///
+ /// Stable identifier for the item this record was imported from (TV Time's own per-movie tracking
+ /// uuid, or a title-derived fallback when the export carries none). Set once at import time and never
+ /// rewritten by reference enrichment, so a re-import matches by this id instead of the
+ /// enrichment-mutable Title and can't create a duplicate. Null for movies created outside the import.
+ ///
+ public string? TvTimeId { get; set; }
+
public int? Year { get; set; }
public float? Rating { get; set; }
- public string? Genre { get; set; }
-
public string? Notes { get; set; }
- public string? ImdbPageId { get; set; }
-
- public string? AllocineId { get; set; }
+ public string? ReferenceId { get; set; }
public DateOnly? FirstSeenAt { get; set; }
+
+ public bool IsFavorite { get; set; }
+
+ public bool WantToWatch { get; set; }
+
+ public bool IsOwned { get; set; }
+
+ public bool IsWishlisted { get; set; }
}
diff --git a/src/Domain/Models/MovieReferenceModel.cs b/src/Domain/Models/MovieReferenceModel.cs
new file mode 100644
index 00000000..169f7106
--- /dev/null
+++ b/src/Domain/Models/MovieReferenceModel.cs
@@ -0,0 +1,39 @@
+using System;
+using System.Collections.Generic;
+using Keeptrack.Common.System;
+
+namespace Keeptrack.Domain.Models;
+
+///
+/// Shared, tenant-agnostic movie metadata sourced from an external provider such as TMDB.
+/// See for why this deliberately has no OwnerId .
+///
+public class MovieReferenceModel : IHasId
+{
+ public string? Id { get; set; }
+
+ public required string Title { get; set; }
+
+ public required string TitleNormalized { get; set; }
+
+ public int? Year { get; set; }
+
+ public string? Synopsis { get; set; }
+
+ public required Dictionary ExternalIds { get; set; }
+
+ ///
+ /// Every (title, year) combination that has ever been confirmed (via TMDB resolution, automatic or
+ /// admin-picked) to mean this movie - not just / . See
+ /// for the full rationale.
+ ///
+ public List MatchedAliases { get; set; } = [];
+
+ public List Genres { get; set; } = [];
+
+ public List Cast { get; set; } = [];
+
+ public string? ImageUrl { get; set; }
+
+ public DateTime? LastEnrichedAt { get; set; }
+}
diff --git a/src/Domain/Models/NextMaintenanceModel.cs b/src/Domain/Models/NextMaintenanceModel.cs
new file mode 100644
index 00000000..774c9ebf
--- /dev/null
+++ b/src/Domain/Models/NextMaintenanceModel.cs
@@ -0,0 +1,16 @@
+using System;
+
+namespace Keeptrack.Domain.Models;
+
+///
+/// Estimated next maintenance due date, assuming a fixed 1-year cadence from the last recorded maintenance
+/// event. Only ever produced when a maintenance history actually exists - see CarMetricsService.
+///
+public class NextMaintenanceModel
+{
+ public required DateOnly LastMaintenanceDate { get; set; }
+
+ public required DateOnly DueDate { get; set; }
+
+ public required int MonthsRemaining { get; set; }
+}
diff --git a/src/Domain/Models/PersonReferenceModel.cs b/src/Domain/Models/PersonReferenceModel.cs
new file mode 100644
index 00000000..d7cadb06
--- /dev/null
+++ b/src/Domain/Models/PersonReferenceModel.cs
@@ -0,0 +1,20 @@
+using System.Collections.Generic;
+using Keeptrack.Common.System;
+
+namespace Keeptrack.Domain.Models;
+
+///
+/// Shared, owner-less actor/person metadata sourced from an external provider such as TMDB. Deduplicated
+/// across every show/movie that credits them - matched by external provider id, not by name (unlike
+/// / , credits already give an exact id).
+///
+public class PersonReferenceModel : IHasId
+{
+ public string? Id { get; set; }
+
+ public required string Name { get; set; }
+
+ public string? ProfileImageUrl { get; set; }
+
+ public required Dictionary ExternalIds { get; set; }
+}
diff --git a/src/Domain/Models/PlaylistModel.cs b/src/Domain/Models/PlaylistModel.cs
new file mode 100644
index 00000000..bdef6e8c
--- /dev/null
+++ b/src/Domain/Models/PlaylistModel.cs
@@ -0,0 +1,19 @@
+using System.Collections.Generic;
+using Keeptrack.Common.System;
+
+namespace Keeptrack.Domain.Models;
+
+public class PlaylistModel : IHasIdAndOwnerId
+{
+ public string? Id { get; set; }
+
+ public required string OwnerId { get; set; }
+
+ public required string Title { get; set; }
+
+ ///
+ /// Ids of documents, in playback order - the list's own order is the
+ /// playlist order, there's no separate position field.
+ ///
+ public List SongIds { get; set; } = [];
+}
diff --git a/src/Domain/Models/PlaythroughModel.cs b/src/Domain/Models/PlaythroughModel.cs
new file mode 100644
index 00000000..e0cb18d3
--- /dev/null
+++ b/src/Domain/Models/PlaythroughModel.cs
@@ -0,0 +1,16 @@
+using System;
+
+namespace Keeptrack.Domain.Models;
+
+///
+/// One recorded run through a game - the base playthrough, an NG+ replay, a speedrun, or any other
+/// attempt the tenant wants to log. is free text rather than a fixed enum because
+/// replays don't fit a closed set (NG+1, NG+2, a side-run "just for fun", ...).
+/// is optional so an in-progress or abandoned run can still be logged.
+///
+public class PlaythroughModel
+{
+ public required string Label { get; set; }
+
+ public DateOnly? CompletedAt { get; set; }
+}
diff --git a/src/Domain/Models/ReferenceEpisodeModel.cs b/src/Domain/Models/ReferenceEpisodeModel.cs
new file mode 100644
index 00000000..d075736e
--- /dev/null
+++ b/src/Domain/Models/ReferenceEpisodeModel.cs
@@ -0,0 +1,20 @@
+using System;
+
+namespace Keeptrack.Domain.Models;
+
+///
+/// One episode's reference metadata (title, air date), embedded within a .
+/// Unlike the per-tenant (a separate collection, since it grows unbounded per
+/// user over years and is queried across shows for Watch Next), this list is bounded to one show's real
+/// runtime and always fetched as a whole, so embedding is the right call here.
+///
+public class ReferenceEpisodeModel
+{
+ public required int SeasonNumber { get; set; }
+
+ public required int EpisodeNumber { get; set; }
+
+ public required string Title { get; set; }
+
+ public DateOnly? AirDate { get; set; }
+}
diff --git a/src/Domain/Models/ReferenceMatchModel.cs b/src/Domain/Models/ReferenceMatchModel.cs
new file mode 100644
index 00000000..4fc58aa8
--- /dev/null
+++ b/src/Domain/Models/ReferenceMatchModel.cs
@@ -0,0 +1,27 @@
+namespace Keeptrack.Domain.Models;
+
+///
+/// One (title, year) combination ever confirmed - via TMDB resolution, automatic or admin-picked - to mean
+/// a specific show/movie, embedded within a / .
+/// See for the full rationale: a title-only match isn't
+/// enough because two tenants (or a tenant and TMDB's own canonical data) can legitimately record different
+/// years for the exact same show/movie, so year has to travel with the specific title variant it was
+/// confirmed under, not live as a single scalar on the reference document.
+///
+public class ReferenceMatchModel
+{
+ public required string Title { get; set; }
+
+ public int? Year { get; set; }
+
+ ///
+ /// Normalized book author / album artist this (title, year) combination was confirmed under - null
+ /// for domains with no creator concept (TV show, movie, video game). Required for Book/Album matching
+ /// specifically: two different tenants' different books/albums can easily share a common (title, year)
+ /// (e.g. two different novels both titled "Echoes" published the same year) - without creator in the
+ /// match key, the second tenant's item would incorrectly latch onto the first tenant's reference
+ /// document. Deliberately generic name, not "Author"/"Artist" - see 's
+ /// own naming rationale, which this follows.
+ ///
+ public string? Creator { get; set; }
+}
diff --git a/src/Domain/Models/ReferenceTrackModel.cs b/src/Domain/Models/ReferenceTrackModel.cs
new file mode 100644
index 00000000..6c1995be
--- /dev/null
+++ b/src/Domain/Models/ReferenceTrackModel.cs
@@ -0,0 +1,21 @@
+namespace Keeptrack.Domain.Models;
+
+///
+/// One track's reference metadata (position, title, duration), embedded within an
+/// - same embedding rationale as
+/// : bounded to one album's real tracklist and always fetched as a whole.
+///
+public class ReferenceTrackModel
+{
+ ///
+ /// Discogs' own position label - not always numeric (vinyl releases use side+track like "A1"/"B2").
+ ///
+ public required string Position { get; set; }
+
+ public required string Title { get; set; }
+
+ ///
+ /// Discogs' own "M:SS" text, often absent - carried through as display text, not parsed.
+ ///
+ public string? Duration { get; set; }
+}
diff --git a/src/Domain/Models/SongModel.cs b/src/Domain/Models/SongModel.cs
new file mode 100644
index 00000000..9625e7ca
--- /dev/null
+++ b/src/Domain/Models/SongModel.cs
@@ -0,0 +1,35 @@
+using Keeptrack.Common.System;
+
+namespace Keeptrack.Domain.Models;
+
+public class SongModel : IHasIdAndOwnerId
+{
+ public string? Id { get; set; }
+
+ public required string OwnerId { get; set; }
+
+ public required string Title { get; set; }
+
+ public string? Artist { get; set; }
+
+ ///
+ /// Optional link to the tenant's own - a soft reference, like
+ /// ReferenceId elsewhere in the codebase: no cascade-delete or enforcement if the album is
+ /// later removed.
+ ///
+ public string? AlbumId { get; set; }
+
+ ///
+ /// Discogs' own "M:SS" text, usually filled in by picking a track from the linked album's reference
+ /// tracklist (see ) rather than typed by hand.
+ ///
+ public string? Duration { get; set; }
+
+ ///
+ /// The this song was created from - combined with
+ /// , this is a track-based song's real identity: picking the same track twice
+ /// must reuse the same , not create a duplicate (see
+ /// SongApiClient.GetOrCreateForTrackAsync ). Null for a song not tied to a specific tracklist entry.
+ ///
+ public string? TrackPosition { get; set; }
+}
diff --git a/src/Domain/Models/TvShowModel.cs b/src/Domain/Models/TvShowModel.cs
index 0a50131d..3dac6ae4 100644
--- a/src/Domain/Models/TvShowModel.cs
+++ b/src/Domain/Models/TvShowModel.cs
@@ -1,9 +1,8 @@
-using System;
-using Keeptrack.Common.System;
+using Keeptrack.Common.System;
namespace Keeptrack.Domain.Models;
-public class TvShowModel : IHasIdAndOwnerId
+public class TvShowModel : IHasIdAndOwnerId, IHasTvTimeId
{
public string? Id { get; set; }
@@ -11,6 +10,14 @@ public class TvShowModel : IHasIdAndOwnerId
public required string Title { get; set; }
+ ///
+ /// Stable identifier for the item this record was imported from (TV Time's own show id, or a
+ /// title-derived fallback when the export carries none). Set once at import time and never rewritten
+ /// by reference enrichment, so a re-import matches by this id instead of the enrichment-mutable Title
+ /// and can't create a duplicate. Null for shows created outside the import.
+ ///
+ public string? TvTimeId { get; set; }
+
public int? Year { get; set; }
public float? Rating { get; set; }
@@ -19,9 +26,15 @@ public class TvShowModel : IHasIdAndOwnerId
public string? LastEpisodeSeen { get; set; }
- public string? ImdbPageId { get; set; }
+ public string? ReferenceId { get; set; }
+
+ public TvShowStatus? State { get; set; }
+
+ public bool IsFavorite { get; set; }
+
+ public bool WantToWatch { get; set; }
- public string? AllocineId { get; set; }
+ public bool IsOwned { get; set; }
- public DateOnly? FinishedAt { get; set; }
+ public bool IsWishlisted { get; set; }
}
diff --git a/src/Domain/Models/TvShowReferenceModel.cs b/src/Domain/Models/TvShowReferenceModel.cs
new file mode 100644
index 00000000..80b886af
--- /dev/null
+++ b/src/Domain/Models/TvShowReferenceModel.cs
@@ -0,0 +1,54 @@
+using System;
+using System.Collections.Generic;
+using Keeptrack.Common.System;
+
+namespace Keeptrack.Domain.Models;
+
+///
+/// Shared, tenant-agnostic show metadata (title, synopsis, episode list) sourced from an external
+/// provider such as TMDB. Deliberately has no OwnerId - unlike every other collection in
+/// Keeptrack, this one carries no user content, only public facts about a real show, so storing it
+/// once and pointing every tenant's own at it avoids duplicating
+/// the same show's data per user.
+///
+public class TvShowReferenceModel : IHasId
+{
+ public string? Id { get; set; }
+
+ public required string Title { get; set; }
+
+ public required string TitleNormalized { get; set; }
+
+ public int? Year { get; set; }
+
+ public string? Synopsis { get; set; }
+
+ ///
+ /// Provider name (e.g. "tmdb") to that provider's id for this show. Keeps the document itself
+ /// source-agnostic: adding a second provider later is just another dictionary entry, not a schema change.
+ ///
+ public required Dictionary ExternalIds { get; set; }
+
+ ///
+ /// Every (title, year) combination that has ever been confirmed (via TMDB resolution, automatic or
+ /// admin-picked) to mean this show - not just / . A tenant
+ /// typing a different-language title, a typo, or a regional variant that an admin resolved to this same
+ /// TMDB entry gets remembered here, so the next tenant with that exact same (title, year) matches
+ /// instantly without a fresh TMDB search. Year travels with its specific title variant rather than living
+ /// as a single scalar on this document, because two tenants (or a tenant and TMDB's own canonical value)
+ /// can legitimately disagree on the year for the same real show - a single top-level year would reject a
+ /// tenant whose recorded year genuinely differs from whichever one happens to be canonical. Always
+ /// includes paired with .
+ ///
+ public List MatchedAliases { get; set; } = [];
+
+ public List Episodes { get; set; } = [];
+
+ public List Genres { get; set; } = [];
+
+ public List Cast { get; set; } = [];
+
+ public string? ImageUrl { get; set; }
+
+ public DateTime? LastEnrichedAt { get; set; }
+}
diff --git a/src/Domain/Models/TvShowStatus.cs b/src/Domain/Models/TvShowStatus.cs
new file mode 100644
index 00000000..2fafe297
--- /dev/null
+++ b/src/Domain/Models/TvShowStatus.cs
@@ -0,0 +1,15 @@
+namespace Keeptrack.Domain.Models;
+
+///
+/// A user's own general watch state for a show - distinct from per-episode watched dates. Nullable on
+/// : unset means "no state chosen yet", not a default of any one value.
+/// The enum type itself keeps the "Status" name (only 's property name was
+/// renamed for parity with - VideoGame's State is a plain string with no
+/// equivalent enum to rename).
+///
+public enum TvShowStatus
+{
+ Current,
+ Finished,
+ Stopped
+}
diff --git a/src/Domain/Models/VideoGameCopyType.cs b/src/Domain/Models/VideoGameCopyType.cs
new file mode 100644
index 00000000..47510d9a
--- /dev/null
+++ b/src/Domain/Models/VideoGameCopyType.cs
@@ -0,0 +1,7 @@
+namespace Keeptrack.Domain.Models;
+
+public enum VideoGameCopyType
+{
+ Physical,
+ Digital
+}
diff --git a/src/Domain/Models/VideoGameModel.cs b/src/Domain/Models/VideoGameModel.cs
index c23d5445..4e9c45ed 100644
--- a/src/Domain/Models/VideoGameModel.cs
+++ b/src/Domain/Models/VideoGameModel.cs
@@ -1,4 +1,4 @@
-using System;
+using System.Collections.Generic;
using Keeptrack.Common.System;
namespace Keeptrack.Domain.Models;
@@ -11,9 +11,7 @@ public class VideoGameModel : IHasIdAndOwnerId
public required string Title { get; set; }
- public required string Platform { get; set; }
-
- public required string State { get; set; }
+ public List Platforms { get; set; } = [];
public int? Year { get; set; }
@@ -21,5 +19,23 @@ public class VideoGameModel : IHasIdAndOwnerId
public string? Notes { get; set; }
- public DateOnly? FinishedAt { get; set; }
+ public string? ReferenceId { get; set; }
+
+ public bool IsOwned { get; set; }
+
+ public bool IsWishlisted { get; set; }
+
+ ///
+ /// Filter-only: matches if any entry in has this platform. Never persisted -
+ /// this property exists solely so can keep doubling as the filter-probe
+ /// object passed to IDataRepository{TModel}.FindAllAsync , the same convention every other
+ /// repository already relies on.
+ ///
+ public string? Platform { get; set; }
+
+ ///
+ /// Filter-only: matches if any entry in has this state. Never persisted - see
+ /// .
+ ///
+ public string? State { get; set; }
}
diff --git a/src/Domain/Models/VideoGamePlatformModel.cs b/src/Domain/Models/VideoGamePlatformModel.cs
new file mode 100644
index 00000000..6d4d3614
--- /dev/null
+++ b/src/Domain/Models/VideoGamePlatformModel.cs
@@ -0,0 +1,26 @@
+using System;
+using System.Collections.Generic;
+
+namespace Keeptrack.Domain.Models;
+
+///
+/// One entry per platform a game is tracked on for a given tenant - not one entry per physical copy.
+/// Owning two PS5 discs of the same game is still a single entry with "PS5".
+/// / ("platinum"/100%) live here rather than
+/// on or on a single entry, because full
+/// completion is a per-platform fact - a PS5 platinum says nothing about Xbox/PC progress on the same game.
+///
+public class VideoGamePlatformModel
+{
+ public required string Platform { get; set; }
+
+ public VideoGameCopyType CopyType { get; set; }
+
+ public string State { get; set; } = "";
+
+ public List Playthroughs { get; set; } = [];
+
+ public bool IsFullyCompleted { get; set; }
+
+ public DateOnly? FullyCompletedAt { get; set; }
+}
diff --git a/src/Domain/Models/VideoGameReferenceModel.cs b/src/Domain/Models/VideoGameReferenceModel.cs
new file mode 100644
index 00000000..e7f474af
--- /dev/null
+++ b/src/Domain/Models/VideoGameReferenceModel.cs
@@ -0,0 +1,43 @@
+using System;
+using System.Collections.Generic;
+using Keeptrack.Common.System;
+
+namespace Keeptrack.Domain.Models;
+
+///
+/// Shared, tenant-agnostic video game metadata sourced from an external provider (RAWG).
+/// See for why this deliberately has no OwnerId .
+///
+public class VideoGameReferenceModel : IHasId
+{
+ public string? Id { get; set; }
+
+ public required string Title { get; set; }
+
+ public required string TitleNormalized { get; set; }
+
+ public int? Year { get; set; }
+
+ public string? Synopsis { get; set; }
+
+ ///
+ /// Every platform RAWG lists as a release target for this game - read-only reference info, distinct
+ /// from , which is the tenant's own "played on" free-text field
+ /// and is never overwritten by reference data.
+ ///
+ public List Platforms { get; set; } = [];
+
+ public required Dictionary ExternalIds { get; set; }
+
+ ///
+ /// Every (title, year) combination that has ever been confirmed to mean this game - see
+ /// for the full rationale.
+ ///
+ public List MatchedAliases { get; set; } = [];
+
+ public List Genres { get; set; } = [];
+
+ public string? ImageUrl { get; set; }
+
+ public DateTime? LastEnrichedAt { get; set; }
+}
diff --git a/src/Domain/Repositories/IAlbumReferenceRepository.cs b/src/Domain/Repositories/IAlbumReferenceRepository.cs
new file mode 100644
index 00000000..ed16bbb4
--- /dev/null
+++ b/src/Domain/Repositories/IAlbumReferenceRepository.cs
@@ -0,0 +1,43 @@
+using System.Collections.Generic;
+using System.Threading.Tasks;
+using Keeptrack.Domain.Models;
+
+namespace Keeptrack.Domain.Repositories;
+
+///
+/// Repository for the shared, owner-less album reference collection. See
+/// for why this doesn't extend .
+///
+public interface IAlbumReferenceRepository
+{
+ Task FindByIdAsync(string id);
+
+ ///
+ /// is required (not optional) as part of the match key, not just a search
+ /// hint: two different tenants' different albums can easily share the same (title, year) - a generic
+ /// title re-released the same year is common - so title+year alone risks silently linking a tenant
+ /// to another tenant's unrelated album. See .
+ ///
+ Task FindByTitleYearAsync(string title, int? year, string artist);
+
+ ///
+ /// Title-only fallback match (normalized, ignores year) for "or just title" matching when a
+ /// title+year lookup finds nothing - still requires to match, for the same
+ /// reason does.
+ ///
+ Task FindByTitleAsync(string title, string artist);
+
+ ///
+ /// Looks up a reference document by external provider id (e.g. its Discogs master id) - the strongest
+ /// possible "is this genuinely the same album" signal, unaffected by title text ever diverging.
+ ///
+ Task FindByExternalIdAsync(string provider, string externalId);
+
+ Task UpsertAsync(AlbumReferenceModel model);
+
+ ///
+ /// Every reference document, for admin export. Bounded, shared metadata (not per-tenant), so a
+ /// full unpaged read is fine.
+ ///
+ Task> FindAllAsync();
+}
diff --git a/src/Domain/Repositories/IAlbumRepository.cs b/src/Domain/Repositories/IAlbumRepository.cs
new file mode 100644
index 00000000..5df8c4f8
--- /dev/null
+++ b/src/Domain/Repositories/IAlbumRepository.cs
@@ -0,0 +1,22 @@
+using System.Collections.Generic;
+using System.Threading.Tasks;
+using Keeptrack.Domain.Models;
+
+namespace Keeptrack.Domain.Repositories;
+
+public interface IAlbumRepository : IDataRepository
+{
+ ///
+ /// Sets , , ,
+ /// and (to the reference's canonical values)
+ /// on every tenant's album matching this title/year that doesn't already have a reference link - see
+ /// .
+ ///
+ Task SetReferenceLinkAsync(string title, int? year, string referenceId, string canonicalTitle, int? canonicalYear = null, string? canonicalArtist = null, string? canonicalGenre = null);
+
+ ///
+ /// Distinct (title, year) pairs across every tenant's albums that have no
+ /// yet - feeds the admin curation queue.
+ ///
+ Task> FindDistinctUnresolvedTitleYearsAsync();
+}
diff --git a/src/Domain/Repositories/IBookReferenceRepository.cs b/src/Domain/Repositories/IBookReferenceRepository.cs
new file mode 100644
index 00000000..d97da182
--- /dev/null
+++ b/src/Domain/Repositories/IBookReferenceRepository.cs
@@ -0,0 +1,43 @@
+using System.Collections.Generic;
+using System.Threading.Tasks;
+using Keeptrack.Domain.Models;
+
+namespace Keeptrack.Domain.Repositories;
+
+///
+/// Repository for the shared, owner-less book reference collection. See
+/// for why this doesn't extend .
+///
+public interface IBookReferenceRepository
+{
+ Task FindByIdAsync(string id);
+
+ ///
+ /// is required (not optional) as part of the match key, not just a search
+ /// hint: two different tenants' different books can easily share the same (title, year) - a generic
+ /// title re-published the same year is common - so title+year alone risks silently linking a tenant
+ /// to another tenant's unrelated book. See .
+ ///
+ Task FindByTitleYearAsync(string title, int? year, string author);
+
+ ///
+ /// Title-only fallback match (normalized, ignores year) for "or just title" matching when a
+ /// title+year lookup finds nothing - still requires to match, for the same
+ /// reason does.
+ ///
+ Task FindByTitleAsync(string title, string author);
+
+ ///
+ /// Looks up a reference document by external provider id (e.g. its Open Library work id) - the
+ /// strongest possible "is this genuinely the same book" signal, unaffected by title text ever diverging.
+ ///
+ Task FindByExternalIdAsync(string provider, string externalId);
+
+ Task UpsertAsync(BookReferenceModel model);
+
+ ///
+ /// Every reference document, for admin export. Bounded, shared metadata (not per-tenant), so a
+ /// full unpaged read is fine.
+ ///
+ Task> FindAllAsync();
+}
diff --git a/src/Domain/Repositories/IBookRepository.cs b/src/Domain/Repositories/IBookRepository.cs
index ea11127e..bb911cc4 100644
--- a/src/Domain/Repositories/IBookRepository.cs
+++ b/src/Domain/Repositories/IBookRepository.cs
@@ -1,7 +1,22 @@
-using Keeptrack.Domain.Models;
+using System.Collections.Generic;
+using System.Threading.Tasks;
+using Keeptrack.Domain.Models;
namespace Keeptrack.Domain.Repositories;
public interface IBookRepository : IDataRepository
{
+ ///
+ /// Sets , , ,
+ /// and (to the reference's canonical values)
+ /// on every tenant's book matching this title/year that doesn't already have a reference link - see
+ /// .
+ ///
+ Task SetReferenceLinkAsync(string title, int? year, string referenceId, string canonicalTitle, int? canonicalYear = null, string? canonicalAuthor = null, string? canonicalGenre = null);
+
+ ///
+ /// Distinct (title, year) pairs across every tenant's books that have no
+ /// yet - feeds the admin curation queue.
+ ///
+ Task> FindDistinctUnresolvedTitleYearsAsync();
}
diff --git a/src/Domain/Repositories/ICarHistoryRepository.cs b/src/Domain/Repositories/ICarHistoryRepository.cs
index beb7921f..24861512 100644
--- a/src/Domain/Repositories/ICarHistoryRepository.cs
+++ b/src/Domain/Repositories/ICarHistoryRepository.cs
@@ -1,7 +1,14 @@
-using Keeptrack.Domain.Models;
+using System.Threading.Tasks;
+using Keeptrack.Domain.Models;
namespace Keeptrack.Domain.Repositories;
public interface ICarHistoryRepository : IDataRepository
{
+ ///
+ /// Deletes every history entry owned by for the given car - used to cascade
+ /// a car deletion, since CarHistory is a separate top-level collection referencing its parent by id
+ /// rather than an embedded array (see CLAUDE.md's "Child entities" section).
+ ///
+ Task DeleteAllForCarAsync(string carId, string ownerId);
}
diff --git a/src/Domain/Repositories/IEpisodeRepository.cs b/src/Domain/Repositories/IEpisodeRepository.cs
new file mode 100644
index 00000000..9c642d57
--- /dev/null
+++ b/src/Domain/Repositories/IEpisodeRepository.cs
@@ -0,0 +1,7 @@
+using Keeptrack.Domain.Models;
+
+namespace Keeptrack.Domain.Repositories;
+
+public interface IEpisodeRepository : IDataRepository
+{
+}
diff --git a/src/Domain/Repositories/IHouseHistoryRepository.cs b/src/Domain/Repositories/IHouseHistoryRepository.cs
new file mode 100644
index 00000000..529c335b
--- /dev/null
+++ b/src/Domain/Repositories/IHouseHistoryRepository.cs
@@ -0,0 +1,14 @@
+using System.Threading.Tasks;
+using Keeptrack.Domain.Models;
+
+namespace Keeptrack.Domain.Repositories;
+
+public interface IHouseHistoryRepository : IDataRepository
+{
+ ///
+ /// Deletes every history entry owned by for the given house - used to cascade
+ /// a house deletion, since HouseHistory is a separate top-level collection referencing its parent by id
+ /// rather than an embedded array (see CLAUDE.md's "Child entities" section).
+ ///
+ Task DeleteAllForHouseAsync(string houseId, string ownerId);
+}
diff --git a/src/Domain/Repositories/IHouseRepository.cs b/src/Domain/Repositories/IHouseRepository.cs
new file mode 100644
index 00000000..0c9c57ee
--- /dev/null
+++ b/src/Domain/Repositories/IHouseRepository.cs
@@ -0,0 +1,7 @@
+using Keeptrack.Domain.Models;
+
+namespace Keeptrack.Domain.Repositories;
+
+public interface IHouseRepository : IDataRepository
+{
+}
diff --git a/src/Domain/Repositories/IMovieReferenceRepository.cs b/src/Domain/Repositories/IMovieReferenceRepository.cs
new file mode 100644
index 00000000..65f8ff41
--- /dev/null
+++ b/src/Domain/Repositories/IMovieReferenceRepository.cs
@@ -0,0 +1,37 @@
+using System.Collections.Generic;
+using System.Threading.Tasks;
+using Keeptrack.Domain.Models;
+
+namespace Keeptrack.Domain.Repositories;
+
+///
+/// Repository for the shared, owner-less movie reference collection. See
+/// for why this doesn't extend .
+///
+public interface IMovieReferenceRepository
+{
+ Task FindByIdAsync(string id);
+
+ Task FindByTitleYearAsync(string title, int? year);
+
+ ///
+ /// Title-only fallback match (normalized, ignores year) for "or just title" matching when a
+ /// title+year lookup finds nothing - e.g. the tenant's recorded year is wrong or missing.
+ ///
+ Task FindByTitleAsync(string title);
+
+ ///
+ /// Looks up a reference document by external provider id (e.g. its TMDB id) - the strongest possible
+ /// "is this genuinely the same movie" signal, unaffected by title text ever diverging. See
+ /// for the equivalent on cast members.
+ ///
+ Task FindByExternalIdAsync(string provider, string externalId);
+
+ Task UpsertAsync(MovieReferenceModel model);
+
+ ///
+ /// Every reference document, for admin export. Bounded, shared metadata (not per-tenant), so a
+ /// full unpaged read is fine.
+ ///
+ Task> FindAllAsync();
+}
diff --git a/src/Domain/Repositories/IMovieRepository.cs b/src/Domain/Repositories/IMovieRepository.cs
index 3cd62121..153eeb79 100644
--- a/src/Domain/Repositories/IMovieRepository.cs
+++ b/src/Domain/Repositories/IMovieRepository.cs
@@ -1,7 +1,21 @@
-using Keeptrack.Domain.Models;
+using System.Collections.Generic;
+using System.Threading.Tasks;
+using Keeptrack.Domain.Models;
namespace Keeptrack.Domain.Repositories;
public interface IMovieRepository : IDataRepository
{
+ ///
+ /// Sets , and
+ /// (to the reference's canonical values) on every tenant's movie matching this title/year that doesn't
+ /// already have a reference link - see .
+ ///
+ Task SetReferenceLinkAsync(string title, int? year, string referenceId, string canonicalTitle, int? canonicalYear = null);
+
+ ///
+ /// Distinct (title, year) pairs across every tenant's movies that have no
+ /// yet - feeds the admin curation queue.
+ ///
+ Task> FindDistinctUnresolvedTitleYearsAsync();
}
diff --git a/src/Domain/Repositories/IMusicAlbumRepository.cs b/src/Domain/Repositories/IMusicAlbumRepository.cs
deleted file mode 100644
index d88f5072..00000000
--- a/src/Domain/Repositories/IMusicAlbumRepository.cs
+++ /dev/null
@@ -1,5 +0,0 @@
-using Keeptrack.Domain.Models;
-
-namespace Keeptrack.Domain.Repositories;
-
-public interface IMusicAlbumRepository : IDataRepository;
diff --git a/src/Domain/Repositories/IPersonReferenceRepository.cs b/src/Domain/Repositories/IPersonReferenceRepository.cs
new file mode 100644
index 00000000..aed8d881
--- /dev/null
+++ b/src/Domain/Repositories/IPersonReferenceRepository.cs
@@ -0,0 +1,24 @@
+using System.Collections.Generic;
+using System.Threading.Tasks;
+using Keeptrack.Domain.Models;
+
+namespace Keeptrack.Domain.Repositories;
+
+///
+/// Repository for the shared, owner-less person/actor reference collection. See
+/// for why this doesn't extend .
+///
+public interface IPersonReferenceRepository
+{
+ Task FindByIdAsync(string id);
+
+ Task FindByExternalIdAsync(string provider, string externalId);
+
+ Task UpsertAsync(PersonReferenceModel model);
+
+ ///
+ /// Every reference document, for admin export. Bounded, shared metadata (not per-tenant), so a
+ /// full unpaged read is fine.
+ ///
+ Task> FindAllAsync();
+}
diff --git a/src/Domain/Repositories/IPlaylistRepository.cs b/src/Domain/Repositories/IPlaylistRepository.cs
new file mode 100644
index 00000000..bc8b96e9
--- /dev/null
+++ b/src/Domain/Repositories/IPlaylistRepository.cs
@@ -0,0 +1,7 @@
+using Keeptrack.Domain.Models;
+
+namespace Keeptrack.Domain.Repositories;
+
+public interface IPlaylistRepository : IDataRepository
+{
+}
diff --git a/src/Domain/Repositories/ISongRepository.cs b/src/Domain/Repositories/ISongRepository.cs
new file mode 100644
index 00000000..addb72ef
--- /dev/null
+++ b/src/Domain/Repositories/ISongRepository.cs
@@ -0,0 +1,7 @@
+using Keeptrack.Domain.Models;
+
+namespace Keeptrack.Domain.Repositories;
+
+public interface ISongRepository : IDataRepository
+{
+}
diff --git a/src/Domain/Repositories/ITvShowReferenceRepository.cs b/src/Domain/Repositories/ITvShowReferenceRepository.cs
new file mode 100644
index 00000000..cb03aca0
--- /dev/null
+++ b/src/Domain/Repositories/ITvShowReferenceRepository.cs
@@ -0,0 +1,38 @@
+using System.Collections.Generic;
+using System.Threading.Tasks;
+using Keeptrack.Domain.Models;
+
+namespace Keeptrack.Domain.Repositories;
+
+///
+/// Repository for the shared, owner-less TV show reference collection. Deliberately not
+/// - that interface (and its Mongo base class) is hard-constrained
+/// to owner-scoped paged CRUD, which doesn't fit a shared lookup table.
+///
+public interface ITvShowReferenceRepository
+{
+ Task FindByIdAsync(string id);
+
+ Task FindByTitleYearAsync(string title, int? year);
+
+ ///
+ /// Title-only fallback match (normalized, ignores year) for "or just title" matching when a
+ /// title+year lookup finds nothing - e.g. the tenant's recorded year is wrong or missing.
+ ///
+ Task FindByTitleAsync(string title);
+
+ ///
+ /// Looks up a reference document by external provider id (e.g. its TMDB id) - the strongest possible
+ /// "is this genuinely the same show" signal, unaffected by title text ever diverging. See
+ /// for the equivalent on cast members.
+ ///
+ Task FindByExternalIdAsync(string provider, string externalId);
+
+ Task UpsertAsync(TvShowReferenceModel model);
+
+ ///
+ /// Every reference document, for admin export. Bounded, shared metadata (not per-tenant), so a
+ /// full unpaged read is fine.
+ ///
+ Task> FindAllAsync();
+}
diff --git a/src/Domain/Repositories/ITvShowRepository.cs b/src/Domain/Repositories/ITvShowRepository.cs
index fce4caa8..1af6d952 100644
--- a/src/Domain/Repositories/ITvShowRepository.cs
+++ b/src/Domain/Repositories/ITvShowRepository.cs
@@ -1,7 +1,24 @@
-using Keeptrack.Domain.Models;
+using System.Collections.Generic;
+using System.Threading.Tasks;
+using Keeptrack.Domain.Models;
namespace Keeptrack.Domain.Repositories;
public interface ITvShowRepository : IDataRepository
{
+ ///
+ /// Sets and (to the reference's
+ /// canonical name) on every tenant's show matching this title/year that doesn't already have a
+ /// reference link - the confirmed cross-tenant propagation for reference-data linking. Also sets
+ /// to when the reference has one, so a
+ /// newly-linked show starts with a trustworthy year instead of whatever the tenant originally guessed
+ /// (still freely editable afterward). Otherwise never touches any tenant's own rating/notes/episodes.
+ ///
+ Task SetReferenceLinkAsync(string title, int? year, string referenceId, string canonicalTitle, int? canonicalYear = null);
+
+ ///
+ /// Distinct (title, year) pairs across every tenant's shows that have no
+ /// yet - feeds the admin curation queue.
+ ///
+ Task> FindDistinctUnresolvedTitleYearsAsync();
}
diff --git a/src/Domain/Repositories/IVideoGameReferenceRepository.cs b/src/Domain/Repositories/IVideoGameReferenceRepository.cs
new file mode 100644
index 00000000..981d250b
--- /dev/null
+++ b/src/Domain/Repositories/IVideoGameReferenceRepository.cs
@@ -0,0 +1,36 @@
+using System.Collections.Generic;
+using System.Threading.Tasks;
+using Keeptrack.Domain.Models;
+
+namespace Keeptrack.Domain.Repositories;
+
+///
+/// Repository for the shared, owner-less video game reference collection. See
+/// for why this doesn't extend .
+///
+public interface IVideoGameReferenceRepository
+{
+ Task FindByIdAsync(string id);
+
+ Task FindByTitleYearAsync(string title, int? year);
+
+ ///
+ /// Title-only fallback match (normalized, ignores year) for "or just title" matching when a
+ /// title+year lookup finds nothing.
+ ///
+ Task FindByTitleAsync(string title);
+
+ ///
+ /// Looks up a reference document by external provider id (e.g. its RAWG game id) - the strongest
+ /// possible "is this genuinely the same game" signal, unaffected by title text ever diverging.
+ ///
+ Task FindByExternalIdAsync(string provider, string externalId);
+
+ Task UpsertAsync(VideoGameReferenceModel model);
+
+ ///
+ /// Every reference document, for admin export. Bounded, shared metadata (not per-tenant), so a
+ /// full unpaged read is fine.
+ ///
+ Task> FindAllAsync();
+}
diff --git a/src/Domain/Repositories/IVideoGameRepository.cs b/src/Domain/Repositories/IVideoGameRepository.cs
index a8709581..7aa13c1d 100644
--- a/src/Domain/Repositories/IVideoGameRepository.cs
+++ b/src/Domain/Repositories/IVideoGameRepository.cs
@@ -1,7 +1,21 @@
-using Keeptrack.Domain.Models;
+using System.Collections.Generic;
+using System.Threading.Tasks;
+using Keeptrack.Domain.Models;
namespace Keeptrack.Domain.Repositories;
public interface IVideoGameRepository : IDataRepository
{
+ ///
+ /// Sets , and
+ /// (to the reference's canonical values) on every tenant's game matching
+ /// this title/year that doesn't already have a reference link - see .
+ ///
+ Task SetReferenceLinkAsync(string title, int? year, string referenceId, string canonicalTitle, int? canonicalYear = null);
+
+ ///
+ /// Distinct (title, year) pairs across every tenant's games that have no
+ /// yet - feeds the admin curation queue.
+ ///
+ Task> FindDistinctUnresolvedTitleYearsAsync();
}
diff --git a/src/Domain/Services/CarMetricsService.cs b/src/Domain/Services/CarMetricsService.cs
new file mode 100644
index 00000000..b8a27c32
--- /dev/null
+++ b/src/Domain/Services/CarMetricsService.cs
@@ -0,0 +1,171 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using Keeptrack.Domain.Models;
+
+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.
+///
+public class CarMetricsService
+{
+ private const double MileageToleranceKm = 1;
+
+ public CarMetricsModel ComputeMetrics(IEnumerable history)
+ {
+ var historyList = history.ToList();
+
+ return new CarMetricsModel
+ {
+ FuelConsumption = ComputeConsumption(historyList, h => h.FuelVolume, out var averageFuel),
+ AverageFuelConsumptionPer100Km = averageFuel,
+ ElectricConsumption = ComputeConsumption(historyList, h => h.ElectricVolume, out var averageElectric),
+ AverageElectricConsumptionPer100Km = averageElectric,
+ CostHistory = ComputeCostHistory(historyList),
+ TotalCost = historyList.Sum(h => h.Cost ?? 0),
+ MileageWarnings = ComputeMileageWarnings(historyList),
+ NextMaintenance = ComputeNextMaintenanceDue(historyList)
+ };
+ }
+
+ ///
+ /// Shared trip-computer algorithm for both fuel (liters) and electric (kWh) consumption - a data point is
+ /// only ever emitted across a full refill/recharge ( ): the
+ /// quantity added by every refill since the last full one is accumulated, and once a full refill closes
+ /// that window, the accumulated quantity is divided by the mileage covered since the previous full refill.
+ /// A partial refill on its own never produces a data point.
+ ///
+ private static List ComputeConsumption(
+ IReadOnlyList history, Func quantitySelector, out double? average)
+ {
+ var refuels = history
+ .Where(h => h.EventType == CarHistoryType.Refuel && h.Mileage is not null && quantitySelector(h) is not null)
+ .OrderBy(h => h.Mileage)
+ .ToList();
+
+ var points = new List();
+ var quantitySinceLastFullRefill = 0.0;
+ CarHistoryModel? lastFullRefill = null;
+
+ foreach (var refuel in refuels)
+ {
+ quantitySinceLastFullRefill += quantitySelector(refuel)!.Value;
+
+ if (refuel.IsFullRefill != true) continue;
+
+ if (lastFullRefill is not null)
+ {
+ var distance = refuel.Mileage!.Value - lastFullRefill.Mileage!.Value;
+ if (distance > 0)
+ {
+ points.Add(new ConsumptionPointModel
+ {
+ Date = DateOnly.FromDateTime(refuel.HistoryDate),
+ ValuePer100Km = quantitySinceLastFullRefill / distance * 100
+ });
+ }
+ }
+
+ lastFullRefill = refuel;
+ quantitySinceLastFullRefill = 0;
+ }
+
+ average = points.Count > 0 ? points.Average(p => p.ValuePer100Km) : null;
+ return points;
+ }
+
+ private static List ComputeCostHistory(IReadOnlyList history) =>
+ history
+ .Where(h => h.Cost is not null)
+ .GroupBy(h => new DateOnly(h.HistoryDate.Year, h.HistoryDate.Month, 1))
+ .OrderBy(g => g.Key)
+ .Select(g => new CarCostHistoryPointModel
+ {
+ Period = g.Key,
+ FuelCost = g.Where(h => h.EventType == CarHistoryType.Refuel).Sum(h => h.Cost!.Value),
+ MaintenanceCost = g.Where(h => h.EventType != CarHistoryType.Refuel).Sum(h => h.Cost!.Value)
+ })
+ .ToList();
+
+ ///
+ /// The automated version of the manual Excel cross-check the user used to do by hand: a regression check
+ /// (the odometer can never go backwards) plus a delta cross-check between the user-entered
+ /// (typically read off the car's trip computer at refuel time)
+ /// and the computed mileage difference between consecutive entries. A mismatch means either number was
+ /// mistyped, or - if the computed delta is larger than the entered one - an entry was never logged at all.
+ /// Advisory only: nothing here excludes an entry from the metrics above or corrects it automatically.
+ ///
+ private static List ComputeMileageWarnings(IReadOnlyList history)
+ {
+ var ordered = history
+ .Where(h => h.Mileage is not null)
+ .OrderBy(h => h.HistoryDate)
+ .ToList();
+
+ var warnings = new List();
+
+ for (var i = 1; i < ordered.Count; i++)
+ {
+ var previous = ordered[i - 1];
+ var current = ordered[i];
+
+ if (current.Mileage < previous.Mileage)
+ {
+ warnings.Add(new CarMileageWarningModel
+ {
+ CarHistoryId = current.Id!,
+ Message = $"Mileage {current.Mileage} on {current.HistoryDate:yyyy-MM-dd} is lower than {previous.Mileage} " +
+ $"recorded on {previous.HistoryDate:yyyy-MM-dd}."
+ });
+ continue;
+ }
+
+ if (current.DeltaMileage is null) continue;
+
+ var computedDelta = current.Mileage!.Value - previous.Mileage!.Value;
+ if (Math.Abs(computedDelta - current.DeltaMileage.Value) <= MileageToleranceKm) continue;
+
+ var suggestsMissingEntry = computedDelta > current.DeltaMileage.Value;
+ warnings.Add(new CarMileageWarningModel
+ {
+ CarHistoryId = current.Id!,
+ Message = $"Entered delta mileage ({current.DeltaMileage}) doesn't match the {computedDelta} " +
+ $"difference between this entry's mileage ({current.Mileage}) and the previous entry's " +
+ $"({previous.Mileage} on {previous.HistoryDate:yyyy-MM-dd})." +
+ (suggestsMissingEntry ? " An entry may be missing in between." : " Check both entries for a typo.")
+ });
+ }
+
+ return warnings;
+ }
+
+ ///
+ /// Assumes a fixed 1-year maintenance cadence from the last recorded maintenance event. Returns null - never
+ /// a guess - when no maintenance history exists yet, same "don't guess without data" principle as
+ /// WatchNextService.
+ ///
+ private static NextMaintenanceModel? ComputeNextMaintenanceDue(IReadOnlyList history)
+ {
+ var lastMaintenance = history
+ .Where(h => h.EventType == CarHistoryType.Maintenance)
+ .OrderByDescending(h => h.HistoryDate)
+ .FirstOrDefault();
+
+ if (lastMaintenance is null) return null;
+
+ var lastMaintenanceDate = DateOnly.FromDateTime(lastMaintenance.HistoryDate);
+ var dueDate = lastMaintenanceDate.AddYears(1);
+ var today = DateOnly.FromDateTime(DateTime.Today);
+ var monthsRemaining = ((dueDate.Year - today.Year) * 12) + (dueDate.Month - today.Month);
+
+ return new NextMaintenanceModel
+ {
+ LastMaintenanceDate = lastMaintenanceDate,
+ DueDate = dueDate,
+ MonthsRemaining = monthsRemaining
+ };
+ }
+}
diff --git a/src/Domain/Services/HouseMetricsService.cs b/src/Domain/Services/HouseMetricsService.cs
new file mode 100644
index 00000000..70f87d8c
--- /dev/null
+++ b/src/Domain/Services/HouseMetricsService.cs
@@ -0,0 +1,34 @@
+using System.Collections.Generic;
+using System.Linq;
+using Keeptrack.Domain.Models;
+
+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.
+///
+public class HouseMetricsService
+{
+ public HouseMetricsModel ComputeMetrics(IEnumerable history) =>
+ new() { CostHistory = ComputeAnnualCostHistory(history) };
+
+ private static List ComputeAnnualCostHistory(IEnumerable history) =>
+ history
+ .Where(h => h.Cost is not null)
+ .GroupBy(h => h.HistoryDate.Year)
+ .OrderBy(g => g.Key)
+ .Select(g => new HouseCostHistoryPointModel
+ {
+ Year = g.Key,
+ TotalCost = g.Sum(h => h.Cost!.Value),
+ CostByCategory = g.GroupBy(h => h.EventType)
+ .Select(cg => new HouseCategoryCostModel { EventType = cg.Key, Cost = cg.Sum(h => h.Cost!.Value) })
+ .OrderBy(c => c.EventType)
+ .ToList()
+ })
+ .ToList();
+}
diff --git a/src/Domain/Services/WatchNextService.cs b/src/Domain/Services/WatchNextService.cs
new file mode 100644
index 00000000..c35ff80c
--- /dev/null
+++ b/src/Domain/Services/WatchNextService.cs
@@ -0,0 +1,74 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using Keeptrack.Domain.Models;
+
+namespace Keeptrack.Domain.Services;
+
+///
+/// Computes, for each in-progress TV show, whether there is a confirmed unseen episode to watch next.
+///
+public static class WatchNextService
+{
+ ///
+ /// A show only appears here if it's marked
+ /// AND has a resolved TMDB episode guide ( ) confirming an aired episode exists after the last one watched.
+ /// Without a reference, Keeptrack has no episode-guide data and can't tell whether a further episode actually exists (the show might already be fully caught up) -
+ /// so unlinked shows are excluded rather than guessed at, the same "don't guess" principle as before, now enforced by data.
+ ///
+ public static List ComputeInProgressShows(
+ IEnumerable shows,
+ IEnumerable episodes,
+ IReadOnlyDictionary referencesByShowId)
+ {
+ var currentShows = shows.Where(s => s.State == Domain.Models.TvShowStatus.Current).ToDictionary(s => s.Id!);
+ var today = DateOnly.FromDateTime(DateTime.Today);
+
+ return episodes
+ .Where(e => currentShows.ContainsKey(e.TvShowId))
+ .GroupBy(e => e.TvShowId)
+ .Select(group =>
+ {
+ var show = currentShows[group.Key];
+ var lastWatched = group
+ .OrderByDescending(e => e.SeasonNumber)
+ .ThenByDescending(e => e.EpisodeNumber)
+ .First();
+
+ if (!referencesByShowId.TryGetValue(group.Key, out var reference)) return null;
+
+ var nextEpisode = reference.Episodes
+ .Where(e => e.SeasonNumber > lastWatched.SeasonNumber
+ || (e.SeasonNumber == lastWatched.SeasonNumber && e.EpisodeNumber > lastWatched.EpisodeNumber))
+ .Where(e => e.AirDate is null || e.AirDate <= today)
+ .OrderBy(e => e.SeasonNumber)
+ .ThenBy(e => e.EpisodeNumber)
+ .FirstOrDefault();
+ if (nextEpisode is null) return null;
+
+ return new InProgressShowModel
+ {
+ TvShowId = show.Id!,
+ TvShowTitle = show.Title,
+ LastSeasonNumber = lastWatched.SeasonNumber,
+ LastEpisodeNumber = lastWatched.EpisodeNumber,
+ LastWatchedAt = lastWatched.WatchedAt,
+ NextSeasonNumber = nextEpisode.SeasonNumber,
+ NextEpisodeNumber = nextEpisode.EpisodeNumber,
+ NextEpisodeTitle = nextEpisode.Title
+ };
+ })
+ .Where(model => model is not null)
+ .Select(model => model!)
+ .OrderByDescending(n => n.LastWatchedAt)
+ .ToList();
+ }
+
+ ///
+ /// A movie flagged "want to watch" that has since been marked seen shouldn't linger in the watchlist -
+ /// mirrors MovieTrackingEventsCsvParser 's "towatch" handling, which never flags an already-watched movie in the first place.
+ /// Toggling "want to watch" directly on a movie's own detail page doesn't clear the flag on watch, so this is filtered here rather than relying on the flag never going stale.
+ ///
+ public static List FilterMoviesToWatch(IEnumerable movies) =>
+ movies.Where(m => m.FirstSeenAt is null).ToList();
+}
diff --git a/src/Domain/Services/WishlistService.cs b/src/Domain/Services/WishlistService.cs
new file mode 100644
index 00000000..a4f226ae
--- /dev/null
+++ b/src/Domain/Services/WishlistService.cs
@@ -0,0 +1,24 @@
+using System.Collections.Generic;
+using System.Linq;
+using Keeptrack.Domain.Models;
+
+namespace Keeptrack.Domain.Services;
+
+///
+/// Assembles the cross-type wishlist view: each list is expected to already be filtered to IsWishlisted == true by the repository query (see WishlistController ),
+/// so this only sorts each type's results alphabetically by title for a stable, scannable display order.
+///
+public static class WishlistService
+{
+ public static List SortMovies(IEnumerable movies) =>
+ movies.OrderBy(m => m.Title).ToList();
+
+ public static List SortTvShows(IEnumerable tvShows) =>
+ tvShows.OrderBy(s => s.Title).ToList();
+
+ public static List SortBooks(IEnumerable books) =>
+ books.OrderBy(b => b.Title).ToList();
+
+ public static List SortVideoGames(IEnumerable videoGames) =>
+ videoGames.OrderBy(g => g.Title).ToList();
+}
diff --git a/src/Infrastructure.MongoDb/Entities/MusicAlbum.cs b/src/Infrastructure.MongoDb/Entities/Album.cs
similarity index 69%
rename from src/Infrastructure.MongoDb/Entities/MusicAlbum.cs
rename to src/Infrastructure.MongoDb/Entities/Album.cs
index 301a99aa..a1220577 100644
--- a/src/Infrastructure.MongoDb/Entities/MusicAlbum.cs
+++ b/src/Infrastructure.MongoDb/Entities/Album.cs
@@ -1,10 +1,10 @@
-using Keeptrack.Common.System;
+using Keeptrack.Common.System;
using MongoDB.Bson;
using MongoDB.Bson.Serialization.Attributes;
namespace Keeptrack.Infrastructure.MongoDb.Entities;
-public class MusicAlbum : IHasIdAndOwnerId
+public class Album : IHasIdAndOwnerId
{
[BsonId]
[BsonRepresentation(BsonType.ObjectId)]
@@ -22,4 +22,10 @@ public class MusicAlbum : IHasIdAndOwnerId
public string? Genre { get; set; }
public float? Rating { get; set; }
+
+ [BsonElement("reference_id")]
+ public string? ReferenceId { get; set; }
+
+ [BsonElement("is_favorite")]
+ public bool IsFavorite { get; set; }
}
diff --git a/src/Infrastructure.MongoDb/Entities/AlbumReference.cs b/src/Infrastructure.MongoDb/Entities/AlbumReference.cs
new file mode 100644
index 00000000..40e8b693
--- /dev/null
+++ b/src/Infrastructure.MongoDb/Entities/AlbumReference.cs
@@ -0,0 +1,45 @@
+using System;
+using System.Collections.Generic;
+using MongoDB.Bson;
+using MongoDB.Bson.Serialization.Attributes;
+
+namespace Keeptrack.Infrastructure.MongoDb.Entities;
+
+///
+/// Shared, owner-less album metadata collection (album_reference ). See
+/// for why this has no owner_id .
+///
+public class AlbumReference
+{
+ [BsonId]
+ [BsonRepresentation(BsonType.ObjectId)]
+ public string? Id { get; set; }
+
+ public required string Title { get; set; }
+
+ [BsonElement("title_normalized")]
+ public required string TitleNormalized { get; set; }
+
+ public int? Year { get; set; }
+
+ public string? Synopsis { get; set; }
+
+ [BsonElement("artist_reference_id")]
+ public string? ArtistReferenceId { get; set; }
+
+ [BsonElement("external_ids")]
+ public required Dictionary ExternalIds { get; set; }
+
+ [BsonElement("matched_aliases")]
+ public List MatchedAliases { get; set; } = [];
+
+ public List Genres { get; set; } = [];
+
+ public List Tracks { get; set; } = [];
+
+ [BsonElement("image_url")]
+ public string? ImageUrl { get; set; }
+
+ [BsonElement("last_enriched_at")]
+ public DateTime? LastEnrichedAt { get; set; }
+}
diff --git a/src/Infrastructure.MongoDb/Entities/Allocine.cs b/src/Infrastructure.MongoDb/Entities/Allocine.cs
deleted file mode 100644
index 9a6801a3..00000000
--- a/src/Infrastructure.MongoDb/Entities/Allocine.cs
+++ /dev/null
@@ -1,11 +0,0 @@
-using MongoDB.Bson;
-using MongoDB.Bson.Serialization.Attributes;
-
-namespace Keeptrack.Infrastructure.MongoDb.Entities;
-
-public class Allocine
-{
- [BsonId]
- [BsonRepresentation(BsonType.ObjectId)]
- public string Id { get; set; } = null!;
-}
diff --git a/src/Infrastructure.MongoDb/Entities/Book.cs b/src/Infrastructure.MongoDb/Entities/Book.cs
index d9e05067..5d24f79c 100644
--- a/src/Infrastructure.MongoDb/Entities/Book.cs
+++ b/src/Infrastructure.MongoDb/Entities/Book.cs
@@ -20,6 +20,8 @@ public class Book : IHasIdAndOwnerId
public string? Series { get; set; }
+ public int? Year { get; set; }
+
public float? Rating { get; set; }
public string? Genre { get; set; }
@@ -28,4 +30,16 @@ public class Book : IHasIdAndOwnerId
[BsonElement("first_read_at")]
public DateTime? FirstReadAt { get; set; }
+
+ [BsonElement("reference_id")]
+ public string? ReferenceId { get; set; }
+
+ [BsonElement("is_favorite")]
+ public bool IsFavorite { get; set; }
+
+ [BsonElement("is_owned")]
+ public bool IsOwned { get; set; }
+
+ [BsonElement("is_wishlisted")]
+ public bool IsWishlisted { get; set; }
}
diff --git a/src/Infrastructure.MongoDb/Entities/BookReference.cs b/src/Infrastructure.MongoDb/Entities/BookReference.cs
new file mode 100644
index 00000000..5ff70d90
--- /dev/null
+++ b/src/Infrastructure.MongoDb/Entities/BookReference.cs
@@ -0,0 +1,43 @@
+using System;
+using System.Collections.Generic;
+using MongoDB.Bson;
+using MongoDB.Bson.Serialization.Attributes;
+
+namespace Keeptrack.Infrastructure.MongoDb.Entities;
+
+///
+/// Shared, owner-less book metadata collection (book_reference ). See
+/// for why this has no owner_id .
+///
+public class BookReference
+{
+ [BsonId]
+ [BsonRepresentation(BsonType.ObjectId)]
+ public string? Id { get; set; }
+
+ public required string Title { get; set; }
+
+ [BsonElement("title_normalized")]
+ public required string TitleNormalized { get; set; }
+
+ public int? Year { get; set; }
+
+ public string? Synopsis { get; set; }
+
+ [BsonElement("author_reference_id")]
+ public string? AuthorReferenceId { get; set; }
+
+ [BsonElement("external_ids")]
+ public required Dictionary ExternalIds { get; set; }
+
+ [BsonElement("matched_aliases")]
+ public List MatchedAliases { get; set; } = [];
+
+ public List Genres { get; set; } = [];
+
+ [BsonElement("image_url")]
+ public string? ImageUrl { get; set; }
+
+ [BsonElement("last_enriched_at")]
+ public DateTime? LastEnrichedAt { get; set; }
+}
diff --git a/src/Infrastructure.MongoDb/Entities/Car.cs b/src/Infrastructure.MongoDb/Entities/Car.cs
index fac6fe07..246bc6de 100644
--- a/src/Infrastructure.MongoDb/Entities/Car.cs
+++ b/src/Infrastructure.MongoDb/Entities/Car.cs
@@ -1,4 +1,5 @@
-using Keeptrack.Common.System;
+using Keeptrack.Common.System;
+using Keeptrack.Domain.Models;
using MongoDB.Bson;
using MongoDB.Bson.Serialization.Attributes;
@@ -15,4 +16,16 @@ public class Car : IHasIdAndOwnerId
[BsonElement("commercial_name")]
public required string Name { get; set; }
+
+ public string? Manufacturer { get; set; }
+
+ public string? Model { get; set; }
+
+ public int? Year { get; set; }
+
+ [BsonElement("license_plate")]
+ public string? LicensePlate { get; set; }
+
+ [BsonElement("energy_type")]
+ public required CarEnergyType EnergyType { get; set; }
}
diff --git a/src/Infrastructure.MongoDb/Entities/CarHistory.cs b/src/Infrastructure.MongoDb/Entities/CarHistory.cs
index 43fd9191..f0808d67 100644
--- a/src/Infrastructure.MongoDb/Entities/CarHistory.cs
+++ b/src/Infrastructure.MongoDb/Entities/CarHistory.cs
@@ -1,6 +1,6 @@
-using System;
-using System.Collections.Generic;
+using System;
using Keeptrack.Common.System;
+using Keeptrack.Domain.Models;
using MongoDB.Bson;
using MongoDB.Bson.Serialization.Attributes;
@@ -19,17 +19,22 @@ public class CarHistory : IHasIdAndOwnerId
public required string CarId { get; set; }
[BsonElement("history_date")]
- public DateTime HistoryDate { get; set; }
+ public required DateTime HistoryDate { get; set; }
public double? Mileage { get; set; }
- public required string Action { get; set; }
+ [BsonElement("event_type")]
+ public required CarHistoryType EventType { get; set; }
- public CarHistoryLocation? Location { get; set; }
+ public string? Description { get; set; }
+
+ public double? Cost { get; set; }
- public List? Coordinates { get; set; }
+ public CarHistoryLocation? Location { get; set; }
public CarHistoryFuel? Fuel { get; set; }
public CarHistoryStation? Station { get; set; }
+
+ public string? Garage { get; set; }
}
diff --git a/src/Infrastructure.MongoDb/Entities/CarHistoryFuel.cs b/src/Infrastructure.MongoDb/Entities/CarHistoryFuel.cs
index 9fb11507..635032cc 100644
--- a/src/Infrastructure.MongoDb/Entities/CarHistoryFuel.cs
+++ b/src/Infrastructure.MongoDb/Entities/CarHistoryFuel.cs
@@ -1,25 +1,25 @@
-using MongoDB.Bson;
using MongoDB.Bson.Serialization.Attributes;
namespace Keeptrack.Infrastructure.MongoDb.Entities;
public class CarHistoryFuel
{
- public required string Category { get; set; }
+ public string? Category { get; set; }
public double? Volume { get; set; }
[BsonElement("unit_price")]
public double? UnitPrice { get; set; }
- public double? Amount { get; set; }
+ [BsonElement("electric_volume")]
+ public double? ElectricVolume { get; set; }
- [BsonElement("is_full_tank")]
- public bool? IsFullTank { get; set; }
+ [BsonElement("electric_unit_price")]
+ public double? ElectricUnitPrice { get; set; }
+
+ [BsonElement("is_full_refill")]
+ public bool? IsFullRefill { get; set; }
[BsonElement("delta_mileage")]
public double? DeltaMileage { get; set; }
-
- [BsonElement("last_refuel_history_id")]
- public ObjectId? LastRefuelHistoryId { get; set; }
}
diff --git a/src/Infrastructure.MongoDb/Entities/CarHistoryLocation.cs b/src/Infrastructure.MongoDb/Entities/CarHistoryLocation.cs
index 3f2f76b1..ac195665 100644
--- a/src/Infrastructure.MongoDb/Entities/CarHistoryLocation.cs
+++ b/src/Infrastructure.MongoDb/Entities/CarHistoryLocation.cs
@@ -1,6 +1,16 @@
-namespace Keeptrack.Infrastructure.MongoDb.Entities;
+using System.Collections.Generic;
+using MongoDB.Bson.Serialization.Attributes;
+
+namespace Keeptrack.Infrastructure.MongoDb.Entities;
public class CarHistoryLocation
{
public required string City { get; set; }
+
+ [BsonElement("postal_code")]
+ public string? PostalCode { get; set; }
+
+ public string? Country { get; set; }
+
+ public List? Coordinates { get; set; }
}
diff --git a/src/Infrastructure.MongoDb/Entities/CastMember.cs b/src/Infrastructure.MongoDb/Entities/CastMember.cs
new file mode 100644
index 00000000..db68d9e0
--- /dev/null
+++ b/src/Infrastructure.MongoDb/Entities/CastMember.cs
@@ -0,0 +1,18 @@
+using MongoDB.Bson.Serialization.Attributes;
+
+namespace Keeptrack.Infrastructure.MongoDb.Entities;
+
+///
+/// Embedded within / - see
+/// for the embedding rationale.
+///
+public class CastMember
+{
+ [BsonElement("person_reference_id")]
+ public required string PersonReferenceId { get; set; }
+
+ [BsonElement("character_name")]
+ public required string CharacterName { get; set; }
+
+ public int Order { get; set; }
+}
diff --git a/src/Infrastructure.MongoDb/Entities/Episode.cs b/src/Infrastructure.MongoDb/Entities/Episode.cs
new file mode 100644
index 00000000..e5eac283
--- /dev/null
+++ b/src/Infrastructure.MongoDb/Entities/Episode.cs
@@ -0,0 +1,30 @@
+using System;
+using Keeptrack.Common.System;
+using MongoDB.Bson;
+using MongoDB.Bson.Serialization.Attributes;
+
+namespace Keeptrack.Infrastructure.MongoDb.Entities;
+
+public class Episode : IHasIdAndOwnerId
+{
+ [BsonId]
+ [BsonRepresentation(BsonType.ObjectId)]
+ public string? Id { get; set; }
+
+ [BsonElement("owner_id")]
+ public required string OwnerId { get; set; }
+
+ [BsonElement("tv_show_id")]
+ public required string TvShowId { get; set; }
+
+ [BsonElement("season_number")]
+ public required int SeasonNumber { get; set; }
+
+ [BsonElement("episode_number")]
+ public required int EpisodeNumber { get; set; }
+
+ [BsonElement("watched_at")]
+ public DateTime? WatchedAt { get; set; }
+
+ public string? Notes { get; set; }
+}
diff --git a/src/Infrastructure.MongoDb/Entities/House.cs b/src/Infrastructure.MongoDb/Entities/House.cs
new file mode 100644
index 00000000..0bc6e1d2
--- /dev/null
+++ b/src/Infrastructure.MongoDb/Entities/House.cs
@@ -0,0 +1,28 @@
+using Keeptrack.Common.System;
+using MongoDB.Bson;
+using MongoDB.Bson.Serialization.Attributes;
+
+namespace Keeptrack.Infrastructure.MongoDb.Entities;
+
+public class House : 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? Address { get; set; }
+
+ public string? City { get; set; }
+
+ [BsonElement("postal_code")]
+ public string? PostalCode { get; set; }
+
+ public string? Country { get; set; }
+
+ public string? Notes { get; set; }
+}
diff --git a/src/Infrastructure.MongoDb/Entities/HouseHistory.cs b/src/Infrastructure.MongoDb/Entities/HouseHistory.cs
new file mode 100644
index 00000000..44c0286c
--- /dev/null
+++ b/src/Infrastructure.MongoDb/Entities/HouseHistory.cs
@@ -0,0 +1,32 @@
+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 HouseHistory : IHasIdAndOwnerId
+{
+ [BsonId]
+ [BsonRepresentation(BsonType.ObjectId)]
+ public string? Id { get; set; }
+
+ [BsonElement("owner_id")]
+ public required string OwnerId { get; set; }
+
+ [BsonElement("house_id")]
+ public required string HouseId { get; set; }
+
+ [BsonElement("history_date")]
+ public required DateTime HistoryDate { get; set; }
+
+ [BsonElement("event_type")]
+ public required HouseEventType EventType { get; set; }
+
+ public string? Description { get; set; }
+
+ public double? Cost { get; set; }
+
+ public string? Provider { get; set; }
+}
diff --git a/src/Infrastructure.MongoDb/Entities/Imdb.cs b/src/Infrastructure.MongoDb/Entities/Imdb.cs
deleted file mode 100644
index 3d85cf12..00000000
--- a/src/Infrastructure.MongoDb/Entities/Imdb.cs
+++ /dev/null
@@ -1,6 +0,0 @@
-namespace Keeptrack.Infrastructure.MongoDb.Entities;
-
-public class Imdb
-{
- public required string PageId { get; set; }
-}
diff --git a/src/Infrastructure.MongoDb/Entities/Movie.cs b/src/Infrastructure.MongoDb/Entities/Movie.cs
index 628ef7ac..3a81d40d 100644
--- a/src/Infrastructure.MongoDb/Entities/Movie.cs
+++ b/src/Infrastructure.MongoDb/Entities/Movie.cs
@@ -16,18 +16,30 @@ public class Movie : IHasIdAndOwnerId
public required string Title { get; set; }
+ [BsonElement("tv_time_id")]
+ public string? TvTimeId { get; set; }
+
public int? Year { get; set; }
public float? Rating { get; set; }
- public string? Genre { get; set; }
-
public string? Notes { get; set; }
- public Imdb? Imdb { get; set; }
-
- public Allocine? Allocine { get; set; }
+ [BsonElement("reference_id")]
+ public string? ReferenceId { get; set; }
[BsonElement("first_seen_at")]
public DateTime? FirstSeenAt { get; set; }
+
+ [BsonElement("is_favorite")]
+ public bool IsFavorite { get; set; }
+
+ [BsonElement("want_to_watch")]
+ public bool WantToWatch { get; set; }
+
+ [BsonElement("is_owned")]
+ public bool IsOwned { get; set; }
+
+ [BsonElement("is_wishlisted")]
+ public bool IsWishlisted { get; set; }
}
diff --git a/src/Infrastructure.MongoDb/Entities/MovieReference.cs b/src/Infrastructure.MongoDb/Entities/MovieReference.cs
new file mode 100644
index 00000000..6b4cbd22
--- /dev/null
+++ b/src/Infrastructure.MongoDb/Entities/MovieReference.cs
@@ -0,0 +1,42 @@
+using System;
+using System.Collections.Generic;
+using MongoDB.Bson;
+using MongoDB.Bson.Serialization.Attributes;
+
+namespace Keeptrack.Infrastructure.MongoDb.Entities;
+
+///
+/// Shared, owner-less movie metadata collection (movie_reference ). See
+/// for why this has no owner_id .
+///
+public class MovieReference
+{
+ [BsonId]
+ [BsonRepresentation(BsonType.ObjectId)]
+ public string? Id { get; set; }
+
+ public required string Title { get; set; }
+
+ [BsonElement("title_normalized")]
+ public required string TitleNormalized { get; set; }
+
+ public int? Year { get; set; }
+
+ public string? Synopsis { get; set; }
+
+ [BsonElement("external_ids")]
+ public required Dictionary ExternalIds { get; set; }
+
+ [BsonElement("matched_aliases")]
+ public List MatchedAliases { get; set; } = [];
+
+ public List Genres { get; set; } = [];
+
+ public List Cast { get; set; } = [];
+
+ [BsonElement("image_url")]
+ public string? ImageUrl { get; set; }
+
+ [BsonElement("last_enriched_at")]
+ public DateTime? LastEnrichedAt { get; set; }
+}
diff --git a/src/Infrastructure.MongoDb/Entities/PersonReference.cs b/src/Infrastructure.MongoDb/Entities/PersonReference.cs
new file mode 100644
index 00000000..3e857005
--- /dev/null
+++ b/src/Infrastructure.MongoDb/Entities/PersonReference.cs
@@ -0,0 +1,24 @@
+using System.Collections.Generic;
+using MongoDB.Bson;
+using MongoDB.Bson.Serialization.Attributes;
+
+namespace Keeptrack.Infrastructure.MongoDb.Entities;
+
+///
+/// Shared, owner-less person/actor metadata collection (person_reference ). See
+/// for why this has no owner_id .
+///
+public class PersonReference
+{
+ [BsonId]
+ [BsonRepresentation(BsonType.ObjectId)]
+ public string? Id { get; set; }
+
+ public required string Name { get; set; }
+
+ [BsonElement("profile_image_url")]
+ public string? ProfileImageUrl { get; set; }
+
+ [BsonElement("external_ids")]
+ public required Dictionary ExternalIds { get; set; }
+}
diff --git a/src/Infrastructure.MongoDb/Entities/Playlist.cs b/src/Infrastructure.MongoDb/Entities/Playlist.cs
new file mode 100644
index 00000000..d395c586
--- /dev/null
+++ b/src/Infrastructure.MongoDb/Entities/Playlist.cs
@@ -0,0 +1,21 @@
+using System.Collections.Generic;
+using Keeptrack.Common.System;
+using MongoDB.Bson;
+using MongoDB.Bson.Serialization.Attributes;
+
+namespace Keeptrack.Infrastructure.MongoDb.Entities;
+
+public class Playlist : IHasIdAndOwnerId
+{
+ [BsonId]
+ [BsonRepresentation(BsonType.ObjectId)]
+ public string? Id { get; set; }
+
+ [BsonElement("owner_id")]
+ public required string OwnerId { get; set; }
+
+ public required string Title { get; set; }
+
+ [BsonElement("song_ids")]
+ public List SongIds { get; set; } = [];
+}
diff --git a/src/Infrastructure.MongoDb/Entities/Playthrough.cs b/src/Infrastructure.MongoDb/Entities/Playthrough.cs
new file mode 100644
index 00000000..fda048a0
--- /dev/null
+++ b/src/Infrastructure.MongoDb/Entities/Playthrough.cs
@@ -0,0 +1,12 @@
+using System;
+using MongoDB.Bson.Serialization.Attributes;
+
+namespace Keeptrack.Infrastructure.MongoDb.Entities;
+
+public class Playthrough
+{
+ public required string Label { get; set; }
+
+ [BsonElement("completed_at")]
+ public DateTime? CompletedAt { get; set; }
+}
diff --git a/src/Infrastructure.MongoDb/Entities/ReferenceEpisode.cs b/src/Infrastructure.MongoDb/Entities/ReferenceEpisode.cs
new file mode 100644
index 00000000..533f41f6
--- /dev/null
+++ b/src/Infrastructure.MongoDb/Entities/ReferenceEpisode.cs
@@ -0,0 +1,21 @@
+using System;
+using MongoDB.Bson.Serialization.Attributes;
+
+namespace Keeptrack.Infrastructure.MongoDb.Entities;
+
+///
+/// Embedded within - see that class for why embedding is the right call here.
+///
+public class ReferenceEpisode
+{
+ [BsonElement("season_number")]
+ public required int SeasonNumber { get; set; }
+
+ [BsonElement("episode_number")]
+ public required int EpisodeNumber { get; set; }
+
+ public required string Title { get; set; }
+
+ [BsonElement("air_date")]
+ public DateTime? AirDate { get; set; }
+}
diff --git a/src/Infrastructure.MongoDb/Entities/ReferenceMatch.cs b/src/Infrastructure.MongoDb/Entities/ReferenceMatch.cs
new file mode 100644
index 00000000..76aa9200
--- /dev/null
+++ b/src/Infrastructure.MongoDb/Entities/ReferenceMatch.cs
@@ -0,0 +1,26 @@
+namespace Keeptrack.Infrastructure.MongoDb.Entities;
+
+///
+/// Embedded within / - see
+/// for why embedding is the right call here.
+///
+public class ReferenceMatch
+{
+ public required string Title { get; set; }
+
+ public int? Year { get; set; }
+
+ ///
+ /// Null for TV show/movie/video game (no creator dimension in their match key). The global
+ /// IgnoreIfNullConvention (InfrastructureServiceCollectionExtensions.AddMongoDbInfrastructure )
+ /// already omits any null property from the document - no per-property [BsonIgnoreIfNull] needed
+ /// here or anywhere else in the codebase. This used to require a per-member AutoMapper opt-out
+ /// (.ForMember(x => x.Creator, opt => opt.AllowNull()) ) because AutoMapper's profile-wide
+ /// AllowNullDestinationValues = false substituted an empty string for the null before BSON
+ /// serialization ever saw it; Mapperly (the current mapper, see ReferenceMatchModel ->
+ /// ReferenceMatch mapping in TvShowReferenceStorageMapper etc.) preserves nulls by
+ /// default, so no such opt-out exists or is needed anymore. Historical documents written under the
+ /// old behavior still store "" instead of an absent field - see MergeMatchedAliases .
+ ///
+ public string? Creator { get; set; }
+}
diff --git a/src/Infrastructure.MongoDb/Entities/ReferenceTrack.cs b/src/Infrastructure.MongoDb/Entities/ReferenceTrack.cs
new file mode 100644
index 00000000..3dc27055
--- /dev/null
+++ b/src/Infrastructure.MongoDb/Entities/ReferenceTrack.cs
@@ -0,0 +1,13 @@
+namespace Keeptrack.Infrastructure.MongoDb.Entities;
+
+///
+/// Embedded within - see that class for why embedding is the right call here.
+///
+public class ReferenceTrack
+{
+ public required string Position { get; set; }
+
+ public required string Title { get; set; }
+
+ public string? Duration { get; set; }
+}
diff --git a/src/Infrastructure.MongoDb/Entities/Song.cs b/src/Infrastructure.MongoDb/Entities/Song.cs
new file mode 100644
index 00000000..cf8c7fe1
--- /dev/null
+++ b/src/Infrastructure.MongoDb/Entities/Song.cs
@@ -0,0 +1,27 @@
+using Keeptrack.Common.System;
+using MongoDB.Bson;
+using MongoDB.Bson.Serialization.Attributes;
+
+namespace Keeptrack.Infrastructure.MongoDb.Entities;
+
+public class Song : IHasIdAndOwnerId
+{
+ [BsonId]
+ [BsonRepresentation(BsonType.ObjectId)]
+ public string? Id { get; set; }
+
+ [BsonElement("owner_id")]
+ public required string OwnerId { get; set; }
+
+ public required string Title { get; set; }
+
+ public string? Artist { get; set; }
+
+ [BsonElement("album_id")]
+ public string? AlbumId { get; set; }
+
+ public string? Duration { get; set; }
+
+ [BsonElement("track_position")]
+ public string? TrackPosition { get; set; }
+}
diff --git a/src/Infrastructure.MongoDb/Entities/TvShow.cs b/src/Infrastructure.MongoDb/Entities/TvShow.cs
index 6d69f612..4de88cee 100644
--- a/src/Infrastructure.MongoDb/Entities/TvShow.cs
+++ b/src/Infrastructure.MongoDb/Entities/TvShow.cs
@@ -1,5 +1,5 @@
-using System;
-using Keeptrack.Common.System;
+using Keeptrack.Common.System;
+using Keeptrack.Domain.Models;
using MongoDB.Bson;
using MongoDB.Bson.Serialization.Attributes;
@@ -16,6 +16,9 @@ public class TvShow : IHasIdAndOwnerId
public required string Title { get; set; }
+ [BsonElement("tv_time_id")]
+ public string? TvTimeId { get; set; }
+
public int? Year { get; set; }
public float? Rating { get; set; }
@@ -25,10 +28,23 @@ public class TvShow : IHasIdAndOwnerId
[BsonElement("last_episode_seen")]
public string? LastEpisodeSeen { get; set; }
- public Imdb? Imdb { get; set; }
+ [BsonElement("reference_id")]
+ public string? ReferenceId { get; set; }
+
+ // storage name kept as "status" deliberately - only the C# property was renamed to State (for parity
+ // with VideoGame.State), so existing documents need no migration.
+ [BsonElement("status")]
+ public TvShowStatus? State { get; set; }
+
+ [BsonElement("is_favorite")]
+ public bool IsFavorite { get; set; }
+
+ [BsonElement("want_to_watch")]
+ public bool WantToWatch { get; set; }
- public Allocine? Allocine { get; set; }
+ [BsonElement("is_owned")]
+ public bool IsOwned { get; set; }
- [BsonElement("finished_at")]
- public DateTime? FinishedAt { get; set; }
+ [BsonElement("is_wishlisted")]
+ public bool IsWishlisted { get; set; }
}
diff --git a/src/Infrastructure.MongoDb/Entities/TvShowReference.cs b/src/Infrastructure.MongoDb/Entities/TvShowReference.cs
new file mode 100644
index 00000000..fab4cc8b
--- /dev/null
+++ b/src/Infrastructure.MongoDb/Entities/TvShowReference.cs
@@ -0,0 +1,44 @@
+using System;
+using System.Collections.Generic;
+using MongoDB.Bson;
+using MongoDB.Bson.Serialization.Attributes;
+
+namespace Keeptrack.Infrastructure.MongoDb.Entities;
+
+///
+/// Shared, owner-less show metadata collection (tvshow_reference ). Deliberately has no
+/// owner_id - see for why.
+///
+public class TvShowReference
+{
+ [BsonId]
+ [BsonRepresentation(BsonType.ObjectId)]
+ public string? Id { get; set; }
+
+ public required string Title { get; set; }
+
+ [BsonElement("title_normalized")]
+ public required string TitleNormalized { get; set; }
+
+ public int? Year { get; set; }
+
+ public string? Synopsis { get; set; }
+
+ [BsonElement("external_ids")]
+ public required Dictionary ExternalIds { get; set; }
+
+ [BsonElement("matched_aliases")]
+ public List MatchedAliases { get; set; } = [];
+
+ public List Episodes { get; set; } = [];
+
+ public List Genres { get; set; } = [];
+
+ public List Cast { get; set; } = [];
+
+ [BsonElement("image_url")]
+ public string? ImageUrl { get; set; }
+
+ [BsonElement("last_enriched_at")]
+ public DateTime? LastEnrichedAt { get; set; }
+}
diff --git a/src/Infrastructure.MongoDb/Entities/VideoGame.cs b/src/Infrastructure.MongoDb/Entities/VideoGame.cs
index 5f3e136e..77148e5a 100644
--- a/src/Infrastructure.MongoDb/Entities/VideoGame.cs
+++ b/src/Infrastructure.MongoDb/Entities/VideoGame.cs
@@ -1,4 +1,4 @@
-using System;
+using System.Collections.Generic;
using Keeptrack.Common.System;
using MongoDB.Bson;
using MongoDB.Bson.Serialization.Attributes;
@@ -16,9 +16,7 @@ public class VideoGame : IHasIdAndOwnerId
public required string Title { get; set; }
- public required string Platform { get; set; }
-
- public required string State { get; set; }
+ public List Platforms { get; set; } = [];
public int? Year { get; set; }
@@ -26,6 +24,12 @@ public class VideoGame : IHasIdAndOwnerId
public string? Notes { get; set; }
- [BsonElement("finished_at")]
- public DateTime? FinishedAt { get; set; }
+ [BsonElement("reference_id")]
+ public string? ReferenceId { get; set; }
+
+ [BsonElement("is_owned")]
+ public bool IsOwned { get; set; }
+
+ [BsonElement("is_wishlisted")]
+ public bool IsWishlisted { get; set; }
}
diff --git a/src/Infrastructure.MongoDb/Entities/VideoGamePlatform.cs b/src/Infrastructure.MongoDb/Entities/VideoGamePlatform.cs
new file mode 100644
index 00000000..b2b36ad0
--- /dev/null
+++ b/src/Infrastructure.MongoDb/Entities/VideoGamePlatform.cs
@@ -0,0 +1,24 @@
+using System;
+using System.Collections.Generic;
+using Keeptrack.Domain.Models;
+using MongoDB.Bson.Serialization.Attributes;
+
+namespace Keeptrack.Infrastructure.MongoDb.Entities;
+
+public class VideoGamePlatform
+{
+ public required string Platform { get; set; }
+
+ [BsonElement("copy_type")]
+ public VideoGameCopyType CopyType { get; set; }
+
+ public string State { get; set; } = "";
+
+ public List Playthroughs { get; set; } = [];
+
+ [BsonElement("is_fully_completed")]
+ public bool IsFullyCompleted { get; set; }
+
+ [BsonElement("fully_completed_at")]
+ public DateTime? FullyCompletedAt { get; set; }
+}
diff --git a/src/Infrastructure.MongoDb/Entities/VideoGameReference.cs b/src/Infrastructure.MongoDb/Entities/VideoGameReference.cs
new file mode 100644
index 00000000..45bab635
--- /dev/null
+++ b/src/Infrastructure.MongoDb/Entities/VideoGameReference.cs
@@ -0,0 +1,42 @@
+using System;
+using System.Collections.Generic;
+using MongoDB.Bson;
+using MongoDB.Bson.Serialization.Attributes;
+
+namespace Keeptrack.Infrastructure.MongoDb.Entities;
+
+///
+/// Shared, owner-less video game metadata collection (videogame_reference ). See
+/// for why this has no owner_id .
+///
+public class VideoGameReference
+{
+ [BsonId]
+ [BsonRepresentation(BsonType.ObjectId)]
+ public string? Id { get; set; }
+
+ public required string Title { get; set; }
+
+ [BsonElement("title_normalized")]
+ public required string TitleNormalized { get; set; }
+
+ public int? Year { get; set; }
+
+ public string? Synopsis { get; set; }
+
+ public List Platforms { get; set; } = [];
+
+ [BsonElement("external_ids")]
+ public required Dictionary ExternalIds { get; set; }
+
+ [BsonElement("matched_aliases")]
+ public List MatchedAliases { get; set; } = [];
+
+ public List Genres { get; set; } = [];
+
+ [BsonElement("image_url")]
+ public string? ImageUrl { get; set; }
+
+ [BsonElement("last_enriched_at")]
+ public DateTime? LastEnrichedAt { get; set; }
+}
diff --git a/src/Infrastructure.MongoDb/Infrastructure.MongoDb.csproj b/src/Infrastructure.MongoDb/Infrastructure.MongoDb.csproj
index 494be141..7d728808 100644
--- a/src/Infrastructure.MongoDb/Infrastructure.MongoDb.csproj
+++ b/src/Infrastructure.MongoDb/Infrastructure.MongoDb.csproj
@@ -8,9 +8,9 @@
-
+
diff --git a/src/Infrastructure.MongoDb/Mappers/AlbumReferenceStorageMapper.cs b/src/Infrastructure.MongoDb/Mappers/AlbumReferenceStorageMapper.cs
new file mode 100644
index 00000000..86253260
--- /dev/null
+++ b/src/Infrastructure.MongoDb/Mappers/AlbumReferenceStorageMapper.cs
@@ -0,0 +1,17 @@
+using Keeptrack.Domain.Models;
+using Keeptrack.Infrastructure.MongoDb.Entities;
+using Riok.Mapperly.Abstractions;
+
+namespace Keeptrack.Infrastructure.MongoDb.Mappers;
+
+///
+/// Injected directly by - see
+/// for why this has no shared interface.
+///
+[Mapper]
+public partial class AlbumReferenceStorageMapper
+{
+ public partial AlbumReference ToEntity(AlbumReferenceModel model);
+
+ public partial AlbumReferenceModel ToModel(AlbumReference entity);
+}
diff --git a/src/Infrastructure.MongoDb/Mappers/AlbumStorageMapper.cs b/src/Infrastructure.MongoDb/Mappers/AlbumStorageMapper.cs
new file mode 100644
index 00000000..6d0deb1b
--- /dev/null
+++ b/src/Infrastructure.MongoDb/Mappers/AlbumStorageMapper.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 AlbumStorageMapper : IStorageMapper
+{
+ public partial Album ToEntity(AlbumModel model);
+
+ public partial AlbumModel ToModel(Album entity);
+
+ public partial List ToModels(List entities);
+}
diff --git a/src/Infrastructure.MongoDb/Mappers/BookReferenceStorageMapper.cs b/src/Infrastructure.MongoDb/Mappers/BookReferenceStorageMapper.cs
new file mode 100644
index 00000000..269756d9
--- /dev/null
+++ b/src/Infrastructure.MongoDb/Mappers/BookReferenceStorageMapper.cs
@@ -0,0 +1,17 @@
+using Keeptrack.Domain.Models;
+using Keeptrack.Infrastructure.MongoDb.Entities;
+using Riok.Mapperly.Abstractions;
+
+namespace Keeptrack.Infrastructure.MongoDb.Mappers;
+
+///
+/// Injected directly by - see
+/// for why this has no shared interface.
+///
+[Mapper]
+public partial class BookReferenceStorageMapper
+{
+ public partial BookReference ToEntity(BookReferenceModel model);
+
+ public partial BookReferenceModel ToModel(BookReference entity);
+}
diff --git a/src/Infrastructure.MongoDb/Mappers/BookStorageMapper.cs b/src/Infrastructure.MongoDb/Mappers/BookStorageMapper.cs
new file mode 100644
index 00000000..79ad8554
--- /dev/null
+++ b/src/Infrastructure.MongoDb/Mappers/BookStorageMapper.cs
@@ -0,0 +1,17 @@
+using System.Collections.Generic;
+using Keeptrack.Domain.Models;
+using Keeptrack.Infrastructure.MongoDb.Entities;
+using Riok.Mapperly.Abstractions;
+
+namespace Keeptrack.Infrastructure.MongoDb.Mappers;
+
+[Mapper]
+[UseStaticMapper(typeof(CommonStorageMappings))]
+public partial class BookStorageMapper : IStorageMapper
+{
+ public partial Book ToEntity(BookModel model);
+
+ public partial BookModel ToModel(Book entity);
+
+ public partial List ToModels(List entities);
+}
diff --git a/src/Infrastructure.MongoDb/Mappers/CarHistoryStorageMapper.cs b/src/Infrastructure.MongoDb/Mappers/CarHistoryStorageMapper.cs
new file mode 100644
index 00000000..8ea259f5
--- /dev/null
+++ b/src/Infrastructure.MongoDb/Mappers/CarHistoryStorageMapper.cs
@@ -0,0 +1,115 @@
+using System;
+using System.Collections.Generic;
+using Keeptrack.Domain.Models;
+using Keeptrack.Infrastructure.MongoDb.Entities;
+
+namespace Keeptrack.Infrastructure.MongoDb.Mappers;
+
+///
+/// Hand-written rather than a Mapperly [Mapper] class: 's flat
+/// City/PostalCode/Country/Longitude/Latitude/Fuel*/StationBrandName fields fan out into
+/// 's Location/Fuel/Station sub-documents on write and back on read, which is
+/// bespoke enough that explicit C# is more readable than attribute configuration.
+///
+public class CarHistoryStorageMapper : IStorageMapper
+{
+ public CarHistory ToEntity(CarHistoryModel model)
+ {
+ return new CarHistory
+ {
+ Id = model.Id,
+ OwnerId = model.OwnerId,
+ CarId = model.CarId,
+ // HistoryDate is a plain DateTime on both sides (the only date field in the codebase that
+ // isn't DateOnly on the model), so nothing upstream stamps DateTimeKind.Utc - the Mongo
+ // driver's default DateTimeSerializer requires it.
+ HistoryDate = DateTime.SpecifyKind(model.HistoryDate, DateTimeKind.Utc),
+ Mileage = model.Mileage,
+ EventType = model.EventType,
+ Description = model.Description,
+ Cost = model.Cost,
+ Location = BuildLocation(model),
+ Fuel = BuildFuel(model),
+ Station = BuildStation(model),
+ Garage = model.Garage,
+ };
+ }
+
+ public CarHistoryModel ToModel(CarHistory entity)
+ {
+ return new CarHistoryModel
+ {
+ Id = entity.Id,
+ OwnerId = entity.OwnerId,
+ CarId = entity.CarId,
+ HistoryDate = entity.HistoryDate,
+ Mileage = (int?)entity.Mileage,
+ EventType = entity.EventType,
+ Description = entity.Description,
+ Cost = entity.Cost,
+ City = entity.Location?.City,
+ PostalCode = entity.Location?.PostalCode,
+ Country = entity.Location?.Country,
+ Longitude = entity.Location?.Coordinates != null ? entity.Location.Coordinates[0] : null,
+ Latitude = entity.Location?.Coordinates != null ? entity.Location.Coordinates[1] : null,
+ FuelCategory = entity.Fuel?.Category,
+ FuelVolume = entity.Fuel?.Volume,
+ FuelUnitPrice = entity.Fuel?.UnitPrice,
+ ElectricVolume = entity.Fuel?.ElectricVolume,
+ ElectricUnitPrice = entity.Fuel?.ElectricUnitPrice,
+ IsFullRefill = entity.Fuel?.IsFullRefill,
+ DeltaMileage = entity.Fuel?.DeltaMileage,
+ StationBrandName = entity.Station?.BrandName,
+ Garage = entity.Garage,
+ };
+ }
+
+ public List ToModels(List entities)
+ {
+ var models = new List(entities.Count);
+ foreach (var entity in entities) models.Add(ToModel(entity));
+ return models;
+ }
+
+ private static CarHistoryLocation BuildLocation(CarHistoryModel model)
+ {
+ return new CarHistoryLocation
+ {
+ // CarHistoryLocation.City is `required` at the entity/schema level, but a non-Refuel event
+ // can legitimately carry no location at all - the null-forgiving operator here preserves that
+ // existing schema/model mismatch rather than introducing a new one.
+ City = model.City!,
+ PostalCode = model.PostalCode,
+ Country = model.Country,
+ // Coordinates: never persist an empty list, only a real 2-element [lon, lat] pair or nothing -
+ // the read side (ToModel) indexes Coordinates[0]/[1] guarded by "!= null", which an empty
+ // (not null) list would defeat. Covered by CarHistoryResourceTest's create/read round-trip.
+ Coordinates = model.Longitude.HasValue && model.Latitude.HasValue
+ ? [model.Longitude.Value, model.Latitude.Value]
+ : null,
+ };
+ }
+
+ private static CarHistoryFuel BuildFuel(CarHistoryModel model)
+ {
+ return new CarHistoryFuel
+ {
+ Category = model.FuelCategory,
+ Volume = model.FuelVolume,
+ UnitPrice = model.FuelUnitPrice,
+ ElectricVolume = model.ElectricVolume,
+ ElectricUnitPrice = model.ElectricUnitPrice,
+ IsFullRefill = model.IsFullRefill,
+ DeltaMileage = model.DeltaMileage,
+ };
+ }
+
+ private static CarHistoryStation BuildStation(CarHistoryModel model)
+ {
+ return new CarHistoryStation
+ {
+ // BrandName is `required` on the entity for the same reason City is above - preserved as-is.
+ BrandName = model.StationBrandName!,
+ };
+ }
+}
diff --git a/src/Infrastructure.MongoDb/Mappers/CarStorageMapper.cs b/src/Infrastructure.MongoDb/Mappers/CarStorageMapper.cs
new file mode 100644
index 00000000..dc334ed8
--- /dev/null
+++ b/src/Infrastructure.MongoDb/Mappers/CarStorageMapper.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 CarStorageMapper : IStorageMapper
+{
+ public partial Car ToEntity(CarModel model);
+
+ public partial CarModel ToModel(Car entity);
+
+ public partial List ToModels(List entities);
+}
diff --git a/src/Infrastructure.MongoDb/Mappers/CommonStorageMappings.cs b/src/Infrastructure.MongoDb/Mappers/CommonStorageMappings.cs
new file mode 100644
index 00000000..648c4c2a
--- /dev/null
+++ b/src/Infrastructure.MongoDb/Mappers/CommonStorageMappings.cs
@@ -0,0 +1,26 @@
+using System;
+using Riok.Mapperly.Abstractions;
+
+namespace Keeptrack.Infrastructure.MongoDb.Mappers;
+
+///
+/// Shared DateOnly <-> DateTime conversion, attached to any storage mapper that needs
+/// it via [UseStaticMapper(typeof(CommonStorageMappings))] . Every Domain model date field is
+/// while its Mongo entity counterpart is - the driver's
+/// DateTimeSerializer requires , so stamps
+/// it explicitly rather than leaving it .
+///
+public static class CommonStorageMappings
+{
+ [UserMapping]
+ public static DateOnly ToDateOnly(DateTime dateTime)
+ {
+ return DateOnly.FromDateTime(dateTime);
+ }
+
+ [UserMapping]
+ public static DateTime ToDateTime(DateOnly dateOnly)
+ {
+ return dateOnly.ToDateTime(TimeOnly.MinValue, DateTimeKind.Utc);
+ }
+}
diff --git a/src/Infrastructure.MongoDb/Mappers/EpisodeStorageMapper.cs b/src/Infrastructure.MongoDb/Mappers/EpisodeStorageMapper.cs
new file mode 100644
index 00000000..b2782613
--- /dev/null
+++ b/src/Infrastructure.MongoDb/Mappers/EpisodeStorageMapper.cs
@@ -0,0 +1,17 @@
+using System.Collections.Generic;
+using Keeptrack.Domain.Models;
+using Keeptrack.Infrastructure.MongoDb.Entities;
+using Riok.Mapperly.Abstractions;
+
+namespace Keeptrack.Infrastructure.MongoDb.Mappers;
+
+[Mapper]
+[UseStaticMapper(typeof(CommonStorageMappings))]
+public partial class EpisodeStorageMapper : IStorageMapper
+{
+ public partial Episode ToEntity(EpisodeModel model);
+
+ public partial EpisodeModel ToModel(Episode entity);
+
+ public partial List ToModels(List entities);
+}
diff --git a/src/Infrastructure.MongoDb/Mappers/HouseHistoryStorageMapper.cs b/src/Infrastructure.MongoDb/Mappers/HouseHistoryStorageMapper.cs
new file mode 100644
index 00000000..dbce03d2
--- /dev/null
+++ b/src/Infrastructure.MongoDb/Mappers/HouseHistoryStorageMapper.cs
@@ -0,0 +1,17 @@
+using System.Collections.Generic;
+using Keeptrack.Domain.Models;
+using Keeptrack.Infrastructure.MongoDb.Entities;
+using Riok.Mapperly.Abstractions;
+
+namespace Keeptrack.Infrastructure.MongoDb.Mappers;
+
+[Mapper]
+[UseStaticMapper(typeof(CommonStorageMappings))]
+public partial class HouseHistoryStorageMapper : IStorageMapper
+{
+ public partial HouseHistory ToEntity(HouseHistoryModel model);
+
+ public partial HouseHistoryModel ToModel(HouseHistory entity);
+
+ public partial List ToModels(List entities);
+}
diff --git a/src/Infrastructure.MongoDb/Mappers/HouseStorageMapper.cs b/src/Infrastructure.MongoDb/Mappers/HouseStorageMapper.cs
new file mode 100644
index 00000000..9ed7544e
--- /dev/null
+++ b/src/Infrastructure.MongoDb/Mappers/HouseStorageMapper.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 HouseStorageMapper : IStorageMapper
+{
+ public partial House ToEntity(HouseModel model);
+
+ public partial HouseModel ToModel(House entity);
+
+ public partial List ToModels(List entities);
+}
diff --git a/src/Infrastructure.MongoDb/Mappers/IStorageMapper.cs b/src/Infrastructure.MongoDb/Mappers/IStorageMapper.cs
new file mode 100644
index 00000000..0e4b3762
--- /dev/null
+++ b/src/Infrastructure.MongoDb/Mappers/IStorageMapper.cs
@@ -0,0 +1,18 @@
+using System.Collections.Generic;
+
+namespace Keeptrack.Infrastructure.MongoDb.Mappers;
+
+///
+/// Maps between a Domain model and its MongoDB entity counterpart. Implemented per entity pair by a
+/// Mapperly [Mapper] partial class (e.g. BookStorageMapper ) and injected into
+/// instead of AutoMapper's untyped
+/// IMapper .
+///
+public interface IStorageMapper
+{
+ TEntity ToEntity(TModel model);
+
+ TModel ToModel(TEntity entity);
+
+ List ToModels(List entities);
+}
diff --git a/src/Infrastructure.MongoDb/Mappers/MovieReferenceStorageMapper.cs b/src/Infrastructure.MongoDb/Mappers/MovieReferenceStorageMapper.cs
new file mode 100644
index 00000000..2e3a0ab6
--- /dev/null
+++ b/src/Infrastructure.MongoDb/Mappers/MovieReferenceStorageMapper.cs
@@ -0,0 +1,17 @@
+using Keeptrack.Domain.Models;
+using Keeptrack.Infrastructure.MongoDb.Entities;
+using Riok.Mapperly.Abstractions;
+
+namespace Keeptrack.Infrastructure.MongoDb.Mappers;
+
+///
+/// Injected directly by - see
+/// for why this has no shared interface.
+///
+[Mapper]
+public partial class MovieReferenceStorageMapper
+{
+ public partial MovieReference ToEntity(MovieReferenceModel model);
+
+ public partial MovieReferenceModel ToModel(MovieReference entity);
+}
diff --git a/src/Infrastructure.MongoDb/Mappers/MovieStorageMapper.cs b/src/Infrastructure.MongoDb/Mappers/MovieStorageMapper.cs
new file mode 100644
index 00000000..fda8ba66
--- /dev/null
+++ b/src/Infrastructure.MongoDb/Mappers/MovieStorageMapper.cs
@@ -0,0 +1,17 @@
+using System.Collections.Generic;
+using Keeptrack.Domain.Models;
+using Keeptrack.Infrastructure.MongoDb.Entities;
+using Riok.Mapperly.Abstractions;
+
+namespace Keeptrack.Infrastructure.MongoDb.Mappers;
+
+[Mapper]
+[UseStaticMapper(typeof(CommonStorageMappings))]
+public partial class MovieStorageMapper : IStorageMapper
+{
+ public partial Movie ToEntity(MovieModel model);
+
+ public partial MovieModel ToModel(Movie entity);
+
+ public partial List ToModels(List entities);
+}
diff --git a/src/Infrastructure.MongoDb/Mappers/PersonReferenceStorageMapper.cs b/src/Infrastructure.MongoDb/Mappers/PersonReferenceStorageMapper.cs
new file mode 100644
index 00000000..bc187d9a
--- /dev/null
+++ b/src/Infrastructure.MongoDb/Mappers/PersonReferenceStorageMapper.cs
@@ -0,0 +1,17 @@
+using Keeptrack.Domain.Models;
+using Keeptrack.Infrastructure.MongoDb.Entities;
+using Riok.Mapperly.Abstractions;
+
+namespace Keeptrack.Infrastructure.MongoDb.Mappers;
+
+///
+/// Injected directly by - see
+/// for why this has no shared interface.
+///
+[Mapper]
+public partial class PersonReferenceStorageMapper
+{
+ public partial PersonReference ToEntity(PersonReferenceModel model);
+
+ public partial PersonReferenceModel ToModel(PersonReference entity);
+}
diff --git a/src/Infrastructure.MongoDb/Mappers/PlaylistStorageMapper.cs b/src/Infrastructure.MongoDb/Mappers/PlaylistStorageMapper.cs
new file mode 100644
index 00000000..fdf3bae5
--- /dev/null
+++ b/src/Infrastructure.MongoDb/Mappers/PlaylistStorageMapper.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 PlaylistStorageMapper : IStorageMapper
+{
+ public partial Playlist ToEntity(PlaylistModel model);
+
+ public partial PlaylistModel ToModel(Playlist entity);
+
+ public partial List ToModels(List entities);
+}
diff --git a/src/Infrastructure.MongoDb/Mappers/SongStorageMapper.cs b/src/Infrastructure.MongoDb/Mappers/SongStorageMapper.cs
new file mode 100644
index 00000000..13fc6a92
--- /dev/null
+++ b/src/Infrastructure.MongoDb/Mappers/SongStorageMapper.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 SongStorageMapper : IStorageMapper
+{
+ public partial Song ToEntity(SongModel model);
+
+ public partial SongModel ToModel(Song entity);
+
+ public partial List ToModels(List entities);
+}
diff --git a/src/Infrastructure.MongoDb/Mappers/TvShowReferenceStorageMapper.cs b/src/Infrastructure.MongoDb/Mappers/TvShowReferenceStorageMapper.cs
new file mode 100644
index 00000000..892f7e06
--- /dev/null
+++ b/src/Infrastructure.MongoDb/Mappers/TvShowReferenceStorageMapper.cs
@@ -0,0 +1,19 @@
+using Keeptrack.Domain.Models;
+using Keeptrack.Infrastructure.MongoDb.Entities;
+using Riok.Mapperly.Abstractions;
+
+namespace Keeptrack.Infrastructure.MongoDb.Mappers;
+
+///
+/// Injected directly by - the owner-less reference
+/// repositories are purpose-built already and need no shared
+/// abstraction, per docs/automapper-removal-plan.md .
+///
+[Mapper]
+[UseStaticMapper(typeof(CommonStorageMappings))]
+public partial class TvShowReferenceStorageMapper
+{
+ public partial TvShowReference ToEntity(TvShowReferenceModel model);
+
+ public partial TvShowReferenceModel ToModel(TvShowReference entity);
+}
diff --git a/src/Infrastructure.MongoDb/Mappers/TvShowStorageMapper.cs b/src/Infrastructure.MongoDb/Mappers/TvShowStorageMapper.cs
new file mode 100644
index 00000000..aa70c7dd
--- /dev/null
+++ b/src/Infrastructure.MongoDb/Mappers/TvShowStorageMapper.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 TvShowStorageMapper : IStorageMapper
+{
+ public partial TvShow ToEntity(TvShowModel model);
+
+ public partial TvShowModel ToModel(TvShow entity);
+
+ public partial List ToModels(List entities);
+}
diff --git a/src/Infrastructure.MongoDb/Mappers/VideoGameReferenceStorageMapper.cs b/src/Infrastructure.MongoDb/Mappers/VideoGameReferenceStorageMapper.cs
new file mode 100644
index 00000000..eea9732c
--- /dev/null
+++ b/src/Infrastructure.MongoDb/Mappers/VideoGameReferenceStorageMapper.cs
@@ -0,0 +1,17 @@
+using Keeptrack.Domain.Models;
+using Keeptrack.Infrastructure.MongoDb.Entities;
+using Riok.Mapperly.Abstractions;
+
+namespace Keeptrack.Infrastructure.MongoDb.Mappers;
+
+///