Skip to content

V2.4.1 Improve code quality, add list thumbnail, improve UI, share to others, add generic import, add Playwright tests - #468

Open
devpro wants to merge 95 commits into
mainfrom
feature/code-improv
Open

V2.4.1 Improve code quality, add list thumbnail, improve UI, share to others, add generic import, add Playwright tests#468
devpro wants to merge 95 commits into
mainfrom
feature/code-improv

Conversation

@devpro

@devpro devpro commented Jul 28, 2026

Copy link
Copy Markdown
Owner

No description provided.

devpro and others added 21 commits July 26, 2026 21:04
┌────────────────────────────────────────┬────────────────────────────────────────────────────────────────────────────────────────────┐
│                  Fix                   │                                        Verification                                        │
├────────────────────────────────────────┼────────────────────────────────────────────────────────────────────────────────────────────┤
│ S2365 — PlaylistDetail.razor rename    │ PlaylistSmokeTest passed against a real browser + real Blazor Server circuit               │
├────────────────────────────────────────┼────────────────────────────────────────────────────────────────────────────────────────────┤
│ ASP0025 ×2 — AddAuthorizationBuilder   │ ReferenceDataAdminResourceTest (AdminOnly) + PlaylistResourceTest/BookResourceTest         │
│                                        │ (MemberOnly) — 9/9 passed over real HTTP with real Firebase auth                           │
├────────────────────────────────────────┼────────────────────────────────────────────────────────────────────────────────────────────┤
│ CA1862 ×2 —                            │ AlbumReferenceRepositoryTest/BookReferenceRepositoryTest — 7/7 passed against real MongoDB │
│ StringComparison.OrdinalIgnoreCase     │                                                                                            │
├────────────────────────────────────────┼────────────────────────────────────────────────────────────────────────────────────────────┤
│ CA1859 ×3 — concrete return types      │ 235/235 unit tests passed                                                                  │
├────────────────────────────────────────┼────────────────────────────────────────────────────────────────────────────────────────────┤
│ JS S2486 — logged exception            │ No test infra exists for this file — still an honest gap, noted in the docs                │
└────────────────────────────────────────┴────────────────────────────────────────────────────────────────────────────────────────────┘

docker itself isn't on PATH in either shell here (confirmed again), but Test-NetConnection -Port 27017 proved Mongo was reachable, which was enough to run everything through it directly.

Updated docs/code-quality-findings.md with the real test results (replacing the earlier "couldn't verify" caveat), and saved a memory note on the working recipe for WebApi.IntegrationTests (not just Playwright) via Local.runsettings env-var loading + --filter-class, since that'll save time next session.
Every image-bearing list page (Movies, TV Shows, Books, Albums, Video
Games, Cars, Houses, Health Profiles, Gear, Collectibles) gains a
list/thumbnail toggle in the search bar. The thumbnail view is a
responsive poster grid that leverages each type's cover art (portrait
posters, square album art, wide game imagery) with title + meta captions.

View mode lives in the URL as ?view=grid, following the existing
search/sort/filter URL-state convention: bookmarkable and restored on
back-nav. It is deliberately kept out of the query signature so switching
views never refetches, and unlike a filter it does not reset the page.
Playlists (no cover art) stays list-only.

MobileScreenshotTest gains a cover-art showcase seed and grid captures at
both phone (390x844) and desktop (1280x900) viewports, verified visually.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WvLYeTtX526Y5QZqrrz1o6
Close the top UI-coverage gap from the new testing assessment: the TV Time,
Amazon, and generic video-game import pages had API-level coverage but were
never driven through the browser.

- Support/*FixtureCsvBuilder + TvTimeImportFixtureZipBuilder: minimal
  GUID-suffixed in-memory fixtures so every run imports a genuinely new item
  it then deletes (no dedup-hidden "already imported" rows, no accumulation).
- Pages/ImportPage + Amazon/GenericVideoGame page objects, plus a
  PageBase.OpenImportAsync nav helper.
- End2EndFixture.GetItemIdsAsync: one reusable list-query helper all three
  tests use for API cleanup (books, video games, tv-shows, episodes).
- docs/testing-assessment.md: the assessment itself, with these gaps marked
  closed.

All three pass in self-hosted mutating mode.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PxyaxubjRzm2PSLMz7AE24
Covers the admin page's provider-free, deterministic surfaces (recommendation
#2 from the testing assessment): page load via the Admin nav link, the System
status panel resolving, unresolved-queue type switching, and the export -> import
round-trip (idempotent upsert-by-id, so it changes no data).

The provider search/link flow is deliberately left to the per-type detail-page
smoke tests (same endpoints, real providers), and a full sync-now poll is left
out as known-flaky on provider latency.

Passes in self-hosted mutating mode.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PxyaxubjRzm2PSLMz7AE24
Close the last genuine coverage gaps from the assessment (#4 account page,
#5 user-preferences UI) - Manage.razor previously had no test at any level.

ManageAccountSmokeTest asserts the signed-in identity renders, then round-trips
a preference toggle through the real UI (toggle -> persisted -> a fresh load
reflects it) and restores the original value via the API so the shared account
is left unchanged. The toggle re-clicks through the prerender->interactive gap
the same way ClickUntilAsync does for buttons.

Also records in the assessment doc why per-type Quick Add scenarios (original
recommendation #3) were withdrawn: they would duplicate coverage the suite
already provides, which the quality bar rejects.

Passes in self-hosted mutating mode.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PxyaxubjRzm2PSLMz7AE24
- Inset the grid from the panel edges (was flush): 1rem desktop, 0.75rem
  mobile, matching the list rows' horizontal padding.
- Size grid columns per cover shape so wide (16:9) tiles are no longer
  tiny strips: wide uses a 260px min (3 large columns on desktop, 2 on
  mobile) for video games/cars/houses/health/gear/collectibles; square
  (albums) 160px; portrait unchanged. The grid container now carries its
  ItemImageShape class so the CSS can target it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WvLYeTtX526Y5QZqrrz1o6
Space the last row of grid items off the card's bottom border (1.5rem
desktop, 1.25rem mobile); the grid previously had no bottom padding so
items sat flush against the panel edge.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WvLYeTtX526Y5QZqrrz1o6
Fixed the problem was in the shared DateTimeFields.razor time input (used by both the health record form and car-history form). It's a free-text HH:mm field with inputmode="numeric", so the phone's numeric keypad shows digits but no : key, making the colon impossible to type.

The owner deliberately chose free-text over the native <input type="time"> picker to guarantee 24h display (documented in the component and CLAUDE.md), so rather than override that decision, I made the colon optional on input:

- SetTimeTextAsync now also accepts bare digits — 1430 → 14:30, 930 → 09:30 — via a new TryParseTime helper, while still accepting 14:30 typed on a desktop keyboard.
- The field reformats to the canonical HH:mm on blur, so the stored/displayed value is unchanged.
- Loosened the pattern to allow the optional colon and added a title hint ("Type HH:mm, or just the digits").
- An unparseable entry is still ignored, leaving the previous value untouched — same behavior as before.

Because it's the shared component, car-history time entry on mobile benefits from the same fix.
The view choice is now remembered instead of defaulting to list on every
page. It is a global user preference (like a theme), not list state: it
never changes which items show or their order, only their presentation.

Stored per-device in localStorage and mirrored in a new circuit-scoped
ListViewPreference so every list page in the session shares one choice.
The app renders InteractiveServer over a WebSocket circuit, so in-app
navigation carries no fresh HttpContext to read a cookie from - hence
localStorage (read once per circuit on first interactive render, since it
is not reachable during prerender) rather than a server cookie. Chose
per-device deliberately: thumbnails suit a desktop while a phone may
prefer the compact list.

The ?view= URL parameter is removed; toggling now re-renders in place
(no navigation, no refetch) and writes the preference. MobileScreenshotTest
drives the captures via the toggle + localStorage instead of ?view=, which
also verifies the preference carries across pages and full reloads.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WvLYeTtX526Y5QZqrrz1o6
These two pages build their own layout (not InventoryList) but render the
same media rows, so they gained the same view toggle and poster grid.

To keep one copy of the logic, the toggle's seed/persist plumbing moved
into a shared ListViewToggle component (used by InventoryList too, which
no longer inlines it or the seeding in InventoryPageBase), and the poster
card moved into a shared ItemGridCard used by all three grids. The card's
caption render-fragment is named MetaContent, not Meta, because <Meta>
collides with the HTML <meta> void element in Razor.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WvLYeTtX526Y5QZqrrz1o6
Fixed. The Reference field in the owned-copy editor (OwnedVersionFields.razor) was misaligned because when an Amazon ASIN is detected, the "Open on Amazon" link renders as a .kt-icon-btn that's 2.25rem (~36px) tall — taller than the ~30px input beside it. The flex row wrapping them used align-items-center, which vertically centered the shorter input inside the taller row, dropping it a few pixels below the Price/Acquired/Vendor inputs in the same row. Switching that row to align-items-start top-aligns the input so it lines up with its siblings again.
Root cause: In the thumbnail (grid) view, the delete button lived inside .kt-grid-cover. On hover, that element gets transform: translateY(-3px) (app.css:479), and a transform creates a new stacking context. That trapped the button's z-index: 2 within the cover's context, so it could no longer sit above the card-level Bootstrap stretched-link (z-index: 1). The link painted over the whole cover — button visible, but the click landed on the link. The list-row view avoids this because its delete button is a direct sibling of the stretched-link with no transformed ancestor between them.

Fix: Moved @actions in ItemGridCard.razor out of .kt-grid-cover to be a direct child of .kt-grid-card — the same stacking context as the stretched-link — so its z-index: 2 genuinely wins. No CSS change needed: .kt-grid-delete is position: absolute; top/right, which now anchors to .kt-grid-card (still position-relative) and stays in the same top-right corner over the cover.
Before: three loose stacked rows (state buttons, a bare fully-completed toggle, playthroughs) with unlabelled date pickers appearing inline with no context.

After:
- A <hr> divider separates the shared owned-copy fields from the game-specific progress controls, so the two areas read as distinct groups.
- State and Completion now sit side-by-side in a two-column row (col-md-6), each under an uppercase form-label matching the rest of the app's fields. On mobile they stack.
- The bare date pickers get contextual muted hints — "Completed on" and "on" (kt-card-meta) — so a lone date box is no longer mysterious. Widened to 170px so full dates aren't cramped.
- A second <hr> sets off Playthroughs, which now shows a "No playthroughs recorded yet." empty state instead of just a bare "+ Add" button, and its remove ✕ got an aria-label for parity with the other remove buttons.

Everything reuses your existing design tokens (form-label, kt-card-meta, .kt-icon-btn, Bootstrap grid), so it's consistent with the other detail pages.
Personal (car/house/health) sharing — list and read-only detail (your key distinction: media = list only; personal = list + full detail with history/metrics/charts).

Backend (SharedWithMeController)
- GET /{shareId}/{cars|houses|health-profiles} (list) and GET /{shareId}/…/{itemId} (parent + full child history + computed metrics), via two generic helpers (ReadPersonalListAsync, LoadSharedParentAsync) — grant resolution stays the single security choke point. Metrics reuse the existing static Car/House/HealthMetricsService. No copy routes for personal categories (view-only, enforced by absence + the classifier).
- Contracts: one generic SharedDetailDto<TParent,TChild,TMetrics> (+ OwnerDisplayName for the breadcrumb).

Recipient UI — reuses the real detail pages (as you steered, "it takes an id to load data")
- CarDetail/HouseDetail/HealthProfileDetail gained a ShareId param → CanEdit => ShareId is null. When set, they load from the ownership-scoped shared endpoints (the one wrinkle: /api/cars/{id} is scoped to the caller's own user_id, so a recipient would 404), and every edit affordance is gated off. Three thin route wrappers own /account/manage/shared/{shareId}/…/{id} + MemberOnly auth, leaving the owner pages' own route/auth untouched.
- History rows gained ReadOnly (hides edit/delete). SharedCollectionPage gained Cars/Houses/Health tabs → new generic SharedPersonalList. Breadcrumb reads Shared with me › <owner> › <item> per your note.

Owner UI — SharingPage now has a Personal group; Health requires an explicit confirm before it can be enabled (never bundled).
The shared personal list (SharedPersonalList.razor) was a plain <a>-per-row list — no covers, no thumbnails, no grid, nothing resembling the media tabs. It now reuses the same InventoryList component the owner's own Cars/Houses/Health pages use, so it gets cover thumbnails, the list/grid toggle, search, sort, and per-type meta lines for free.

Concretely:

1. InventoryList gained an optional DetailHref selector. Its read-only mode was built for the media shared list, where rows deliberately don't navigate anywhere. Personal items do have a full read-only detail page, so when DetailHref is set, read-only rows and grid cards link to it (media rows, with no DetailHref, are unchanged).
2. SharedPersonalList now wraps InventoryList in read-only mode with cover thumbnails (ImageUrl, wide shape, matching the owner pages), the per-type meta row, and detail links. Since a person has only a handful of cars/houses/profiles, the set is fetched once and search/sort run client-side over it (no server paging needed).
3. Extracted CarMetaRow / HouseMetaRow / HealthMetaRow into Components/Inventory/Meta/, mirroring the existing MovieMetaRow/etc. pattern, and pointed both the owner list pages and the shared list at them — so the meta line is defined once, not duplicated between the two views (per the no-duplication quality bar).
4. SharedCollectionPage passes the image/meta/detail params for the three personal tabs; removed the now-dead ItemSubtitle/CarSubtitle code.

Both BlazorApp and the Playwright test project build clean with zero warnings. The existing SharedCollectionViewPage locators (.kt-item-row, row click) still work — the new rows keep that class and the stretched-link makes the whole row clickable, and the default view is the list view.

One note: the grid/list toggle in this shared view respects the same circuit-wide ListViewPreference as every other list page, so if the recipient has switched to grid elsewhere, the shared personal tabs will show cover cards too — consistent with the media tabs' behavior.
Domain
- ShareCategory (Domain + Contracts): added Collectibles and Gears.
- ShareKind: added a third value Collection — a view-only list — alongside Media (copyable list) and Personal (view-only detail). ShareCategoryClassifier maps the two new categories to Collection, and IsCopyable stays the single rule KindOf == Media, so they're non-copyable.

Backend (SharedWithMeController)
- Injected the collectible/gear repos + DTO mappers.
- New generic helper ReadOwnedListAsync<TModel,TDto> — the media read path minus reference-image hydration and copy-dedup (constraint is plain IHasId, not IReferenceLinkedDto), still routing through the one ResolveGrantAsync security choke point.
- GET /{shareId}/collectibles and GET /{shareId}/gear. No copy routes. Search/sort/favourite/owned filters work via the existing repo GetFilters.

Recipient UI
- SharedCategoryList.Copy is now optional — when null, no per-row "add" action renders; it's the same full InventoryList (search/sort/filters/thumbnails/grid) but purely read-only.
- SharedCollectionPage gained Collectibles/Gear tabs.
- Extracted CollectibleMetaRow.razor / GearMetaRow.razor and reused them in the owner list pages too (same meta-extraction pattern as the media rows).

Owner UI
- SharingPage gained a third "Collections (view-only)" group; SharingLabels maps Gears → "Gear".

Tests
- ShareCategoryClassifierTest: added a Collection-kind/never-copyable theory.
- ShareResourceTest.CollectionShare_IsReadableAsAFilterableList_ButNeverCopyable: paged/searchable/favourite-filtered read, empty AlreadyInCollectionIds, out-of-scope 404, and copy route absent.
What was happening

The shared collection's tabs were rendered straight from share.IncludedCategories, whose stored order reflects the order the owner clicked the category buttons when creating the share. That's because SharingPage.razor builds the grant from a HashSet<ShareCategory> (IncludedCategories = [.. _selected]), so click order leaks through to storage and then to the recipient's tabs.

Fix

Added a single canonical ordering helper, SharingLabels.Ordered(...), whose order matches the left nav menu (NavMenu.razor): Movies, TV shows, Books, Albums, Video games, Cars, Houses, Health, Collectibles, Gear. (Note the enum's own declaration order differs — it lists Collectibles/Gear before Cars/Houses/Health — so sorting by the enum wouldn't have matched the menu.)

Applied it everywhere categories are displayed, so nothing depends on the stored click order anymore:
- SharedCollectionPage.razor — the recipient's tab bar, and the default-selected tab fallback (so the landing tab is the first one shown).
- SharingPage.razor — the owner's "Active shares" category summary.
- SharedWithMeListPage.razor — the recipient's "shared with me" list summary.

The stored data is untouched (categories are stored by name), this is purely a display-order change.

One note: SharingLabels lives in BlazorApp, which has no unit-test project (tests are WebApi/integration/Playwright only), so the ordering helper isn't covered by an automated test — it's straightforward display logic.
Comment thread src/BlazorApp/Components/Import/GenericImportPage.razor Fixed
devpro added 8 commits July 29, 2026 10:10
The wishlist wasn't showing books' custom images because WishlistController.BuildWishlistAsync only hydrated the cover from the linked reference document, never applying the tenant-owned CustomImageUrl override that books (and video games) carry.

The override logic (hydrate reference cover, then let a non-empty CustomImageUrl win) was already duplicated in four places — BookController, VideoGameController, AlbumController, and a private helper in SharedWithMeController — and simply missing from the wishlist. Rather than add a fifth copy, I:

- Promoted the helper into the shared ReferenceImageHydrator.HydrateWithCustomOverrideAsync (the one place that already owns the reference-image batch-lookup logic).
- Routed every call site through it: the three CRUD controllers' OnListMappedAsync, SharedWithMeController's three reads (removing its now-redundant private helper), and — the actual fix — the wishlist's Books and VideoGames.

Movies and TV shows in the wishlist stay on the plain HydrateAsync path since neither has a CustomImageUrl concept. Net result: the algorithm now lives once, and custom book/game covers render in the wishlist (and shared wishlist) just as they do on the list pages.
The fix

Books' (and video games') tenant-owned CustomImageUrl override was applied on the per-type list endpoints but not in the wishlist, so custom covers silently fell back to the reference cover (or nothing).

- Promoted the "hydrate reference cover, then let CustomImageUrl win" logic into a shared ReferenceImageHydrator.HydrateWithCustomOverrideAsync — it previously existed as a private copy in SharedWithMeController plus three near-identical inline copies in Book/VideoGame/Album controllers.
- Routed all of them through it, plus the actual bug fix in WishlistController for Books and VideoGames (movies/TV shows stay on the plain path — they have no CustomImageUrl).

Tests

New WishlistResourceTest with two cases asserting a CustomImageUrl beats the linked reference cover in the wishlist payload, for books and video games. Both pass against local MongoDB.

Docs

- Fixed the now-inaccurate BookController.OnListMappedAsync doc comment (it claimed the override was "Book-specific, not shared via ReferenceImageHydrator" — the opposite of what it now is).
- CLAUDE.md doesn't document CustomImageUrl, so nothing needed changing there for the feature itself.

The test-filter gotcha you hit

Documented in CLAUDE.md's commands section: --settings Local.runsettings and --filter-method can't be combined — --settings forces legacy VSTest mode, which rejects the Microsoft.Testing.Platform filter flags and silently runs zero tests (exit 5, "error: 1"). Added the working recipe: load the runsettings' env vars into the shell, then filter without --settings. A full unfiltered run can still use --settings.

One thing I did not do: I left the AlbumResourceTest/BookResourceTest/VideoGameResourceTest existing override tests untouched since they still pass and cover the list-endpoint path. The shared-with-me endpoints already had override coverage via their own reads. If you'd like belt-and-suspenders coverage for the shared wishlist anonymous path (/api/wishlist/shared/{token}) specifically, I can add that too — but it exercises the identical BuildWishlistAsync code path, so I judged it redundant.
Summary of this round

- Shorter messages (ReferenceRefreshMessage.cs): No match found → No match, Unlinked - no match → Unlinked, Already linked → No change. Linked! unchanged.
- Narrower slot (app.css): reserved width 8.5rem → 6rem, which comfortably fits all four messages now.
- Mobile: no reservation — inside the existing @media (max-width: 767px) block, .kt-title-toast-slot collapses to width: auto; flex: 0 1 auto, so on phones the (already small) title keeps its full width and the slot only takes room while a message is actually showing.

Desktop now keeps the title stable with a modest 6rem reserved gap; mobile behaves as it did before, minus the permanent squeeze.
Root cause

The error is a DI scope mismatch, not a Blazor-lifecycle timing issue.

AuthenticationTokenHandler is wired into every API client with AddHttpMessageHandler<AuthenticationTokenHandler>(). IHttpClientFactory builds the message-handler pipeline in its own handler scope, separate from the component render scope. So the NavigationManager you constructor-injected into the handler was a fresh RemoteNavigationManager that the renderer never called .Initialize() on.

That instance is fine as long as nobody touches it — but RedirectToLogin() reads navigationManager.Uri. That path only fires when the Firebase token is stale/expired (past the ~1h token life, before the client-side refresh caught up) and the API returns 401, or when there's no token. Exactly the intermittent condition you saw. During the SSR/prerender pass (WaitForNonStreamingPendingTasks in your stack trace), .Uri on the uninitialized manager throws RemoteNavigationManager has not been initialized, the framework's clean-redirect path never runs, and you get the red error until you refresh (a refresh re-issues the token via the cookie, so the 401 doesn't recur).

Fix

src/BlazorApp/Components/Account/AuthenticationTokenHandler.cs: resolve NavigationManager from httpContext.RequestServices instead of the constructor. That's the request's own scope — the one the endpoint renderer already initialized before running OnInitializedAsync — so NavigateTo throws the proper NavigationException the framework converts into a real redirect to login. httpContext is already guaranteed non-null on this path (the guard at the top of SendAsync), and I made RedirectToLogin static since it no longer needs instance state.

Net effect: a stale token now cleanly bounces the user to the login page (re-running Firebase sign-in and re-issuing the cookie) instead of showing the red error.

Worth noting

I kept the change surgical. Two things I'd flag rather than silently change:

- This handler throws InvalidOperationException("HttpContext is not available") when HttpContext is null, which is the case inside a live interactive circuit. So these authenticated API calls effectively only succeed during the SSR/prerender pass. That's a broader design characteristic, not something this bug requires touching — but if you ever see failures on interactive re-fetches (paging/search after the circuit is live), that's the thing to look at.
- I didn't add a test: this is a rendering-scope/SSR behavior that a mocked unit test can't reproduce (it needs a real prerender host), and per your quality notes a mock here would prove nothing. If you want coverage, the right home is a Playwright smoke test that forces a stale token and asserts the login redirect
The fix

src/BlazorApp/Components/Account/AuthenticationTokenHandler.cs — resolve NavigationManager from httpContext.RequestServices (the request's render scope, where the endpoint renderer already initialized it) instead of constructor-injecting it (which came from IHttpClientFactory's separate handler scope, where the RemoteNavigationManager was never initialized). That uninitialized instance is what threw RemoteNavigationManager has not been initialized on .Uri during the SSR/prerender pass whenever a stale token produced a 401 — the red error you had to refresh past. Now the properly-initialized manager throws the NavigationException the framework converts into a clean redirect to login.

The test

test/BlazorApp.PlaywrightTests/Smoke/StaleTokenRedirectSmokeTest.cs (+ a ForgeStaleTokenMemberCookie() helper on End2EndFixture). It forges an auth cookie with a valid member principal but a Firebase token WebApi rejects, hits the exact reported page (/account/manage/shared/{id}), and asserts a redirect to login.

Verified both directions:
- With the fix: passes (~5–12s).
- With the fix reverted: fails, reproducing your exact stack trace (SharedCollectionPage.OnInitializedAsync → WaitForNonStreamingPendingTasks → uninitialized RemoteNavigationManager).

Recipe updated

I rewrote the e2e-run memory so this doesn't cost time again. The checks I ran (provider keys, Mongo port, browsers) were the recipe's gap — it now leads with "preconditions are already satisfied, don't re-verify," states plainly that no provider keys need setting (the host loads real ones from appsettings.Development.json; placeholder x values are pointless), and gives a copy-paste PowerShell block for a read-only single-class run.

One thing I'll flag again since it's adjacent: this handler throws HttpContext is not available when HttpContext is null (inside a live circuit), so these authenticated API calls effectively only succeed during SSR. Not in scope for this bug, but worth knowing if interactive re-fetches ever misbehave.
What was already there

The sharing feature already had a committed SharingSmokeTest (from c89eae2 Share car/house/health) plus its page objects, covering two of the three sharing shapes:
- Media (Movies) — copyable list with the "In collection" badge
- Personal (Cars) — list plus a read-only detail page

The actual gap

The plan's progress log flagged one deferred Playwright leg: the collection categories (Collectibles + Gear), which are a genuinely third shape — a full read-only list, but view-only (no shared reference to copy, so no "add to my collection" action and no read-only detail page). Nothing exercised those two tabs.

What I added

1. SharedCollectionViewPage.AddButton(itemTitle) — a page-object locator for the per-row data-testid="share-add" action (kept on the page object per your locator-discipline convention).
2. SharingSmokeTest.ShareCollections_RecipientSeesReadOnlyListWithNoAddAction — self-shares Collectibles + Gear through the owner UI, then as the recipient opens each tab and asserts the item is listed read-only with the add button absent (ToHaveCountAsync(0)) and no "In collection" badge. That absence is the whole point — it's what separates a view-only collection tab from a copyable media tab.
3. Updated the plan's progress log to mark the leg as written.

test/BlazorApp.PlaywrightTests builds clean (0 warnings). Like every other smoke test it self-skips unless E2E_ENABLED=true, so I couldn't run it here — it needs your WSL E2E env (MongoDB + Firebase creds + browsers). Per your run recipe, that's where it should actually execute.

I've left everything uncommitted for you to review first.

One note on scope: the existing test covers Cars but not Houses/Health for the personal shape. Those three go through the identical SharedPersonalList + read-only-detail mechanism, so Cars is representative — I didn't add redundant House/Health legs. Say the word if you'd rather have explicit coverage for those too.
Done

You were right — the WantToWatch flag was dead weight on TV shows. WatchNextService.ComputeInProgressShows drives shows purely from State == Current + a confirmed unseen episode and never looked at the flag; there was no "shows to watch" surface for it to feed either. It's a genuinely movie-only concept, which is left untouched.

Removed across every layer (build clean, 288 unit tests green):

- Model / Entity / DTO — TvShowModel, TvShow (want_to_watch BSON), TvShowDto. Mapperly stayed balanced, so the build itself is the proof nothing was missed.
- Repository filter — the input.WantToWatch clause in TvShowRepository.GetFilter (it wasn't even exposed as a list filter button).
- UI — the "To watch" badge in TvShowMetaRow and the "Watchlist" toggle button + ToggleWantToWatchAsync in TvShowDetail.
- TV Time import — ShowEnrichment no longer maps the export's for_later status into shows; the now-unused ShowStatusCsvParser.ForLaterStatus constant went too (parser test keeps its for_later row coverage via the literal, since the parser still must read that column faithfully for favorite).
- Index — dropped tvshow_want_to_watch from mongodb-create-index.js, with a note explaining why.

Data cleanup: added scripts/unset-tvshow-want-to-watch.js (idempotent $unset, same run-once style as the poster-url migration) to strip the orphaned field from existing tvshow documents — run once per environment.

Docs: updated the stale CLAUDE.md import line and added a short design note recording that WantToWatch is movie-only by design, so it doesn't get reintroduced as a flag later (if you ever want a "shows I want to start" view, it should be a real Watch Next section, not a flag with no reader).
Summary

#1 — Killed the N+1 reference lookup (WatchNextController.cs)
The old code awaited a separate FindByIdAsync per in-progress show (N serial round trips to tvshow_reference). Now it collects the candidate shows' reference ids and does one batched FindByIdsAsync, then maps references back per show — the same batched pattern the movie-poster hydration one line below already used.

#2 (cheap) — Stopped fetching the whole episode history (EpisodeRepository + IEpisodeRepository)
The old code pulled every episode the owner has (int.MaxValue), then WatchNextService discarded every non-Current show's episodes in memory — cost scaled with total lifetime watch history. Added a batched, owner-scoped FindByShowIdsAsync(ownerId, showIds); the controller now fetches episodes only for the Current+linked shows that can actually appear in the result. The owner_id + tv_show_id IN(...) filter rides the leading fields of the existing episode_last_watched index.

Output is behaviorally identical — non-Current and unlinked shows were already excluded downstream.

Tests
- New EpisodeRepositoryTest (integration, real MongoDB): verifies FindByShowIdsAsync returns only the requested shows' episodes, is owner-scoped (a different owner tracking the same show id is excluded), and returns empty for no ids. Both pass.
- Updated the FakeEpisodeRepository in TvTimeImportServiceIdempotencyTest to implement the new interface member.
- Full unit suite (288) green; the two new integration tests green against your local Mongo.

Not done (as agreed): the aggregation version of #2 (server-side max (season,episode) per show) and reference caching — hold those unless this isn't enough. If you want to sanity-check the win, the Watch Next page load should now issue a small constant number of queries regardless of how many finished shows/episodes are in the collection.

One note: ITvShowReferenceRepository.FindByIdAsync is no longer called from this controller but is still used elsewhere, so I left it in place.

the movies tab is already in good shape, unlike the show-reference path we just fixed. I checked all three layers:

What's already correct

1. Server-side filter, with a matching index. WantToWatch = true is pushed into the Mongo query (MovieRepository.GetFilter:34), and there's a dedicated partial index movie_want_to_watch (owner_id, want_to_watch, filtered to want_to_watch: true) backing exactly that shape. So it fetches only the watchlist, never the whole movie collection.
2. Batched poster hydration, no N+1. ReferenceImageHydrator.HydrateAsync collects all reference ids and does a single FindByIdsAsync — the same batched pattern I just applied to the shows. No per-movie round trip.

So the two things that mattered on the shows side (a server-side filter and avoiding N+1) are both already right here.

Two marginal nits (I'd leave both)

- The "already seen" exclusion runs in memory. FilterMoviesToWatch drops movies with FirstSeenAt != null in C#, even though GetFilter already supports pushing it down via IsUnseen = true (line 37). Pushing it to the DB would transfer slightly fewer docs — but the set is already just the watchlist (small), the FirstSeenAt condition wouldn't be index-covered anyway (only want_to_watch is in the partial index), and FilterMoviesToWatch is a documented, unit-tested method that exists specifically because the flag goes stale. Net payoff ≈ zero. Not worth the change.
- A wasted count query — but this is not movie-specific and not really an inefficiency of the movies tab per se: FindAllAsync always runs a CountDocumentsAsync before the Find to populate PagedResult metadata that Watch Next discards. Both the shows fetch and the movies fetch pay it (2 round trips where 1 would do). It's indexed on owner_id and cheap; removing it would mean adding a non-paged read method to the repos — more API surface than the tiny saving justifies.

Bottom line: the movies tab doesn't have the kind of scaling problem the episode/reference path had — its cost is bounded by watchlist size, not by total library size. I wouldn't change anything there. If you want me to push the seen-filter down anyway (turning FilterMoviesToWatch into a DB filter) I can, but I'd consider it cosmetic.
devpro added 3 commits August 4, 2026 22:57
ReferenceDataImportService writes those one InsertOne/ReplaceOne at a time (after reading all six collections whole first) — minutes of work, and it was running inside a single blocking HTTP request. HttpClient's default 100s timeout cancelled the BlazorApp side while the WebApi kept importing. So your import very likely succeeded server-side after the UI reported failure.

About the missing WebApi logs — that's expected and not evidence the request didn't arrive: src/WebApi/appsettings.json sets "Microsoft": "Warning", so ASP.NET Core never logs request start/finish, and the import path only logs its skipped-external-id warnings, at the very end.

Fix

Moved it onto the JobStore pattern the project already mandates for long-running work (same shape as TV Time import and sync-now):

- POST /api/reference-data/import buffers the upload, starts the work on a fresh DI scope, returns 202 + job id; GET /api/reference-data/import/{jobId} polls. src/WebApi/ReferenceData/ReferenceDataAdminController.cs:87
- The job runs on ApplicationStopping and the import checks that token per document, so a shutdown mid-import stops promptly and says so instead of writing against a disposed Mongo client — safe because re-running the same zip is idempotent.
- Domain reports which collection it's writing (ReferenceDataImportCollection); the web layer names that as a job stage. The admin page now shows a real per-collection progress bar, with people flagged as "most of the wait".
- Secondary issue on the same path: the page handed IBrowserFile.OpenReadStream() straight to StreamContent, so the browser fed the file down the SignalR circuit during the POST, against the same timeout. It's buffered first now.
- Signature bundling into ReferenceRepositorySet so ImportAsync stays under Sonar's S107 param limit, same fix as OwnedItemImportAdapter.

Updated CLAUDE.md and docs/code-quality-findings.md.

Verified: build clean (0 warnings), 406 unit tests pass, and all 9 ReferenceDataImportResourceTest cases pass against real MongoDB through the new 202-plus-polling flow.

Two things to note:

1. Still one round trip per document, so a full export will take a couple of minutes in the background. Batching into BulkWrite pages would cut that by an order of magnitude but touches all six repositories and the merge loop's incremental indexing — I left it and recorded it as open in the findings doc. Say the word if you want it.
The new test passes:

Import_WhenOneDocumentIsKnownAndOneIsNot_CountsEachAgainstWhatItActuallyDid — passed (3s)

It runs both branches in one import over the same collection and asserts what the numbers claim about the database: the updated show is still under the target's original _id and still the only document carrying its TMDB id (nothing was inserted beside it), while the created one now exists.

To answer your question directly — the count comes from one variable that also decides the write:

var match = FindMatch(document, byExternalId, byId);   // dictionaries built from findAllAsync(), before any write
if (match is not null) { merge(match, document); document.Id = match.Id; }   // → ReplaceOne on an existing _id
...
if (match is not null) counts.Updated++; else counts.Created++;              // → ReplaceOne(IsUpsert) inserts

There's no path where an insert increments Updated. The one asymmetry: two entries in the same zip sharing a provider id count 1 created + 1 updated, because the second merges into the one just inserted — but that needs Created > 0 in that collection, which is why it couldn't explain what you saw.
src/WebApi/Filters/ApiExceptionFilterAttribute.cs — TimeoutRejectedException / BrokenCircuitException / HttpRequestException now map to 502 Bad Gateway, logged as a warning. A 500 claims the fault is ours; it also made a provider outage indistinguishable from a real defect, for a log reader and for a test. Everything else still 500, argument exceptions still 400. Covered by a new theory in ApiExceptionFilterAttributeTest (7 tests pass).

BookProviderSearchAndLinkResourceTest — now a [Theory] over googlebooks (the deployment default, so the provider a real user's search reaches) and bnf (keyless, quota-free), each case skipping itself on a 502. Narrow on purpose: only 502 skips, a 500 still fails.

ResourceTestBase — GetThroughLiveProviderAsync / PostNoContentThroughLiveProviderAsync carry that guard; the four repeated StringContent/deserialize blocks collapsed into JsonBody/ReadJsonAsync on the way.
public async Task<TDto?> GetOneAsync(string id)
{
return await http.GetFromJsonAsync<TDto>($"{ApiResourceName}/{id}");
var response = await http.GetAsync($"{ApiResourceName}/{id}");
// pulled down the SignalR circuit in small chunks, and doing that *during* the POST would count the
// whole upload against the request's own timeout.
using var buffer = new MemoryStream();
await using (var stream = e.File.OpenReadStream(MaxImportFileSize))
devpro and others added 6 commits August 5, 2026 11:27
…ures honestly

Reported as a broken ISBN search (9782265002104) failing with "Google Books
search failed (... 502 (Bad Gateway))". Not a regression: Google Books' search
endpoint was answering 503 to everything, reproduced with curl and no
application code involved - q=a and q=dune failed identically, with and without
the fields= parameter, on both hosts, with the key in the query string and in a
header, while volumes/{id} on the same key answered 200 and a bogus key answered
400. The key, the project and the API enablement were all fine; only search was
down. Two real defects underneath it, both fixed here.

An ISBN was only ever searchable through Google Books. OpenLibraryClient and
BnfClient accepted the isbn parameter and silently ignored it, so switching
provider in the admin picker - the one advertised way around a provider outage -
quietly downgraded an exact-identifier search to a fuzzy title match. Both now
search by it (q=isbn: and bib.isbn all, each confirmed against the real API).

The ordering and fallbacks every book provider shares moved into the new
BookReferenceClientBase (isbn, then title+author, then title, widening only on
an empty result); a client now supplies just its own two query shapes. The
author-retry loop was the same algorithm copy-pasted into all three clients.

An ISBN miss widens to the title search rather than short-circuiting, which does
change Google Books' previous behaviour. BnF holds no record for that ISBN while
Open Library resolves it in one call, so reporting "no results" for a book the
same provider can find by title would make supplying an ISBN worse than leaving
it blank. No new mis-linking risk: the fallback runs the exact query an
ISBN-less item already runs, and automatic resolution still only acts on a
single confident candidate.

The error text also named the wrong system. ApiExceptionFilterAttribute already
returned 502 with an { error } body, but the Blazor client used
EnsureSuccessStatusCode, which throws with only the status line and discards the
body - so the API's explanation reached nobody and the admin saw our gateway
status with the provider's real one nowhere in it. The filter now describes what
the provider actually did (returned 503, unreachable, timed out, circuit open),
ApiResponseExtensions reads that body into an ApiRequestException, and
InlineReferenceLinker names the provider and points at the picker when the
failure was upstream and the domain has another provider.

Tests: BookReferenceClientBaseTest proves the shared policy once against a
recording fake; OpenLibraryClientTest/BnfClientTest cover each ISBN query shape;
ApiExceptionFilterAttributeTest covers the new messages; ApiResponseExtensionsTest
covers reading the error body and falling back to the status line. 452/452 unit
tests pass.

Note for the next outage: BookProviderSearchAndLinkResourceTest does cover
googlebooks search+link live, but skips on a 502 by design, so it goes
green-by-skipping during exactly this scenario. That is deliberate and should
not be narrowed, but it means no test will warn that a provider is down.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012yXBT2z6j5sy9fGMTpepKK
…ed its backfill

Reported one day after a deploy: half the movie list showed no rating, and the
admin's rating recompute kept answering "0 references checked, 0 items updated"
however often it was clicked.

The recompute is a no-op by design there and always would have been - it
re-stamps the denormalized rating on tenant items from what the reference
document already holds, makes no provider call, and early-outs on
CountLinkedOnOtherRatingSourceAsync, which returned 0 because every linked movie
was already correctly stamped imdb. It can never produce a rating value. The
missing half was ratings.imdb on the reference documents: 1516 linked movies
(999 rated, 517 not), 509 movie references holding an imdb id, no imdb rating
and no attempt stamp, against provider_quota showing omdb:2026-08-04 at
1000/1000.

The bug is in the no-change short-circuit of RefreshTvShowReferenceAsync and
RefreshMovieReferenceAsync. BackfillImdbRatingAsync correctly declines to spend
a call it cannot afford, and correctly leaves RatingsCheckedAt unstamped so the
title is not written off - but both callers then set LastEnrichedAt = UtcNow
regardless. That marks a document the pass admittedly did nothing for as freshly
enriched and drops it out of FindStaleAsync for the whole 3-day window, so a
quota-capped day converges in 3-day steps instead of daily. That is what left
the catalogue at half coverage with no admin action able to move it.

BackfillImdbRatingAsync now returns an ImdbBackfillOutcome instead of a bool,
and LastEnrichedAt is stamped for every outcome but Deferred.

Two properties keep that from being the starvation this codebase otherwise
stamps to avoid. It is narrow: only a spent allowance defers, checked before the
call and re-read after it, since the budget can run out mid-pass through another
replica or OMDb's own "Request limit reached!" 401. A missing key or a failed
request deliberately still stamps - neither can be retried into working, so
deferring on them would pin every reference at the head of the queue forever.
And it is self-limiting: the allowance renews at UTC midnight, so a deferred
reference costs one cheap TMDB /changes call per pass until then, and guarantees
the next affordable calls are spent on the references actually missing a rating.

The full-fetch path keeps stamping on purpose. Its expensive half - details,
cast and the person upserts behind it - genuinely completed, so re-paying all of
it every pass to retry one OMDb call would be the wrong trade; such a reference
is left holding a TMDB rating, which routes it through the cheap short-circuit
next time round.

Tests: five cases in ReferenceEnrichmentServiceTest - the quota regression for
movies and TV, the budget running out during the pass, and both narrowness
guards (an unreachable OMDb for any other reason still stamps, and so does a
reference that needed no call). The three pinning the fix were confirmed to fail
without it. FakeOmdbClient gained an opt-in ReportsLimitReached that writes the
day off through the shared budget, mirroring OmdbClient. 431/431 unit tests pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0122bNmGMVVdaCTn1HPtNFhY
The guard keeping a video game reference's RAWG key art from being replaced
by an IGDB cover keyed on "this document carries a rawg id", which is only a
proxy for "the stored image is a RAWG image". The two diverge as soon as a
document holds both ids - the normal state after the sync adopts the default
provider - and the proxy then inverted the rule in the case where the operator
acted deliberately: the admin picker passes its provider straight into
ResolveVideoGameAsync, which writes that provider's id before computing the
image, so re-linking through RAWG made the guard fire on the rawg id it had
just written and discard the key art it had just fetched.

That also left a dead RAWG URL unrepairable by anything short of unlinking
(which deletes the shared reference document), and froze every image
permanently on a deployment running RAWG as the default provider.

PreferredImageUrl now takes the fetching client's ProviderKey and exempts
RAWG. Every other provider still may not overwrite a stored image on a
rawg-linked reference; RAWG stays authoritative for its own data.

Covered by four RefreshVideoGameReferenceAsync cases and one
ResolveVideoGameAsync case, the latter failing against the old predicate.
The rule had no test at all before this.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Explore excludes a suggestion two ways, and the RAWG -> IGDB switch broke both
on the same documents. The id half asks each linked reference for the discovery
provider's id, so a reference still carrying only a rawg id contributes nothing;
the title half compared titles with exact normalized equality, which fails on
precisely the spelling divergences that had blocked adoption in the first place.
105 of 344 references were stuck, 76 of them invisible to both halves - so the
owner's own games, Elden Ring and Red Dead Redemption 2 among them, led their
Explore feed. Movies and TV were never affected: TMDB is both their discovery
provider and the one that linked every reference.

Restore the invariant (every reference carries the current default provider's
id) rather than working around it in Explore:

- TitleNormalizer.NormalizeLoose/LooselyEqual for provider-to-provider matching,
  and StripDisambiguator for re-querying without a "(1997)" suffix - confirmed
  live, IGDB answers nothing at all to either query shape for such a title.
  Normalize stays strict; it keys aliases matched against tenant-typed text.
- IVideoGameReferenceClient.FindGamesByExactTitleAsync (IGDB `where name ~`,
  RAWG search_exact plus a client-side equality check), and a query ladder in
  adoption that widens only on an empty result. Measured against live IGDB over
  the real stuck set: 49 of 105 now adopt unattended, and the rest reach the
  admin queue with candidates where they previously produced none.
- ProviderAdoptionCheckedAt, so a fruitless attempt is remembered for 7 days
  instead of re-paid every pass.

Add an admin provider-reconciliation surface for what adoption refuses to guess:
the gap queue with per-row candidates and one-click adopt (writing onto the
existing document, never minting a second), and duplicate groups with a merge
that re-points every tenant's item before deleting the absorbed document. The
merge keeps a RAWG-linked document's cover whichever one survives, computed
before the ids are unioned - afterwards the survivor carries a rawg id whatever
its own cover is.

Report the import's blind spot rather than closing it: matching is by provider
id, so an IGDB-era export lands beside a target's RAWG-era copies of the same
games. Title text is not identity, so an import must not fuse them on its own.

Finally, derive the video game domain's selectable rating sources from its
registered default provider (new RatingSourceOptions) instead of a hardcoded
list. That list was wrong in both directions: it offered Metacritic long after
IGDB became the default although IGDB cannot produce it - so every game linked
since resolved to no rating, while preserved RAWG-era values hid the breakage -
and it would equally have kept offering IGDB's scores on a deployment back on
RAWG. A stored override that is not currently on offer is ignored but never
erased, so setting ReferenceData:VideoGameProvider=rawg brings rawg/metacritic
back and honours the admin's existing choice untouched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QU4wnTX63eL6J6wpu3MmBy
Reported from the running app: an album search by title and artist returned
results where the searched title occurred in the artist name rather than in any
release title. q= is Discogs' free-text parameter, matching the artist name,
label, credits and tracklist - artist= narrows the pool but does not constrain
what q= matched on, and the pre-existing zero-result retry drops artist=
entirely, leaving nothing but free text. Confirmed live: q=Discovery with
artist=Daft Punk returns "Live @ Rex Club, Paris" and "MP3 Collection" beside
the album, and q=Sabbath returns 2350 hits whose only occurrence of the word is
"Black Sabbath" in the artist name.

That noise made albums unlinkable rather than merely untidy. Automatic
resolution acts on a single candidate, so a title colliding with a prolific
artist's back catalogue never auto-resolved however unambiguous it was, and the
admin picker only shows five candidates - noise ranked above the real master
pushed it off the list, leaving no way to link by hand either.

Re-check every candidate's own parsed release title client-side and discard
mismatches, the same treatment BnfClient.AuthorMatches already applies to a
provider clause that is not a strict filter. Running it on the parsed title from
SplitArtistTitle is what excludes a match that only ever occurred in the artist
half of Discogs' combined "Artist - Title" string. The filter sits inside
SearchAlbumsCoreAsync, so the widening step needs no new condition: "answered,
but nothing was actually titled that" reaches the artist retry as the same state
as an empty response.

TitleNormalizer.LooselyContains joins LooselyEqual for this - whole-word
containment under NormalizeLoose, so "Nevermind (Demo & Outtakes)" and
"Homework / Discovery" are kept while "Blue" does not match "Blueprint", and a
title normalizing to nothing matches everything rather than filtering a caller
down to none.

Switching to the field-scoped release_title= instead was measured and rejected:
precise (2350 hits down to 209 for "Sabbath", all genuine title matches) but it
reorders badly - release_title=Nevermind with artist=Nirvana ranks the canonical
1991 album fourth, behind "Nevermind Sessions" - which the five-candidate cap
turns into a worse failure than the noise. Filtering keeps q='s relevance order.

Open Library's q= has the identical problem and is deliberately left alone: the
book ladder is multi-provider and widens only on an empty step, so a filter
changes which rung it lands on, and the ISBN-first fallback that carried book
search through the Google Books outage is not worth risking for this. Documented
in docs/code-quality-findings.md with the constraints for picking it up later.

Covered by DiscogsClientTest (verbatim real-API response shapes, including the
retry path) and TitleNormalizerTest.LooselyContains_*. The client had no test at
all before this.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XzsptC25NT8QvjGceAzW6r
@sonarqubecloud

sonarqubecloud Bot commented Aug 5, 2026

Copy link
Copy Markdown

Quality Gate Failed Quality Gate failed

Failed conditions
C Security Rating on New Code (required ≥ A)

See analysis details on SonarQube Cloud

Catch issues before they fail your Quality Gate with our IDE extension SonarQube for IDE

devpro and others added 19 commits August 6, 2026 14:45
An Explore suggestion is by definition not in the collection yet, so there
was nothing to click: no detail page exists for it, and deciding whether to
add or dismiss meant leaving the app and searching for the title by hand.
Each card and row is now a link to the title's own provider page, opened in
a new tab so the list being worked through survives the detour.

It follows the rating the card displays wherever it can, which is the point:
a movie showing an IMDb score opens IMDb, not TMDB, so the reader lands
where the number came from. It falls back to the discovery provider's page
otherwise, since the bounded IMDb backfill is always behind the ranking and
an entry it hasn't reached must still open somewhere.

The URLs are stored per source on the catalogue entry (web_urls, merged
key-by-key exactly like ratings) rather than derived at read time, because
two of the four cannot be derived: IGDB and RAWG key their pages on a slug,
so those come back from the listing itself. ProviderWebLinks holds only the
ones an id does determine.

The IMDb link is a free by-product of the rating backfill, built from the id
that lookup has to resolve anyway, and stored even when the OMDb call never
happened - withholding a fact already in hand because a separate, budgeted
call failed would leave the card pointing at the wrong site for another week.
FindMissingRatingOrLinkAsync also takes an entry that has a rating but no
link, deliberately without the 90-day window on that half: those are entries
from before links were stored, one lookup closes each for good, and making
them wait would leave the top of the ranking linking to TMDB. It still can't
loop, because a title the provider has no id for gets no rating either and so
can only match through the windowed rating branch; and an entry pulled in for
its link alone spends no OMDb call.

Existing entries gain links on the next pass that rewrites them (weekly, or
sync-now with Force); until then the card renders as plain text rather than
as an anchor pointing nowhere.

Also drops the provider name from the page blurb, which had said "from RAWG"
since IGDB became the default - the discovery provider is a deployment-time
choice the client cannot know, and each card now names its own destination.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TRHuQKQwHLtpZZ6LMXK5WP
Provider reconciliation left rows nothing could clear, because the
adoption ladder trusted a provider's search too far. Every finding below
was measured against the live IGDB API using this database's stuck rows.

- Widen on "nothing *matched*", not on "nothing came back", and keep
  every rung's candidates instead of the last reply replacing them. A
  relevance search hands back an unrelated non-empty answer as readily
  as an empty one - IGDB answers "NieR:Automata" with a lone "Untitled
  NieR:Automata Project" - and that ended the ladder before the rung
  that finds the game ever ran.
- Ask with TitleNormalizer.ToProviderQuery: a search is far more
  punctuation-sensitive than the catalogue behind it. `search "NieR
  Automata"` returns the game first where `search "NieR:Automata"` never
  returns it at all, so that reference now adopts unattended.
- Then, only while nothing has matched, retry with trailing words
  dropped (bounded, floor of two words), and finally with every word as
  a substring - the one shape that survives the provider spelling a
  title with punctuation the reference omits. Those rungs surface the
  right game for "NieR Replicant v1.22474487139", "Pokemon: Let's Go,
  Pikachu! and Eevee!" and "Marvel's Avengers", none of which had any
  candidate before. Substring results are unranked, so they are
  shortlisted to the eight closest to the title asked for.
- Confirmation is unchanged and still judges a candidate against the
  reference's own title, so none of this widens what links itself.
  Apostrophes are now dropped rather than spaced in NormalizeLoose,
  which was producing "assassin s creed".
- Give the admin row its own search box and accept a pasted provider
  page URL or id, since a search has genuine dead ends. Candidates are
  ordered closest-title-first so the likeliest answer leads the row.

Also adds ?exploreOnly=true to sync-now, so rebuilding the Explore
rankings no longer costs a full reference pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Gjyjjf1cN2BFP7QsBYhcE
The failure screenshot every Playwright test was supposed to leave was
reaching the wrong page, and chasing one real failure with it turned up
two suites quietly writing to each other's database.

Failure diagnostics, for every test in the suite:

- Capture walks every context the test opened, not just the shared
  signed-in one, and every open page in it. Four tests built their own
  anonymous context and disposed it inside the test body, so on failure
  they screenshotted the untouched shared page and traced a context
  nothing had happened in. They now open it through
  SmokeTestBase.NewAnonymousPageAsync, which keeps it alive until the
  diagnostics have run and removes the same four copies of the same
  context options.
- Capturing can no longer replace the failure being diagnosed: a page
  that has crashed or is mid-navigation is reported and skipped, where
  before it threw out of DisposeAsync and cost the trace that would
  have explained the run.
- The written paths are printed to the failing test's own output, so a
  CI log names its evidence instead of assuming the reader knows the
  convention (bin/<config>/net10.0/e2e-diagnostics).

Then what that screenshot showed: 26 seeded Explore suggestions
interleaved with 18 real films.

- The sync-now tests get a 202 and finish while the job they started
  runs on against the live providers, writing the real TMDB ranking
  into the shared explore_catalogue minutes later - documents no
  cleanup can register at creation time, because they don't exist yet.
  Nothing there asserts anything a provider returns, so those classes
  are hosted through ProviderlessWebAppFactory and now write nothing,
  call nobody and spend none of OMDb's daily budget.
  ReferenceSyncPollingResourceTest, the one test that wants the live
  pass, moves to its own class and keeps the ordinary host.
- ServerDerivedDataSweep (an assembly fixture, so it disposes after
  every class and every host - nothing can write behind it) empties the
  derived collections at the end of a run, as the backstop for that one
  test. It is best-effort by design: a cleanup that fails an assembly
  fixture turns 198 passes into 198 failures.
- ExploreSmokeTest now checks the premise it rests on and names this
  cause, instead of failing on a card count that matches nothing.

Database isolation, so neither of the above can recur:

- Each suite settles its own database - keeptrack_integrationtests and
  keeptrack_e2e - and pushes the resolved name into its host's
  configuration, which is what stops the silent fallback to
  keeptrack_dev. Infrastructure__MongoDB__DatabaseName and
  E2E_MONGODB_DATABASE still override; neither is required any more.
- Defaulting rather than demanding is the point. An IDE sets test
  environment variables once for the whole solution, so a variable both
  suites read could only ever hand them the same database, and removing
  it to stop that failed all 198 integration tests at once. The guard
  now vouches for the name a run will actually use, so a dev/prod name
  still fails fast.

And the import smoke test that started this: /import renders a section
per importer, each with its own level-1 heading and a link, so a
sub-page object waiting for "Amazon" was ready while the browser was
still on the hub - and the upload then ran against the hub's DOM, which
has three file inputs. Sub-pages wait for their route (PageBase.Route)
and scope the input by accept, like ImportPage already did.

Also carries the empty param tags added to two ReferenceData files.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BePNQ16PETbqLm23ESFC4D
Adding a fuel entry asked for the same station's city, postal code and
coordinates on every single refuel, offered no field at all for the fuel
grade, and put the cost far enough from the volume and unit price that a
mistyped total went unnoticed.

The cost is now checked against volume x unit price as you type
(RefuelCostCheck, in Common.System because the check has to run in the
browser and BlazorApp never references Domain). It only ever warns: a
receipt legitimately differs from the pump - a car wash on the same
ticket, a loyalty discount - so nothing is auto-filled or blocked, though
a missing total when one is computable does count as a mismatch. The
tolerance is derived from what the pump's display rounds away rather than
being a flat constant, which would either fire on every large tank or let
a real error through on a small one.

The station stops being free text repeated per entry and becomes a
reference into the new shared, owner-less car_station collection - the
same deliberate exception to "every collection has an owner_id" as the
*_reference collections, since a station at an address is a public fact.
The location moves with it: a refuel no longer carries one at all, and
CarHistoryController hydrates the name and city for display with one
batched lookup per page. Maintenance/Other entries keep their own
location and free-text garage, which have nothing to de-duplicate
against.

Members create stations inline from the picker (POST is find-or-create on
a normalized brand + city + postal code key, unique-indexed) so nobody
waits on an admin to log a refuel; /admin/car-stations is where a station
gains its real city and coordinates and where duplicates are merged. A
station still in use cannot be deleted, only merged - it is reachable
only through an entry's StationId, so deleting one would blank every
referencing refuel's location with nothing to recover it from. The merge
re-points the entries before removing the absorbed document and fills
only the survivor's gaps.

Fuel type is offered through SuggestInput fed by a new distinct-values
endpoint, the same shape as gear categories - both now over a single
MongoDbRepositoryBase.FindDistinctValuesAsync instead of two copies.

The form itself was cramped in a 520px modal: a four-field row wrapped
the longer labels and knocked their inputs out of line, and the station
dropdown was clipped by the modal body's scroll container with no way to
scroll it back into view. The multi-column form modals are now 880px, the
row is bottom-aligned so a wrapping label never moves its own input, and
the station sits above the fuel row where its dropdown always has room.

Existing data migrates with scripts/migrate-car-history-station.js; run
it before re-running mongodb-create-index.js, since its find-or-create is
what keeps the new unique index from rejecting duplicates. A refuel that
named no station keeps its location and is reported rather than blanked.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKX137km1ZcPXRdM2icvPJ
Both fields were bare text inputs, so the same specialty and the same
doctor were re-typed visit after visit - which is what lets "dentiste"
and "Dentiste" drift into two values in a journal meant to be browsed by
either. They now offer what this account has already recorded, through
GET /api/health-records/suggestions and SuggestInput, the same "suggest
what you've already typed" shape as gear categories and fuel grades and
over the same MongoDbRepositoryBase.FindDistinctValuesAsync.

The two lists travel in one response rather than an endpoint each,
because the form needs both the moment it opens. They are owner-scoped
and must stay that way: unlike car_station, a specialty or a doctor's
name is one account's medical history, not a public fact about a place.
No new index - the query already plans as an IXSCAN on health_record's
owner_id followed by a fetch of that tenant's documents alone, measured
at 0 ms over a real 263-document journal, and two more indexes would be
paid for on every write to buy nothing at that size.

Clicking a suggestion did nothing at all, in every caller. Blazor Server
runs one event at a time per circuit, so the focusout that a click's
mousedown causes ran its whole handler - closing the menu, re-rendering,
disposing the item's click handler - before the click was ever
dispatched. The guard meant to prevent this could only be set by the
click it was waiting for, and the Task.Delay it sat behind could not
help either, since the click cannot be processed during it. Preventing
mousedown's default keeps focus in the input so no focusout happens at
all; a mousedown flag remains as the fallback for a browser that blurs
anyway, that being the one signal which reaches the server ahead of the
focusout it causes.

Enter and Tab completed nothing either, because nothing was highlighted
until an arrow key was pressed - the ARIA combobox pattern's manual
selection, which makes every keyboard completion cost a trip to the
arrow keys. Typing now highlights the exact match, or the first one, so
Enter takes it directly. Tab only takes a highlight the user moved to
themselves: it is a navigation key, and auto-completing on it would
silently rewrite a genuinely new value into an existing one that merely
contains it. Escape drops the highlight for exactly that case.

The e2e test that shipped alongside the broken version filled the input
and saved, which exercises a plain text field and proves nothing about a
dropdown. The new one takes a value by clicking an item and another by
pressing Enter on what typing highlighted, and pins the Tab rule.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S632gCxmajuXKk9DaK87mP
The Explore smoke tests only ever drove movies and TV shows. Video games
differ in more than the page: their tab is member-only behind an
AuthorizeView, their ranking is IGDB's rather than TMDB's, and their
dismissals are recorded in IGDB's id space - so dismiss and "Load more"
are now theories over movies and video games rather than a second copy,
and adding a suggestion is proven against a real IGDB id (72, Portal 2 -
the only entry that exact name matches, this domain's search being noisy
enough that a well-known title returns mods and editions above the game).

The automatic top-up had no coverage at all, in any domain: removing a
card takes the list below a page and the page continues from its cursor
on its own. Seeding exactly one suggestion past a page proves the
backfill arrives appended last, which is what tells it apart from a
top-up that re-requests page one and reorders what is on screen.

A domain's entry is now seeded under every ranking ExploreRankings
declares for it. Which one the page reads follows the admin-selected
primary rating source, a stored setting no test controls, so seeding the
guessed-at one breaks on a database where the other was picked.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PpWVnEygxYBDGJWLLNSzrK
Six surface decisions repeated on every page, found by walking the app at
390x844 and 1440x900.

The hero: Book/Movie/TvShow put the cover in a col-md-2 - a sixth of the
row, so ~130px of artwork with the fields stranded beside it - while Album
did it properly through its own one-off grid. One DetailHero component now
owns the cover column, the "no cover -> single column" rule, the per-shape
sizing and the stacking breakpoint, and renders the fields row itself. On a
phone the cover is capped rather than full-bleed, which would otherwise push
every field below the fold.

The sidebar: eighteen rows of geometric primitives that are indistinguish-
able at 16px and mean nothing. NavIcon draws them instead, stroke:currentColor
so each inherits the nav link's own colour states - no webfont, no CDN, and
no emoji codepoint, which is what the no-colour-emoji rule actually forbids.
Three group labels give the column the shape it never had.

The rest: list titles are body text, not accent-blue links (the grid always
rendered them that way); the search box's magnifier was a colour emoji; the
accent top border sat on every card and so marked nothing; field labels drop
uppercase, which now belongs to section headings and table headers alone;
list rows and detail forms cap their measure so a row's delete button and a
120px Year input stop drifting to the far edge of a 1100px column; grid
captions clamp to two lines instead of truncating mid-word.

.kt-corner-flag keeps its place on the card - "seen"/"read" outranks the
header flags and its green says finished where their blue says flagged - but
becomes a real button with aria-pressed and nests into the card's corner.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XUksBJaqa3BZbTft5oHe7p
The sidebar got drawn icons last commit, which left the rest of the app
looking half-converted: filter chips still used a text star, a filled circle
and a hollow square; pagination used four arrow codepoints; refresh was a
text glyph in nine places; the list/grid toggle, the hamburger, the upload
dropzones and every empty state were all glyphs picked for looking roughly
right. Two vocabularies at two optical weights.

Components/Shared/Icon.razor is now the only one. It absorbs NavIcon,
TrashIcon, ExternalLinkIcon and AddToCollectionIcon - four components that
were the same fifteen lines of svg with different paths - and adds the glyph
replacements, so a new icon is a case, not a file.

Deliberately still text, and marked as such in the component:
  - the star in .kt-stars and the rating pills. That is a rating *value*,
    not an icon, and partial fills render by clipping an overlaid text glyph
    to a fraction of its width.
  - the diamond brandmark in .navbar-brand / .kt-login-logo - a logotype.
  - the breadcrumb separator and the arrows inside text links - typography
    inside a sentence, not affordances.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XUksBJaqa3BZbTft5oHe7p
The "not yet matched to a reference source (admin only)" panel rendered on
any unlinked item, which for an admin is most of them - so the first thing
on the page was a maintenance tool, above the item's own data. It now stays
where it is (the search has to happen at the top, the linking work is
laborious enough) but is revealed by the check-for-match button coming back
without a link, and its banner is gone: one click either links the item
outright or puts the search right there. Each detail page gates it on
_showLinker, reset on every load so linking from it closes it again.

A missing cover was a faint diamond on a grey block, which reads as a failed
image load - and two of them dominate a six-up grid. CoverPlaceholder draws
the title's initial instead, low-contrast: it can't look broken, it gives
each placeholder a different face, and it carries a little real information.
A title starting with punctuation stays blank rather than blowing that
character up to 2.6rem.

The rest is space and legibility:
  - the editable page title says so, with a faint rule and a text cursor
    rather than a pencil icon, which would have read as a fourth button in
    a row that already holds real ones.
  - on a phone the list toolbar was three stacked rows before the first
    item. Search keeps its own row; sort, view and filters now share one
    that scrolls sideways rather than wrapping, since they are one ordered
    set and a wrap splits it wherever the viewport happens to fall.
  - the home hero no longer centres a 70vh block on a phone, which put the
    collection tiles below the fold on the one screen that should open on
    data. Desktop keeps the centred treatment.
  - money and odometer columns get lining figures and right alignment;
    45825 and 45100 were impossible to compare left-aligned.
  - the health journal has a header row, which the table directly below it
    always had.

SearchAndLinkFirstResultAsync follows the new flow: it clicks the check
first, and returns early when that linked the item by itself - which it does
from the second run onwards, since the reference document a previous run
created is deliberately left behind.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XUksBJaqa3BZbTft5oHe7p
Reference matching for video games failed in four separate ways, each hidden
behind a green unit suite that only ever exercised the matching rule and never
the journey around it. The rule was right every time; everything about it was
not.

Search. IgdbClient asked for five results and returned them raw, and never used
the year at all - so `search "Code Vein"` put the 2019 game sixth, behind its
sequel and three DLC packs, and the picker never showed it. RAWG had the
opposite defect, sending the year as a hard `&dates=` filter that made a
one-year disagreement mean "absent" rather than "ranked lower".
VideoGameReferenceClientBase now writes that policy once, the shape
BookReferenceClientBase already had: ask the exact-name query first so a perfect
title match can never be ranked out, union a 50-deep relevance pool, then rank.

Identity. VideoGameMatchRules is the one declaration of "is this candidate that
game", read by the search ranking, by automatic resolution and by provider
adoption alike. The year is not a tie-break in this domain, it is the identity -
IGDB holds eight games named exactly "Resident Evil 2" - so a year is now
required for any automatic link, and automatic resolution confirms a single
named match rather than trusting that the provider happened to return one row.

Lost update. A PUT is a full replace and the detail page sends the whole DTO on
every field edit, holding a copy fetched before the background resolution had
linked it - so saving any field wrote that stale empty link back over the real
one. This is what was really behind a long run of "it doesn't match, but if I
click refresh it matches" reports: the match had happened, the next edit undid
it, and the button resolved it again. DataCrudControllerBase now restores the
server-owned link on every update, for all five reference-linked types.

Ambiguity. FindByTitleAsync was a FirstOrDefaultAsync over an unsorted match, so
a yearless item adopted whichever same-titled reference sorted first. It now
answers null for "several" as well as for "none", which is the same answer to a
caller that must not guess. scripts/unlink-yearless-ambiguous-reference-matches.js
clears the links that rule already wrote.

Also: the Add form holds itself open for up to two seconds so the detail page
opens already showing the result instead of popping it in, "check for reference
match" escalates to the provider when nothing local answers (otherwise an item
created before its year was known could never be matched at all), and
.kt-icon-spin spins about its own centre now that it wraps an svg rather than a
text glyph.

The three journeys are covered by VideoGameReferenceMatchSmokeTest through the
real UI and the real IGDB, and verified by mutation: the first version of those
tests passed with the fix disabled, because references left behind by an earlier
run let the local lookup answer instead.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AaSVKvqNPk5W7cotF3QzS4
VideoGameMatchRules only ever answered one domain's version of a question every reference-linked domain asks, so it is now ReferenceMatchRules and all five read it.
It carries two identity shapes rather than one, because that difference between the domains is real rather than a drift to flatten.
A film, a show and a game are identified by a title and a year: TMDB holds a "Road House" from 1989 and another from 2024, and the year is the only thing that tells them apart.
A book and an album are identified by a title and a creator: Google Books answers intitle:The Hobbit+inauthor:Tolkien with 300 volumes spanning 1981 to 2012, every one of them the same book, so requiring a year there would refuse every edition of every work.
That is also why several confirmed candidates are editions rather than an ambiguity in those two domains, and the best one is linked instead of the whole set being refused.
Waiting for the provider to return exactly one row, which is what this used to do, meant no book could ever link at all.
A book now needs an author to link automatically, the way the others need a year, and the detail page's check for reference match escalates to the provider when nothing local answers.

Linking also stopped waiting on Open Library.
ResolveBookAsync fetched a star rating from it on every link, and the guard meant to keep that rare, a rating being present already, is false for every book Google Books links, since Google Books serves no ratings at all.
So one admin click called a second and slower provider for a number that has no bearing on the link, and with Open Library unreachable, measured at no response in 60s, it spent the whole 40s resilience budget before the failure was swallowed.
The lookup stays on RefreshBookReferenceAsync, where nobody is waiting, and a rating that is already stored is now carried across a re-link in memory rather than re-fetched.
With that leg gone the e2e wait for a check for reference match drops from 90s to 45s, one full provider budget plus margin, and the wait for search results shares the same constant instead of restating it.

GoogleBooksSmokeTest no longer fills in an author.
The provider picker renders only while a book is unlinked, so an automatic link on "The Hobbit" by "J.R.R. Tolkien", which is exactly what the new rules link on sight, left the test waiting 30s for a button that could never appear.
Withholding the author makes an unlinked check a guarantee rather than a hope, since that is the one thing the automatic path refuses to link without.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QFbNDhttoEWgSKukSkrSPF
`matched_aliases` is the local match key: a lookup that finds one links a tenant's item to a reference with no provider call at all.
That only holds while every stored entry names exactly one work, and three paths were writing entries that name many.
Every reference repository's `UpsertAsync` added the document's own (title, year) pair on every upsert with no creator, which on a book or an album is a key claiming every author or artist at once.
A resolve recorded whatever the tenant searched with, year or no year, so a title-only alias matched that title under any year.
Album aliases carried a year, so one release accumulated an entry per pressing year anyone ever typed.

`Domain/Services/ReferenceAliasRule.cs` is now the single declaration of what an alias must carry, read by the enrichment merge and by each repository's canonical-alias safety net.
A film, a show and a game need a title and a year.
An album needs a title and a creator, and stores no year, since one release exists as many pressings under as many years.
A book needs a title and a creator, or an ISBN, and records the year whenever one is known, because a work is republished as revisions the year tells apart.
The year is recorded rather than required there: requiring it would leave a book whose provider reports no year with no alias at all, and an item with no alias is actively unlinked the next time anyone presses "check for reference match".

Every `TryAutoResolve*Async` now asks the local aliases before it asks a provider (`TryLinkKnownReferenceAsync`), which is the path that matters most: creating an item used to go straight to the provider even when the reference was already stored.
Books gained the tier that makes this real for that domain, `(title, creator)` regardless of year, asked after the ISBN and the exact `(title, creator, year)`.
Albums lost their year-narrowed lookup entirely, replaced by a single `FindByTitleCreatorAsync`.
The five copies of the `ElemMatch` lookups moved into `ReferenceAliasQueries` over a new `IHasMatchedAliases` entity interface, the same consolidation as `ReferenceRatingQueries`.

Indexes now follow each domain's lookup instead of one shape for all five, since the old title+year index matched neither the book nor the album query's second field and left the `ElemMatch` scanning.
Run `scripts/prune-incomplete-matched-aliases.js` (dry run by default, `APPLY=1` to write) and then `scripts/mongodb-create-index.js`.

Two rendering fixes came out of the e2e runs, both the same rule: a component must never paint over a page the user has moved on to.
`Home.razor` renders only while the browser is still on its own route, because its load is `/api/stats`' eleven sequential counts and its completion render was putting the dashboard back over whatever had been navigated to, measured at 0.28s, 0.45s and 5.4s after the navigation.
`PendingReferenceLink` stops polling at a page's first save and discards a read that a save overtook, which was resurrecting a video game platform that had just been removed.

Covered by `ReferenceAliasRuleTest`, `PendingReferenceLinkTest`, twelve new enrichment cases proving no provider is called on a local hit, and real-MongoDB repository cases for the ISBN and creator lookups.
The Playwright page objects reload once and re-assert when a navigation never renders or a list still shows an item as it was before a detail-page save.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LMfSkdW4XgDT8kxo23AY7N
AGENTS.md was long enough to be skimmed rather than read, and it never followed its own writing style: 145 lines wrapped mid-sentence and the file argued the same rule in three places.
It is now 20% shorter with every rule, gotcha, named symbol and constant intact.
What went is the evidence that only re-proved a rule already stated, and the repeated maxims.
The reference-data section, which was half the file, is split into named subsections.

docs/code-quality-findings.md was 100KB read whole to answer one question.
Its 39 findings are now one file per subject under docs/findings/, indexed in docs/findings/README.md, and AGENTS.md links to the right file where each topic comes up rather than once at the end.
Each finding is 3 to 6KB, so a link lands on roughly one screen.
The 14 source and test comments that cited the old file now cite the finding they meant.

Seven finished plans and one dated assessment move to docs/archived/, with a README saying what each was and why it is archived.
Every reference to a moved file is repointed, in prose and in code comments alike.

The whole of docs/findings/ carries the writing style now: one sentence per line, no dashes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FcQ9pgcRaWM9tXQp4dH5VU
…rs first

The video game detail page cropped its artwork with `width: 100%; max-height: 320px; object-fit: cover`, a box far wider than 16:9, so key art lost the top and bottom of every frame.
Beside it sat a `col-md-4` column of six stacked fields with nothing but Notes in the `col-md-8` next to it.

The banner is now a plain 16:9 box holding the art with `object-fit: cover`, at the same width as the cards below it and with no card wrapper of its own.
A random sample of the stored images measured 1920x1080, 4000x2250, 2560x1440, 1536x864 and 1000x562, all exactly 16:9, so the ordinary case is not cropped at all and the few that are 16:10 or wider lose a few percent of one edge.
The handful of portrait covers are cropped hard by the same rule, biased upward by `object-position: center 40%` because box art carries its title at the top: a zoom into the middle beats empty bands either side.

The first attempt at this was built around IGDB's portrait box art, on the strength of a comment describing what IGDB returns.
One query over `videogame_reference` showed 329 of 345 covers were RAWG key art and 16 were IGDB, so that design was aimed at 5% of the collection.
The counts are now in the CSS comment and in AGENTS.md, with the query that produces them.

The fields use the width instead of a narrow strip: year, rating and reference ratings on one line, genres and platforms as chips rather than a comma-joined sentence, notes full width, synopsis last.
The cover URL box is folded behind a control unless the item already carries one.
A platform card leads with its platform, its state badge, a fully-completed badge and its playthrough count, so a game owned on three platforms reads at a glance, and `StateBadgeClass` moves to `VideoGames` so the list row and the detail page cannot drift into two colour schemes.
Playthrough rows become a grid, so the date input keeps a real width and the columns line up.
The page sits in its own 920px column rather than `.content`'s 1100px, which is what puts the banner, the fields and the platform cards on one shared width.

MobileScreenshotTest gains what this work needed and did not have: a game seeded with no `CustomImageUrl` so a capture finally shows a provider's real cover, both game captures targeted by title rather than "first in the list", and desktop shots for the album, movie, TV show and book detail pages.

scripts/load-runsettings.js exports a runsettings file as quoted shell statements, for the filtered runs that cannot use `--settings`.
Sourcing those values as plain `NAME=value` lines runs each through shell expansion, so a password containing `$` arrives two characters short and Firebase answers `INVALID_PASSWORD`, which reads as a wrong password rather than a mangled one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01183Dv5Q1376n4anxfUBmT1
…istered provider instead of the configured default

ReferenceClientRegistry.All is DI registration order (IGDB always registers before RAWG), and both admin
picker components picked FirstOrDefault() as the initial selection, so IGDB stayed selected even when
ReferenceData:VideoGameProvider was set to rawg.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RLXGPjBLcpCRU5h4cAXAXU
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants