From 52828b82e56a08d2238b345ff5299f3e258ee2e4 Mon Sep 17 00:00:00 2001 From: Bertrand THOMAS Date: Sat, 18 Jul 2026 02:11:53 +0200 Subject: [PATCH 01/32] Improve load in src/BlazorApp/Components/Inventory/Pages/MovieDetail.razor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - _movie and _reference became public Movie/Reference auto-properties marked [PersistentState] (.NET 10's declarative prerendered-state persistence — public properties are a framework requirement). The data loaded during the prerender pass is now serialized into the page and restored when the interactive circuit attaches. - OnParametersSetAsync now skips the reload when Movie?.Id == Id — i.e. when the restored state already matches the route. That's what removes the flash: the first interactive render reuses the prerendered data, so there's no spinner reset and no duplicate API calls. The id check means an in-circuit navigation to a different movie still reloads normally, and the explicit refresh paths (RefreshReferenceAsync, the admin linker's OnLinked) still call LoadAsync unconditionally, so they force a real re-fetch as before. --- .../Inventory/Pages/MovieDetail.razor | 142 ++++++++++-------- 1 file changed, 80 insertions(+), 62 deletions(-) diff --git a/src/BlazorApp/Components/Inventory/Pages/MovieDetail.razor b/src/BlazorApp/Components/Inventory/Pages/MovieDetail.razor index 40eec748..b30e32fe 100644 --- a/src/BlazorApp/Components/Inventory/Pages/MovieDetail.razor +++ b/src/BlazorApp/Components/Inventory/Pages/MovieDetail.razor @@ -11,7 +11,7 @@ Loading… } -else if (_movie is null) +else if (Movie is null) {
@@ -20,13 +20,13 @@ else if (_movie is null) } else { - +
- + @if (_refreshMessage is not null) @@ -35,72 +35,72 @@ else }
- - - + + +
- @if (string.IsNullOrEmpty(_movie.ReferenceId)) + @if (string.IsNullOrEmpty(Movie.ReferenceId)) { - + }
- - @(_movie.FirstSeenAt is not null ? "✓ Watched" : "Mark as watched") + + @(Movie.FirstSeenAt is not null ? "✓ Watched" : "Mark as watched")
- @if (!string.IsNullOrEmpty(_reference?.ImageUrl)) + @if (!string.IsNullOrEmpty(Reference?.ImageUrl)) {
- @_movie.Title poster + @Movie.Title poster
}
+ value="@Movie.Year" @onchange="SaveYearAsync"/>
- +
- @if (_movie.FirstSeenAt is not null) + @if (Movie.FirstSeenAt is not null) {
+ value="@Movie.FirstSeenAt?.ToString("yyyy-MM-dd")" @onchange="SetWatchedDateAsync"/>
} - @if (_reference?.Genres.Count > 0) + @if (Reference?.Genres.Count > 0) { -

@string.Join(", ", _reference.Genres)

+

@string.Join(", ", Reference.Genres)

}
- +
- @if (!string.IsNullOrEmpty(_reference?.Synopsis)) + @if (!string.IsNullOrEmpty(Reference?.Synopsis)) {
-

@_reference.Synopsis

+

@Reference.Synopsis

}
- + - @if (_reference?.Cast.Count > 0) + @if (Reference?.Cast.Count > 0) {

Cast

- + } } @@ -114,90 +114,108 @@ else private static readonly TimeSpan RefreshMessageDuration = TimeSpan.FromSeconds(4); private bool _loading = true; - private MovieDto? _movie; - private MovieReferenceDto? _reference; + + // Public properties (a framework requirement for [PersistentState]): the data loaded during the + // prerender pass is carried over to the interactive circuit, so the first interactive render reuses + // it instead of resetting to the spinner and re-fetching - the cause of the visible + // content -> spinner -> content flash on every detail-page open. + [PersistentState] + public MovieDto? Movie { get; set; } + + [PersistentState] + public MovieReferenceDto? Reference { get; set; } private bool _refreshingReference; private string? _refreshMessage; private string _refreshMessageStyle = "neutral"; private object? _refreshMessageToken; - protected override async Task OnParametersSetAsync() => await LoadAsync(); + protected override async Task OnParametersSetAsync() + { + // Movie already holds this route's item when [PersistentState] restored the prerendered data; + // the id check keeps an in-circuit navigation to a *different* movie reloading as before. + if (Movie?.Id == Id) + { + _loading = false; + return; + } + await LoadAsync(); + } private async Task LoadAsync() { _loading = true; - _movie = await MovieApi.GetOneAsync(Id); - _reference = string.IsNullOrEmpty(_movie?.ReferenceId) ? null : await ReferenceDataApi.GetMovieAsync(_movie.ReferenceId); + 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); + 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); + if (Movie is null) return; + Movie.WantToWatch = !Movie.WantToWatch; + await MovieApi.UpdateAsync(Movie); } private async Task SaveMovieAsync() { - if (_movie is null) return; - await MovieApi.UpdateAsync(_movie); + if (Movie is null) return; + await MovieApi.UpdateAsync(Movie); } private async Task ToggleWishlistedAsync() { - if (_movie is null) return; - _movie.IsWishlisted = !_movie.IsWishlisted; - await MovieApi.UpdateAsync(_movie); + 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); + 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); + 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); + 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; + if (Movie is null) return; var title = e.Value?.ToString(); if (string.IsNullOrWhiteSpace(title)) return; - _movie.Title = title; - await MovieApi.UpdateAsync(_movie); + Movie.Title = title; + await MovieApi.UpdateAsync(Movie); } private async Task RefreshReferenceAsync() { - if (_movie?.Id is null) return; + if (Movie?.Id is null) return; - var previousReferenceId = _movie.ReferenceId; + var previousReferenceId = Movie.ReferenceId; _refreshingReference = true; _refreshMessage = null; try { - var refreshed = await MovieApi.RefreshReferenceAsync(_movie.Id); + var refreshed = await MovieApi.RefreshReferenceAsync(Movie.Id); await LoadAsync(); (_refreshMessage, _refreshMessageStyle) = string.IsNullOrEmpty(refreshed.ReferenceId) @@ -238,15 +256,15 @@ else 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); + 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); + if (Movie is null) return; + Movie.Notes = e.Value?.ToString(); + await MovieApi.UpdateAsync(Movie); } } From db28929b2ea59ee65af662cac1f04ab0019b4ed4 Mon Sep 17 00:00:00 2001 From: Bertrand THOMAS Date: Sat, 18 Jul 2026 02:12:11 +0200 Subject: [PATCH 02/32] TvShowDetail.razor now uses the same pattern MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Same shape as Movie, plus one extra: LoadAsync also fetches the watched-episodes list, so I persist three things — Show, Reference, and the raw Episodes list — and extracted the season/watched dictionary derivation into a local BuildDerivedState() (tuple-keyed dictionaries can't round-trip through the JSON-persisted state, so they're rebuilt cheaply on restore). OnParametersSetAsync skips the re-fetch when the restored Show.Id matches the route; all explicit LoadAsync callers (refresh reference, episode check/uncheck, manual add) still force a real reload. --- .../Inventory/Pages/TvShowDetail.razor | 158 +++++++++++------- 1 file changed, 93 insertions(+), 65 deletions(-) diff --git a/src/BlazorApp/Components/Inventory/Pages/TvShowDetail.razor b/src/BlazorApp/Components/Inventory/Pages/TvShowDetail.razor index ca27cd65..917bb18c 100644 --- a/src/BlazorApp/Components/Inventory/Pages/TvShowDetail.razor +++ b/src/BlazorApp/Components/Inventory/Pages/TvShowDetail.razor @@ -11,7 +11,7 @@ Loading…
} -else if (_show is null) +else if (Show is null) {
@@ -20,13 +20,13 @@ else if (_show is null) } else { - +
- + @if (_refreshMessage is not null) @@ -35,71 +35,71 @@ else }
- - - + + +
- - - + + +
- @if (string.IsNullOrEmpty(_show.ReferenceId)) + @if (string.IsNullOrEmpty(Show.ReferenceId)) { - + }
- @if (!string.IsNullOrEmpty(_reference?.ImageUrl)) + @if (!string.IsNullOrEmpty(Reference?.ImageUrl)) {
- @_show.Title poster + @Show.Title poster
}
+ value="@Show.Year" @onchange="SaveYearAsync"/>
- +
- @if (_reference?.Genres.Count > 0) + @if (Reference?.Genres.Count > 0) { -

@string.Join(", ", _reference.Genres)

+

@string.Join(", ", Reference.Genres)

}
- +
- @if (!string.IsNullOrEmpty(_reference?.Synopsis)) + @if (!string.IsNullOrEmpty(Reference?.Synopsis)) {
-

@_reference.Synopsis

+

@Reference.Synopsis

}
- @if (_reference?.Cast.Count > 0) + @if (Reference?.Cast.Count > 0) {

Cast

- + } - + - @if (_reference is not null) + @if (Reference is not null) { @if (_seasons.Count == 0) { @@ -213,8 +213,20 @@ else [Inject] private ReferenceDataApiClient ReferenceDataApi { get; set; } = null!; private bool _loading = true; - private TvShowDto? _show; - private TvShowReferenceDto? _reference; + + // Public properties (a framework requirement for [PersistentState]): the data loaded during the + // prerender pass is carried over to the interactive circuit, so the first interactive render reuses + // it instead of resetting to the spinner and re-fetching - same pattern as MovieDetail. The derived + // season/watched lookups are rebuilt locally from these (a tuple-keyed dictionary can't round-trip + // through the JSON-serialized persisted state). + [PersistentState] + public TvShowDto? Show { get; set; } + + [PersistentState] + public TvShowReferenceDto? Reference { get; set; } + + [PersistentState] + public List? Episodes { get; set; } private Dictionary<(int Season, int Episode), EpisodeDto> _watchedByKey = new(); private Dictionary> _referenceEpisodesBySeason = new(); private List _seasons = []; @@ -232,27 +244,45 @@ else private int _newEpisode = 1; private DateOnly? _newWatchedAt = DateOnly.FromDateTime(DateTime.Today); - protected override async Task OnParametersSetAsync() => await LoadAsync(); + protected override async Task OnParametersSetAsync() + { + // Show already holds this route's item when [PersistentState] restored the prerendered data; + // the id check keeps an in-circuit navigation to a *different* show reloading as before. + if (Show?.Id == Id && Episodes is not null) + { + BuildDerivedState(); + _loading = false; + return; + } + await LoadAsync(); + } private async Task LoadAsync() { _loading = true; - _show = await TvShowApi.GetOneAsync(Id); + Show = await TvShowApi.GetOneAsync(Id); var episodes = await EpisodeApi.GetAsync(string.Empty, 1, 5000, new Dictionary { ["TvShowId"] = Id }); + Episodes = episodes.Items; // 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)); + Reference = string.IsNullOrEmpty(Show?.ReferenceId) ? null : await ReferenceDataApi.GetTvShowAsync(Show.ReferenceId); + BuildDerivedState(); + _loading = false; + } + + private void BuildDerivedState() + { + _watchedByKey = Episodes!.ToDictionary(e => (e.SeasonNumber, e.EpisodeNumber)); - if (_reference is not null) + 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 + _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()); @@ -262,50 +292,48 @@ else else { // unresolved: only what's actually been recorded, most recently aired first - _unresolvedEpisodesBySeason = episodes.Items + _unresolvedEpisodesBySeason = Episodes! .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); + 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); + if (Show is null) return; + Show.WantToWatch = !Show.WantToWatch; + await TvShowApi.UpdateAsync(Show); } private async Task SaveShowAsync() { - if (_show is null) return; - await TvShowApi.UpdateAsync(_show); + if (Show is null) return; + await TvShowApi.UpdateAsync(Show); } private async Task ToggleWishlistedAsync() { - if (_show is null) return; - _show.IsWishlisted = !_show.IsWishlisted; - await TvShowApi.UpdateAsync(_show); + 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); + if (Show is null) return; + Show.Rating = rating; + await TvShowApi.UpdateAsync(Show); } /// @@ -315,30 +343,30 @@ else /// private async Task SetStateAsync(TvShowStatus state) { - if (_show is null) return; - _show.State = _show.State == state ? null : state; - await TvShowApi.UpdateAsync(_show); + 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; + if (Show is null) return; var title = e.Value?.ToString(); if (string.IsNullOrWhiteSpace(title)) return; - _show.Title = title; - await TvShowApi.UpdateAsync(_show); + Show.Title = title; + await TvShowApi.UpdateAsync(Show); } private async Task RefreshReferenceAsync() { - if (_show?.Id is null) return; + if (Show?.Id is null) return; - var previousReferenceId = _show.ReferenceId; + var previousReferenceId = Show.ReferenceId; _refreshingReference = true; _refreshMessage = null; try { - var refreshed = await TvShowApi.RefreshReferenceAsync(_show.Id); + var refreshed = await TvShowApi.RefreshReferenceAsync(Show.Id); await LoadAsync(); (_refreshMessage, _refreshMessageStyle) = string.IsNullOrEmpty(refreshed.ReferenceId) @@ -379,16 +407,16 @@ else 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); + 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); + if (Show is null) return; + Show.Notes = e.Value?.ToString(); + await TvShowApi.UpdateAsync(Show); } private bool IsSelectedSeasonFullyWatched() => From 17c736f912ca793b2f39557c3f9f0103fa6ff54c Mon Sep 17 00:00:00 2001 From: Bertrand THOMAS Date: Sat, 18 Jul 2026 10:25:07 +0200 Subject: [PATCH 03/32] Apply pattern persistence on all items --- .../Components/Inventory/InventoryPageBase.cs | 41 +++-- .../Inventory/Pages/AlbumDetail.razor | 133 +++++++++------- .../Components/Inventory/Pages/Albums.razor | 4 +- .../Inventory/Pages/BookDetail.razor | 149 ++++++++++-------- .../Components/Inventory/Pages/Books.razor | 4 +- .../Inventory/Pages/CarDetail.razor | 125 +++++++++------ .../Components/Inventory/Pages/Cars.razor | 4 +- .../Inventory/Pages/HealthProfileDetail.razor | 84 ++++++---- .../Inventory/Pages/HealthProfiles.razor | 4 +- .../Inventory/Pages/HouseDetail.razor | 102 +++++++----- .../Components/Inventory/Pages/Houses.razor | 4 +- .../Components/Inventory/Pages/Movies.razor | 4 +- .../Inventory/Pages/PlaylistDetail.razor | 87 ++++++---- .../Inventory/Pages/Playlists.razor | 4 +- .../Components/Inventory/Pages/TvShows.razor | 4 +- .../Inventory/Pages/VideoGameDetail.razor | 107 +++++++------ .../Inventory/Pages/VideoGames.razor | 4 +- 17 files changed, 523 insertions(+), 341 deletions(-) diff --git a/src/BlazorApp/Components/Inventory/InventoryPageBase.cs b/src/BlazorApp/Components/Inventory/InventoryPageBase.cs index 19eb7ab1..1305a667 100644 --- a/src/BlazorApp/Components/Inventory/InventoryPageBase.cs +++ b/src/BlazorApp/Components/Inventory/InventoryPageBase.cs @@ -9,7 +9,22 @@ public abstract class InventoryPageBase : ComponentBase { private const int PageSize = 20; - protected List _items = []; + // Public properties (a framework requirement for [PersistentState]): the page loaded during the + // prerender pass is carried over to the interactive circuit, so the first interactive render reuses + // it instead of resetting to the spinner and re-fetching - same pattern as the detail pages + // (MovieDetail, etc.). Items is nullable (no property initializer) so [PersistentState] restoration + // isn't fighting a default value - markup falls back to an empty list via "Items ?? []", same as + // every other nullable persisted list in this codebase. LoadedQuery is the query signature + // Items/TotalCount were loaded for, so a restore only skips the reload when it still matches the + // current search/filter/sort/page - any of those changing must still trigger a real reload. + [PersistentState] + public List? Items { get; set; } + + [PersistentState] + public long TotalCount { get; set; } + + [PersistentState] + public string? LoadedQuery { get; set; } protected TDto _form = new(); @@ -25,11 +40,7 @@ public abstract class InventoryPageBase : ComponentBase protected int _page = 1; - protected long _totalCount; - - private string? _loadedQuery; - - protected int TotalPages => (int)Math.Ceiling(_totalCount / (double)PageSize); + protected int TotalPages => (int)Math.Ceiling(TotalCount / (double)PageSize); [Inject] protected NavigationManager Navigation { get; set; } = null!; @@ -74,11 +85,19 @@ protected override async Task OnParametersSetAsync() _sort = SortQuery ?? ""; _page = PageQuery is > 0 ? PageQuery.Value : 1; var query = BuildQuerySignature(); - if (query != _loadedQuery) + + // Items/TotalCount already hold this exact query's results when [PersistentState] restored the + // prerendered data - the signature check keeps this skip from also swallowing a genuine + // search/filter/sort/page change (a different signature) or an unrelated parameter update (e.g. + // a cascading auth-state refresh), both of which must still reload. + if (query == LoadedQuery) { - _loadedQuery = query; - await LoadAsync(); + _loading = false; + return; } + + LoadedQuery = query; + await LoadAsync(); } protected void OnSearchChanged(string value) => _search = value; @@ -173,8 +192,8 @@ protected async Task LoadAsync() { _loading = true; var result = await Api.GetAsync(_search, _page, PageSize, ExtraQuery, _sort); - _items = result.Items; - _totalCount = result.TotalCount; + Items = result.Items; + TotalCount = result.TotalCount; } catch (Exception ex) { diff --git a/src/BlazorApp/Components/Inventory/Pages/AlbumDetail.razor b/src/BlazorApp/Components/Inventory/Pages/AlbumDetail.razor index 9c527673..a16f778a 100644 --- a/src/BlazorApp/Components/Inventory/Pages/AlbumDetail.razor +++ b/src/BlazorApp/Components/Inventory/Pages/AlbumDetail.razor @@ -11,7 +11,7 @@ Loading…
} -else if (_album is null) +else if (Album is null) {
@@ -20,13 +20,13 @@ else if (_album is null) } else { - +
- + @if (_refreshMessage is not null) @@ -35,65 +35,65 @@ else }
- +
- @if (string.IsNullOrEmpty(_album.ReferenceId)) + @if (string.IsNullOrEmpty(Album.ReferenceId)) { - + }
-
- @if (!string.IsNullOrEmpty(_reference?.ImageUrl)) +
+ @if (!string.IsNullOrEmpty(Reference?.ImageUrl)) { - @_album.Title cover + @Album.Title cover }
- +
+ value="@Album.Year" @onchange="SaveYearAsync"/>
- +
- +
- @if (!string.IsNullOrEmpty(_reference?.Synopsis)) + @if (!string.IsNullOrEmpty(Reference?.Synopsis)) { -

@_reference.Synopsis

+

@Reference.Synopsis

}
- + - @if (!string.IsNullOrEmpty(_reference?.ArtistImageUrl)) + @if (!string.IsNullOrEmpty(Reference?.ArtistImageUrl)) {

Artist

} - @if (_reference?.Tracks.Count > 0) + @if (Reference?.Tracks.Count > 0) {

Tracklist

- @foreach (var track in _reference.Tracks) + @foreach (var track in Reference.Tracks) {
@track.Position @track.Title @@ -105,13 +105,13 @@ else {

Add to playlist

- @if (_playlists.Count == 0) + @if (Playlists is null or { Count: 0 }) {

Create a playlist first

} else { - @foreach (var playlist in _playlists) + @foreach (var playlist in Playlists) { } @@ -138,9 +138,20 @@ else private static readonly TimeSpan RefreshMessageDuration = TimeSpan.FromSeconds(4); private bool _loading = true; - private AlbumDto? _album; - private AlbumReferenceDto? _reference; - private List _playlists = []; + + // Public properties (a framework requirement for [PersistentState]): the data loaded during the + // prerender pass is carried over to the interactive circuit, so the first interactive render reuses + // it instead of resetting to the spinner and re-fetching - same pattern as MovieDetail. Playlists is + // a co-fetched list (like TvShowDetail's Episodes), so it's nullable to distinguish "not loaded yet" + // from "loaded, no playlists". + [PersistentState] + public AlbumDto? Album { get; set; } + + [PersistentState] + public AlbumReferenceDto? Reference { get; set; } + + [PersistentState] + public List? Playlists { get; set; } private string? _openTrackMenu; private bool _refreshingReference; private string? _refreshMessage; @@ -149,19 +160,29 @@ else /// 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 + private List _artistCard => Reference is null ? [] - : [new CastMemberDto { Name = _reference.ArtistName ?? _album?.Artist ?? "", CharacterName = "", ProfileImageUrl = _reference.ArtistImageUrl }]; + : [new CastMemberDto { Name = Reference.ArtistName ?? Album?.Artist ?? "", CharacterName = "", ProfileImageUrl = Reference.ArtistImageUrl }]; - protected override async Task OnParametersSetAsync() => await LoadAsync(); + protected override async Task OnParametersSetAsync() + { + // Album already holds this route's item when [PersistentState] restored the prerendered data; + // the id check keeps an in-circuit navigation to a *different* album reloading as before. + if (Album?.Id == Id && Playlists is not null) + { + _loading = false; + return; + } + await LoadAsync(); + } private async Task LoadAsync() { _loading = true; - _album = await AlbumApi.GetOneAsync(Id); - _reference = string.IsNullOrEmpty(_album?.ReferenceId) ? null : await ReferenceDataApi.GetAlbumAsync(_album.ReferenceId); + 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; + Playlists = playlists.Items; _loading = false; } @@ -178,9 +199,9 @@ else private async Task AddTrackToPlaylistAsync(ReferenceTrackDto track, PlaylistDto playlist) { _openTrackMenu = null; - if (_album?.Id is null) return; + if (Album?.Id is null) return; - var song = await SongApi.GetOrCreateForTrackAsync(_album.Id, track.Position, track.Title, track.Duration, _album.Artist); + 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); @@ -190,59 +211,59 @@ else private async Task SetRatingAsync(float rating) { - if (_album is null) return; - _album.Rating = rating; - await AlbumApi.UpdateAsync(_album); + 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); + if (Album is null) return; + Album.IsFavorite = !Album.IsFavorite; + await AlbumApi.UpdateAsync(Album); } private async Task SaveAlbumAsync() { - if (_album is null) return; - await AlbumApi.UpdateAsync(_album); + if (Album is null) return; + await AlbumApi.UpdateAsync(Album); } private async Task SaveTitleAsync(ChangeEventArgs e) { - if (_album is null) return; + if (Album is null) return; var title = e.Value?.ToString(); if (string.IsNullOrWhiteSpace(title)) return; - _album.Title = title; - await AlbumApi.UpdateAsync(_album); + Album.Title = title; + await AlbumApi.UpdateAsync(Album); } private async Task SaveArtistAsync(ChangeEventArgs e) { - if (_album is null) return; + if (Album is null) return; var artist = e.Value?.ToString(); if (string.IsNullOrWhiteSpace(artist)) return; - _album.Artist = artist; - await AlbumApi.UpdateAsync(_album); + 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); + if (Album is null) return; + Album.Genre = e.Value?.ToString(); + await AlbumApi.UpdateAsync(Album); } private async Task RefreshReferenceAsync() { - if (_album?.Id is null) return; + if (Album?.Id is null) return; - var previousReferenceId = _album.ReferenceId; + var previousReferenceId = Album.ReferenceId; _refreshingReference = true; _refreshMessage = null; try { - var refreshed = await AlbumApi.RefreshReferenceAsync(_album.Id); + var refreshed = await AlbumApi.RefreshReferenceAsync(Album.Id); await LoadAsync(); (_refreshMessage, _refreshMessageStyle) = string.IsNullOrEmpty(refreshed.ReferenceId) @@ -283,8 +304,8 @@ else 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); + 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/Albums.razor b/src/BlazorApp/Components/Inventory/Pages/Albums.razor index 999b0944..28f403a0 100644 --- a/src/BlazorApp/Components/Inventory/Pages/Albums.razor +++ b/src/BlazorApp/Components/Inventory/Pages/Albums.razor @@ -7,13 +7,13 @@ Title="Albums" ItemName="Album" Route="@ListRoute" - Items="@_items" + Items="@(Items ?? [])" Loading="@_loading" Error="@_error" Search="@_search" Page="@_page" TotalPages="@TotalPages" - TotalCount="@_totalCount" + TotalCount="@TotalCount" ShowForm="@_showForm" Form="@_form" ItemTitle="@(album => album.Title)" diff --git a/src/BlazorApp/Components/Inventory/Pages/BookDetail.razor b/src/BlazorApp/Components/Inventory/Pages/BookDetail.razor index a18e483a..6db392c4 100644 --- a/src/BlazorApp/Components/Inventory/Pages/BookDetail.razor +++ b/src/BlazorApp/Components/Inventory/Pages/BookDetail.razor @@ -11,7 +11,7 @@ Loading…
} -else if (_book is null) +else if (Book is null) {
@@ -20,13 +20,13 @@ else if (_book is null) } else { - +
- + @if (_refreshMessage is not null) @@ -35,74 +35,74 @@ else }
- - + +
- @if (string.IsNullOrEmpty(_book.ReferenceId)) + @if (string.IsNullOrEmpty(Book.ReferenceId)) { - + }
- - @(_book.FirstReadAt is not null ? "✓ Read" : "Mark as read") + + @(Book.FirstReadAt is not null ? "✓ Read" : "Mark as read")
- @if (!string.IsNullOrEmpty(_reference?.ImageUrl)) + @if (!string.IsNullOrEmpty(Reference?.ImageUrl)) {
- @_book.Title cover + @Book.Title cover
}
- +
- +
+ value="@Book.Year" @onchange="SaveYearAsync"/>
- +
- @if (_book.FirstReadAt is not null) + @if (Book.FirstReadAt is not null) {
+ value="@Book.FirstReadAt?.ToString("yyyy-MM-dd")" @onchange="SetReadDateAsync"/>
}
- +
- +
- @if (!string.IsNullOrEmpty(_reference?.Synopsis)) + @if (!string.IsNullOrEmpty(Reference?.Synopsis)) {
-

@_reference.Synopsis

+

@Reference.Synopsis

}
- + } @code { @@ -115,106 +115,123 @@ else private static readonly TimeSpan RefreshMessageDuration = TimeSpan.FromSeconds(4); private bool _loading = true; - private BookDto? _book; - private BookReferenceDto? _reference; + + // Public properties (a framework requirement for [PersistentState]): the data loaded during the + // prerender pass is carried over to the interactive circuit, so the first interactive render reuses + // it instead of resetting to the spinner and re-fetching - same pattern as MovieDetail. + [PersistentState] + public BookDto? Book { get; set; } + + [PersistentState] + public BookReferenceDto? Reference { get; set; } private bool _refreshingReference; private string? _refreshMessage; private string _refreshMessageStyle = "neutral"; private object? _refreshMessageToken; - protected override async Task OnParametersSetAsync() => await LoadAsync(); + protected override async Task OnParametersSetAsync() + { + // Book already holds this route's item when [PersistentState] restored the prerendered data; + // the id check keeps an in-circuit navigation to a *different* book reloading as before. + if (Book?.Id == Id) + { + _loading = false; + return; + } + await LoadAsync(); + } private async Task LoadAsync() { _loading = true; - _book = await BookApi.GetOneAsync(Id); - _reference = string.IsNullOrEmpty(_book?.ReferenceId) ? null : await ReferenceDataApi.GetBookAsync(_book.ReferenceId); + 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); + 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); + if (Book is null) return; + Book.IsFavorite = !Book.IsFavorite; + await BookApi.UpdateAsync(Book); } private async Task SaveBookAsync() { - if (_book is null) return; - await BookApi.UpdateAsync(_book); + if (Book is null) return; + await BookApi.UpdateAsync(Book); } private async Task ToggleWishlistedAsync() { - if (_book is null) return; - _book.IsWishlisted = !_book.IsWishlisted; - await BookApi.UpdateAsync(_book); + 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); + 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); + 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; + if (Book is null) return; var title = e.Value?.ToString(); if (string.IsNullOrWhiteSpace(title)) return; - _book.Title = title; - await BookApi.UpdateAsync(_book); + Book.Title = title; + await BookApi.UpdateAsync(Book); } private async Task SaveAuthorAsync(ChangeEventArgs e) { - if (_book is null) return; + if (Book is null) return; var author = e.Value?.ToString(); if (string.IsNullOrWhiteSpace(author)) return; - _book.Author = author; - await BookApi.UpdateAsync(_book); + 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); + 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); + if (Book is null) return; + Book.Genre = e.Value?.ToString(); + await BookApi.UpdateAsync(Book); } private async Task RefreshReferenceAsync() { - if (_book?.Id is null) return; + if (Book?.Id is null) return; - var previousReferenceId = _book.ReferenceId; + var previousReferenceId = Book.ReferenceId; _refreshingReference = true; _refreshMessage = null; try { - var refreshed = await BookApi.RefreshReferenceAsync(_book.Id); + var refreshed = await BookApi.RefreshReferenceAsync(Book.Id); await LoadAsync(); (_refreshMessage, _refreshMessageStyle) = string.IsNullOrEmpty(refreshed.ReferenceId) @@ -255,15 +272,15 @@ else 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); + 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); + 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 e6baa4b4..fc412e3b 100644 --- a/src/BlazorApp/Components/Inventory/Pages/Books.razor +++ b/src/BlazorApp/Components/Inventory/Pages/Books.razor @@ -7,13 +7,13 @@ Title="Books" ItemName="Book" Route="@ListRoute" - Items="@_items" + Items="@(Items ?? [])" Loading="@_loading" Error="@_error" Search="@_search" Page="@_page" TotalPages="@TotalPages" - TotalCount="@_totalCount" + TotalCount="@TotalCount" ShowForm="@_showForm" Form="@_form" ItemTitle="@(book => book.Title)" diff --git a/src/BlazorApp/Components/Inventory/Pages/CarDetail.razor b/src/BlazorApp/Components/Inventory/Pages/CarDetail.razor index b4e713cf..ed722995 100644 --- a/src/BlazorApp/Components/Inventory/Pages/CarDetail.razor +++ b/src/BlazorApp/Components/Inventory/Pages/CarDetail.razor @@ -10,7 +10,7 @@ Loading…
} -else if (_car is null) +else if (Car is null) {
@@ -19,11 +19,11 @@ else if (_car is null) } else { - +
- +
@@ -31,19 +31,19 @@ else
- +
- +
- +
- +
@@ -51,7 +51,7 @@ else
@foreach (var energyType in Enum.GetValues()) { - }
@@ -60,7 +60,7 @@ else @if (HasMetricsToShow) { - var metrics = _metrics!; + var metrics = Metrics!;

Metrics

@if (metrics.NextMaintenance is { } next) @@ -131,7 +131,7 @@ else (City/Description) stay desktop-only via d-none d-md-table-cell. One tab per year (same kt-season-picker/kt-page-btn header as TvShowDetail.razor's season tabs and HealthProfileDetail.razor's journal), newest year first. *@ - @if (_history.Count == 0) + @if (History!.Count == 0) {
@@ -306,12 +306,23 @@ else [Inject] private CarHistoryApiClient CarHistoryApi { get; set; } = null!; private bool _loading = true; - private CarDto? _car; - private List _history = []; + + // Public properties (a framework requirement for [PersistentState]): the data loaded during the + // prerender pass is carried over to the interactive circuit, so the first interactive render reuses + // it instead of resetting to the spinner and re-fetching - same pattern as MovieDetail. The derived + // by-year grouping and mileage-warning lookup are rebuilt locally from these in BuildDerivedState, + // same as TvShowDetail's Episodes -> _referenceEpisodesBySeason/_watchedByKey. + [PersistentState] + public CarDto? Car { get; set; } + + [PersistentState] + public List? History { get; set; } + + [PersistentState] + public CarMetricsDto? Metrics { get; set; } private Dictionary> _historyByYear = new(); private List _years = []; private int _selectedYear; - private CarMetricsDto? _metrics; private CarHistoryDto _modalEntry = NewEntry(""); private bool _showModal; private Dictionary _warningsByEntryId = []; @@ -345,21 +356,32 @@ else } } - private bool ShowFuel => _car?.EnergyType != CarEnergyType.Electric; + private bool ShowFuel => Car?.EnergyType != CarEnergyType.Electric; - private bool ShowElectric => _car?.EnergyType is CarEnergyType.Electric or CarEnergyType.Hybrid; + 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 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() + { + // Car already holds this route's item when [PersistentState] restored the prerendered data; + // the id check keeps an in-circuit navigation to a *different* car reloading as before. + if (Car?.Id == Id && History is not null && Metrics is not null) + { + BuildDerivedState(); + _loading = false; + return; + } + await LoadAsync(); + } private static CarHistoryDto NewEntry(string carId) => new() { @@ -371,74 +393,79 @@ else private async Task LoadAsync() { _loading = true; - _car = await CarApi.GetOneAsync(Id); - if (_car is not null) + Car = await CarApi.GetOneAsync(Id); + if (Car is not null) { var result = await CarHistoryApi.GetAsync("", 1, int.MaxValue, new Dictionary { ["CarId"] = Id }); // most recent first - same journal convention as HealthProfileDetail/HouseDetail - _history = result.Items.OrderByDescending(h => h.HistoryDate).ToList(); - _historyByYear = _history.GroupBy(h => h.HistoryDate.Year).ToDictionary(g => g.Key, g => g.ToList()); - _years = _historyByYear.Keys.OrderByDescending(y => y).ToList(); - // same "keep the current tab if it still exists, else default to the newest" rule as - // TvShowDetail.razor's own season selection - if (!_years.Contains(_selectedYear)) _selectedYear = _years.Count > 0 ? _years[0] : 0; - _metrics = await CarApi.GetMetricsAsync(Id); - _warningsByEntryId = _metrics.MileageWarnings - .GroupBy(w => w.CarHistoryId) - .ToDictionary(g => g.Key, g => string.Join(" ", g.Select(w => w.Message))); + History = result.Items.OrderByDescending(h => h.HistoryDate).ToList(); + Metrics = await CarApi.GetMetricsAsync(Id); + BuildDerivedState(); } _loading = false; } + private void BuildDerivedState() + { + _historyByYear = History!.GroupBy(h => h.HistoryDate.Year).ToDictionary(g => g.Key, g => g.ToList()); + _years = _historyByYear.Keys.OrderByDescending(y => y).ToList(); + // same "keep the current tab if it still exists, else default to the newest" rule as + // TvShowDetail.razor's own season selection + if (!_years.Contains(_selectedYear)) _selectedYear = _years.Count > 0 ? _years[0] : 0; + _warningsByEntryId = Metrics!.MileageWarnings + .GroupBy(w => w.CarHistoryId) + .ToDictionary(g => g.Key, g => string.Join(" ", g.Select(w => w.Message))); + } + private void SelectYear(int year) => _selectedYear = year; private async Task SaveCarAsync() { - if (_car is null) return; - await CarApi.UpdateAsync(_car); + if (Car is null) return; + await CarApi.UpdateAsync(Car); } private async Task SaveNameAsync(ChangeEventArgs e) { - if (_car is null) return; + if (Car is null) return; var name = e.Value?.ToString(); if (string.IsNullOrWhiteSpace(name)) return; - _car.Name = name; + Car.Name = name; await SaveCarAsync(); } private async Task SaveManufacturerAsync(ChangeEventArgs e) { - if (_car is null) return; - _car.Manufacturer = e.Value?.ToString(); + 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(); + 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; + 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(); + 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; + if (Car is null) return; + Car.EnergyType = energyType; await SaveCarAsync(); } diff --git a/src/BlazorApp/Components/Inventory/Pages/Cars.razor b/src/BlazorApp/Components/Inventory/Pages/Cars.razor index 63a118ad..d3d29a56 100644 --- a/src/BlazorApp/Components/Inventory/Pages/Cars.razor +++ b/src/BlazorApp/Components/Inventory/Pages/Cars.razor @@ -7,13 +7,13 @@ Title="Cars" ItemName="Car" Route="@ListRoute" - Items="@_items" + Items="@(Items ?? [])" Loading="@_loading" Error="@_error" Search="@_search" Page="@_page" TotalPages="@TotalPages" - TotalCount="@_totalCount" + TotalCount="@TotalCount" ShowForm="@_showForm" Form="@_form" ItemTitle="@(car => car.Name)" diff --git a/src/BlazorApp/Components/Inventory/Pages/HealthProfileDetail.razor b/src/BlazorApp/Components/Inventory/Pages/HealthProfileDetail.razor index 18436f5e..3638b7b8 100644 --- a/src/BlazorApp/Components/Inventory/Pages/HealthProfileDetail.razor +++ b/src/BlazorApp/Components/Inventory/Pages/HealthProfileDetail.razor @@ -10,7 +10,7 @@ Loading…
} -else if (_profile is null) +else if (Profile is null) {
@@ -19,27 +19,27 @@ else if (_profile is null) } else { - +
- +
- +
@* No summary panels before the journal on purpose (owner feedback): the journal is the page's primary content, and the per-row warning sign is the whole warning surface. "Last seen" is a bare specialty + last-date list - no names, no counts, no field labels. *@ - @if (_metrics is { LastVisits.Count: > 0 }) + @if (Metrics is { LastVisits.Count: > 0 }) {

Last seen

- @foreach (var visit in _metrics.LastVisits) + @foreach (var visit in Metrics.LastVisits) {
@visit.Specialty @@ -58,7 +58,7 @@ else sign when money remains unaccounted, and edit/delete icons. Everything else lives in the modal. One tab per year (same kt-season-picker/kt-page-btn header as TvShowDetail.razor's season tabs), newest year first, so a long-running journal doesn't render every entry on one page. *@ - @if (_records.Count == 0) + @if (Records!.Count == 0) {
@@ -92,7 +92,7 @@ else
} - @if (_metrics is { CostHistory.Count: > 0 }) + @if (Metrics is { CostHistory.Count: > 0 }) {

Yearly cost review

@@ -107,7 +107,7 @@ else - @foreach (var point in _metrics.CostHistory) + @foreach (var point in Metrics.CostHistory) { @point.Year @@ -225,12 +225,22 @@ else [Inject] private HealthRecordApiClient HealthRecordApi { get; set; } = null!; private bool _loading = true; - private HealthProfileDto? _profile; - private List _records = []; + + // Public properties (a framework requirement for [PersistentState]): the data loaded during the + // prerender pass is carried over to the interactive circuit, so the first interactive render reuses + // it instead of resetting to the spinner and re-fetching - same pattern as MovieDetail. The derived + // by-year grouping is rebuilt locally from these in BuildDerivedState, same as CarDetail/HouseDetail. + [PersistentState] + public HealthProfileDto? Profile { get; set; } + + [PersistentState] + public List? Records { get; set; } + + [PersistentState] + public HealthMetricsDto? Metrics { get; set; } private Dictionary> _recordsByYear = new(); private List _years = []; private int _selectedYear; - private HealthMetricsDto? _metrics; private HealthRecordDto _modalEntry = NewEntry(""); private bool _showModal; @@ -239,7 +249,7 @@ else /// The journal rows' warning flags come from the metrics (the one balance computation, /// server-side in HealthMetricsService) rather than re-deriving the arithmetic per row. private HashSet UnbalancedRecordIds => - _metrics is null ? [] : [.. _metrics.UnbalancedRecords.Select(u => u.RecordId)]; + Metrics is null ? [] : [.. Metrics.UnbalancedRecords.Select(u => u.RecordId)]; /// Live preview of the same balance the server computes - what the modal shows while typing. private double ModalMissingAmount => @@ -249,7 +259,18 @@ else ? "What was wrong (fever, migraine, gastro, …)" : "Reason for the visit / what happened"; - protected override async Task OnParametersSetAsync() => await LoadAsync(); + protected override async Task OnParametersSetAsync() + { + // Profile already holds this route's item when [PersistentState] restored the prerendered data; + // the id check keeps an in-circuit navigation to a *different* profile reloading as before. + if (Profile?.Id == Id && Records is not null && Metrics is not null) + { + BuildDerivedState(); + _loading = false; + return; + } + await LoadAsync(); + } private static HealthRecordDto NewEntry(string profileId) => new() { @@ -284,43 +305,48 @@ else private async Task LoadAsync() { _loading = true; - _profile = await HealthProfileApi.GetOneAsync(Id); - if (_profile is not null) + Profile = await HealthProfileApi.GetOneAsync(Id); + if (Profile is not null) { var result = await HealthRecordApi.GetAsync("", 1, int.MaxValue, new Dictionary { ["HealthProfileId"] = Id }); // most recent first - a journal reads with the newest entry on top, same call as HouseDetail - _records = result.Items.OrderByDescending(r => r.HistoryDate).ToList(); - _recordsByYear = _records.GroupBy(r => r.HistoryDate.Year).ToDictionary(g => g.Key, g => g.ToList()); - _years = _recordsByYear.Keys.OrderByDescending(y => y).ToList(); - // same "keep the current tab if it still exists, else default to the newest" rule as - // TvShowDetail.razor's own season selection - if (!_years.Contains(_selectedYear)) _selectedYear = _years.Count > 0 ? _years[0] : 0; - _metrics = await HealthProfileApi.GetMetricsAsync(Id); + Records = result.Items.OrderByDescending(r => r.HistoryDate).ToList(); + Metrics = await HealthProfileApi.GetMetricsAsync(Id); + BuildDerivedState(); } _loading = false; } + private void BuildDerivedState() + { + _recordsByYear = Records!.GroupBy(r => r.HistoryDate.Year).ToDictionary(g => g.Key, g => g.ToList()); + _years = _recordsByYear.Keys.OrderByDescending(y => y).ToList(); + // same "keep the current tab if it still exists, else default to the newest" rule as + // TvShowDetail.razor's own season selection + if (!_years.Contains(_selectedYear)) _selectedYear = _years.Count > 0 ? _years[0] : 0; + } + private void SelectYear(int year) => _selectedYear = year; private async Task SaveProfileAsync() { - if (_profile is null) return; - await HealthProfileApi.UpdateAsync(_profile); + if (Profile is null) return; + await HealthProfileApi.UpdateAsync(Profile); } private async Task SaveNameAsync(ChangeEventArgs e) { - if (_profile is null) return; + if (Profile is null) return; var name = e.Value?.ToString(); if (string.IsNullOrWhiteSpace(name)) return; - _profile.Name = name; + Profile.Name = name; await SaveProfileAsync(); } private async Task SaveNotesAsync(ChangeEventArgs e) { - if (_profile is null) return; - _profile.Notes = e.Value?.ToString(); + if (Profile is null) return; + Profile.Notes = e.Value?.ToString(); await SaveProfileAsync(); } diff --git a/src/BlazorApp/Components/Inventory/Pages/HealthProfiles.razor b/src/BlazorApp/Components/Inventory/Pages/HealthProfiles.razor index ac4b2902..779de547 100644 --- a/src/BlazorApp/Components/Inventory/Pages/HealthProfiles.razor +++ b/src/BlazorApp/Components/Inventory/Pages/HealthProfiles.razor @@ -7,13 +7,13 @@ Title="Health" ItemName="Profile" Route="@ListRoute" - Items="@_items" + Items="@(Items ?? [])" Loading="@_loading" Error="@_error" Search="@_search" Page="@_page" TotalPages="@TotalPages" - TotalCount="@_totalCount" + TotalCount="@TotalCount" ShowForm="@_showForm" Form="@_form" ItemTitle="@(profile => profile.Name)" diff --git a/src/BlazorApp/Components/Inventory/Pages/HouseDetail.razor b/src/BlazorApp/Components/Inventory/Pages/HouseDetail.razor index 2466751f..eebb5b1c 100644 --- a/src/BlazorApp/Components/Inventory/Pages/HouseDetail.razor +++ b/src/BlazorApp/Components/Inventory/Pages/HouseDetail.razor @@ -9,7 +9,7 @@ Loading…
} -else if (_house is null) +else if (House is null) {
@@ -18,18 +18,18 @@ else if (_house is null) } else { - +
- +
@foreach (var propertyType in Enum.GetValues()) { - + }
@@ -37,26 +37,26 @@ else
- +
- +
- +
- +
@if (HasMetricsToShow) { - var metrics = _metrics!; + var metrics = Metrics!;

Yearly cost review

Total cost by year - @metrics.CostHistory.Sum(p => p.TotalCost).ToString("F2") across the whole history

@@ -101,7 +101,7 @@ else (Provider/Description) stay desktop-only via d-none d-md-table-cell. One tab per year (same kt-season-picker/kt-page-btn header as TvShowDetail.razor's season tabs and HealthProfileDetail.razor's journal), newest year first. *@ - @if (_history.Count == 0) + @if (History!.Count == 0) {
@@ -200,12 +200,22 @@ else [Inject] private HouseHistoryApiClient HouseHistoryApi { get; set; } = null!; private bool _loading = true; - private HouseDto? _house; - private List _history = []; + + // Public properties (a framework requirement for [PersistentState]): the data loaded during the + // prerender pass is carried over to the interactive circuit, so the first interactive render reuses + // it instead of resetting to the spinner and re-fetching - same pattern as MovieDetail. The derived + // by-year grouping is rebuilt locally from these in BuildDerivedState, same as CarDetail. + [PersistentState] + public HouseDto? House { get; set; } + + [PersistentState] + public List? History { get; set; } + + [PersistentState] + public HouseMetricsDto? Metrics { get; set; } private Dictionary> _historyByYear = new(); private List _years = []; private int _selectedYear; - private HouseMetricsDto? _metrics; private HouseHistoryDto _modalEntry = NewEntry(""); private bool _showModal; @@ -214,9 +224,20 @@ else /// `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 }; + private bool HasMetricsToShow => Metrics is { CostHistory.Count: > 0 }; - protected override async Task OnParametersSetAsync() => await LoadAsync(); + protected override async Task OnParametersSetAsync() + { + // House already holds this route's item when [PersistentState] restored the prerendered data; + // the id check keeps an in-circuit navigation to a *different* house reloading as before. + if (House?.Id == Id && History is not null && Metrics is not null) + { + BuildDerivedState(); + _loading = false; + return; + } + await LoadAsync(); + } private static HouseHistoryDto NewEntry(string houseId) => new() { @@ -228,71 +249,76 @@ else private async Task LoadAsync() { _loading = true; - _house = await HouseApi.GetOneAsync(Id); - if (_house is not null) + 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 - same journal convention as HealthProfileDetail/CarDetail - _history = result.Items.OrderByDescending(h => h.HistoryDate).ToList(); - _historyByYear = _history.GroupBy(h => h.HistoryDate.Year).ToDictionary(g => g.Key, g => g.ToList()); - _years = _historyByYear.Keys.OrderByDescending(y => y).ToList(); - // same "keep the current tab if it still exists, else default to the newest" rule as - // TvShowDetail.razor's own season selection - if (!_years.Contains(_selectedYear)) _selectedYear = _years.Count > 0 ? _years[0] : 0; - _metrics = await HouseApi.GetMetricsAsync(Id); + History = result.Items.OrderByDescending(h => h.HistoryDate).ToList(); + Metrics = await HouseApi.GetMetricsAsync(Id); + BuildDerivedState(); } _loading = false; } + private void BuildDerivedState() + { + _historyByYear = History!.GroupBy(h => h.HistoryDate.Year).ToDictionary(g => g.Key, g => g.ToList()); + _years = _historyByYear.Keys.OrderByDescending(y => y).ToList(); + // same "keep the current tab if it still exists, else default to the newest" rule as + // TvShowDetail.razor's own season selection + if (!_years.Contains(_selectedYear)) _selectedYear = _years.Count > 0 ? _years[0] : 0; + } + private void SelectYear(int year) => _selectedYear = year; private async Task SaveHouseAsync() { - if (_house is null) return; - await HouseApi.UpdateAsync(_house); + if (House is null) return; + await HouseApi.UpdateAsync(House); } private async Task SaveNameAsync(ChangeEventArgs e) { - if (_house is null) return; + if (House is null) return; var name = e.Value?.ToString(); if (string.IsNullOrWhiteSpace(name)) return; - _house.Name = name; + House.Name = name; await SaveHouseAsync(); } private async Task SetPropertyTypeAsync(PropertyType propertyType) { - if (_house is null) return; - _house.PropertyType = propertyType; + if (House is null) return; + House.PropertyType = propertyType; await SaveHouseAsync(); } private async Task SaveCityAsync(ChangeEventArgs e) { - if (_house is null) return; - _house.City = e.Value?.ToString(); + if (House is null) return; + House.City = e.Value?.ToString(); await SaveHouseAsync(); } private async Task SaveMovedInAtAsync(ChangeEventArgs e) { - if (_house is null) return; - _house.MovedInAt = DateOnly.TryParse(e.Value?.ToString(), out var date) ? date : null; + if (House is null) return; + House.MovedInAt = DateOnly.TryParse(e.Value?.ToString(), out var date) ? date : null; await SaveHouseAsync(); } private async Task SaveMovedOutAtAsync(ChangeEventArgs e) { - if (_house is null) return; - _house.MovedOutAt = DateOnly.TryParse(e.Value?.ToString(), out var date) ? date : null; + if (House is null) return; + House.MovedOutAt = DateOnly.TryParse(e.Value?.ToString(), out var date) ? date : null; await SaveHouseAsync(); } private async Task SaveNotesAsync(ChangeEventArgs e) { - if (_house is null) return; - _house.Notes = e.Value?.ToString(); + if (House is null) return; + House.Notes = e.Value?.ToString(); await SaveHouseAsync(); } diff --git a/src/BlazorApp/Components/Inventory/Pages/Houses.razor b/src/BlazorApp/Components/Inventory/Pages/Houses.razor index 65276afc..9d8551a6 100644 --- a/src/BlazorApp/Components/Inventory/Pages/Houses.razor +++ b/src/BlazorApp/Components/Inventory/Pages/Houses.razor @@ -7,13 +7,13 @@ Title="Houses" ItemName="House" Route="@ListRoute" - Items="@_items" + Items="@(Items ?? [])" Loading="@_loading" Error="@_error" Search="@_search" Page="@_page" TotalPages="@TotalPages" - TotalCount="@_totalCount" + TotalCount="@TotalCount" ShowForm="@_showForm" Form="@_form" ItemTitle="@(house => house.Name)" diff --git a/src/BlazorApp/Components/Inventory/Pages/Movies.razor b/src/BlazorApp/Components/Inventory/Pages/Movies.razor index 46cbe8a2..1b68f89b 100644 --- a/src/BlazorApp/Components/Inventory/Pages/Movies.razor +++ b/src/BlazorApp/Components/Inventory/Pages/Movies.razor @@ -7,13 +7,13 @@ Title="Movies" ItemName="Movie" Route="@ListRoute" - Items="@_items" + Items="@(Items ?? [])" Loading="@_loading" Error="@_error" Search="@_search" Page="@_page" TotalPages="@TotalPages" - TotalCount="@_totalCount" + TotalCount="@TotalCount" ShowForm="@_showForm" Form="@_form" ItemTitle="@(movie => movie.Title)" diff --git a/src/BlazorApp/Components/Inventory/Pages/PlaylistDetail.razor b/src/BlazorApp/Components/Inventory/Pages/PlaylistDetail.razor index a963ff61..b0c0b679 100644 --- a/src/BlazorApp/Components/Inventory/Pages/PlaylistDetail.razor +++ b/src/BlazorApp/Components/Inventory/Pages/PlaylistDetail.razor @@ -10,7 +10,7 @@ Loading…
} -else if (_playlist is null) +else if (Playlist is null) {
@@ -19,11 +19,11 @@ else if (_playlist is null) } else { - +
- +
@@ -49,7 +49,7 @@ else
- +
@@ -61,7 +61,7 @@ else + @if (_refreshMessage is not null) @@ -35,23 +35,23 @@ else }
- +
- @if (string.IsNullOrEmpty(_game.ReferenceId)) + @if (string.IsNullOrEmpty(Game.ReferenceId)) { - + } - @if (!string.IsNullOrEmpty(_reference?.ImageUrl)) + @if (!string.IsNullOrEmpty(Reference?.ImageUrl)) {
- @_game.Title cover + @Game.Title cover
} @@ -61,30 +61,30 @@ else
+ value="@Game.Year" @onchange="SaveYearAsync"/>
- +
- @if (_reference?.Platforms.Count > 0) + @if (Reference?.Platforms.Count > 0) { -

Available on: @string.Join(", ", _reference.Platforms)

+

Available on: @string.Join(", ", Reference.Platforms)

} - @if (_reference?.Genres.Count > 0) + @if (Reference?.Genres.Count > 0) { -

@string.Join(", ", _reference.Genres)

+

@string.Join(", ", Reference.Genres)

}
- +
- @if (!string.IsNullOrEmpty(_reference?.Synopsis)) + @if (!string.IsNullOrEmpty(Reference?.Synopsis)) {
-

@_reference.Synopsis

+

@Reference.Synopsis

}
@@ -96,12 +96,12 @@ else ConfirmLabel="Remove" OnConfirm="ConfirmRemovePlatformAsync" OnCancel="() => _pendingRemovePlatform = null"/> - @if (_game.Platforms.Count == 0 && _draftPlatform is null) + @if (Game.Platforms.Count == 0 && _draftPlatform is null) {

No platforms tracked yet - add one below.

} - @foreach (var entry in _game.Platforms) + @foreach (var entry in Game.Platforms) {
@@ -189,8 +189,15 @@ else private static readonly TimeSpan RefreshMessageDuration = TimeSpan.FromSeconds(4); private bool _loading = true; - private VideoGameDto? _game; - private VideoGameReferenceDto? _reference; + + // Public properties (a framework requirement for [PersistentState]): the data loaded during the + // prerender pass is carried over to the interactive circuit, so the first interactive render reuses + // it instead of resetting to the spinner and re-fetching - same pattern as MovieDetail. + [PersistentState] + public VideoGameDto? Game { get; set; } + + [PersistentState] + public VideoGameReferenceDto? Reference { get; set; } private bool _refreshingReference; private string? _refreshMessage; private string _refreshMessageStyle = "neutral"; @@ -198,58 +205,68 @@ else private VideoGamePlatformDto? _draftPlatform; private VideoGamePlatformDto? _pendingRemovePlatform; - protected override async Task OnParametersSetAsync() => await LoadAsync(); + protected override async Task OnParametersSetAsync() + { + // Game already holds this route's item when [PersistentState] restored the prerendered data; + // the id check keeps an in-circuit navigation to a *different* game reloading as before. + if (Game?.Id == Id) + { + _loading = false; + return; + } + await LoadAsync(); + } private async Task LoadAsync() { _loading = true; - _game = await VideoGameApi.GetOneAsync(Id); - _reference = string.IsNullOrEmpty(_game?.ReferenceId) ? null : await ReferenceDataApi.GetVideoGameAsync(_game.ReferenceId); + 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 + /// 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); + if (Game is null) return; + await VideoGameApi.UpdateAsync(Game); } private async Task SetRatingAsync(float rating) { - if (_game is null) return; - _game.Rating = rating; + if (Game is null) return; + Game.Rating = rating; await SaveGameAsync(); } private async Task ToggleWishlistedAsync() { - if (_game is null) return; - _game.IsWishlisted = !_game.IsWishlisted; + if (Game is null) return; + Game.IsWishlisted = !Game.IsWishlisted; await SaveGameAsync(); } private async Task SaveTitleAsync(ChangeEventArgs e) { - if (_game is null) return; + if (Game is null) return; var title = e.Value?.ToString(); if (string.IsNullOrWhiteSpace(title)) return; - _game.Title = title; + 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; + 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(); + if (Game is null) return; + Game.Notes = e.Value?.ToString(); await SaveGameAsync(); } @@ -259,8 +276,8 @@ else private async Task SaveDraftPlatformAsync() { - if (_game is null || _draftPlatform is null || string.IsNullOrEmpty(_draftPlatform.Platform)) return; - _game.Platforms.Add(_draftPlatform); + if (Game is null || _draftPlatform is null || string.IsNullOrEmpty(_draftPlatform.Platform)) return; + Game.Platforms.Add(_draftPlatform); _draftPlatform = null; await SaveGameAsync(); } @@ -290,8 +307,8 @@ else private async Task RemovePlatformAsync(VideoGamePlatformDto entry) { - if (_game is null) return; - _game.Platforms.Remove(entry); + if (Game is null) return; + Game.Platforms.Remove(entry); await SaveGameAsync(); } @@ -356,14 +373,14 @@ else private async Task RefreshReferenceAsync() { - if (_game?.Id is null) return; + if (Game?.Id is null) return; - var previousReferenceId = _game.ReferenceId; + var previousReferenceId = Game.ReferenceId; _refreshingReference = true; _refreshMessage = null; try { - var refreshed = await VideoGameApi.RefreshReferenceAsync(_game.Id); + var refreshed = await VideoGameApi.RefreshReferenceAsync(Game.Id); await LoadAsync(); (_refreshMessage, _refreshMessageStyle) = string.IsNullOrEmpty(refreshed.ReferenceId) diff --git a/src/BlazorApp/Components/Inventory/Pages/VideoGames.razor b/src/BlazorApp/Components/Inventory/Pages/VideoGames.razor index 797ee8e3..33580c89 100644 --- a/src/BlazorApp/Components/Inventory/Pages/VideoGames.razor +++ b/src/BlazorApp/Components/Inventory/Pages/VideoGames.razor @@ -7,13 +7,13 @@ Title="Video Games" ItemName="Video game" Route="@ListRoute" - Items="@_items" + Items="@(Items ?? [])" Loading="@_loading" Error="@_error" Search="@_search" Page="@_page" TotalPages="@TotalPages" - TotalCount="@_totalCount" + TotalCount="@TotalCount" ShowForm="@_showForm" Form="@_form" ItemTitle="@(game => game.Title)" From cb89bd85f5ee890253af9122a80c25468664a435 Mon Sep 17 00:00:00 2001 From: Bertrand THOMAS Date: Sat, 18 Jul 2026 10:32:56 +0200 Subject: [PATCH 04/32] =?UTF-8?q?Remove=20the=20light=20theme=20entirely?= =?UTF-8?q?=20=E2=80=94=20the=20app=20is=20now=20dark-only?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - App.razor: data-bs-theme="dark" is set statically on in server-rendered markup (no client JS), so there's no flash on load or between page navigations — this was the actual cause of the buttons flipping white/dark you saw. - app.css: kept only the dark token values in :root; removed the light token block, the [data-bs-theme="dark"] override selector, and the theme-toggle button CSS. - NavMenu.razor: removed the "Toggle theme" button. - Deleted wwwroot/theme.js (picked initial theme, exposed ktToggleTheme()) and wwwroot/Keeptrack.BlazorApp.lib.module.js (only existed to re-apply the theme after Blazor's enhanced-navigation DOM diff — no longer needed since the theme is static server markup now). - Updated CLAUDE.md's Theme section to document the dark-only design and why. Verified dotnet build src/BlazorApp succeeds with 0 warnings/errors, and confirmed no other files (Playwright tests, other Razor components) reference the removed toggle/JS. --- CLAUDE.md | 16 ++-- src/BlazorApp/Components/App.razor | 3 +- src/BlazorApp/Components/Layout/NavMenu.razor | 7 -- .../wwwroot/Keeptrack.BlazorApp.lib.module.js | 7 -- src/BlazorApp/wwwroot/app.css | 79 +++---------------- src/BlazorApp/wwwroot/theme.js | 15 ---- 6 files changed, 22 insertions(+), 105 deletions(-) delete mode 100644 src/BlazorApp/wwwroot/Keeptrack.BlazorApp.lib.module.js delete mode 100644 src/BlazorApp/wwwroot/theme.js diff --git a/CLAUDE.md b/CLAUDE.md index 18683a79..cf068f64 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -769,9 +769,12 @@ Always write `Title="@_movie.Title"` for string parameters bound to a field/prop ### 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. +The app is dark-only — there is no light theme and no in-app toggle. +`App.razor` sets `data-bs-theme="dark"` statically on ``, server-rendered as part of the initial markup rather than applied by client-side JS, so there's no flash of a different theme on first paint or between page loads. +`app.css`'s token block (`:root { --kt-bg: ...; }`, Bootstrap 5.3's native color-mode variables) only ever defines the dark values now; a previous light+dark version (with a `wwwroot/theme.js` that picked the initial theme from `localStorage`/`prefers-color-scheme`, +a `ktToggleTheme()` toggle button in `NavMenu.razor`, and a `Keeptrack.BlazorApp.lib.module.js` JS initializer that re-applied `data-bs-theme` after Blazor's enhanced-navigation DOM diff) was removed entirely at the owner's request — +the toggle caused visibly jarring light/dark flashes on every page load, and a single dark theme is simpler to maintain than two. +Don't reintroduce `data-bs-theme` as something client-side JS sets or removes; keep it a static attribute on `` so enhanced navigation can never strip it. 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: `◼ ▭ ▬ ◆`), @@ -786,10 +789,9 @@ and were simply dropped rather than replaced with an approximate glyph, since a `.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/Layout/NavMenu.razor b/src/BlazorApp/Components/Layout/NavMenu.razor index 34aca4c3..203179d7 100644 --- a/src/BlazorApp/Components/Layout/NavMenu.razor +++ b/src/BlazorApp/Components/Layout/NavMenu.razor @@ -99,13 +99,6 @@ - +
+ @* the euro sign is a display choice, not stored data - a per-user currency setting may replace it later *@ +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
public DateOnly? FullyCompletedAt { get; set; } + + /// + /// Price paid, in the user's own currency (currently always displayed as euros). + /// + public decimal? Price { get; set; } + + /// + /// Where this copy was bought (store, site, marketplace seller...). + /// + public string? Vendor { get; set; } + + /// + /// When this copy was acquired, if recorded. + /// + public DateOnly? AcquiredAt { get; set; } + + /// + /// Free-text reference for this copy: edition name, order number, barcode... + /// + public string? Reference { get; set; } } diff --git a/test/BlazorApp.PlaywrightTests/Smoke/VideoGamePlatformSmokeTest.cs b/test/BlazorApp.PlaywrightTests/Smoke/VideoGamePlatformSmokeTest.cs index 474109f1..4403ea2a 100644 --- a/test/BlazorApp.PlaywrightTests/Smoke/VideoGamePlatformSmokeTest.cs +++ b/test/BlazorApp.PlaywrightTests/Smoke/VideoGamePlatformSmokeTest.cs @@ -10,7 +10,8 @@ namespace Keeptrack.BlazorApp.PlaywrightTests.Smoke; /// /// Proves the video game platform editor's draft-card + trash-icon flow end-to-end - the same /// draft-until-Save and confirm-before-remove UX as 's owned-versions -/// flow, applied here to VideoGamePlatformDto's own field set (state, not price/vendor/reference). +/// flow, applied here to VideoGamePlatformDto's own field set (state and completion, on top of +/// the price/vendor/acquired-date/reference fields it now shares with every other media type's copies). /// [Trait("Category", "E2eTests")] [Trait("Mode", "Mutating")] diff --git a/test/WebApi.IntegrationTests/Resources/VideoGameResourceTest.cs b/test/WebApi.IntegrationTests/Resources/VideoGameResourceTest.cs index dc78b488..2c3248e1 100644 --- a/test/WebApi.IntegrationTests/Resources/VideoGameResourceTest.cs +++ b/test/WebApi.IntegrationTests/Resources/VideoGameResourceTest.cs @@ -1,3 +1,4 @@ +using System; using System.Linq; using System.Net; using System.Threading.Tasks; @@ -84,12 +85,22 @@ public async Task VideoGameResourceOwnedAndWishlistedFilters_OnlyReturnMatchingI { await Authenticate(); - var title = System.Guid.NewGuid().ToString(); + var title = Guid.NewGuid().ToString(); + var platforms = new[] + { + // "owned" is derived from having at least one platform entry (a game's copies), not a stored flag - + // this entry also carries the same ownership fields (price/vendor/acquired/reference) as every + // other media type's owned copies, see OwnedVersionModel + new VideoGamePlatformDto + { + Platform = "PS5", CopyType = CopyType.Physical, State = "Available", + Price = 59.99m, Vendor = "Some store", Reference = "Collector's edition", AcquiredAt = new DateOnly(2024, 5, 17) + } + }; var created = await PostAsync($"/{ResourceEndpoint}", new VideoGameDto { Title = title, - // "owned" is derived from having at least one platform entry (a game's copies), not a stored flag - Platforms = [new VideoGamePlatformDto { Platform = "PS5", CopyType = CopyType.Physical, State = "Available" }], + Platforms = [.. platforms], IsWishlisted = true }); @@ -98,6 +109,10 @@ public async Task VideoGameResourceOwnedAndWishlistedFilters_OnlyReturnMatchingI var owned = await GetAsync>($"/{ResourceEndpoint}?IsOwned=true&search={title}"); owned.Items.Should().ContainSingle(x => x.Id == created.Id); + // the platform entry's ownership fields must survive the full DTO -> model -> BSON round trip (incl. the decimal price) + var fetchedPlatforms = owned.Items.Single(x => x.Id == created.Id).Platforms; + fetchedPlatforms.Should().BeEquivalentTo(platforms); + var wishlisted = await GetAsync>($"/{ResourceEndpoint}?IsWishlisted=true&search={title}"); wishlisted.Items.Should().ContainSingle(x => x.Id == created.Id); } From c93b329ede176f1b7707180c0380e709aca0efc1 Mon Sep 17 00:00:00 2001 From: Bertrand THOMAS Date: Sat, 18 Jul 2026 11:05:23 +0200 Subject: [PATCH 06/32] Fix loading flash MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause: Blazor's ComponentBase.SetParametersAsync calls StateHasChanged() unconditionally right after the synchronous prefix of OnParametersSetAsync runs — before any awaited fetch resolves. My first pass added a 200ms delay before setting _loading = true inside the load method, but the _loading field itself still defaulted to true at declaration. Since a new detail-page component instance is created for each new item (different route Id → fresh field defaults), that forced render always caught _loading == true and painted the spinner for one frame, regardless of the delay logic inside LoadAsync. Fix: replaced the single _loading flag with two: - _loaded — false until a load attempt (fresh fetch or [PersistentState]-restored) has actually finished; both default to false now. - _loading — still delay-gated, only flips true if the fetch is genuinely still running after 200ms. Markup now reads @if (!_loaded) { @if (_loading) { spinner } } else if (X is null) { not found } else { content } — so that forced early render (before data or the delay have resolved) shows nothing instead of a spinner or a premature "not found" flash. Applied to all 9 detail pages, InventoryPageBase/InventoryList (covers all list pages), consistently. --- Directory.Build.props | 2 +- .../Components/Inventory/InventoryPageBase.cs | 24 +++++++++++--- .../Inventory/Pages/AlbumDetail.razor | 31 ++++++++++++++----- .../Components/Inventory/Pages/Albums.razor | 1 + .../Inventory/Pages/BookDetail.razor | 31 ++++++++++++++----- .../Components/Inventory/Pages/Books.razor | 1 + .../Inventory/Pages/CarDetail.razor | 31 ++++++++++++++----- .../Components/Inventory/Pages/Cars.razor | 1 + .../Inventory/Pages/HealthProfileDetail.razor | 31 ++++++++++++++----- .../Inventory/Pages/HealthProfiles.razor | 1 + .../Inventory/Pages/HouseDetail.razor | 31 ++++++++++++++----- .../Components/Inventory/Pages/Houses.razor | 1 + .../Inventory/Pages/MovieDetail.razor | 31 ++++++++++++++----- .../Components/Inventory/Pages/Movies.razor | 1 + .../Inventory/Pages/PlaylistDetail.razor | 31 ++++++++++++++----- .../Inventory/Pages/Playlists.razor | 1 + .../Inventory/Pages/TvShowDetail.razor | 31 ++++++++++++++----- .../Components/Inventory/Pages/TvShows.razor | 1 + .../Inventory/Pages/VideoGameDetail.razor | 31 ++++++++++++++----- .../Inventory/Pages/VideoGames.razor | 1 + .../Inventory/Shared/InventoryList.razor | 23 +++++++++++--- .../Components/Shared/LoadingIndicator.cs | 24 ++++++++++++++ 22 files changed, 278 insertions(+), 83 deletions(-) create mode 100644 src/BlazorApp/Components/Shared/LoadingIndicator.cs diff --git a/Directory.Build.props b/Directory.Build.props index ba3f273c..d4cacdfb 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -5,7 +5,7 @@ - 2.2.0 + 2.3.0 diff --git a/src/BlazorApp/Components/Inventory/InventoryPageBase.cs b/src/BlazorApp/Components/Inventory/InventoryPageBase.cs index 1305a667..ae695df1 100644 --- a/src/BlazorApp/Components/Inventory/InventoryPageBase.cs +++ b/src/BlazorApp/Components/Inventory/InventoryPageBase.cs @@ -1,3 +1,4 @@ +using Keeptrack.BlazorApp.Components.Shared; using Keeptrack.Common.System; using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components.Web; @@ -30,7 +31,14 @@ public abstract class InventoryPageBase : ComponentBase protected bool _showForm; - protected bool _loading = true; + // _loading is delay-gated (see LoadingIndicator) and only turns on for a load that's genuinely slow. + // _loaded tracks whether a load attempt has finished at all, fresh or restored from persisted + // prerender state - both default false so the forced render Blazor triggers right after the + // synchronous prefix of OnParametersSetAsync (before any awaited fetch resolves) shows blank instead + // of a spinner flash on every navigation to this page. + protected bool _loading; + + protected bool _loaded; protected string? _error; @@ -93,6 +101,7 @@ protected override async Task OnParametersSetAsync() if (query == LoadedQuery) { _loading = false; + _loaded = true; return; } @@ -190,10 +199,7 @@ protected async Task LoadAsync() { try { - _loading = true; - var result = await Api.GetAsync(_search, _page, PageSize, ExtraQuery, _sort); - Items = result.Items; - TotalCount = result.TotalCount; + await LoadingIndicator.RunAsync(FetchAsync(), v => _loading = v, StateHasChanged); } catch (Exception ex) { @@ -202,9 +208,17 @@ protected async Task LoadAsync() finally { _loading = false; + _loaded = true; } } + private async Task FetchAsync() + { + var result = await Api.GetAsync(_search, _page, PageSize, ExtraQuery, _sort); + Items = result.Items; + TotalCount = result.TotalCount; + } + private string BuildQuerySignature() { var extra = ExtraQuery is null ? "" : string.Join('&', ExtraQuery.Select(pair => $"{pair.Key}={pair.Value}")); diff --git a/src/BlazorApp/Components/Inventory/Pages/AlbumDetail.razor b/src/BlazorApp/Components/Inventory/Pages/AlbumDetail.razor index a16f778a..94dac423 100644 --- a/src/BlazorApp/Components/Inventory/Pages/AlbumDetail.razor +++ b/src/BlazorApp/Components/Inventory/Pages/AlbumDetail.razor @@ -4,12 +4,15 @@ @using Keeptrack.BlazorApp.Components.ReferenceData @using Keeptrack.BlazorApp.Components.ReferenceDataAdmin -@if (_loading) +@if (!_loaded) { -
-
- Loading… -
+ @if (_loading) + { +
+
+ Loading… +
+ } } else if (Album is null) { @@ -137,7 +140,13 @@ else private static readonly TimeSpan RefreshMessageDuration = TimeSpan.FromSeconds(4); - private bool _loading = true; + // _loading is delay-gated (see LoadingIndicator) and only turns on for a load that's genuinely slow. + // _loaded tracks whether a load attempt has finished at all, fresh or restored from persisted + // prerender state - both default false so the forced render Blazor triggers right after the + // synchronous prefix of OnParametersSetAsync (before any awaited fetch resolves) shows blank instead + // of a spinner flash on every navigation to this page. + private bool _loading; + private bool _loaded; // Public properties (a framework requirement for [PersistentState]): the data loaded during the // prerender pass is carried over to the interactive circuit, so the first interactive render reuses @@ -171,6 +180,7 @@ else if (Album?.Id == Id && Playlists is not null) { _loading = false; + _loaded = true; return; } await LoadAsync(); @@ -178,12 +188,17 @@ else private async Task LoadAsync() { - _loading = true; + await LoadingIndicator.RunAsync(FetchAsync(), v => _loading = v, StateHasChanged); + _loading = false; + _loaded = true; + } + + private async Task FetchAsync() + { 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) diff --git a/src/BlazorApp/Components/Inventory/Pages/Albums.razor b/src/BlazorApp/Components/Inventory/Pages/Albums.razor index 28f403a0..0106776a 100644 --- a/src/BlazorApp/Components/Inventory/Pages/Albums.razor +++ b/src/BlazorApp/Components/Inventory/Pages/Albums.razor @@ -9,6 +9,7 @@ Route="@ListRoute" Items="@(Items ?? [])" Loading="@_loading" + Loaded="@_loaded" Error="@_error" Search="@_search" Page="@_page" diff --git a/src/BlazorApp/Components/Inventory/Pages/BookDetail.razor b/src/BlazorApp/Components/Inventory/Pages/BookDetail.razor index 6db392c4..f21a204c 100644 --- a/src/BlazorApp/Components/Inventory/Pages/BookDetail.razor +++ b/src/BlazorApp/Components/Inventory/Pages/BookDetail.razor @@ -4,12 +4,15 @@ @using Keeptrack.BlazorApp.Components.ReferenceData @using Keeptrack.BlazorApp.Components.ReferenceDataAdmin -@if (_loading) +@if (!_loaded) { -
-
- Loading… -
+ @if (_loading) + { +
+
+ Loading… +
+ } } else if (Book is null) { @@ -114,7 +117,13 @@ else private static readonly TimeSpan RefreshMessageDuration = TimeSpan.FromSeconds(4); - private bool _loading = true; + // _loading is delay-gated (see LoadingIndicator) and only turns on for a load that's genuinely slow. + // _loaded tracks whether a load attempt has finished at all, fresh or restored from persisted + // prerender state - both default false so the forced render Blazor triggers right after the + // synchronous prefix of OnParametersSetAsync (before any awaited fetch resolves) shows blank instead + // of a spinner flash on every navigation to this page. + private bool _loading; + private bool _loaded; // Public properties (a framework requirement for [PersistentState]): the data loaded during the // prerender pass is carried over to the interactive circuit, so the first interactive render reuses @@ -136,6 +145,7 @@ else if (Book?.Id == Id) { _loading = false; + _loaded = true; return; } await LoadAsync(); @@ -143,10 +153,15 @@ else private async Task LoadAsync() { - _loading = true; + await LoadingIndicator.RunAsync(FetchAsync(), v => _loading = v, StateHasChanged); + _loading = false; + _loaded = true; + } + + private async Task FetchAsync() + { Book = await BookApi.GetOneAsync(Id); Reference = string.IsNullOrEmpty(Book?.ReferenceId) ? null : await ReferenceDataApi.GetBookAsync(Book.ReferenceId); - _loading = false; } private async Task SetRatingAsync(float rating) diff --git a/src/BlazorApp/Components/Inventory/Pages/Books.razor b/src/BlazorApp/Components/Inventory/Pages/Books.razor index fc412e3b..e92eda78 100644 --- a/src/BlazorApp/Components/Inventory/Pages/Books.razor +++ b/src/BlazorApp/Components/Inventory/Pages/Books.razor @@ -9,6 +9,7 @@ Route="@ListRoute" Items="@(Items ?? [])" Loading="@_loading" + Loaded="@_loaded" Error="@_error" Search="@_search" Page="@_page" diff --git a/src/BlazorApp/Components/Inventory/Pages/CarDetail.razor b/src/BlazorApp/Components/Inventory/Pages/CarDetail.razor index ed722995..808284a6 100644 --- a/src/BlazorApp/Components/Inventory/Pages/CarDetail.razor +++ b/src/BlazorApp/Components/Inventory/Pages/CarDetail.razor @@ -3,12 +3,15 @@ @attribute [Authorize] @using System.Globalization -@if (_loading) +@if (!_loaded) { -
-
- Loading… -
+ @if (_loading) + { +
+
+ Loading… +
+ } } else if (Car is null) { @@ -305,7 +308,13 @@ else [Inject] private CarHistoryApiClient CarHistoryApi { get; set; } = null!; - private bool _loading = true; + // _loading is delay-gated (see LoadingIndicator) and only turns on for a load that's genuinely slow. + // _loaded tracks whether a load attempt has finished at all, fresh or restored from persisted + // prerender state - both default false so the forced render Blazor triggers right after the + // synchronous prefix of OnParametersSetAsync (before any awaited fetch resolves) shows blank instead + // of a spinner flash on every navigation to this page. + private bool _loading; + private bool _loaded; // Public properties (a framework requirement for [PersistentState]): the data loaded during the // prerender pass is carried over to the interactive circuit, so the first interactive render reuses @@ -378,6 +387,7 @@ else { BuildDerivedState(); _loading = false; + _loaded = true; return; } await LoadAsync(); @@ -392,7 +402,13 @@ else private async Task LoadAsync() { - _loading = true; + await LoadingIndicator.RunAsync(FetchAsync(), v => _loading = v, StateHasChanged); + _loading = false; + _loaded = true; + } + + private async Task FetchAsync() + { Car = await CarApi.GetOneAsync(Id); if (Car is not null) { @@ -402,7 +418,6 @@ else Metrics = await CarApi.GetMetricsAsync(Id); BuildDerivedState(); } - _loading = false; } private void BuildDerivedState() diff --git a/src/BlazorApp/Components/Inventory/Pages/Cars.razor b/src/BlazorApp/Components/Inventory/Pages/Cars.razor index d3d29a56..a5a24980 100644 --- a/src/BlazorApp/Components/Inventory/Pages/Cars.razor +++ b/src/BlazorApp/Components/Inventory/Pages/Cars.razor @@ -9,6 +9,7 @@ Route="@ListRoute" Items="@(Items ?? [])" Loading="@_loading" + Loaded="@_loaded" Error="@_error" Search="@_search" Page="@_page" diff --git a/src/BlazorApp/Components/Inventory/Pages/HealthProfileDetail.razor b/src/BlazorApp/Components/Inventory/Pages/HealthProfileDetail.razor index 3638b7b8..2de08bd1 100644 --- a/src/BlazorApp/Components/Inventory/Pages/HealthProfileDetail.razor +++ b/src/BlazorApp/Components/Inventory/Pages/HealthProfileDetail.razor @@ -3,12 +3,15 @@ @attribute [Authorize] @using System.Globalization -@if (_loading) +@if (!_loaded) { -
-
- Loading… -
+ @if (_loading) + { +
+
+ Loading… +
+ } } else if (Profile is null) { @@ -224,7 +227,13 @@ else [Inject] private HealthRecordApiClient HealthRecordApi { get; set; } = null!; - private bool _loading = true; + // _loading is delay-gated (see LoadingIndicator) and only turns on for a load that's genuinely slow. + // _loaded tracks whether a load attempt has finished at all, fresh or restored from persisted + // prerender state - both default false so the forced render Blazor triggers right after the + // synchronous prefix of OnParametersSetAsync (before any awaited fetch resolves) shows blank instead + // of a spinner flash on every navigation to this page. + private bool _loading; + private bool _loaded; // Public properties (a framework requirement for [PersistentState]): the data loaded during the // prerender pass is carried over to the interactive circuit, so the first interactive render reuses @@ -267,6 +276,7 @@ else { BuildDerivedState(); _loading = false; + _loaded = true; return; } await LoadAsync(); @@ -304,7 +314,13 @@ else private async Task LoadAsync() { - _loading = true; + await LoadingIndicator.RunAsync(FetchAsync(), v => _loading = v, StateHasChanged); + _loading = false; + _loaded = true; + } + + private async Task FetchAsync() + { Profile = await HealthProfileApi.GetOneAsync(Id); if (Profile is not null) { @@ -314,7 +330,6 @@ else Metrics = await HealthProfileApi.GetMetricsAsync(Id); BuildDerivedState(); } - _loading = false; } private void BuildDerivedState() diff --git a/src/BlazorApp/Components/Inventory/Pages/HealthProfiles.razor b/src/BlazorApp/Components/Inventory/Pages/HealthProfiles.razor index 779de547..e80d2752 100644 --- a/src/BlazorApp/Components/Inventory/Pages/HealthProfiles.razor +++ b/src/BlazorApp/Components/Inventory/Pages/HealthProfiles.razor @@ -9,6 +9,7 @@ Route="@ListRoute" Items="@(Items ?? [])" Loading="@_loading" + Loaded="@_loaded" Error="@_error" Search="@_search" Page="@_page" diff --git a/src/BlazorApp/Components/Inventory/Pages/HouseDetail.razor b/src/BlazorApp/Components/Inventory/Pages/HouseDetail.razor index eebb5b1c..a7473fd5 100644 --- a/src/BlazorApp/Components/Inventory/Pages/HouseDetail.razor +++ b/src/BlazorApp/Components/Inventory/Pages/HouseDetail.razor @@ -2,12 +2,15 @@ @rendermode InteractiveServer @attribute [Authorize] -@if (_loading) +@if (!_loaded) { -
-
- Loading… -
+ @if (_loading) + { +
+
+ Loading… +
+ } } else if (House is null) { @@ -199,7 +202,13 @@ else [Inject] private HouseHistoryApiClient HouseHistoryApi { get; set; } = null!; - private bool _loading = true; + // _loading is delay-gated (see LoadingIndicator) and only turns on for a load that's genuinely slow. + // _loaded tracks whether a load attempt has finished at all, fresh or restored from persisted + // prerender state - both default false so the forced render Blazor triggers right after the + // synchronous prefix of OnParametersSetAsync (before any awaited fetch resolves) shows blank instead + // of a spinner flash on every navigation to this page. + private bool _loading; + private bool _loaded; // Public properties (a framework requirement for [PersistentState]): the data loaded during the // prerender pass is carried over to the interactive circuit, so the first interactive render reuses @@ -234,6 +243,7 @@ else { BuildDerivedState(); _loading = false; + _loaded = true; return; } await LoadAsync(); @@ -248,7 +258,13 @@ else private async Task LoadAsync() { - _loading = true; + await LoadingIndicator.RunAsync(FetchAsync(), v => _loading = v, StateHasChanged); + _loading = false; + _loaded = true; + } + + private async Task FetchAsync() + { House = await HouseApi.GetOneAsync(Id); if (House is not null) { @@ -258,7 +274,6 @@ else Metrics = await HouseApi.GetMetricsAsync(Id); BuildDerivedState(); } - _loading = false; } private void BuildDerivedState() diff --git a/src/BlazorApp/Components/Inventory/Pages/Houses.razor b/src/BlazorApp/Components/Inventory/Pages/Houses.razor index 9d8551a6..439f3a88 100644 --- a/src/BlazorApp/Components/Inventory/Pages/Houses.razor +++ b/src/BlazorApp/Components/Inventory/Pages/Houses.razor @@ -9,6 +9,7 @@ Route="@ListRoute" Items="@(Items ?? [])" Loading="@_loading" + Loaded="@_loaded" Error="@_error" Search="@_search" Page="@_page" diff --git a/src/BlazorApp/Components/Inventory/Pages/MovieDetail.razor b/src/BlazorApp/Components/Inventory/Pages/MovieDetail.razor index b30e32fe..ff019956 100644 --- a/src/BlazorApp/Components/Inventory/Pages/MovieDetail.razor +++ b/src/BlazorApp/Components/Inventory/Pages/MovieDetail.razor @@ -4,12 +4,15 @@ @using Keeptrack.BlazorApp.Components.ReferenceData @using Keeptrack.BlazorApp.Components.ReferenceDataAdmin -@if (_loading) +@if (!_loaded) { -
-
- Loading… -
+ @if (_loading) + { +
+
+ Loading… +
+ } } else if (Movie is null) { @@ -113,7 +116,13 @@ else private static readonly TimeSpan RefreshMessageDuration = TimeSpan.FromSeconds(4); - private bool _loading = true; + // _loading is delay-gated (see LoadingIndicator) and only turns on for a load that's genuinely slow. + // _loaded tracks whether a load attempt has finished at all, fresh or restored from persisted + // prerender state - both default false so the forced render Blazor triggers right after the + // synchronous prefix of OnParametersSetAsync (before any awaited fetch resolves) shows blank instead + // of a spinner flash on every navigation to this page. + private bool _loading; + private bool _loaded; // Public properties (a framework requirement for [PersistentState]): the data loaded during the // prerender pass is carried over to the interactive circuit, so the first interactive render reuses @@ -136,6 +145,7 @@ else if (Movie?.Id == Id) { _loading = false; + _loaded = true; return; } await LoadAsync(); @@ -143,10 +153,15 @@ else private async Task LoadAsync() { - _loading = true; + await LoadingIndicator.RunAsync(FetchAsync(), v => _loading = v, StateHasChanged); + _loading = false; + _loaded = true; + } + + private async Task FetchAsync() + { Movie = await MovieApi.GetOneAsync(Id); Reference = string.IsNullOrEmpty(Movie?.ReferenceId) ? null : await ReferenceDataApi.GetMovieAsync(Movie.ReferenceId); - _loading = false; } private async Task ToggleFavoriteAsync() diff --git a/src/BlazorApp/Components/Inventory/Pages/Movies.razor b/src/BlazorApp/Components/Inventory/Pages/Movies.razor index 1b68f89b..1dafe3a9 100644 --- a/src/BlazorApp/Components/Inventory/Pages/Movies.razor +++ b/src/BlazorApp/Components/Inventory/Pages/Movies.razor @@ -9,6 +9,7 @@ Route="@ListRoute" Items="@(Items ?? [])" Loading="@_loading" + Loaded="@_loaded" Error="@_error" Search="@_search" Page="@_page" diff --git a/src/BlazorApp/Components/Inventory/Pages/PlaylistDetail.razor b/src/BlazorApp/Components/Inventory/Pages/PlaylistDetail.razor index b0c0b679..95d2a56c 100644 --- a/src/BlazorApp/Components/Inventory/Pages/PlaylistDetail.razor +++ b/src/BlazorApp/Components/Inventory/Pages/PlaylistDetail.razor @@ -3,12 +3,15 @@ @attribute [Authorize] @using Keeptrack.BlazorApp.Components.ReferenceData -@if (_loading) +@if (!_loaded) { -
-
- Loading… -
+ @if (_loading) + { +
+
+ Loading… +
+ } } else if (Playlist is null) { @@ -105,7 +108,13 @@ else [Inject] private ReferenceDataApiClient ReferenceDataApi { get; set; } = null!; - private bool _loading = true; + // _loading is delay-gated (see LoadingIndicator) and only turns on for a load that's genuinely slow. + // _loaded tracks whether a load attempt has finished at all, fresh or restored from persisted + // prerender state - both default false so the forced render Blazor triggers right after the + // synchronous prefix of OnParametersSetAsync (before any awaited fetch resolves) shows blank instead + // of a spinner flash on every navigation to this page. + private bool _loading; + private bool _loaded; // Public properties (a framework requirement for [PersistentState]): the data loaded during the // prerender pass is carried over to the interactive circuit, so the first interactive render reuses @@ -147,6 +156,7 @@ else { BuildDerivedState(); _loading = false; + _loaded = true; return; } await LoadAsync(); @@ -154,14 +164,19 @@ else private async Task LoadAsync() { - _loading = true; + await LoadingIndicator.RunAsync(FetchAsync(), v => _loading = v, StateHasChanged); + _loading = false; + _loaded = true; + } + + private async Task FetchAsync() + { Playlist = await PlaylistApi.GetOneAsync(Id); var songs = await SongApi.GetAsync("", 1, 5000); Songs = songs.Items; var albums = await AlbumApi.GetAsync("", 1, 5000); Albums = albums.Items; BuildDerivedState(); - _loading = false; } private void BuildDerivedState() diff --git a/src/BlazorApp/Components/Inventory/Pages/Playlists.razor b/src/BlazorApp/Components/Inventory/Pages/Playlists.razor index 2ac76fcf..a8fa0188 100644 --- a/src/BlazorApp/Components/Inventory/Pages/Playlists.razor +++ b/src/BlazorApp/Components/Inventory/Pages/Playlists.razor @@ -9,6 +9,7 @@ Route="@ListRoute" Items="@(Items ?? [])" Loading="@_loading" + Loaded="@_loaded" Error="@_error" Search="@_search" Page="@_page" diff --git a/src/BlazorApp/Components/Inventory/Pages/TvShowDetail.razor b/src/BlazorApp/Components/Inventory/Pages/TvShowDetail.razor index 917bb18c..62c3fc31 100644 --- a/src/BlazorApp/Components/Inventory/Pages/TvShowDetail.razor +++ b/src/BlazorApp/Components/Inventory/Pages/TvShowDetail.razor @@ -4,12 +4,15 @@ @using Keeptrack.BlazorApp.Components.ReferenceData @using Keeptrack.BlazorApp.Components.ReferenceDataAdmin -@if (_loading) +@if (!_loaded) { -
-
- Loading… -
+ @if (_loading) + { +
+
+ Loading… +
+ } } else if (Show is null) { @@ -212,7 +215,13 @@ else [Inject] private ReferenceDataApiClient ReferenceDataApi { get; set; } = null!; - private bool _loading = true; + // _loading is delay-gated (see LoadingIndicator) and only turns on for a load that's genuinely slow. + // _loaded tracks whether a load attempt has finished at all, fresh or restored from persisted + // prerender state - both default false so the forced render Blazor triggers right after the + // synchronous prefix of OnParametersSetAsync (before any awaited fetch resolves) shows blank instead + // of a spinner flash on every navigation to this page. + private bool _loading; + private bool _loaded; // Public properties (a framework requirement for [PersistentState]): the data loaded during the // prerender pass is carried over to the interactive circuit, so the first interactive render reuses @@ -252,6 +261,7 @@ else { BuildDerivedState(); _loading = false; + _loaded = true; return; } await LoadAsync(); @@ -259,7 +269,13 @@ else private async Task LoadAsync() { - _loading = true; + await LoadingIndicator.RunAsync(FetchAsync(), v => _loading = v, StateHasChanged); + _loading = false; + _loaded = true; + } + + private async Task FetchAsync() + { Show = await TvShowApi.GetOneAsync(Id); var episodes = await EpisodeApi.GetAsync(string.Empty, 1, 5000, new Dictionary { ["TvShowId"] = Id }); Episodes = episodes.Items; @@ -269,7 +285,6 @@ else // blocks on it - see the two very different rendering branches below. Reference = string.IsNullOrEmpty(Show?.ReferenceId) ? null : await ReferenceDataApi.GetTvShowAsync(Show.ReferenceId); BuildDerivedState(); - _loading = false; } private void BuildDerivedState() diff --git a/src/BlazorApp/Components/Inventory/Pages/TvShows.razor b/src/BlazorApp/Components/Inventory/Pages/TvShows.razor index 62b3f710..6bc9d27e 100644 --- a/src/BlazorApp/Components/Inventory/Pages/TvShows.razor +++ b/src/BlazorApp/Components/Inventory/Pages/TvShows.razor @@ -9,6 +9,7 @@ Route="@ListRoute" Items="@(Items ?? [])" Loading="@_loading" + Loaded="@_loaded" Error="@_error" Search="@_search" Page="@_page" diff --git a/src/BlazorApp/Components/Inventory/Pages/VideoGameDetail.razor b/src/BlazorApp/Components/Inventory/Pages/VideoGameDetail.razor index 908d45f6..5209eab6 100644 --- a/src/BlazorApp/Components/Inventory/Pages/VideoGameDetail.razor +++ b/src/BlazorApp/Components/Inventory/Pages/VideoGameDetail.razor @@ -5,12 +5,15 @@ @using Keeptrack.BlazorApp.Components.ReferenceData @using Keeptrack.BlazorApp.Components.ReferenceDataAdmin -@if (_loading) +@if (!_loaded) { -
-
- Loading… -
+ @if (_loading) + { +
+
+ Loading… +
+ } } else if (Game is null) { @@ -213,7 +216,13 @@ else private static readonly TimeSpan RefreshMessageDuration = TimeSpan.FromSeconds(4); - private bool _loading = true; + // _loading is delay-gated (see LoadingIndicator) and only turns on for a load that's genuinely slow. + // _loaded tracks whether a load attempt has finished at all, fresh or restored from persisted + // prerender state - both default false so the forced render Blazor triggers right after the + // synchronous prefix of OnParametersSetAsync (before any awaited fetch resolves) shows blank instead + // of a spinner flash on every navigation to this page. + private bool _loading; + private bool _loaded; // Public properties (a framework requirement for [PersistentState]): the data loaded during the // prerender pass is carried over to the interactive circuit, so the first interactive render reuses @@ -237,6 +246,7 @@ else if (Game?.Id == Id) { _loading = false; + _loaded = true; return; } await LoadAsync(); @@ -244,10 +254,15 @@ else private async Task LoadAsync() { - _loading = true; + await LoadingIndicator.RunAsync(FetchAsync(), v => _loading = v, StateHasChanged); + _loading = false; + _loaded = true; + } + + private async Task FetchAsync() + { 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 diff --git a/src/BlazorApp/Components/Inventory/Pages/VideoGames.razor b/src/BlazorApp/Components/Inventory/Pages/VideoGames.razor index 33580c89..d1275491 100644 --- a/src/BlazorApp/Components/Inventory/Pages/VideoGames.razor +++ b/src/BlazorApp/Components/Inventory/Pages/VideoGames.razor @@ -9,6 +9,7 @@ Route="@ListRoute" Items="@(Items ?? [])" Loading="@_loading" + Loaded="@_loaded" Error="@_error" Search="@_search" Page="@_page" diff --git a/src/BlazorApp/Components/Inventory/Shared/InventoryList.razor b/src/BlazorApp/Components/Inventory/Shared/InventoryList.razor index b9413e11..34795094 100644 --- a/src/BlazorApp/Components/Inventory/Shared/InventoryList.razor +++ b/src/BlazorApp/Components/Inventory/Shared/InventoryList.razor @@ -24,12 +24,15 @@
} -@if (Loading) +@if (!Loaded) { -
-
- Loading your collection… -
+ @if (Loading) + { +
+
+ Loading your collection… +
+ } } else { @@ -199,6 +202,16 @@ else [Parameter] public required List Items { get; set; } [Parameter] public required TDto Form { get; set; } [Parameter] public required bool Loading { get; set; } + + /// + /// True once a load attempt (fresh or restored from persisted prerender state) has finished, found + /// or not - distinct from , which is delay-gated and only turns on for a load + /// that's genuinely slow. Before this is true, nothing renders except a delayed + /// spinner, so the forced render Blazor triggers right after the synchronous prefix of + /// OnParametersSetAsync (before any awaited fetch resolves) shows blank instead of a flash of the + /// empty-collection state. Defaults true so a hypothetical caller that never sets it renders as before. + /// + [Parameter] public bool Loaded { get; set; } = true; [Parameter] public required bool ShowForm { get; set; } [Parameter] public required string? Error { get; set; } [Parameter] public required string Search { get; set; } diff --git a/src/BlazorApp/Components/Shared/LoadingIndicator.cs b/src/BlazorApp/Components/Shared/LoadingIndicator.cs new file mode 100644 index 00000000..eadf7736 --- /dev/null +++ b/src/BlazorApp/Components/Shared/LoadingIndicator.cs @@ -0,0 +1,24 @@ +namespace Keeptrack.BlazorApp.Components.Shared; + +/// +/// Gates a page's loading-spinner flag behind a short delay. Blazor's async lifecycle rendering +/// (render once before the first await, once after) flashes the spinner for a single frame even +/// when the load is fast - the common case for an in-circuit navigation between two already- +/// connected pages, which is usually faster than . The spinner only actually +/// appears when the load is still running once that delay elapses. +/// +public static class LoadingIndicator +{ + private static readonly TimeSpan Delay = TimeSpan.FromMilliseconds(200); + + public static async Task RunAsync(Task load, Action setLoading, Action stateHasChanged) + { + if (await Task.WhenAny(load, Task.Delay(Delay)) != load) + { + setLoading(true); + stateHasChanged(); + } + + await load; + } +} From b1600445acc11edb45d4f3f1803b29867bf9e91b Mon Sep 17 00:00:00 2001 From: Bertrand THOMAS Date: Sat, 18 Jul 2026 16:07:38 +0200 Subject: [PATCH 07/32] Add docs/prerender-flash-fix.md --- Keeptrack.slnx | 1 + docs/prerender-flash-fix.md | 117 ++++++++++++++++++++++++++++++++++++ 2 files changed, 118 insertions(+) create mode 100644 docs/prerender-flash-fix.md diff --git a/Keeptrack.slnx b/Keeptrack.slnx index 9d3dd81f..74ed63a1 100644 --- a/Keeptrack.slnx +++ b/Keeptrack.slnx @@ -17,6 +17,7 @@ + diff --git a/docs/prerender-flash-fix.md b/docs/prerender-flash-fix.md new file mode 100644 index 00000000..f004bb76 --- /dev/null +++ b/docs/prerender-flash-fix.md @@ -0,0 +1,117 @@ +# .NET 10 PersistentState prerender-flash fix + +## First version + +### Investigation + +The flash is a prerender double-render, and it's inherent to the app's current architecture + +Every page declares @rendermode InteractiveServer individually while the router (Routes.razor) stays static. +So opening an item from a list is not an in-circuit SPA navigation — it's an enhanced navigation that renders the detail page twice: + +1. The server statically prerenders the detail page (prerendering is on by default). +LoadAsync (MovieDetail.razor:124-132) runs to completion server-side, so the HTML swapped into the DOM already shows the full movie content — that's the "partially displayed" page you see first. +2. Then blazor.web.js attaches the page to the SignalR circuit as a fresh component instance with no state. +Its first interactive render resets to _loading = true — the full content is briefly replaced by the "Loading…" spinner — +then LoadAsync runs again (a second round of API calls: the item, plus the reference document), and the content redraws. + +So the sequence is content → spinner → content. +That's the "refresh" you're seeing. +A side effect worth knowing: every detail-page open (and every list-page load) currently hits the WebApi twice for the same data. + +Why you may not have noticed before + +Nothing on this branch (or in V2.1) changed navigation behavior — +I diffed the shared list/nav components and checked App.razor history; this double-render has been the architecture since the v1.1 rework. +I also ruled out FirebaseSessionRefresh (it's a passive token-refresh script, no reloads). +So your second message is probably right: it was always there, but it's grown more visible as detail pages got heavier — +LoadAsync now chains multiple sequential API calls (item + reference, and Car/House/Health also load history and metrics), +so the spinner window between the two renders is longer than it used to be. + +How it can be fixed + +.NET 10 added exactly the tool for this: the declarative [PersistentState] attribute. +Mark the loaded state on a detail page with it: + +[PersistentState] +public MovieDto? Movie { get; set; } + +Blazor then serializes the prerendered data into the page and restores it when the interactive instance attaches — +LoadAsync can skip the refetch when the state is already there. +That removes the flash (the interactive render diffs to identical DOM) and halves the API calls, while keeping prerendering's fast first paint. +It would need a small refactor per detail page (and could be applied to InventoryPageBase for lists too — they have the same double-fetch). +The alternative — disabling prerender via @(new InteractiveServerRenderMode(prerender: false)) — +also kills the flash but trades it for a blank-then-spinner first paint, so [PersistentState] is the better fit here. + +Happy to implement it if you want — I'd start with one detail page (Movie) to validate the pattern, then roll it across the others and the list base. + +Sources: +[Blazor prerendered state persistence (Microsoft Learn)](https://learn.microsoft.com/en-us/aspnet/core/blazor/state-management/prerendered-state-persistence), +[Prerender ASP.NET Core Razor components (Microsoft Learn)](https://learn.microsoft.com/en-us/aspnet/core/blazor/components/prerender), +[.NET 10 fixes prerendering with [PersistentState]](https://dotnet8.patrickgod.com/posts/net-10-finally-fixes-prerendering-in-blazor-with-persistentstate) + +### Original pattern recap + +- Convert the fields holding fetched data (`_x`, `_reference`, etc.) into public auto-properties marked [PersistentState] (public is a framework requirement). + Keep `_loading` and everything else as private fields. +- OnParametersSetAsync: when the restored item's Id matches the route Id (and any co-fetched lists are non-null), skip LoadAsync, set _loading = false, and return; otherwise LoadAsync as before. + Never touch explicit LoadAsync callers (refresh-reference, OnLinked, row save/delete) — they must still force a real re-fetch. +- Derived/non-serializable state (tuple-keyed dictionaries, grouped lookups, computed metrics view-models): + persist only the raw fetched DTOs/lists, extract the derivation into a private BuildDerivedState() called from both LoadAsync and the restore path (see TvShowDetail). +- Careful with mechanical renames: `_reference` is a prefix of `_referenceEpisodesBySeason-style` fields — don't corrupt those (this bit once already in TvShowDetail). + +## Limitations + +### Known .NET 10 framework bug + +The setup matches it exactly. + +Only on watch next page, clicking on the Movies tab does nothing and I have an error in the Chrome console: + +```txt +blazor.web.f7fxoh55ae.js:1 +[2026-07-18T13:33:36.743Z] +Error: Connection disconnected with error 'Error: Server returned an error on close: Connection closed with an error.'. +Uncaught (in promise) +Error: Server returned an error on close: Connection closed with an error. + at kn._processIncomingData (blazor.web.f7fxoh55ae.js:1:67187) + at connection.onreceive (blazor.web.f7fxoh55ae.js:1:60364) + at i.onmessage (blazor.web.f7fxoh55ae.js:1:84422). +``` + +**Diagnosis**: + +dotnet/aspnetcore#65101 — "PersistentState crash the app if SignalR message size limit is hit". +The mechanics: + +1. `[PersistentState]` serializes the whole WatchNextDto into the prerendered HTML, and the client must send that payload back to the server over SignalR when the interactive circuit takes over the page. +2. The Blazor hub's default MaximumReceiveMessageSize is 32 KB. + When the persisted state exceeds it, the server rejects the message and closes the connection — producing exactly your console error (Server returned an error on close: + Connection closed with an error), with no auto-reconnect and nothing at Information level in the logs. The real error (InvalidDataException: The maximum message size of 32768B was exceeded) only surfaces with detailed errors enabled. + In the reported issue, ~100 small objects were enough. + +This explains every observation that had me going in circles: + +- Why only Watch Next: MoviesToWatch embeds full MovieDtos for every want-to-watch movie — TV Time "towatch" imports land there, so it's likely your largest payload. Your wishlist (ownership) is a smaller curated list that stays under 32 KB. +- Why the smoke test passes: it seeds 1 show + 1 movie — a few hundred bytes. +- Why clicking "does nothing" then errors: the circuit dies during the state handoff right after navigation; the tabs you see are dead prerendered HTML, and the click goes nowhere. +- Why other pages break until reload: the circuit is shared across in-app navigations — once it's dead, everything is dead until a full reload builds a fresh one. +- Why nothing is logged: the oversized-message rejection isn't logged as a circuit error at Error level. + +To confirm it in 30 seconds: +view-source on /watch-next and look at the size of the comment near the end (I'd expect > ~43 KB of base64 ≈ 32 KB raw), +or temporarily add .AddInteractiveServerComponents(o => o.DetailedErrors = true) in src/BlazorApp/Program.cs:5 and repro — the console error will then name the 32768B limit. + +**How to fix**: + +Two real options, and I have a clear preference: + +1. Drop `[PersistentState]` from WatchNextPage and WishlistPage only (recommended). + Persisting an unbounded aggregate list into page HTML is the design flaw here — it also bloats every prerendered response. + These two pages would just re-fetch on circuit start, and your new LoadingIndicator 200 ms delay-gate already prevents the spinner flash for fast loads, which was the original point of the commit. The detail pages persist a single bounded DTO and are fine to keep as-is. +2. Raise the limit via .AddHubOptions(o => o.MaximumReceiveMessageSize = 256 * 1024) — one line, keeps the no-refetch behavior, but the payload still grows with your collection and the cliff just moves further out. + +Sources: +[dotnet/aspnetcore#65101](https://github.com/dotnet/aspnetcore/issues/65101), +[Blazor SignalR guidance — message size](https://learn.microsoft.com/en-us/aspnet/core/blazor/fundamentals/signalr), +[Telerik KB on the 32 KB disconnect symptom](https://www.telerik.com/blazor-ui/documentation/knowledge-base/common-connection-closed) From 01079b0a9cb5d2c36a9134c10d848586f8f2af51 Mon Sep 17 00:00:00 2001 From: Bertrand THOMAS Date: Sat, 18 Jul 2026 16:08:24 +0200 Subject: [PATCH 08/32] Cosmetic code change Causes error --- .../Smoke/WatchNextSmokeTest.cs | 24 +++++++++---------- 1 file changed, 11 insertions(+), 13 deletions(-) diff --git a/test/BlazorApp.PlaywrightTests/Smoke/WatchNextSmokeTest.cs b/test/BlazorApp.PlaywrightTests/Smoke/WatchNextSmokeTest.cs index 1bbdcd74..4e89d6ec 100644 --- a/test/BlazorApp.PlaywrightTests/Smoke/WatchNextSmokeTest.cs +++ b/test/BlazorApp.PlaywrightTests/Smoke/WatchNextSmokeTest.cs @@ -7,23 +7,21 @@ namespace Keeptrack.BlazorApp.PlaywrightTests.Smoke; /// -/// Runs alone (never concurrently with any other test) - Watch Next is a cross-entity aggregation over the -/// whole shared e2e tenant, so parallel classes creating/deleting their own shows and movies race its -/// assertions (observed as a real intermittent element-not-found failure in a full parallel run that -/// passes in isolation). DisableParallelization gives it the quiet tenant it needs without slowing the -/// rest of the suite down. +/// Runs alone (never concurrently with any other test) - Watch Next is a cross-entity aggregation over the whole shared e2e tenant, +/// so parallel classes creating/deleting their own shows and movies race its assertions +/// (observed as a real intermittent element-not-found failure in a full parallel run that passes in isolation). +/// DisableParallelization gives it the quiet tenant it needs without slowing the rest of the suite down. /// [CollectionDefinition(nameof(WatchNextSmokeTest), DisableParallelization = true)] public class WatchNextSmokeTestCollection; /// -/// Watch Next needs real, reference-linked, partially-watched data to assert anything meaningful (an empty -/// state is already covered by NavigationSmokeTest). Real-world airing status doesn't matter for the -/// "Current" state - WatchNextService only checks the tenant's own State field, not whether the -/// show is still airing in reality - so a long-finished, TMDB-stable show works and stays deterministic -/// (its episode list will never change between runs, unlike a currently-airing show's). -/// Uses different real titles than / since every -/// smoke test shares the same e2e tenant and a fixed real-world title would otherwise collide. +/// Watch Next needs real, reference-linked, partially-watched data to assert anything meaningful (an empty state is already covered by NavigationSmokeTest). +/// Real-world airing status doesn't matter for the "Current" state - WatchNextService only checks the tenant's own State field, +/// not whether the show is still airing in reality - +/// so a long-finished, TMDB-stable show works and stays deterministic. +/// (Its episode list will never change between runs, unlike a currently-airing show's.) +/// Uses different real titles than since every smoke test shares the same e2e tenant and a fixed real-world title would otherwise collide. /// See for why this class never runs in parallel with others. /// [Trait("Category", "E2eTests")] @@ -71,7 +69,7 @@ public async Task WatchNext_ShowsConfirmedNextEpisodeAndWishlistedMovie() try { - await Page.GetByRole(AriaRole.Button, new() { Name = "Watchlist" }).ClickAsync(); + await Page.GetByRole(AriaRole.Button, new PageGetByRoleOptions { Name = "Watchlist" }).ClickAsync(); var watchNext = await movieDetail.OpenWatchNextAsync(); From 090b0e5ccdd0b84010a5306862f1de3171887066 Mon Sep 17 00:00:00 2001 From: Bertrand THOMAS Date: Sat, 18 Jul 2026 16:16:11 +0200 Subject: [PATCH 09/32] No flashing loading for WatchNextPage and WishListPage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Removed [PersistentState]; Data is now a plain private nullable property, with a comment explaining why this page must not persist its unbounded payload (dotnet/aspnetcore#65101, pointing at your docs/prerender-flash-fix.md). - OnInitializedAsync collapsed to just LoadAsync() — no restore branch. - Kept the flash fix intact: _loaded/_loading still default false so the pre-await render shows blank, and LoadingIndicator.RunAsync only shows the spinner if the fetch is still running after 200 ms. The interactive circuit will re-fetch on every visit now, but that call should stay under the 200 ms gate most of the time, so no spinner flash. - [PersistentState] removed; Data is now a private WishlistDto? with the same comment explaining the unbounded-payload / 32 KB hub limit rationale (dotnet/aspnetcore#65101, pointing at docs/prerender-flash-fix.md). - OnInitializedAsync collapsed to LoadAsync(); the restore branch is gone. The delay-gated spinner logic is untouched. --- .../Components/WatchNext/WatchNextPage.razor | 55 ++++++++++++----- .../Components/Wishlist/WishlistPage.razor | 59 +++++++++++++------ 2 files changed, 80 insertions(+), 34 deletions(-) diff --git a/src/BlazorApp/Components/WatchNext/WatchNextPage.razor b/src/BlazorApp/Components/WatchNext/WatchNextPage.razor index a8370ff0..0f201b01 100644 --- a/src/BlazorApp/Components/WatchNext/WatchNextPage.razor +++ b/src/BlazorApp/Components/WatchNext/WatchNextPage.razor @@ -6,21 +6,24 @@

Watch next

-@if (_loading) +@if (!_loaded) { -
-
- Loading… -
+ @if (_loading) + { +
+
+ Loading… +
+ } } -else +else if (Data is not null) {
@@ -29,7 +32,7 @@ else

Current shows with a confirmed new episode ready to watch.

- @if (_data.InProgressShows.Count == 0) + @if (Data.InProgressShows.Count == 0) {
@@ -40,7 +43,7 @@ else {
- @foreach (var show in _data.InProgressShows) + @foreach (var show in Data.InProgressShows) { @@ -60,7 +63,7 @@ else } else { - @if (_data.MoviesToWatch.Count == 0) + @if (Data.MoviesToWatch.Count == 0) {
@@ -71,7 +74,7 @@ else {
- @foreach (var movie in _data.MoviesToWatch) + @foreach (var movie in Data.MoviesToWatch) { @@ -97,14 +100,34 @@ else [Inject] private WatchNextApiClient WatchNextApi { get; set; } = null!; - private bool _loading = true; - private WatchNextDto _data = new(); + // _loading is delay-gated (see LoadingIndicator) and only turns on for a load that's genuinely slow. + // _loaded tracks whether a load attempt has finished at all - both default false so the forced + // render Blazor triggers right after the synchronous prefix of OnInitializedAsync (before any + // awaited fetch resolves) shows blank instead of a spinner flash on every navigation to this page. + private bool _loading; + private bool _loaded; + + // Deliberately NOT [PersistentState], unlike the detail pages: WatchNextDto embeds every to-watch + // movie, an unbounded payload that can exceed the Blazor hub's 32 KB MaximumReceiveMessageSize and + // kill the circuit on the prerender-to-interactive handoff (dotnet/aspnetcore#65101, see + // docs/prerender-flash-fix.md). The interactive circuit re-fetches instead; the delay-gated + // spinner above keeps that re-fetch flash-free when it's fast. + private WatchNextDto? Data { get; set; } + private Tab _tab = Tab.TvShows; - protected override async Task OnInitializedAsync() + protected override Task OnInitializedAsync() => LoadAsync(); + + private async Task LoadAsync() { - _data = await WatchNextApi.GetAsync(); + await LoadingIndicator.RunAsync(FetchAsync(), v => _loading = v, StateHasChanged); _loading = false; + _loaded = true; + } + + private async Task FetchAsync() + { + Data = await WatchNextApi.GetAsync(); } private void SelectTab(Tab tab) => _tab = tab; diff --git a/src/BlazorApp/Components/Wishlist/WishlistPage.razor b/src/BlazorApp/Components/Wishlist/WishlistPage.razor index 1efbc5d3..9f1dbb49 100644 --- a/src/BlazorApp/Components/Wishlist/WishlistPage.razor +++ b/src/BlazorApp/Components/Wishlist/WishlistPage.razor @@ -39,27 +39,30 @@
} -@if (_loading) +@if (!_loaded) { -
-
- Loading… -
+ @if (_loading) + { +
+
+ Loading… +
+ } } -else +else if (Data is not null) {
@@ -108,8 +111,20 @@ else [Inject] private IJSRuntime JsRuntime { get; set; } = null!; - private bool _loading = true; - private WishlistDto _data = new(); + // _loading is delay-gated (see LoadingIndicator) and only turns on for a load that's genuinely slow. + // _loaded tracks whether a load attempt has finished at all - both default false so the forced + // render Blazor triggers right after the synchronous prefix of OnInitializedAsync (before any + // awaited fetch resolves) shows blank instead of a spinner flash on every navigation to this page. + private bool _loading; + private bool _loaded; + + // Deliberately NOT [PersistentState], unlike the detail pages: WishlistDto embeds every wishlisted + // item across four collections, an unbounded payload that can exceed the Blazor hub's 32 KB + // MaximumReceiveMessageSize and kill the circuit on the prerender-to-interactive handoff + // (dotnet/aspnetcore#65101, see docs/prerender-flash-fix.md). The interactive circuit re-fetches + // instead; the delay-gated spinner above keeps that re-fetch flash-free when it's fast. + private WishlistDto? Data { get; set; } + private Tab _tab = Tab.Movies; private bool _showSharePanel; @@ -120,10 +135,18 @@ else private string ShareUrl(WishlistShareDto share) => Navigation.ToAbsoluteUri($"shared/wishlist/{share.Token}").ToString(); - protected override async Task OnInitializedAsync() + protected override Task OnInitializedAsync() => LoadAsync(); + + private async Task LoadAsync() { - _data = await WishlistApi.GetAsync(); + await LoadingIndicator.RunAsync(FetchAsync(), v => _loading = v, StateHasChanged); _loading = false; + _loaded = true; + } + + private async Task FetchAsync() + { + Data = await WishlistApi.GetAsync(); } private void SelectTab(Tab tab) => _tab = tab; @@ -181,10 +204,10 @@ else private List CurrentRows() => _tab switch { - Tab.Movies => WishlistRow.FromMovies(_data.Movies), - Tab.TvShows => WishlistRow.FromTvShows(_data.TvShows), - Tab.Books => WishlistRow.FromBooks(_data.Books), - _ => WishlistRow.FromVideoGames(_data.VideoGames) + Tab.Movies => WishlistRow.FromMovies(Data!.Movies), + Tab.TvShows => WishlistRow.FromTvShows(Data!.TvShows), + Tab.Books => WishlistRow.FromBooks(Data!.Books), + _ => WishlistRow.FromVideoGames(Data!.VideoGames) }; private string EmptyMessage => _tab switch From 59ca6cd45eb0d61405241dcc8162d7c163ab9dda Mon Sep 17 00:00:00 2001 From: Bertrand THOMAS Date: Sat, 18 Jul 2026 17:20:43 +0200 Subject: [PATCH 10/32] Prototype MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Confirmed then — that's the circuit-reuse bug I flagged, not a deployment issue and not something [PersistentState] can fix as currently applied. What's actually happening: the first page you load establishes one SignalR circuit for your whole session. Every later click on an item is an enhanced-nav within that same live circuit — the server prerenders the new page and embeds a fresh persisted-state blob, but attaching that new page's component to an already-running circuit doesn't go through the same restore handshake a fresh circuit boot does. So Movie comes back null, OnParametersSetAsync falls through to LoadAsync(), and you get the full double-fetch and flash again — every time, because "click from list" is the only way anyone actually uses this app. This means [PersistentState] was the wrong tool for this specific case. It only pays off for a page that's the very first thing loaded cold in a tab — real, but not your actual usage pattern. The real fix has to remove the second HTTP round-trip entirely rather than try to make it cheap. Concretely: keep the render-mode boundary on Routes.razor/a shared layout instead of on each individual page (@rendermode InteractiveServer per page is what forces every navigation through a fresh HTTP prerender). If the boundary is set once at the root, item clicks become a normal in-circuit Blazor route change — no second HTTP request, no re-prerender, no restore handshake to fail. That's a bigger, riskier change than what's on this branch (it affects every page, not just detail pages), so I don't want to just start rewriting it silently. Why it can't be scoped narrower: true in-circuit navigation (no HTTP round-trip at all between pages) requires the Router itself to be interactive, not just the pages. Right now Routes.razor's is static — that's exactly why every navigation, even between two pages that both say @rendermode InteractiveServer, still goes through a full server round-trip (the Router has to run server-side to resolve each new URL). Attaching the render mode to a shared layout instead of each page wouldn't help — the Router sits above the layout, so it'd still re-execute per click. The collateral effect: making the Router interactive is app-wide, not per-route — everything reachable through it becomes part of one continuous circuit-bound tree. That includes SharedWishlistPage.razor, which is deliberately static with no render mode today (comment right in the file: "no @rendermode... the recipient has no account and needs no interactivity"). An anonymous share-link visitor would start pulling a SignalR circuit too, which was an explicit design choice, not an oversight. --- docs/plan-quick-add.md | 127 ++++++++++++++++++ src/BlazorApp/Components/App.razor | 4 +- .../Components/Import/ImportPage.razor | 1 - .../Inventory/Pages/AlbumDetail.razor | 1 - .../Components/Inventory/Pages/Albums.razor | 1 - .../Inventory/Pages/BookDetail.razor | 1 - .../Components/Inventory/Pages/Books.razor | 1 - .../Inventory/Pages/CarDetail.razor | 1 - .../Components/Inventory/Pages/Cars.razor | 1 - .../Inventory/Pages/HealthProfileDetail.razor | 1 - .../Inventory/Pages/HealthProfiles.razor | 1 - .../Inventory/Pages/HouseDetail.razor | 1 - .../Components/Inventory/Pages/Houses.razor | 1 - .../Inventory/Pages/MovieDetail.razor | 1 - .../Components/Inventory/Pages/Movies.razor | 1 - .../Inventory/Pages/PlaylistDetail.razor | 1 - .../Inventory/Pages/Playlists.razor | 1 - .../Inventory/Pages/TvShowDetail.razor | 1 - .../Components/Inventory/Pages/TvShows.razor | 1 - .../Inventory/Pages/VideoGameDetail.razor | 1 - .../Inventory/Pages/VideoGames.razor | 1 - .../ReferenceDataAdminPage.razor | 1 - .../Components/WatchNext/WatchNextPage.razor | 1 - .../Wishlist/SharedWishlistApp.razor | 27 ++++ .../Wishlist/SharedWishlistPage.razor | 12 +- .../Components/Wishlist/WishlistPage.razor | 1 - src/BlazorApp/GlobalUsings.cs | 2 + src/BlazorApp/Program.cs | 5 + 28 files changed, 171 insertions(+), 28 deletions(-) create mode 100644 docs/plan-quick-add.md create mode 100644 src/BlazorApp/Components/Wishlist/SharedWishlistApp.razor diff --git a/docs/plan-quick-add.md b/docs/plan-quick-add.md new file mode 100644 index 00000000..4d00427c --- /dev/null +++ b/docs/plan-quick-add.md @@ -0,0 +1,127 @@ +# Plan to implement "Quick Add — one-tap capture of any entry" + +## Context + +Recording something new today means navigating to the right list page (or the right parent detail page for car/house/health records) an using that page's own add form/modal — +several taps through a collapsed sidebar on mobile, the primary adoption surface. +The goal: a single "Quick Add" entry point (nav item below Home + Home page button) opening a mobile-first form where the user picks a type, +fills one all-in-one form (including an optional owned copy for media), saves, and lands on the created item's detail page (parent's detail page for records) to review/check reference/refine. + +Decisions confirmed with the owner: + +- 8 types: Movie, TV show, Book, Video game, Album, Car record, House record, Health record (Playlist out). +- Owned copy = for media items (movie, tv show, book, video game, album) collapsed "I own a copy" toggle revealing the copy fields; for games the platform entry is the copy. +- After save: item detail page (media) / parent detail page (records). + +## Design summary + + A new routable page `/add` in a `Components/QuickAdd/` feature folder (WatchNext-style: own layout on kt-* classes, not `InventoryPageBase` — so the record DTOs' required members cause no new() constraint problem). + The selected type lives in the URL (`/add?type=movie`) via `[SupplyParameterFromQuery]`, per the app's URL-state convention — back button returns from form to picker, deep links work. + No WebApi/Domain changes: everything goes through the existing DI-registered API clients; `InventoryApiClientBase.AddAsync` already surfaces the free-tier quota 403 `{error}` text as an exception, and media controllers' `OnCreatedAsync` auto-enrichment fires for free on quick-added items. + + Three extraction refactors come first — they remove exactly the duplication Quick Add would otherwise copy (the CLAUDE.md zero-duplication bar): + + 1. `DateTimeFields` — the ModalDate/ModalTimeText DateOnly+"HH:mm" proxy pair exists twice today (CarDetail.razor ~344-366, HealthProfileDetail.razor ~295-312). + 2. `CarHistoryForm/HouseHistoryForm/HealthRecordForm` — the three record modal form bodies become shared components used by both the detail-page modals and Quick Add. + 3. `IOwnedCopyDto` + `OwnedVersionFields` — the per-copy fields are already duplicated between OwnedVersionsEditor.razor and VideoGameDetail.razor's platform cards; + one shared component fixes that existing duplication and serves Quick Add too. + +## Implementation steps (each independently reviewable) + +1. `DateTimeFields` extraction (pure refactor) + + New `src/BlazorApp/Components/Shared/DateTimeFields.razor`: + the Date `` + free-text "HH:mm" Time input pair, with the tw proxy properties (`TimeOnly.TryParseExact("HH:mm", InvariantCulture)`) moved in verbatim, keeping both doc comments (why a proxy pair; why not ``). + + Parameters: `DateTime Value` + `EventCallback` `ValueChanged` (callers use @bind-Value). + Standardize on one column-width pair (Car uses col-md-3/3, Health col-md-4/3 — negligible visual diff). + + Rewire `CarDetail.razor` and `HealthProfileDetail.razor`; delete the local pairs. + +2. `CarHistoryForm` extraction + + New `src/BlazorApp/Components/Inventory/Shared/CarHistoryForm.razor` — params required `CarHistoryDto` Entry, bool `ShowFuel`, bool `ShowElectric` (flags stay computed by the caller from `CarDto.EnergyType`). + Body = the entire row g-3 from CarDetail's modal (~195-293): DateTimeFields, Mileage, ΔMileage, CarHistoryType button group, refuel-gated fuel/electric/station/full-refill block, Garage else-branch, location, coordinates, Cost, Description. + public static CarHistoryDto NewEntry(string carId) (CarId, HistoryDate = DateTime.Now, EventType = Refuel) moves here so the defaults exist once. + CarDetail keeps its modal chrome, _showModal/IsEditMode/clone-for-edit/SaveModalEntryAsync — per-page edit behaviors Quick Add doesn't need; sharing them would be over-generalization. + +3. HouseHistoryForm + HealthRecordForm + + Same pattern: House (DateOnly date, Cost, HouseEventType group, Provider, Description) and Health (HealthEventType group, DateTimeField, appointment-only Specialty/Practitioner/money fields with the live balance preview — MissingAmount and DescriptionPlaceholder move in as pure functions of Entry; the balance rule stays authoritative in HealthMetricsService). + NewEntry(houseId) / NewEntry(profileId) move in. + Existing data-testid attributes (e.g. practitioner-input, price-input) travel with the markup — health/car smoke tests depend on them. + Deliberately not extracted: a generic enum button-group component (the button rows differ in semantics across pages — per-item value vs. filter vs. clear-on-reclick, a conflation CLAUDE.md documents as a past bug). + +4. IOwnedCopyDto + OwnedVersionFields + + - New src/WebApi.Contracts/Dto/IOwnedCopyDto.cs: CopyType, Price, AcquiredAt, Vendor, Reference. Implemented by OwnedVersionDto and VideoGamePlatformDto (identical existing members; precedent: IReferenceLinkedDto; no Mapperly impact — mappers map members, not interfaces). + - New src/BlazorApp/Components/Inventory/Shared/OwnedVersionFields.razor: params required IOwnedCopyDto Copy, optional EventCallback OnChanged (default no-op for Quick Add's nothing-persists-until-Save flow). Body = Physical/Digital button pair + the four fields (invariant-culture decimal parsing included), keeping data-testid="version-*-input". + - Rewire OwnedVersionsEditor.razor (draft/auto-save/remove/confirm logic and IsEmpty stay) and VideoGameDetail.razor's platform cards (State buttons/playthroughs/fully-completed stay local — only the copy fields move). + +5. Quick Add page — picker + media types + + src/BlazorApp/Components/QuickAdd/QuickAddPage.razor + .razor.cs, @page "/add", [Authorize], same render-mode convention as the other interactive pages. + + - [SupplyParameterFromQuery(Name = "type")] string? Type — movie|tv-show|book|album|video-game|car|house|health. Tile clicks navigate va Navigation.GetUriWithQueryParameters (list-page pattern). + - OnParametersSetAsync: only on an actual Type change, build the fresh draft DTO and clear _error — it also fires on unrelated updates (cascading auth refresh), which must not wipe a half-filled form. + - Picker: tile grid reusing .kt-stat-grid/.kt-stat-tile with NavMenu's vetted text-presentation glyphs (◼ ▭ ▬ ♪ ◆ ◉ ▲ ✚), data-testid="quickadd-type-*". + Movie + TV show always visible; the other six tiles AND their forms inside with the preview-account note in (hiding is UX; the API enforces). + - Media forms (per-type markup local to the page, same convention as the list FormTemplates): + - Movie: Title, Year, "Watched on" (FirstSeenAt, default today for the "just saw it" scenario — visible and clearable, since prefilling marks it Seen), Rating. + - TV show: Title, Year. Book: Title, Author, Year, FirstReadAt default today. Album: Title, Artist, Year (Author/Artist feed Open Library/Discogs auto-resolution). + - Video game: Title, Year, Platform ` + free-text "HH:mm" Time input pair, with the tw proxy properties (`TimeOnly.TryParseExact("HH:mm", InvariantCulture)`) moved in verbatim, keeping both doc comments (why a proxy pair; why not ``). + the Date `` + free-text "HH:mm" Time input pair, with the tw proxy properties (`TimeOnly.TryParseExact("HH:mm", InvariantCulture)`) moved in verbatim, + keeping both doc comments (why a proxy pair; why not ``). Parameters: `DateTime Value` + `EventCallback` `ValueChanged` (callers use @bind-Value). Standardize on one column-width pair (Car uses col-md-3/3, Health col-md-4/3 — negligible visual diff). @@ -45,33 +47,43 @@ Decisions confirmed with the owner: public static CarHistoryDto NewEntry(string carId) (CarId, HistoryDate = DateTime.Now, EventType = Refuel) moves here so the defaults exist once. CarDetail keeps its modal chrome, _showModal/IsEditMode/clone-for-edit/SaveModalEntryAsync — per-page edit behaviors Quick Add doesn't need; sharing them would be over-generalization. -3. HouseHistoryForm + HealthRecordForm +3. `HouseHistoryForm` + `HealthRecordForm` + + Same pattern: House (DateOnly date, Cost, HouseEventType group, Provider, Description) and Health (HealthEventType group, DateTimeField, appointment-only Specialty/Practitioner/money fields with the live balance preview — + MissingAmount and DescriptionPlaceholder move in as pure functions of Entry; the balance rule stays authoritative in HealthMetricsService). - Same pattern: House (DateOnly date, Cost, HouseEventType group, Provider, Description) and Health (HealthEventType group, DateTimeField, appointment-only Specialty/Practitioner/money fields with the live balance preview — MissingAmount and DescriptionPlaceholder move in as pure functions of Entry; the balance rule stays authoritative in HealthMetricsService). NewEntry(houseId) / NewEntry(profileId) move in. + Existing data-testid attributes (e.g. practitioner-input, price-input) travel with the markup — health/car smoke tests depend on them. + Deliberately not extracted: a generic enum button-group component (the button rows differ in semantics across pages — per-item value vs. filter vs. clear-on-reclick, a conflation CLAUDE.md documents as a past bug). -4. IOwnedCopyDto + OwnedVersionFields +4. `IOwnedCopyDto` + `OwnedVersionFields` - - New src/WebApi.Contracts/Dto/IOwnedCopyDto.cs: CopyType, Price, AcquiredAt, Vendor, Reference. Implemented by OwnedVersionDto and VideoGamePlatformDto (identical existing members; precedent: IReferenceLinkedDto; no Mapperly impact — mappers map members, not interfaces). - - New src/BlazorApp/Components/Inventory/Shared/OwnedVersionFields.razor: params required IOwnedCopyDto Copy, optional EventCallback OnChanged (default no-op for Quick Add's nothing-persists-until-Save flow). Body = Physical/Digital button pair + the four fields (invariant-culture decimal parsing included), keeping data-testid="version-*-input". - - Rewire OwnedVersionsEditor.razor (draft/auto-save/remove/confirm logic and IsEmpty stay) and VideoGameDetail.razor's platform cards (State buttons/playthroughs/fully-completed stay local — only the copy fields move). + - New `src/WebApi.Contracts/Dto/IOwnedCopyDto.cs`: + CopyType, Price, AcquiredAt, Vendor, Reference. Implemented by OwnedVersionDto and VideoGamePlatformDto (identical existing members; precedent: IReferenceLinkedDto; no Mapperly impact — mappers map members, not interfaces). + - New `src/BlazorApp/Components/Inventory/Shared/OwnedVersionFields.razor`: + params required IOwnedCopyDto Copy, optional EventCallback OnChanged (default no-op for Quick Add's nothing-persists-until-Save flow). + Body = Physical/Digital button pair + the four fields (invariant-culture decimal parsing included), keeping data-testid="version-*-input". + - Rewire `OwnedVersionsEditor.razor` (draft/auto-save/remove/confirm logic and IsEmpty stay) and `VideoGameDetail.razor`'s platform cards (State buttons/playthroughs/fully-completed stay local — only the copy fields move). 5. Quick Add page — picker + media types - src/BlazorApp/Components/QuickAdd/QuickAddPage.razor + .razor.cs, @page "/add", [Authorize], same render-mode convention as the other interactive pages. + `src/BlazorApp/Components/QuickAdd/QuickAddPage.razor` + .razor.cs, `@page "/add"`, `[Authorize]`, same render-mode convention as the other interactive pages. - - [SupplyParameterFromQuery(Name = "type")] string? Type — movie|tv-show|book|album|video-game|car|house|health. Tile clicks navigate va Navigation.GetUriWithQueryParameters (list-page pattern). - - OnParametersSetAsync: only on an actual Type change, build the fresh draft DTO and clear _error — it also fires on unrelated updates (cascading auth refresh), which must not wipe a half-filled form. - - Picker: tile grid reusing .kt-stat-grid/.kt-stat-tile with NavMenu's vetted text-presentation glyphs (◼ ▭ ▬ ♪ ◆ ◉ ▲ ✚), data-testid="quickadd-type-*". + - `[SupplyParameterFromQuery(Name = "type")] string?` Type — movie|tv-show|book|album|video-game|car|house|health. + Tile clicks navigate va Navigation.GetUriWithQueryParameters (list-page pattern). + - `OnParametersSetAsync`: only on an actual Type change, build the fresh draft DTO and clear _error — it also fires on unrelated updates (cascading auth refresh), which must not wipe a half-filled form. + - `Picker`: tile grid reusing `.kt-stat-grid`/`.kt-stat-tile` with NavMenu's vetted text-presentation glyphs (◼ ▭ ▬ ♪ ◆ ◉ ▲ ✚), `data-testid="quickadd-type-*"`. Movie + TV show always visible; the other six tiles AND their forms inside with the preview-account note in (hiding is UX; the API enforces). - Media forms (per-type markup local to the page, same convention as the list FormTemplates): - Movie: Title, Year, "Watched on" (FirstSeenAt, default today for the "just saw it" scenario — visible and clearable, since prefilling marks it Seen), Rating. - TV show: Title, Year. Book: Title, Author, Year, FirstReadAt default today. Album: Title, Artist, Year (Author/Artist feed Open Library/Discogs auto-resolution). - Video game: Title, Year, Platform -
-
- - -
+
@@ -337,33 +328,6 @@ else 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; diff --git a/src/BlazorApp/Components/Inventory/Pages/HealthProfileDetail.razor b/src/BlazorApp/Components/Inventory/Pages/HealthProfileDetail.razor index 000714a2..c03fc042 100644 --- a/src/BlazorApp/Components/Inventory/Pages/HealthProfileDetail.razor +++ b/src/BlazorApp/Components/Inventory/Pages/HealthProfileDetail.razor @@ -1,6 +1,5 @@ @page "/health/{Id}" @attribute [Authorize] -@using System.Globalization @if (!_loaded) { @@ -144,15 +143,7 @@ else }
-
- - -
-
- - -
+ @if (_modalEntry.EventType == HealthEventType.Appointment) {
@@ -289,28 +280,6 @@ else EventType = HealthEventType.Appointment }; - /// Separate Date/Time proxies over the single _modalEntry.HistoryDate - see CarDetail.razor's - /// identical pair for why a plain @bind to HistoryDate would lose the time on every date change. - private DateOnly ModalDate - { - get => DateOnly.FromDateTime(_modalEntry.HistoryDate); - set => _modalEntry.HistoryDate = value.ToDateTime(TimeOnly.FromDateTime(_modalEntry.HistoryDate)); - } - - /// Plain "HH:mm" text instead of <input type="time"> - see CarDetail.razor's ModalTimeText - /// for why the native picker can't be forced to 24h. - private string ModalTimeText - { - get => TimeOnly.FromDateTime(_modalEntry.HistoryDate).ToString("HH:mm"); - set - { - if (TimeOnly.TryParseExact(value, "HH:mm", CultureInfo.InvariantCulture, DateTimeStyles.None, out var time)) - { - _modalEntry.HistoryDate = DateOnly.FromDateTime(_modalEntry.HistoryDate).ToDateTime(time); - } - } - } - private async Task LoadAsync() { await LoadingIndicator.RunAsync(FetchAsync(), v => _loading = v, StateHasChanged); diff --git a/src/BlazorApp/Components/Shared/DateTimeFields.razor b/src/BlazorApp/Components/Shared/DateTimeFields.razor new file mode 100644 index 00000000..20718fbb --- /dev/null +++ b/src/BlazorApp/Components/Shared/DateTimeFields.razor @@ -0,0 +1,47 @@ +@using System.Globalization + +@* Date + time input pair proxying a single DateTime value via two separate fields, so editing one + doesn't reset the other back to midnight - a plain @bind straight to a DateTime input would lose the + time on every date change (its built-in converter always parses a bare yyyy-MM-dd). *@ + +
+ + +
+
+ + @* A plain text "HH:mm" field rather than - 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. *@ + +
+ +@code { + [Parameter] public DateTime Value { get; set; } + + [Parameter] public EventCallback ValueChanged { get; set; } + + private DateOnly DateValue => DateOnly.FromDateTime(Value); + + private string TimeText => TimeOnly.FromDateTime(Value).ToString("HH:mm"); + + private Task SetDateAsync(DateOnly value) => UpdateAsync(value.ToDateTime(TimeOnly.FromDateTime(Value))); + + private Task SetTimeTextAsync(string value) + { + if (TimeOnly.TryParseExact(value, "HH:mm", CultureInfo.InvariantCulture, DateTimeStyles.None, out var time)) + { + return UpdateAsync(DateOnly.FromDateTime(Value).ToDateTime(time)); + } + + return Task.CompletedTask; + } + + private Task UpdateAsync(DateTime value) + { + Value = value; + return ValueChanged.InvokeAsync(value); + } +} From cb0ad254cfe06721edce26def10b51863f97c9aa Mon Sep 17 00:00:00 2001 From: Bertrand THOMAS Date: Sat, 18 Jul 2026 20:40:53 +0200 Subject: [PATCH 14/32] CarHistoryForm extraction (Quick Add plan, step 2) Extracts CarDetail.razor's history modal field body into a shared Components/Inventory/Shared/CarHistoryForm.razor component (Entry/ShowFuel/ShowElectric params, plus the NewEntry factory), so Quick Add's car-record form reuses it instead of duplicating the field layout. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01UxPGSjNnoa6Ltuk4pHdFaw --- .../Inventory/Pages/CarDetail.razor | 103 +--------------- .../Inventory/Shared/CarHistoryForm.razor | 112 ++++++++++++++++++ 2 files changed, 115 insertions(+), 100 deletions(-) create mode 100644 src/BlazorApp/Components/Inventory/Shared/CarHistoryForm.razor diff --git a/src/BlazorApp/Components/Inventory/Pages/CarDetail.razor b/src/BlazorApp/Components/Inventory/Pages/CarDetail.razor index 6045c6f3..9f1e466e 100644 --- a/src/BlazorApp/Components/Inventory/Pages/CarDetail.razor +++ b/src/BlazorApp/Components/Inventory/Pages/CarDetail.razor @@ -190,97 +190,7 @@ else
-
- -
- - -
-
- - -
-
- -
- @foreach (var eventType in Enum.GetValues()) - { - - } -
-
- @if (_modalEntry.EventType == CarHistoryType.Refuel) - { - @if (ShowFuel) - { -
- - -
-
- - -
- } - @if (ShowElectric) - { -
- - -
-
- - -
- } -
- - -
-
-
- - -
-
- } - else - { -
- - -
- } - -
- - -
-
- - -
-
- - -
-
- -
- - -
-
-
- - -
-
- - -
-
+
-
-
- -
- @foreach (var eventType in Enum.GetValues()) - { - - } -
-
- - @if (_modalEntry.EventType == HealthEventType.Appointment) - { -
- - -
-
- - @* data-testid: the label/input pair has no for/id association, so GetByLabel - can't resolve it in e2e tests - same as every inventory Add form *@ - -
-
- - -
-
- - -
-
- - -
-
- - -
- @if (_modalEntry.Price is > 0) - { - var missing = ModalMissingAmount; -
- @if (Math.Abs(missing) <= 0.005) - { - ✓ Balanced - paid, reimbursements and not-covered add up to zero. - } - else - { - - @missing.ToString("F2") € @(missing > 0 ? "still unaccounted for - this entry will appear under \"Reimbursements to check\"." : "received beyond the price - worth a look.") - - } -
- } - } -
- - -
-
- - -
-
+
-
-
- - -
-
- - -
-
- -
- @foreach (var eventType in Enum.GetValues()) - { - - } -
-
-
- - -
-
- - -
-
+
- + } else if (Type == "album") { @@ -184,7 +180,7 @@ else }
- + } else if (Type == "video-game") { @@ -216,7 +212,7 @@ else }
- + } else if (Type == "car") { @@ -238,7 +234,7 @@ else { @if (_cars.Count > 1) { -
+
@foreach (var car in _cars) { @@ -248,7 +244,7 @@ else
- + } } else if (Type == "house") @@ -271,7 +267,7 @@ else { @if (_houses.Count > 1) { -
+
@foreach (var house in _houses) { @@ -281,7 +277,7 @@ else
- + } } else if (Type == "health") @@ -304,7 +300,7 @@ else { @if (_healthProfiles.Count > 1) { -
+
@foreach (var profile in _healthProfiles) { @@ -314,7 +310,7 @@ else
- + } } diff --git a/src/BlazorApp/Components/QuickAdd/QuickAddPage.razor.cs b/src/BlazorApp/Components/QuickAdd/QuickAddPage.razor.cs index dad29114..017f7a38 100644 --- a/src/BlazorApp/Components/QuickAdd/QuickAddPage.razor.cs +++ b/src/BlazorApp/Components/QuickAdd/QuickAddPage.razor.cs @@ -118,9 +118,7 @@ protected override async Task OnParametersSetAsync() switch (Type) { case "movie": - // defaults to today for the "just saw it" scenario - visible and clearable, since - // prefilling it marks the movie Seen - _movie = new MovieDto { FirstSeenAt = DateOnly.FromDateTime(DateTime.Today) }; + _movie = new MovieDto(); _movieOwned = false; _movieOwnedDraft = new OwnedVersionDto(); break; @@ -130,7 +128,7 @@ protected override async Task OnParametersSetAsync() _tvShowOwnedDraft = new OwnedVersionDto(); break; case "book": - _book = new BookDto { FirstReadAt = DateOnly.FromDateTime(DateTime.Today) }; + _book = new BookDto(); _bookOwned = false; _bookOwnedDraft = new OwnedVersionDto(); break; diff --git a/src/BlazorApp/wwwroot/app.css b/src/BlazorApp/wwwroot/app.css index fcfad243..ba38720b 100644 --- a/src/BlazorApp/wwwroot/app.css +++ b/src/BlazorApp/wwwroot/app.css @@ -472,6 +472,14 @@ a.kt-item-row { color: inherit; text-decoration: none; } } .kt-quickadd-back:hover { color: var(--kt-text); } +/* Equal-width segmented button rows: event-type toggles (CarHistoryForm/HouseHistoryForm/HealthRecordForm) + and Quick Add's own parent-selector rows (car/house/health-profile) - each button's default width comes + from its own text length, which reads as arbitrary/lopsided for a set of options that are otherwise + equal choices. flex-basis:0 with equal grow/shrink divides the row evenly regardless of label length; + a still-too-long label (e.g. "Installation") is allowed to keep its own min-content width instead of + being squashed unreadable, which is why this isn't a plain CSS grid instead. */ +.kt-btn-row-even > button { flex: 1 1 0; } + /* 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 @@ -918,9 +926,12 @@ a.kt-item-row { color: inherit; text-decoration: none; } /* Quick Add: the type picker forces 2 columns rather than kt-stat-grid's own auto-fit (which can settle on 3 narrow columns on some phone widths), and every form's Save button goes full-width - there's no - sticky save bar in this first version since these forms are short enough not to need one. */ + sticky save bar in this first version since these forms are short enough not to need one. + Scoped to .kt-quickadd-save-btn specifically, not a bare .btn-primary - the event-type/parent-selector + toggle rows also turn btn-primary on whichever option is selected, and a blanket selector previously + stretched whichever one of those happened to be active to full width too. */ .kt-quickadd-picker { grid-template-columns: repeat(2, 1fr); } - .kt-quickadd .btn-primary { width: 100%; } + .kt-quickadd-save-btn { width: 100%; } } @media (min-width: 768px) and (max-width: 1024px) { From 1f66b4b8b014600f9cd097f736c9f879b1d2e356 Mon Sep 17 00:00:00 2001 From: Bertrand THOMAS Date: Sat, 18 Jul 2026 23:26:14 +0200 Subject: [PATCH 23/32] Quick Add: per-form titles; drop Home's secondary CTA link Adds a "New " heading (New movie, New TV show, New car record, etc.) above each Quick Add form, right below the "choose a different type" link, so it's clear at a glance which form is showing. Also reflects the owner's own edits: Home's empty-state CTA drops the "Go to my movies" secondary link (didn't display well next to the primary Quick Add button), plus minor reformatting in NavMenu.razor and the Playwright PageBase.cs doc comments. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01UxPGSjNnoa6Ltuk4pHdFaw --- src/BlazorApp/Components/Layout/NavMenu.razor | 8 ++--- src/BlazorApp/Components/Pages/Home.razor | 25 ++++++--------- .../Components/QuickAdd/QuickAddPage.razor | 2 ++ .../Components/QuickAdd/QuickAddPage.razor.cs | 13 ++++++++ .../Pages/PageBase.cs | 32 ++++++++----------- 5 files changed, 40 insertions(+), 40 deletions(-) diff --git a/src/BlazorApp/Components/Layout/NavMenu.razor b/src/BlazorApp/Components/Layout/NavMenu.razor index 93d2865c..a5a1bcd2 100644 --- a/src/BlazorApp/Components/Layout/NavMenu.razor +++ b/src/BlazorApp/Components/Layout/NavMenu.razor @@ -39,9 +39,8 @@ TV shows
- @* Free preview accounts (no Firebase "member"/"admin" role) only get movies and TV shows - - the two links above sit outside this block on purpose. Hiding is UX, not security: - the API enforces the same policy on every request. *@ + @* Free preview accounts (no Firebase "member"/"admin" role) only get movies and TV shows - the two links above sit outside this block on purpose. + Hiding is UX, not security: the API enforces the same policy on every request. *@
- - - @if (!string.IsNullOrEmpty(Reference?.ArtistImageUrl)) - { -

Artist

- - } - @if (Reference?.Tracks.Count > 0) {

Tracklist

@@ -124,6 +112,14 @@ else }
} + + + + @if (!string.IsNullOrEmpty(Reference?.ArtistImageUrl)) + { +

Artist

+ + } } @code { From d7e7e0cdb74ca54c439a7a9f62c93f08ba013cab Mon Sep 17 00:00:00 2001 From: Bertrand THOMAS Date: Sun, 19 Jul 2026 00:58:57 +0200 Subject: [PATCH 25/32] Improve UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. Mobile menu auto-close — recreated Keeptrack.BlazorApp.lib.module.js hooking enhancedload to uncheck #nav-toggle. 2. Unseen/Unread filters — added to Movie/Book lists, repos, and mappers. 3. New sorts — Movie "Last seen", Book "Last read", Video Game "Last completed" (backed by a genuinely new CompletedAt field on platform entries, auto-populated when state is set to "Completed"). 4. Modal safety — House/Car/Health history modals no longer close on outside click/drag; Cancel/✕ now warn via ConfirmModal if the draft has unsaved changes (new DirtyTracking helper). 5. Health yearly cost review — now newest year first. 6. Car — maintenance-overdue check removed; "Last record" per event type added (mirroring Health's "Last seen"). 7. House — same "Last record" per event type added. 8. Wishlist filter buttons — removed from Movie/Book/TvShow/VideoGame list pages (flag, badge, /wishlist page untouched). 9. Health/House/Car lists — now default-sort by name. --- .../Components/Inventory/InventoryPageBase.cs | 9 ++- .../Components/Inventory/Pages/Books.razor | 6 +- .../Components/Inventory/Pages/Books.razor.cs | 6 +- .../Inventory/Pages/CarDetail.razor | 62 ++++++++++++++----- .../Components/Inventory/Pages/Cars.razor.cs | 2 + .../Inventory/Pages/HealthProfileDetail.razor | 42 ++++++++++--- .../Inventory/Pages/HealthProfiles.razor.cs | 3 + .../Inventory/Pages/HouseDetail.razor | 50 +++++++++++++-- .../Inventory/Pages/Houses.razor.cs | 2 + .../Components/Inventory/Pages/Movies.razor | 6 +- .../Inventory/Pages/Movies.razor.cs | 6 +- .../Components/Inventory/Pages/TvShows.razor | 1 - .../Inventory/Pages/TvShows.razor.cs | 4 -- .../Inventory/Pages/VideoGameDetail.razor | 14 ++++- .../Inventory/Pages/VideoGames.razor | 5 +- .../Inventory/Pages/VideoGames.razor.cs | 4 -- .../Inventory/Shared/InventoryList.razor | 12 ++++ src/BlazorApp/Components/Layout/NavMenu.razor | 26 +++++++- .../Components/Shared/DirtyTracking.cs | 15 +++++ src/Common.System/ListSort.cs | 9 +++ src/Domain/Models/BookModel.cs | 6 ++ src/Domain/Models/CarMetricsModel.cs | 14 ++++- src/Domain/Models/HouseMetricsModel.cs | 14 +++++ src/Domain/Models/MovieModel.cs | 6 ++ src/Domain/Models/NextMaintenanceModel.cs | 16 ----- src/Domain/Models/VideoGamePlatformModel.cs | 6 ++ src/Domain/Services/CarMetricsService.cs | 37 ++++------- src/Domain/Services/HealthMetricsService.cs | 2 +- src/Domain/Services/HouseMetricsService.cs | 19 +++++- .../Entities/VideoGamePlatform.cs | 3 + .../Mappers/BookStorageMapper.cs | 2 + .../Mappers/MovieStorageMapper.cs | 2 + .../Repositories/BookRepository.cs | 5 ++ .../Repositories/MongoDbRepositoryBase.cs | 15 ++++- .../Repositories/MovieRepository.cs | 5 ++ .../Repositories/TvShowRepository.cs | 2 + .../Repositories/VideoGameRepository.cs | 15 +++++ src/WebApi.Contracts/Dto/BookDto.cs | 6 ++ src/WebApi.Contracts/Dto/CarMetricsDto.cs | 23 ++++++- src/WebApi.Contracts/Dto/HouseMetricsDto.cs | 24 ++++++- src/WebApi.Contracts/Dto/MovieDto.cs | 6 ++ .../Dto/NextMaintenanceDto.cs | 24 ------- .../Dto/VideoGamePlatformDto.cs | 6 ++ src/WebApi/Mappers/CarMetricsDtoMapper.cs | 7 ++- .../Resources/BookResourceTest.cs | 1 + .../Resources/CarResourceTest.cs | 2 +- .../Resources/MovieResourceTest.cs | 1 + .../Resources/TvShowResourceTest.cs | 1 + .../Resources/VideoGameResourceTest.cs | 1 + .../Services/CarMetricsServiceTest.cs | 40 ++++-------- .../Services/HealthMetricsServiceTest.cs | 14 ++--- .../Services/HouseMetricsServiceTest.cs | 19 ++++++ 52 files changed, 458 insertions(+), 170 deletions(-) create mode 100644 src/BlazorApp/Components/Shared/DirtyTracking.cs delete mode 100644 src/Domain/Models/NextMaintenanceModel.cs delete mode 100644 src/WebApi.Contracts/Dto/NextMaintenanceDto.cs diff --git a/src/BlazorApp/Components/Inventory/InventoryPageBase.cs b/src/BlazorApp/Components/Inventory/InventoryPageBase.cs index ae695df1..587fb01e 100644 --- a/src/BlazorApp/Components/Inventory/InventoryPageBase.cs +++ b/src/BlazorApp/Components/Inventory/InventoryPageBase.cs @@ -81,6 +81,13 @@ public abstract class InventoryPageBase : ComponentBase /// protected virtual IReadOnlyDictionary? ExtraQuery => null; + /// + /// The sort key used when the URL carries none - "" (newest-first) for every page unless overridden. + /// Health/House/Car list themselves by person/vehicle/property name rather than creation order, so + /// their pages override this to instead. + /// + protected virtual string DefaultSort => ""; + /// /// Runs on the initial load and again whenever the router supplies new query-parameter values /// (a filter/page click's NavigateTo, but also browser back/forward), so every way of changing @@ -90,7 +97,7 @@ public abstract class InventoryPageBase : ComponentBase protected override async Task OnParametersSetAsync() { _search = SearchQuery ?? ""; - _sort = SortQuery ?? ""; + _sort = SortQuery ?? DefaultSort; _page = PageQuery is > 0 ? PageQuery.Value : 1; var query = BuildQuerySignature(); diff --git a/src/BlazorApp/Components/Inventory/Pages/Books.razor b/src/BlazorApp/Components/Inventory/Pages/Books.razor index 6f3189be..6693cd38 100644 --- a/src/BlazorApp/Components/Inventory/Pages/Books.razor +++ b/src/BlazorApp/Components/Inventory/Pages/Books.razor @@ -28,11 +28,13 @@ OnSearchChanged="@OnSearchChanged" Sort="@_sort" OnSortChanged="@SetSort" - HasRatingSort="true"> + HasRatingSort="true" + ExtraSortValue="@ListSort.LastRead" + ExtraSortLabel="Read"> - + @if (book.Year > 0) diff --git a/src/BlazorApp/Components/Inventory/Pages/Books.razor.cs b/src/BlazorApp/Components/Inventory/Pages/Books.razor.cs index f6caa092..645eab3c 100644 --- a/src/BlazorApp/Components/Inventory/Pages/Books.razor.cs +++ b/src/BlazorApp/Components/Inventory/Pages/Books.razor.cs @@ -17,8 +17,8 @@ public partial class Books : InventoryPageBase [SupplyParameterFromQuery(Name = "owned")] public bool OwnedFilter { get; set; } - [SupplyParameterFromQuery(Name = "wishlisted")] - public bool WishlistedFilter { get; set; } + [SupplyParameterFromQuery(Name = "unread")] + public bool UnreadFilter { get; set; } protected override IReadOnlyDictionary? ExtraQuery { @@ -27,7 +27,7 @@ protected override IReadOnlyDictionary? ExtraQuery var query = new Dictionary(); if (FavoriteFilter) query["IsFavorite"] = "true"; if (OwnedFilter) query["IsOwned"] = "true"; - if (WishlistedFilter) query["IsWishlisted"] = "true"; + if (UnreadFilter) query["IsUnread"] = "true"; return query.Count > 0 ? query : null; } } diff --git a/src/BlazorApp/Components/Inventory/Pages/CarDetail.razor b/src/BlazorApp/Components/Inventory/Pages/CarDetail.razor index 9f1e466e..6be383fd 100644 --- a/src/BlazorApp/Components/Inventory/Pages/CarDetail.razor +++ b/src/BlazorApp/Components/Inventory/Pages/CarDetail.razor @@ -64,15 +64,6 @@ else var metrics = Metrics!;

Metrics

- @if (metrics.NextMaintenance is { } next) - { -

- @(next.MonthsRemaining < 0 - ? $"Maintenance overdue by {-next.MonthsRemaining} month(s) - last done {next.LastMaintenanceDate:yyyy-MM-dd}." - : $"Next maintenance due in {next.MonthsRemaining} month(s) ({next.DueDate:yyyy-MM-dd}).") -

- } - @if (metrics.MileageWarnings.Count > 0) {

@@ -123,6 +114,20 @@ else

} + @if (Metrics is { LastRecords.Count: > 0 }) + { +

Last record

+
+ @foreach (var record in Metrics.LastRecords) + { +
+ @record.EventType + @record.LastDate.ToString("yyyy-MM-dd") +
+ } +
+ } +

History

@@ -183,22 +188,28 @@ else @if (_showModal) { -
-
+
+
@(IsEditMode ? "Edit" : "Add") history entry
- +
} + + } @code { @@ -233,7 +244,9 @@ else private List _years = []; private int _selectedYear; private CarHistoryDto _modalEntry = CarHistoryForm.NewEntry(""); + private CarHistoryDto _pristineModalEntry = CarHistoryForm.NewEntry(""); private bool _showModal; + private bool _showDiscardConfirm; private Dictionary _warningsByEntryId = []; private bool IsEditMode => !string.IsNullOrEmpty(_modalEntry.Id); @@ -249,8 +262,7 @@ else Metrics.FuelConsumption.Count > 0 || Metrics.ElectricConsumption.Count > 0 || Metrics.CostHistory.Count > 0 || - Metrics.MileageWarnings.Count > 0 || - Metrics.NextMaintenance is not null); + Metrics.MileageWarnings.Count > 0); protected override async Task OnParametersSetAsync() { @@ -353,6 +365,7 @@ else private void ShowAddModal() { _modalEntry = CarHistoryForm.NewEntry(Id); + _pristineModalEntry = CloneEntry(_modalEntry); _showModal = true; } @@ -361,10 +374,27 @@ else private void ShowEditModal(CarHistoryDto entry) { _modalEntry = CloneEntry(entry); + _pristineModalEntry = CloneEntry(entry); _showModal = true; } - private void CancelModal() => _showModal = false; + /// + /// A click/drag outside the modal no longer closes it (see the backdrop markup above) - only these + /// explicit Cancel/✕ buttons can, and they route through this unsaved-data check first. + /// + private void RequestCancelModal() + { + if (DirtyTracking.IsDirty(_pristineModalEntry, _modalEntry)) _showDiscardConfirm = true; + else _showModal = false; + } + + private void ConfirmDiscard() + { + _showDiscardConfirm = false; + _showModal = false; + } + + private void CancelDiscard() => _showDiscardConfirm = false; /// /// Every history mutation reloads the whole car (history + metrics) rather than patching local state - diff --git a/src/BlazorApp/Components/Inventory/Pages/Cars.razor.cs b/src/BlazorApp/Components/Inventory/Pages/Cars.razor.cs index 009cf3fd..731baa74 100644 --- a/src/BlazorApp/Components/Inventory/Pages/Cars.razor.cs +++ b/src/BlazorApp/Components/Inventory/Pages/Cars.razor.cs @@ -1,3 +1,4 @@ +using Keeptrack.Common.System; using Keeptrack.WebApi.Contracts.Dto; using Microsoft.AspNetCore.Components; @@ -11,4 +12,5 @@ public partial class Cars : InventoryPageBase protected override string ListRoute => "/cars"; + protected override string DefaultSort => ListSort.Title; } diff --git a/src/BlazorApp/Components/Inventory/Pages/HealthProfileDetail.razor b/src/BlazorApp/Components/Inventory/Pages/HealthProfileDetail.razor index 32fedf57..92722a42 100644 --- a/src/BlazorApp/Components/Inventory/Pages/HealthProfileDetail.razor +++ b/src/BlazorApp/Components/Inventory/Pages/HealthProfileDetail.razor @@ -33,9 +33,9 @@ else
- @* No summary panels before the journal on purpose (owner feedback): the journal is the page's - primary content, and the per-row warning sign is the whole warning surface. "Last seen" is a bare - specialty + last-date list - no names, no counts, no field labels. *@ + @* No summary panels before the journal on purpose (owner feedback): + the journal is the page's primary content, and the per-row warning sign is the whole warning surface. + "Last seen" is a bare specialty + last-date list - no names, no counts, no field labels. *@ @if (Metrics is { LastVisits.Count: > 0 }) {

Last seen

@@ -125,22 +125,28 @@ else @if (_showModal) { -
-
+
+
@(IsEditMode ? "Edit" : "Add") journal entry
- +
} + + } @code { @@ -174,7 +180,9 @@ else private List _years = []; private int _selectedYear; private HealthRecordDto _modalEntry = HealthRecordForm.NewEntry(""); + private HealthRecordDto _pristineModalEntry = HealthRecordForm.NewEntry(""); private bool _showModal; + private bool _showDiscardConfirm; private bool IsEditMode => !string.IsNullOrEmpty(_modalEntry.Id); @@ -253,6 +261,7 @@ else private void ShowAddModal() { _modalEntry = HealthRecordForm.NewEntry(Id); + _pristineModalEntry = CloneEntry(_modalEntry); _showModal = true; } @@ -261,10 +270,27 @@ else private void ShowEditModal(HealthRecordDto entry) { _modalEntry = CloneEntry(entry); + _pristineModalEntry = CloneEntry(entry); _showModal = true; } - private void CancelModal() => _showModal = false; + /// + /// A click/drag outside the modal no longer closes it (see the backdrop markup above) - only these + /// explicit Cancel/✕ buttons can, and they route through this unsaved-data check first. + /// + private void RequestCancelModal() + { + if (DirtyTracking.IsDirty(_pristineModalEntry, _modalEntry)) _showDiscardConfirm = true; + else _showModal = false; + } + + private void ConfirmDiscard() + { + _showDiscardConfirm = false; + _showModal = false; + } + + private void CancelDiscard() => _showDiscardConfirm = false; /// Every journal mutation reloads the whole profile (journal + metrics) rather than patching /// local state - an edit can change the yearly totals, the row badges and the last-visit table, so diff --git a/src/BlazorApp/Components/Inventory/Pages/HealthProfiles.razor.cs b/src/BlazorApp/Components/Inventory/Pages/HealthProfiles.razor.cs index 5c6d98fd..7fda0e0e 100644 --- a/src/BlazorApp/Components/Inventory/Pages/HealthProfiles.razor.cs +++ b/src/BlazorApp/Components/Inventory/Pages/HealthProfiles.razor.cs @@ -1,3 +1,4 @@ +using Keeptrack.Common.System; using Keeptrack.WebApi.Contracts.Dto; using Microsoft.AspNetCore.Components; @@ -10,4 +11,6 @@ public partial class HealthProfiles : InventoryPageBase protected override InventoryApiClientBase Api => HealthProfileApi; protected override string ListRoute => "/health"; + + protected override string DefaultSort => ListSort.Title; } diff --git a/src/BlazorApp/Components/Inventory/Pages/HouseDetail.razor b/src/BlazorApp/Components/Inventory/Pages/HouseDetail.razor index 8e934e75..8550d6ea 100644 --- a/src/BlazorApp/Components/Inventory/Pages/HouseDetail.razor +++ b/src/BlazorApp/Components/Inventory/Pages/HouseDetail.razor @@ -94,6 +94,20 @@ else
} + @if (Metrics is { LastRecords.Count: > 0 }) + { +

Last record

+
+ @foreach (var record in Metrics.LastRecords) + { +
+ @record.EventType + @record.LastDate.ToString("yyyy-MM-dd") +
+ } +
+ } +

History

@@ -149,22 +163,28 @@ else @if (_showModal) { -
-
+
+
@(IsEditMode ? "Edit" : "Add") history entry
- +
} + + } @code { @@ -198,7 +218,9 @@ else private List _years = []; private int _selectedYear; private HouseHistoryDto _modalEntry = HouseHistoryForm.NewEntry(""); + private HouseHistoryDto _pristineModalEntry = HouseHistoryForm.NewEntry(""); private bool _showModal; + private bool _showDiscardConfirm; private bool IsEditMode => !string.IsNullOrEmpty(_modalEntry.Id); @@ -305,6 +327,7 @@ else private void ShowAddModal() { _modalEntry = HouseHistoryForm.NewEntry(Id); + _pristineModalEntry = CloneEntry(_modalEntry); _showModal = true; } @@ -313,10 +336,27 @@ else private void ShowEditModal(HouseHistoryDto entry) { _modalEntry = CloneEntry(entry); + _pristineModalEntry = CloneEntry(entry); _showModal = true; } - private void CancelModal() => _showModal = false; + /// + /// A click/drag outside the modal no longer closes it (see the backdrop markup above) - only these + /// explicit Cancel/✕ buttons can, and they route through this unsaved-data check first. + /// + private void RequestCancelModal() + { + if (DirtyTracking.IsDirty(_pristineModalEntry, _modalEntry)) _showDiscardConfirm = true; + else _showModal = false; + } + + private void ConfirmDiscard() + { + _showDiscardConfirm = false; + _showModal = false; + } + + private void CancelDiscard() => _showDiscardConfirm = 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 diff --git a/src/BlazorApp/Components/Inventory/Pages/Houses.razor.cs b/src/BlazorApp/Components/Inventory/Pages/Houses.razor.cs index b975bf53..2f5c492c 100644 --- a/src/BlazorApp/Components/Inventory/Pages/Houses.razor.cs +++ b/src/BlazorApp/Components/Inventory/Pages/Houses.razor.cs @@ -1,3 +1,4 @@ +using Keeptrack.Common.System; using Keeptrack.WebApi.Contracts.Dto; using Microsoft.AspNetCore.Components; @@ -11,4 +12,5 @@ public partial class Houses : InventoryPageBase protected override string ListRoute => "/houses"; + protected override string DefaultSort => ListSort.Title; } diff --git a/src/BlazorApp/Components/Inventory/Pages/Movies.razor b/src/BlazorApp/Components/Inventory/Pages/Movies.razor index e9f2edc7..10453724 100644 --- a/src/BlazorApp/Components/Inventory/Pages/Movies.razor +++ b/src/BlazorApp/Components/Inventory/Pages/Movies.razor @@ -28,11 +28,13 @@ OnSearchChanged="@OnSearchChanged" Sort="@_sort" OnSortChanged="@SetSort" - HasRatingSort="true"> + HasRatingSort="true" + ExtraSortValue="@ListSort.LastSeen" + ExtraSortLabel="Seen"> - + @if (movie.Year > 0) diff --git a/src/BlazorApp/Components/Inventory/Pages/Movies.razor.cs b/src/BlazorApp/Components/Inventory/Pages/Movies.razor.cs index 68b76136..48f2c3d3 100644 --- a/src/BlazorApp/Components/Inventory/Pages/Movies.razor.cs +++ b/src/BlazorApp/Components/Inventory/Pages/Movies.razor.cs @@ -17,8 +17,8 @@ public partial class Movies : InventoryPageBase [SupplyParameterFromQuery(Name = "owned")] public bool OwnedFilter { get; set; } - [SupplyParameterFromQuery(Name = "wishlisted")] - public bool WishlistedFilter { get; set; } + [SupplyParameterFromQuery(Name = "unseen")] + public bool UnseenFilter { get; set; } protected override IReadOnlyDictionary? ExtraQuery { @@ -27,7 +27,7 @@ protected override IReadOnlyDictionary? ExtraQuery var query = new Dictionary(); if (FavoriteFilter) query["IsFavorite"] = "true"; if (OwnedFilter) query["IsOwned"] = "true"; - if (WishlistedFilter) query["IsWishlisted"] = "true"; + if (UnseenFilter) query["IsUnseen"] = "true"; return query.Count > 0 ? query : null; } } diff --git a/src/BlazorApp/Components/Inventory/Pages/TvShows.razor b/src/BlazorApp/Components/Inventory/Pages/TvShows.razor index 4734f546..59e67db7 100644 --- a/src/BlazorApp/Components/Inventory/Pages/TvShows.razor +++ b/src/BlazorApp/Components/Inventory/Pages/TvShows.razor @@ -36,7 +36,6 @@ - @if (show.Year > 0) diff --git a/src/BlazorApp/Components/Inventory/Pages/TvShows.razor.cs b/src/BlazorApp/Components/Inventory/Pages/TvShows.razor.cs index c9fa2b10..1e92a279 100644 --- a/src/BlazorApp/Components/Inventory/Pages/TvShows.razor.cs +++ b/src/BlazorApp/Components/Inventory/Pages/TvShows.razor.cs @@ -20,9 +20,6 @@ public partial class TvShows : InventoryPageBase [SupplyParameterFromQuery(Name = "owned")] public bool OwnedFilter { get; set; } - [SupplyParameterFromQuery(Name = "wishlisted")] - public bool WishlistedFilter { get; set; } - private TvShowStatus? StateFilter => Enum.TryParse(StateQuery, true, out var state) ? state : null; protected override IReadOnlyDictionary? ExtraQuery @@ -33,7 +30,6 @@ protected override IReadOnlyDictionary? ExtraQuery 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; } } diff --git a/src/BlazorApp/Components/Inventory/Pages/VideoGameDetail.razor b/src/BlazorApp/Components/Inventory/Pages/VideoGameDetail.razor index 145f5a86..db804f0b 100644 --- a/src/BlazorApp/Components/Inventory/Pages/VideoGameDetail.razor +++ b/src/BlazorApp/Components/Inventory/Pages/VideoGameDetail.razor @@ -115,11 +115,16 @@ else -
+
@foreach (var state in VideoGames.VideoGameStates) { } + @if (entry.State == "Completed") + { + + }
@@ -339,6 +344,13 @@ else private async Task SetPlatformStateAsync(VideoGamePlatformDto entry, string state) { entry.State = entry.State == state ? "" : state; + if (entry.State == "Completed") entry.CompletedAt ??= DateOnly.FromDateTime(DateTime.Today); + await SaveGameAsync(); + } + + private async Task SetPlatformCompletedDateAsync(VideoGamePlatformDto entry, ChangeEventArgs e) + { + entry.CompletedAt = DateOnly.TryParse(e.Value?.ToString(), out var date) ? date : null; await SaveGameAsync(); } diff --git a/src/BlazorApp/Components/Inventory/Pages/VideoGames.razor b/src/BlazorApp/Components/Inventory/Pages/VideoGames.razor index 2b252275..09755f3f 100644 --- a/src/BlazorApp/Components/Inventory/Pages/VideoGames.razor +++ b/src/BlazorApp/Components/Inventory/Pages/VideoGames.razor @@ -29,7 +29,9 @@ OnSearchChanged="@OnSearchChanged" Sort="@_sort" OnSortChanged="@SetSort" - HasRatingSort="true"> + HasRatingSort="true" + ExtraSortValue="@ListSort.LastCompleted" + ExtraSortLabel="Ended"> @foreach (var state in VideoGameStates) @@ -37,7 +39,6 @@ } - @if (game.Year > 0) diff --git a/src/BlazorApp/Components/Inventory/Pages/VideoGames.razor.cs b/src/BlazorApp/Components/Inventory/Pages/VideoGames.razor.cs index 0c0c17c1..5d512866 100644 --- a/src/BlazorApp/Components/Inventory/Pages/VideoGames.razor.cs +++ b/src/BlazorApp/Components/Inventory/Pages/VideoGames.razor.cs @@ -31,9 +31,6 @@ public partial class VideoGames : InventoryPageBase [SupplyParameterFromQuery(Name = "owned")] public bool OwnedFilter { get; set; } - [SupplyParameterFromQuery(Name = "wishlisted")] - public bool WishlistedFilter { get; set; } - protected override IReadOnlyDictionary? ExtraQuery { get @@ -41,7 +38,6 @@ protected override IReadOnlyDictionary? ExtraQuery 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; } } diff --git a/src/BlazorApp/Components/Inventory/Shared/InventoryList.razor b/src/BlazorApp/Components/Inventory/Shared/InventoryList.razor index 34795094..3aab9215 100644 --- a/src/BlazorApp/Components/Inventory/Shared/InventoryList.razor +++ b/src/BlazorApp/Components/Inventory/Shared/InventoryList.razor @@ -63,6 +63,10 @@ else { } + @if (ExtraSortValue is not null) + { + + } @if (Filters is not null) { @@ -221,6 +225,14 @@ else /// Offers the "Rating" sort option - only for types that carry a rating field. [Parameter] public bool HasRatingSort { get; set; } + + /// + /// Query value for an extra, type-specific sort option (e.g. Movie's "seen", Book's "read", Video + /// Game's "completed") - unset means no extra option renders. Paired with . + /// + [Parameter] public string? ExtraSortValue { get; set; } + + [Parameter] public string? ExtraSortLabel { get; set; } [Parameter] public required int Page { get; set; } [Parameter] public required int TotalPages { get; set; } [Parameter] public required long TotalCount { get; set; } diff --git a/src/BlazorApp/Components/Layout/NavMenu.razor b/src/BlazorApp/Components/Layout/NavMenu.razor index a5a1bcd2..84706af8 100644 --- a/src/BlazorApp/Components/Layout/NavMenu.razor +++ b/src/BlazorApp/Components/Layout/NavMenu.razor @@ -1,9 +1,11 @@ -
+@implements IDisposable + +
Keeptrack
- + @code { + [Inject] private NavigationManager Navigation { get; set; } = null!; + + // The mobile menu is otherwise a pure-CSS checkbox toggle (see app.css's #nav-toggle:checked rule) - + // this is the one bit of real Blazor state, needed to force it closed after a nav-link click. + // Navigating between interactively-rendered pages happens through the circuit's own router (not a raw + // DOM/enhanced-navigation event), so NavigationManager.LocationChanged - the one abstraction that fires + // reliably regardless of which navigation path Blazor takes - is what drives this, not a JS event. + private bool _menuOpen; + + protected override void OnInitialized() => Navigation.LocationChanged += CloseMenu; + + private void CloseMenu(object? sender, LocationChangedEventArgs e) + { + if (!_menuOpen) return; + _menuOpen = false; + InvokeAsync(StateHasChanged); + } + + public void Dispose() => Navigation.LocationChanged -= CloseMenu; + private static string GetInitial(string? name) => string.IsNullOrEmpty(name) ? "" : name[..1].ToUpperInvariant(); } diff --git a/src/BlazorApp/Components/Shared/DirtyTracking.cs b/src/BlazorApp/Components/Shared/DirtyTracking.cs new file mode 100644 index 00000000..b5b96886 --- /dev/null +++ b/src/BlazorApp/Components/Shared/DirtyTracking.cs @@ -0,0 +1,15 @@ +using System.Text.Json; + +namespace Keeptrack.BlazorApp.Components.Shared; + +/// +/// Generic "has this draft changed since it was opened" check for a modal entry form, shared across DTO +/// types (Car/House/Health history entries) without giving each one its own hand-written equality member. +/// A JSON-serialization diff is good enough for these flat DTOs - it's not meant for anything with cycles +/// or non-deterministic member order. +/// +public static class DirtyTracking +{ + public static bool IsDirty(T pristine, T current) => + JsonSerializer.Serialize(pristine) != JsonSerializer.Serialize(current); +} diff --git a/src/Common.System/ListSort.cs b/src/Common.System/ListSort.cs index 2013a558..ea4fd8ea 100644 --- a/src/Common.System/ListSort.cs +++ b/src/Common.System/ListSort.cs @@ -13,4 +13,13 @@ public static class ListSort /// Best rated first; unrated items last. public const string Rating = "rating"; + + /// Most recently watched movie first (Movie's FirstSeenAt); unwatched items last. + public const string LastSeen = "seen"; + + /// Most recently read book first (Book's FirstReadAt); unread items last. + public const string LastRead = "read"; + + /// Most recently completed video game first (max CompletedAt across a game's platform entries); items with none last. + public const string LastCompleted = "completed"; } diff --git a/src/Domain/Models/BookModel.cs b/src/Domain/Models/BookModel.cs index 7a456fa9..58226bfa 100644 --- a/src/Domain/Models/BookModel.cs +++ b/src/Domain/Models/BookModel.cs @@ -38,5 +38,11 @@ public class BookModel : IHasIdAndOwnerId ///
public bool IsOwned { get; set; } + /// + /// Filter-only: matches if is unset. Never persisted - see + /// for the filter-probe convention. + /// + public bool IsUnread { get; set; } + public bool IsWishlisted { get; set; } } diff --git a/src/Domain/Models/CarMetricsModel.cs b/src/Domain/Models/CarMetricsModel.cs index 8d60705d..47922a23 100644 --- a/src/Domain/Models/CarMetricsModel.cs +++ b/src/Domain/Models/CarMetricsModel.cs @@ -1,3 +1,4 @@ +using System; using System.Collections.Generic; namespace Keeptrack.Domain.Models; @@ -18,5 +19,16 @@ public class CarMetricsModel public required List MileageWarnings { get; set; } - public NextMaintenanceModel? NextMaintenance { get; set; } + public required List LastRecords { get; set; } +} + +/// +/// "When did I last log each kind of car event" - one line per . Same shape as +/// , keyed by the discriminated event-type enum instead of a free-text specialty. +/// +public class CarLastRecordModel +{ + public required CarHistoryType EventType { get; set; } + + public required DateTime LastDate { get; set; } } diff --git a/src/Domain/Models/HouseMetricsModel.cs b/src/Domain/Models/HouseMetricsModel.cs index e9e7f3b5..09bc3e71 100644 --- a/src/Domain/Models/HouseMetricsModel.cs +++ b/src/Domain/Models/HouseMetricsModel.cs @@ -1,3 +1,4 @@ +using System; using System.Collections.Generic; namespace Keeptrack.Domain.Models; @@ -5,4 +6,17 @@ namespace Keeptrack.Domain.Models; public class HouseMetricsModel { public required List CostHistory { get; set; } + + public required List LastRecords { get; set; } +} + +/// +/// "When did I last log each kind of house event" - one line per . Same shape +/// as /. +/// +public class HouseLastRecordModel +{ + public required HouseEventType EventType { get; set; } + + public required DateOnly LastDate { get; set; } } diff --git a/src/Domain/Models/MovieModel.cs b/src/Domain/Models/MovieModel.cs index be80b943..6d3a53a0 100644 --- a/src/Domain/Models/MovieModel.cs +++ b/src/Domain/Models/MovieModel.cs @@ -43,5 +43,11 @@ public class MovieModel : IHasIdAndOwnerId, IHasTvTimeId ///
public bool IsOwned { get; set; } + /// + /// Filter-only: matches if is unset. Never persisted - see + /// for the filter-probe convention. + /// + public bool IsUnseen { get; set; } + public bool IsWishlisted { get; set; } } diff --git a/src/Domain/Models/NextMaintenanceModel.cs b/src/Domain/Models/NextMaintenanceModel.cs deleted file mode 100644 index 774c9ebf..00000000 --- a/src/Domain/Models/NextMaintenanceModel.cs +++ /dev/null @@ -1,16 +0,0 @@ -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/VideoGamePlatformModel.cs b/src/Domain/Models/VideoGamePlatformModel.cs index 9b48dc0a..d517d9ce 100644 --- a/src/Domain/Models/VideoGamePlatformModel.cs +++ b/src/Domain/Models/VideoGamePlatformModel.cs @@ -18,6 +18,12 @@ public class VideoGamePlatformModel public string State { get; set; } = ""; + /// + /// The date this entry's was last set to "Completed" - auto-populated, not to be + /// confused with / (the platinum/100% flag). + /// + public DateOnly? CompletedAt { get; set; } + public List Playthroughs { get; set; } = []; public bool IsFullyCompleted { get; set; } diff --git a/src/Domain/Services/CarMetricsService.cs b/src/Domain/Services/CarMetricsService.cs index c25923db..5a7355e1 100644 --- a/src/Domain/Services/CarMetricsService.cs +++ b/src/Domain/Services/CarMetricsService.cs @@ -6,7 +6,7 @@ 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. +/// Computes fuel/electric consumption, cost history, mileage-consistency warnings and a last-record-per-type readout 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 static class CarMetricsService @@ -26,7 +26,7 @@ public static CarMetricsModel ComputeMetrics(IEnumerable histor CostHistory = ComputeCostHistory(historyList), TotalCost = historyList.Sum(h => h.Cost ?? 0), MileageWarnings = ComputeMileageWarnings(historyList), - NextMaintenance = ComputeNextMaintenanceDue(historyList) + LastRecords = ComputeLastRecords(historyList) }; } @@ -142,29 +142,14 @@ private static List ComputeMileageWarnings(IReadOnlyList } /// - /// 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. + /// "When did I last log each kind of event" - one line per , most recent + /// first. Same shape as HealthMetricsService.ComputeLastVisits, simpler (an enum key needs no + /// whitespace/Trim() handling a free-text specialty does). /// - 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 - }; - } + private static List ComputeLastRecords(IReadOnlyList history) => + history + .GroupBy(h => h.EventType) + .Select(g => new CarLastRecordModel { EventType = g.Key, LastDate = g.Max(h => h.HistoryDate) }) + .OrderByDescending(r => r.LastDate) + .ToList(); } diff --git a/src/Domain/Services/HealthMetricsService.cs b/src/Domain/Services/HealthMetricsService.cs index 7e85b979..d18c869e 100644 --- a/src/Domain/Services/HealthMetricsService.cs +++ b/src/Domain/Services/HealthMetricsService.cs @@ -43,7 +43,7 @@ private static List ComputeAnnualCostHistory(IEnume return records .Where(r => r.Price is not null || r.PublicReimbursement is not null || r.InsuranceReimbursement is not null) .GroupBy(r => r.HistoryDate.Year) - .OrderBy(g => g.Key) + .OrderByDescending(g => g.Key) .Select(g => { var paid = g.Sum(r => r.Price ?? 0); diff --git a/src/Domain/Services/HouseMetricsService.cs b/src/Domain/Services/HouseMetricsService.cs index 49cdd3aa..cd5b6d2c 100644 --- a/src/Domain/Services/HouseMetricsService.cs +++ b/src/Domain/Services/HouseMetricsService.cs @@ -6,14 +6,16 @@ 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. +/// Deliberately limited to a yearly cost breakdown plus a last-record-per-type readout: House has no +/// reminders/due-date engine by design - the owner tracks recurring bills/maintenance elsewhere and only +/// wants an exhaustive record plus a yearly cost review here. /// public static class HouseMetricsService { public static HouseMetricsModel ComputeMetrics(IEnumerable history) { - return new HouseMetricsModel { CostHistory = ComputeAnnualCostHistory(history) }; + var list = history.ToList(); + return new HouseMetricsModel { CostHistory = ComputeAnnualCostHistory(list), LastRecords = ComputeLastRecords(list) }; } private static List ComputeAnnualCostHistory(IEnumerable history) @@ -33,4 +35,15 @@ private static List ComputeAnnualCostHistory(IEnumer }) .ToList(); } + + /// + /// "When did I last log each kind of house event" - one line per , most + /// recent first. Same shape as 's own ComputeLastRecords. + /// + private static List ComputeLastRecords(IEnumerable history) => + history + .GroupBy(h => h.EventType) + .Select(g => new HouseLastRecordModel { EventType = g.Key, LastDate = g.Max(h => h.HistoryDate) }) + .OrderByDescending(r => r.LastDate) + .ToList(); } diff --git a/src/Infrastructure.MongoDb/Entities/VideoGamePlatform.cs b/src/Infrastructure.MongoDb/Entities/VideoGamePlatform.cs index 6b7c1878..c5abe624 100644 --- a/src/Infrastructure.MongoDb/Entities/VideoGamePlatform.cs +++ b/src/Infrastructure.MongoDb/Entities/VideoGamePlatform.cs @@ -15,6 +15,9 @@ public class VideoGamePlatform public string State { get; set; } = ""; + [BsonElement("completed_at")] + public DateTime? CompletedAt { get; set; } + public List Playthroughs { get; set; } = []; [BsonElement("is_fully_completed")] diff --git a/src/Infrastructure.MongoDb/Mappers/BookStorageMapper.cs b/src/Infrastructure.MongoDb/Mappers/BookStorageMapper.cs index 446f8bda..8d95825c 100644 --- a/src/Infrastructure.MongoDb/Mappers/BookStorageMapper.cs +++ b/src/Infrastructure.MongoDb/Mappers/BookStorageMapper.cs @@ -11,9 +11,11 @@ public partial class BookStorageMapper : IStorageMapper { // IsOwned is filter-only (derived from OwnedVersions) - see MovieStorageMapper. [MapperIgnoreSource(nameof(BookModel.IsOwned))] + [MapperIgnoreSource(nameof(BookModel.IsUnread))] public partial Book ToEntity(BookModel model); [MapperIgnoreTarget(nameof(BookModel.IsOwned))] + [MapperIgnoreTarget(nameof(BookModel.IsUnread))] public partial BookModel ToModel(Book entity); public partial List ToModels(List entities); diff --git a/src/Infrastructure.MongoDb/Mappers/MovieStorageMapper.cs b/src/Infrastructure.MongoDb/Mappers/MovieStorageMapper.cs index 0d4fe562..b2e4a704 100644 --- a/src/Infrastructure.MongoDb/Mappers/MovieStorageMapper.cs +++ b/src/Infrastructure.MongoDb/Mappers/MovieStorageMapper.cs @@ -13,9 +13,11 @@ public partial class MovieStorageMapper : IStorageMapper // OwnedVersions being non-empty, never stored as its own flag. See VideoGameStorageMapper for the // filter-only ignore convention. [MapperIgnoreSource(nameof(MovieModel.IsOwned))] + [MapperIgnoreSource(nameof(MovieModel.IsUnseen))] public partial Movie ToEntity(MovieModel model); [MapperIgnoreTarget(nameof(MovieModel.IsOwned))] + [MapperIgnoreTarget(nameof(MovieModel.IsUnseen))] public partial MovieModel ToModel(Movie entity); public partial List ToModels(List entities); diff --git a/src/Infrastructure.MongoDb/Repositories/BookRepository.cs b/src/Infrastructure.MongoDb/Repositories/BookRepository.cs index 41cfae80..3a0bcda5 100644 --- a/src/Infrastructure.MongoDb/Repositories/BookRepository.cs +++ b/src/Infrastructure.MongoDb/Repositories/BookRepository.cs @@ -23,6 +23,8 @@ public class BookRepository(IMongoDatabase mongoDatabase, ILogger> SortRatingField => x => x.Rating!; + protected override Expression> SortSecondaryDateField => x => x.FirstReadAt!; + protected override FilterDefinition GetFilter(string ownerId, string? search, BookModel input) { var builder = Builders.Filter; @@ -33,6 +35,9 @@ protected override FilterDefinition GetFilter(string ownerId, string? sear if (input.IsFavorite) filter &= builder.Eq(f => f.IsFavorite, true); // "owned" means at least one owned version - see MovieRepository.GetFilter if (input.IsOwned) filter &= builder.SizeGt(f => f.OwnedVersions, 0); + if (input.IsUnread) filter &= builder.Eq(f => f.FirstReadAt, null); + // WishlistController.BuildWishlistAsync still relies on this filter-probe clause even though the + // list page's own "Wishlist" toggle button was removed - don't drop it again. if (input.IsWishlisted) filter &= builder.Eq(f => f.IsWishlisted, true); return filter; } diff --git a/src/Infrastructure.MongoDb/Repositories/MongoDbRepositoryBase.cs b/src/Infrastructure.MongoDb/Repositories/MongoDbRepositoryBase.cs index 20d29727..ac668409 100644 --- a/src/Infrastructure.MongoDb/Repositories/MongoDbRepositoryBase.cs +++ b/src/Infrastructure.MongoDb/Repositories/MongoDbRepositoryBase.cs @@ -69,17 +69,28 @@ public async Task> FindAllAsync(string ownerId, int page, in /// protected virtual Expression>? SortRatingField => null; + /// + /// Field behind / (descending, unset + /// items last) - same contract as . A single hook covers both keys: a + /// collection only ever advertises one of the two via its own list page's UI (Movie: last seen, Book: + /// last read), so both keys resolving to the same field is harmless. + /// + protected virtual Expression>? SortSecondaryDateField => null; + /// /// "_id" descending doubles as the "recently added" default (ObjectIds embed their creation timestamp, so no separate created-at field is needed) - /// and as the deterministic tie-break appended to every other sort. + /// and as the deterministic tie-break appended to every other sort. Virtual so a collection whose extra + /// sort key doesn't fit the single-scalar-field hooks above (e.g. VideoGame's "last completed", the max + /// of an array field) can add its own case - see VideoGameRepository.GetSort. /// - private SortDefinition GetSort(string? sort) + protected virtual SortDefinition GetSort(string? sort) { var builder = Builders.Sort; return sort switch { ListSort.Title when SortTitleField is not null => builder.Ascending(SortTitleField).Descending("_id"), ListSort.Rating when SortRatingField is not null => builder.Descending(SortRatingField).Descending("_id"), + ListSort.LastSeen or ListSort.LastRead when SortSecondaryDateField is not null => builder.Descending(SortSecondaryDateField).Descending("_id"), _ => builder.Descending("_id") }; } diff --git a/src/Infrastructure.MongoDb/Repositories/MovieRepository.cs b/src/Infrastructure.MongoDb/Repositories/MovieRepository.cs index 8da5cb76..2c9812f7 100644 --- a/src/Infrastructure.MongoDb/Repositories/MovieRepository.cs +++ b/src/Infrastructure.MongoDb/Repositories/MovieRepository.cs @@ -23,6 +23,8 @@ public class MovieRepository(IMongoDatabase mongoDatabase, ILogger> SortRatingField => x => x.Rating!; + protected override Expression> SortSecondaryDateField => x => x.FirstSeenAt!; + protected override FilterDefinition GetFilter(string ownerId, string? search, MovieModel input) { var builder = Builders.Filter; @@ -32,6 +34,9 @@ protected override FilterDefinition GetFilter(string ownerId, string? sea if (input.WantToWatch) filter &= builder.Eq(f => f.WantToWatch, true); // "owned" means at least one owned version - renders as { "owned_versions.0": { $exists: true } } if (input.IsOwned) filter &= builder.SizeGt(f => f.OwnedVersions, 0); + if (input.IsUnseen) filter &= builder.Eq(f => f.FirstSeenAt, null); + // WishlistController.BuildWishlistAsync still relies on this filter-probe clause even though the + // list page's own "Wishlist" toggle button was removed - don't drop it again. if (input.IsWishlisted) filter &= builder.Eq(f => f.IsWishlisted, true); return filter; } diff --git a/src/Infrastructure.MongoDb/Repositories/TvShowRepository.cs b/src/Infrastructure.MongoDb/Repositories/TvShowRepository.cs index 3641e5f0..5ad7ad41 100644 --- a/src/Infrastructure.MongoDb/Repositories/TvShowRepository.cs +++ b/src/Infrastructure.MongoDb/Repositories/TvShowRepository.cs @@ -33,6 +33,8 @@ protected override FilterDefinition GetFilter(string ownerId, string? se if (input.State is not null) filter &= builder.Eq(f => f.State, input.State); // "owned" means at least one owned version - see MovieRepository.GetFilter if (input.IsOwned) filter &= builder.SizeGt(f => f.OwnedVersions, 0); + // WishlistController.BuildWishlistAsync still relies on this filter-probe clause even though the + // list page's own "Wishlist" toggle button was removed - don't drop it again. if (input.IsWishlisted) filter &= builder.Eq(f => f.IsWishlisted, true); return filter; } diff --git a/src/Infrastructure.MongoDb/Repositories/VideoGameRepository.cs b/src/Infrastructure.MongoDb/Repositories/VideoGameRepository.cs index 7ac669c5..61eddd48 100644 --- a/src/Infrastructure.MongoDb/Repositories/VideoGameRepository.cs +++ b/src/Infrastructure.MongoDb/Repositories/VideoGameRepository.cs @@ -4,6 +4,7 @@ using System.Linq.Expressions; using System.Text.RegularExpressions; using System.Threading.Tasks; +using Keeptrack.Common.System; using Keeptrack.Domain.Models; using Keeptrack.Domain.Repositories; using Keeptrack.Infrastructure.MongoDb.Entities; @@ -23,6 +24,18 @@ public class VideoGameRepository(IMongoDatabase mongoDatabase, ILogger> SortRatingField => x => x.Rating!; + /// + /// "Last completed" needs the max CompletedAt across a game's + /// array, not a single scalar field, so it can't use the shared SortSecondaryDateField hook. + /// MongoDB natively compares a dotted array field by its max element on a descending sort - no + /// aggregation pipeline needed (the existing AnyEq(f => f.Platforms.Select(p => p.State), ...) + /// filter above already confirms the driver resolves this entity's "platforms.*" dotted paths). + /// + protected override SortDefinition GetSort(string? sort) => + sort == ListSort.LastCompleted + ? Builders.Sort.Descending("platforms.completed_at").Descending("_id") + : base.GetSort(sort); + protected override FilterDefinition GetFilter(string ownerId, string? search, VideoGameModel input) { var builder = Builders.Filter; @@ -33,6 +46,8 @@ protected override FilterDefinition GetFilter(string ownerId, string? // "owned" means at least one platform entry (a game's copies) - the platform-entry equivalent of // MovieRepository.GetFilter's owned-versions rule if (input.IsOwned) filter &= builder.SizeGt(f => f.Platforms, 0); + // WishlistController.BuildWishlistAsync still relies on this filter-probe clause even though the + // list page's own "Wishlist" toggle button was removed - don't drop it again. if (input.IsWishlisted) filter &= builder.Eq(f => f.IsWishlisted, true); return filter; } diff --git a/src/WebApi.Contracts/Dto/BookDto.cs b/src/WebApi.Contracts/Dto/BookDto.cs index 10b7cf86..e9d31715 100644 --- a/src/WebApi.Contracts/Dto/BookDto.cs +++ b/src/WebApi.Contracts/Dto/BookDto.cs @@ -72,5 +72,11 @@ public class BookDto : IHasId, IReferenceLinkedDto /// public bool IsOwned { get; set; } + /// + /// Filter-only query parameter: matches items with no set. Never populated on + /// a returned item - see for the convention. + /// + public bool IsUnread { get; set; } + public bool IsWishlisted { get; set; } } diff --git a/src/WebApi.Contracts/Dto/CarMetricsDto.cs b/src/WebApi.Contracts/Dto/CarMetricsDto.cs index 0509319a..791cef2e 100644 --- a/src/WebApi.Contracts/Dto/CarMetricsDto.cs +++ b/src/WebApi.Contracts/Dto/CarMetricsDto.cs @@ -1,9 +1,10 @@ +using System; using System.Collections.Generic; namespace Keeptrack.WebApi.Contracts.Dto; /// -/// Computed metrics for one car: consumption, cost history, mileage warnings and next-maintenance estimate. +/// Computed metrics for one car: consumption, cost history, mileage warnings and last-record-per-type readout. /// public class CarMetricsDto { @@ -43,7 +44,23 @@ public class CarMetricsDto public required List MileageWarnings { get; set; } /// - /// Estimated next maintenance due, or null if no maintenance has been recorded yet. + /// When each event type was last recorded, most recent first. /// - public NextMaintenanceDto? NextMaintenance { get; set; } + public required List LastRecords { get; set; } +} + +/// +/// When a car history event type was last recorded. +/// +public class CarLastRecordDto +{ + /// + /// The event type. + /// + public required CarHistoryType EventType { get; set; } + + /// + /// The most recent recorded date for that event type. + /// + public required DateTime LastDate { get; set; } } diff --git a/src/WebApi.Contracts/Dto/HouseMetricsDto.cs b/src/WebApi.Contracts/Dto/HouseMetricsDto.cs index 3ccd2f24..a6bafdf5 100644 --- a/src/WebApi.Contracts/Dto/HouseMetricsDto.cs +++ b/src/WebApi.Contracts/Dto/HouseMetricsDto.cs @@ -1,9 +1,10 @@ +using System; using System.Collections.Generic; namespace Keeptrack.WebApi.Contracts.Dto; /// -/// Computed metrics for one house: yearly cost history. +/// Computed metrics for one house: yearly cost history and a last-record-per-type readout. /// public class HouseMetricsDto { @@ -11,4 +12,25 @@ public class HouseMetricsDto /// Total cost of ownership, by year. /// public required List CostHistory { get; set; } + + /// + /// When each event type was last recorded, most recent first. + /// + public required List LastRecords { get; set; } +} + +/// +/// When a house history event type was last recorded. +/// +public class HouseLastRecordDto +{ + /// + /// The event type. + /// + public required HouseEventType EventType { get; set; } + + /// + /// The most recent recorded date for that event type. + /// + public required DateOnly LastDate { get; set; } } diff --git a/src/WebApi.Contracts/Dto/MovieDto.cs b/src/WebApi.Contracts/Dto/MovieDto.cs index df7c1bda..f8d78cbe 100644 --- a/src/WebApi.Contracts/Dto/MovieDto.cs +++ b/src/WebApi.Contracts/Dto/MovieDto.cs @@ -50,5 +50,11 @@ public class MovieDto : IHasId, IReferenceLinkedDto /// public bool IsOwned { get; set; } + /// + /// Filter-only query parameter: matches items with no set. Never populated on + /// a returned item - see for the convention. + /// + public bool IsUnseen { get; set; } + public bool IsWishlisted { get; set; } } diff --git a/src/WebApi.Contracts/Dto/NextMaintenanceDto.cs b/src/WebApi.Contracts/Dto/NextMaintenanceDto.cs deleted file mode 100644 index a5e7b13a..00000000 --- a/src/WebApi.Contracts/Dto/NextMaintenanceDto.cs +++ /dev/null @@ -1,24 +0,0 @@ -using System; - -namespace Keeptrack.WebApi.Contracts.Dto; - -/// -/// Estimated next maintenance due date, assuming a fixed 1-year cadence from the last recorded maintenance event. -/// -public class NextMaintenanceDto -{ - /// - /// Date of the last recorded maintenance event. - /// - public required DateOnly LastMaintenanceDate { get; set; } - - /// - /// Estimated due date for the next maintenance. - /// - public required DateOnly DueDate { get; set; } - - /// - /// Months remaining until the due date (negative if overdue). - /// - public required int MonthsRemaining { get; set; } -} diff --git a/src/WebApi.Contracts/Dto/VideoGamePlatformDto.cs b/src/WebApi.Contracts/Dto/VideoGamePlatformDto.cs index eea5ba86..350fe40c 100644 --- a/src/WebApi.Contracts/Dto/VideoGamePlatformDto.cs +++ b/src/WebApi.Contracts/Dto/VideoGamePlatformDto.cs @@ -23,6 +23,12 @@ public class VideoGamePlatformDto : IOwnedCopyDto /// public string? State { get; set; } + /// + /// The date this entry's was last set to "Completed" - auto-populated. Not to be + /// confused with (the platinum/100% date). + /// + public DateOnly? CompletedAt { get; set; } + /// /// Every recorded run through the game on this platform. /// diff --git a/src/WebApi/Mappers/CarMetricsDtoMapper.cs b/src/WebApi/Mappers/CarMetricsDtoMapper.cs index 2fc0be40..1238d510 100644 --- a/src/WebApi/Mappers/CarMetricsDtoMapper.cs +++ b/src/WebApi/Mappers/CarMetricsDtoMapper.cs @@ -6,10 +6,11 @@ namespace Keeptrack.WebApi.Mappers; /// /// One-directional (Model -> Dto): is a pure Domain-level /// computation with no Dto dependency, so maps its -/// result here. Mapperly preserves a null as null natively - -/// no AllowNull()-style opt-out needed, unlike the AutoMapper original. +/// result here. ByName is required because (Domain's +/// ) maps to a differently-typed Contracts-side duplicate enum of the same name +/// - same reason / set it. /// -[Mapper] +[Mapper(EnumMappingStrategy = EnumMappingStrategy.ByName)] public partial class CarMetricsDtoMapper { public partial CarMetricsDto ToDto(CarMetricsModel model); diff --git a/test/WebApi.IntegrationTests/Resources/BookResourceTest.cs b/test/WebApi.IntegrationTests/Resources/BookResourceTest.cs index 32ec9f73..d365f369 100644 --- a/test/WebApi.IntegrationTests/Resources/BookResourceTest.cs +++ b/test/WebApi.IntegrationTests/Resources/BookResourceTest.cs @@ -70,6 +70,7 @@ public async Task BookResourceOwnedAndWishlistedFilters_OnlyReturnMatchingItems_ var owned = await GetAsync>($"/{ResourceEndpoint}?IsOwned=true&search={uniqueTitle}"); owned.Items.Should().ContainSingle(b => b.Id == created.Id); + // this is the WishlistController filter-probe, not a list-page UI filter (removed) - still real API behavior var wishlisted = await GetAsync>($"/{ResourceEndpoint}?IsWishlisted=true&search={uniqueTitle}"); wishlisted.Items.Should().ContainSingle(b => b.Id == created.Id); } diff --git a/test/WebApi.IntegrationTests/Resources/CarResourceTest.cs b/test/WebApi.IntegrationTests/Resources/CarResourceTest.cs index e7c5eb55..a60aebc6 100644 --- a/test/WebApi.IntegrationTests/Resources/CarResourceTest.cs +++ b/test/WebApi.IntegrationTests/Resources/CarResourceTest.cs @@ -111,7 +111,7 @@ public async Task CarResourceMetrics_ReturnsEmptyMetrics_ForACarWithNoHistoryYet metrics.ElectricConsumption.Should().BeEmpty(); metrics.CostHistory.Should().BeEmpty(); metrics.MileageWarnings.Should().BeEmpty(); - metrics.NextMaintenance.Should().BeNull(); + metrics.LastRecords.Should().BeEmpty(); } finally { diff --git a/test/WebApi.IntegrationTests/Resources/MovieResourceTest.cs b/test/WebApi.IntegrationTests/Resources/MovieResourceTest.cs index 421603bb..759b5f2d 100644 --- a/test/WebApi.IntegrationTests/Resources/MovieResourceTest.cs +++ b/test/WebApi.IntegrationTests/Resources/MovieResourceTest.cs @@ -97,6 +97,7 @@ public async Task MovieResourceOwnedAndWishlistedFilters_OnlyReturnMatchingItems var fetchedVersions = owned.Items.Single(m => m.Id == created.Id).OwnedVersions; fetchedVersions.Should().BeEquivalentTo(input.OwnedVersions); + // this is the WishlistController filter-probe, not a list-page UI filter (removed) - still real API behavior var wishlisted = await GetAsync>($"/{ResourceEndpoint}?IsWishlisted=true&search={uniqueTitle}"); wishlisted.Items.Should().ContainSingle(m => m.Id == created.Id); } diff --git a/test/WebApi.IntegrationTests/Resources/TvShowResourceTest.cs b/test/WebApi.IntegrationTests/Resources/TvShowResourceTest.cs index 6e98a63a..753371d8 100644 --- a/test/WebApi.IntegrationTests/Resources/TvShowResourceTest.cs +++ b/test/WebApi.IntegrationTests/Resources/TvShowResourceTest.cs @@ -31,6 +31,7 @@ public async Task TvShowResourceOwnedAndWishlistedFilters_OnlyReturnMatchingItem var owned = await GetAsync>($"/{ResourceEndpoint}?IsOwned=true&search={title}"); owned.Items.Should().ContainSingle(s => s.Id == created.Id); + // this is the WishlistController filter-probe, not a list-page UI filter (removed) - still real API behavior var wishlisted = await GetAsync>($"/{ResourceEndpoint}?IsWishlisted=true&search={title}"); wishlisted.Items.Should().ContainSingle(s => s.Id == created.Id); } diff --git a/test/WebApi.IntegrationTests/Resources/VideoGameResourceTest.cs b/test/WebApi.IntegrationTests/Resources/VideoGameResourceTest.cs index 2c3248e1..6d0b2af3 100644 --- a/test/WebApi.IntegrationTests/Resources/VideoGameResourceTest.cs +++ b/test/WebApi.IntegrationTests/Resources/VideoGameResourceTest.cs @@ -113,6 +113,7 @@ public async Task VideoGameResourceOwnedAndWishlistedFilters_OnlyReturnMatchingI var fetchedPlatforms = owned.Items.Single(x => x.Id == created.Id).Platforms; fetchedPlatforms.Should().BeEquivalentTo(platforms); + // this is the WishlistController filter-probe, not a list-page UI filter (removed) - still real API behavior var wishlisted = await GetAsync>($"/{ResourceEndpoint}?IsWishlisted=true&search={title}"); wishlisted.Items.Should().ContainSingle(x => x.Id == created.Id); } diff --git a/test/WebApi.UnitTests/Services/CarMetricsServiceTest.cs b/test/WebApi.UnitTests/Services/CarMetricsServiceTest.cs index 6d738509..678865bd 100644 --- a/test/WebApi.UnitTests/Services/CarMetricsServiceTest.cs +++ b/test/WebApi.UnitTests/Services/CarMetricsServiceTest.cs @@ -100,38 +100,22 @@ public void ComputeMetrics_CostHistory_GroupsByMonthAndSplitsFuelFromMaintenance } [Fact] - public void ComputeMetrics_NextMaintenance_IsNullWhenNoMaintenanceHistoryExists() + public void ComputeMetrics_LastRecords_GroupsByEventType_MostRecentFirst() { - var history = new[] { Refuel("h1", new DateTime(2024, 1, 1), 1000, fuelVolume: 40) }; - - var result = CarMetricsService.ComputeMetrics(history); - - result.NextMaintenance.Should().BeNull(); - } - - [Fact] - public void ComputeMetrics_NextMaintenance_IsOneYearAfterTheLastMaintenanceEvent() - { - var lastMaintenance = DateOnly.FromDateTime(DateTime.Today).AddMonths(-10); - var history = new[] { Maintenance("h1", lastMaintenance.ToDateTime(TimeOnly.MinValue)) }; - - var result = CarMetricsService.ComputeMetrics(history); - - result.NextMaintenance.Should().NotBeNull(); - result.NextMaintenance!.LastMaintenanceDate.Should().Be(lastMaintenance); - result.NextMaintenance.DueDate.Should().Be(lastMaintenance.AddYears(1)); - result.NextMaintenance.MonthsRemaining.Should().Be(2); - } - - [Fact] - public void ComputeMetrics_NextMaintenance_ReportsNegativeMonthsWhenOverdue() - { - var lastMaintenance = DateOnly.FromDateTime(DateTime.Today).AddMonths(-14); - var history = new[] { Maintenance("h1", lastMaintenance.ToDateTime(TimeOnly.MinValue)) }; + var history = new[] + { + Refuel("h1", new DateTime(2024, 1, 10), 1000, fuelVolume: 40), + Refuel("h2", new DateTime(2024, 3, 1), 1200, fuelVolume: 40), + Maintenance("h3", new DateTime(2024, 2, 1)) + }; var result = CarMetricsService.ComputeMetrics(history); - result.NextMaintenance!.MonthsRemaining.Should().Be(-2); + result.LastRecords.Should().HaveCount(2); + result.LastRecords[0].EventType.Should().Be(CarHistoryType.Refuel); + result.LastRecords[0].LastDate.Should().Be(new DateTime(2024, 3, 1)); + result.LastRecords[1].EventType.Should().Be(CarHistoryType.Maintenance); + result.LastRecords[1].LastDate.Should().Be(new DateTime(2024, 2, 1)); } [Fact] diff --git a/test/WebApi.UnitTests/Services/HealthMetricsServiceTest.cs b/test/WebApi.UnitTests/Services/HealthMetricsServiceTest.cs index 12efa795..7764c08f 100644 --- a/test/WebApi.UnitTests/Services/HealthMetricsServiceTest.cs +++ b/test/WebApi.UnitTests/Services/HealthMetricsServiceTest.cs @@ -59,17 +59,17 @@ public void ComputeMetrics_CostHistory_GroupsByYear_AndComputesOutOfPocketAfterB var result = HealthMetricsService.ComputeMetrics(records); result.CostHistory.Should().HaveCount(2); - var year2025 = result.CostHistory[0]; - year2025.Year.Should().Be(2025); - year2025.TotalPaid.Should().Be(85); - year2025.TotalReimbursed.Should().Be(28.5); - year2025.OutOfPocket.Should().Be(56.5); - - var year2026 = result.CostHistory[1]; + var year2026 = result.CostHistory[0]; year2026.Year.Should().Be(2026); year2026.TotalPaid.Should().Be(26.5); year2026.TotalReimbursed.Should().Be(16.5); year2026.OutOfPocket.Should().BeApproximately(10, 0.0001); + + var year2025 = result.CostHistory[1]; + year2025.Year.Should().Be(2025); + year2025.TotalPaid.Should().Be(85); + year2025.TotalReimbursed.Should().Be(28.5); + year2025.OutOfPocket.Should().Be(56.5); } [Fact] diff --git a/test/WebApi.UnitTests/Services/HouseMetricsServiceTest.cs b/test/WebApi.UnitTests/Services/HouseMetricsServiceTest.cs index 9ce51883..01037c5b 100644 --- a/test/WebApi.UnitTests/Services/HouseMetricsServiceTest.cs +++ b/test/WebApi.UnitTests/Services/HouseMetricsServiceTest.cs @@ -69,4 +69,23 @@ public void ComputeMetrics_CostHistory_BreaksDownByCategoryWithinAYear() year2024.CostByCategory.Should().ContainSingle(c => c.EventType == HouseEventType.Bill && c.Cost == 100); year2024.CostByCategory.Should().ContainSingle(c => c.EventType == HouseEventType.Maintenance && c.Cost == 150); } + + [Fact] + public void ComputeMetrics_LastRecords_GroupsByEventType_MostRecentFirst() + { + var history = new[] + { + Entry("h1", new DateOnly(2024, 1, 10), HouseEventType.Bill), + Entry("h2", new DateOnly(2024, 3, 1), HouseEventType.Bill), + Entry("h3", new DateOnly(2024, 2, 1), HouseEventType.Maintenance) + }; + + var result = HouseMetricsService.ComputeMetrics(history); + + result.LastRecords.Should().HaveCount(2); + result.LastRecords[0].EventType.Should().Be(HouseEventType.Bill); + result.LastRecords[0].LastDate.Should().Be(new DateOnly(2024, 3, 1)); + result.LastRecords[1].EventType.Should().Be(HouseEventType.Maintenance); + result.LastRecords[1].LastDate.Should().Be(new DateOnly(2024, 2, 1)); + } } From 50614df81b8b425f75bda502880c3bc71a1e2943 Mon Sep 17 00:00:00 2001 From: Bertrand THOMAS Date: Sun, 19 Jul 2026 02:56:00 +0200 Subject: [PATCH 26/32] Add Google Books API as additional book reference provider --- CLAUDE.md | 27 ++- CONTRIBUTING.md | 15 +- docs/code-quality-findings.md | 20 ++ .../Inventory/Pages/BookDetail.razor | 28 ++- .../InlineReferenceLinker.razor | 72 +++++-- .../ReferenceDataAdminApiClient.cs | 13 +- .../ReferenceDataAdminPage.razor | 40 +++- src/Domain/Models/BookModel.cs | 9 + src/Domain/Models/BookReferenceModel.cs | 6 + src/Domain/Repositories/IBookRepository.cs | 9 +- src/Infrastructure.MongoDb/Entities/Book.cs | 5 + .../Entities/BookReference.cs | 2 + .../Repositories/BookRepository.cs | 4 +- src/WebApi.Contracts/Dto/BookDto.cs | 17 +- src/WebApi.Contracts/Dto/BookProviderDto.cs | 14 ++ src/WebApi.Contracts/Dto/BookReferenceDto.cs | 3 + .../Dto/LinkReferenceRequestDto.cs | 7 + src/WebApi/AppConfiguration.cs | 10 +- src/WebApi/Controllers/BookController.cs | 15 +- .../Controllers/SystemStatusController.cs | 5 +- src/WebApi/Program.cs | 46 +++-- src/WebApi/ReferenceData/BnfClient.cs | 176 ++++++++++++++++++ .../BookReferenceClientRegistry.cs | 29 +++ src/WebApi/ReferenceData/GoogleBooksClient.cs | 161 ++++++++++++++++ .../ReferenceData/GoogleBooksSettings.cs | 6 + .../ReferenceData/IBookReferenceClient.cs | 9 +- src/WebApi/ReferenceData/OpenLibraryClient.cs | 2 + .../ReferenceDataAdminController.cs | 19 +- .../ReferenceEnrichmentService.Books.cs | 73 +++++--- .../ReferenceEnrichmentService.cs | 2 +- src/WebApi/appsettings.json | 5 +- .../Pages/AlbumDetailPage.cs | 2 +- .../Pages/BookDetailPage.cs | 10 +- .../Pages/MovieDetailPage.cs | 2 +- .../Pages/ReferenceableDetailPageBase.cs | 8 +- .../Pages/TvShowDetailPage.cs | 2 +- .../Pages/VideoGameDetailPage.cs | 2 +- .../Smoke/GoogleBooksSmokeTest.cs | 57 ++++++ .../BookProviderSearchAndLinkResourceTest.cs | 58 ++++++ .../Resources/BookReferenceRepositoryTest.cs | 41 ++++ .../Resources/BookResourceTest.cs | 65 ++++++- .../ReferenceDataAdminResourceTest.cs | 20 ++ .../Resources/ResourceTestBase.cs | 12 ++ .../ReferenceData/BnfClientTest.cs | 153 +++++++++++++++ .../ReferenceData/FakeBnfClient.cs | 35 ++++ .../ReferenceData/FakeBookReferenceClient.cs | 2 + .../ReferenceData/GoogleBooksClientTest.cs | 115 ++++++++++++ .../ReferenceEnrichmentServiceTest.cs | 57 +++++- .../ReferenceData/ReferenceSyncServiceTest.cs | 2 +- 49 files changed, 1376 insertions(+), 116 deletions(-) create mode 100644 src/WebApi.Contracts/Dto/BookProviderDto.cs create mode 100644 src/WebApi/ReferenceData/BnfClient.cs create mode 100644 src/WebApi/ReferenceData/BookReferenceClientRegistry.cs create mode 100644 src/WebApi/ReferenceData/GoogleBooksClient.cs create mode 100644 src/WebApi/ReferenceData/GoogleBooksSettings.cs create mode 100644 test/BlazorApp.PlaywrightTests/Smoke/GoogleBooksSmokeTest.cs create mode 100644 test/WebApi.IntegrationTests/Resources/BookProviderSearchAndLinkResourceTest.cs create mode 100644 test/WebApi.UnitTests/ReferenceData/BnfClientTest.cs create mode 100644 test/WebApi.UnitTests/ReferenceData/FakeBnfClient.cs create mode 100644 test/WebApi.UnitTests/ReferenceData/GoogleBooksClientTest.cs diff --git a/CLAUDE.md b/CLAUDE.md index cf068f64..3720c4de 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -706,13 +706,30 @@ that display was replaced with a plain editable `Genre` input (same shape as `Au 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`. +an `IBookReferenceClient.ProviderKey` string, plus a `DisplayName` for admin UI text) 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. + +**Book is also the one reference domain with more than one provider registered at once**: `GoogleBooksClient`/`ProviderKey` `"googlebooks"` (the default - real synopses, cover art, language and by far the widest catalogue coverage of the three, including manga/comics), +`OpenLibraryClient`/`"openlibrary"` (free/keyless, kept as a fallback), and `BnfClient`/`"bnf"` (BnF's SRU Catalogue général, free/keyless, also kept as a fallback). +Google Books became the default after both Open Library and BnF were found lacking in practice for this app's purposes: BnF in particular returns long library-cataloguing-style titles, no cover art at all, and little to no real synopsis, and doesn't meaningfully cover manga/comics - it can still occasionally surface a French title the other two lack, which is why it's kept registered rather than removed, just never the default. +`Program.cs` registers every implemented book provider unconditionally (typed `AddHttpClient` bridged to the shared interface via `AddTransient(sp => sp.GetRequiredService())` - `AddTransient`, not `AddSingleton`, so `IHttpClientFactory`'s handler rotation isn't defeated by a long-lived captured client), rather than the old switch that picked exactly one. +Registration order in `Program.cs` doubles as the admin UI's provider-picker display order (`BookReferenceClientRegistry.All` preserves it) - Google Books is registered first for exactly that reason. +`BookReferenceClientRegistry` (`WebApi/ReferenceData/`) is the one place that resolves a provider key (or falls back to `ReferenceData:BookProvider`'s deployment default, matched case-insensitively so an old PascalCase `"OpenLibrary"` setting still resolves against the new lowercase `ProviderKey` convention) to a concrete client - `ReferenceEnrichmentService`/`ReferenceDataAdminController` both depend on this registry instead of a single injected `IBookReferenceClient`. +An admin picks the provider per search/link action (`GET /api/reference-data/book-providers` lists what's registered; `LinkReferenceRequestDto.Provider`/the `search` endpoint's `provider` query param carry the choice through) - this is deliberately a per-request admin choice, not just the old deployment-wide config switch, so every provider stays usable side by side. +The admin UI's provider buttons are selection-only (`_selectedProvider = key`, no implicit re-search) - they used to also immediately re-run the search when one was already displayed, which duplicated the explicit "Search"/"↻ Search again" button's job and confused which action did what; now there is exactly one way to trigger a search. +`RefreshBookReferenceAsync` checks every *registered* provider's key against `BookReferenceModel.ExternalIds`, not just the configured default's - it used to only check the default's key, so a reference linked through any other provider would have silently stopped refreshing forever once this became possible. +BnF's ordinary catalogue records carry no cover-art field at all (only a digitized Gallica item would, via a separate API `BnfClient` doesn't call), unlike Google Books/Open Library/RAWG/Discogs - a book linked via BnF simply has no cover, which is expected, not a bug. +`BnfClient` parses SRU/XML (Dublin Core embedded in each `srw:record`), the one non-JSON provider client in the codebase; its `ExternalId` is the record's bare ARK (from `srw:recordIdentifier`, re-queried via the `bib.persistentid` CQL criterion for `GetBookDetailsAsync`), and its `dc:creator` values ("LastName, FirstName (dates). Role") are cleaned up into a plain "FirstName LastName" shape to match every other provider's author format. +**Gotcha, confirmed against the real API:** BnF's own `"and (bib.author ...)"` CQL combination is not a strict intersection - querying title "La Peste" and author "Victor Hugo" (who never wrote that book) returned several genuine Victor Hugo anthologies instead of zero, none of them actually titled "La Peste" (title "La Peste" + the correct author "Albert Camus" does correctly narrow to 69 genuine matches, so the server-side clause isn't useless, just not trustworthy on its own). +`BnfClient.SearchBooksCoreAsync` therefore re-checks every candidate's parsed author client-side (`AuthorMatches`, a normalized word-presence check) and discards any that don't actually match, rather than trusting BnF's own filtering - without this, mismatched candidates silently leaked through, which read to a user as "the author isn't considered" even though it nominally was, server-side. +`GoogleBooksClient` (JSON, like every provider except BnF) uses `intitle:`/`inauthor:` query qualifiers; `volumeInfo.description` is documented as HTML-formatted ("b", "i", "br" tags), so `CleanDescription` strips tags/decodes entities before it becomes `Synopsis` - passing raw HTML through would show literal escaped tags in the UI, since Blazor's plain `@candidate.Synopsis` interpolation isn't `MarkupString`. +It also upgrades `volumeInfo.imageLinks.thumbnail` from `http://` to `https://` (a widely-documented characteristic of Google's own API responses) so the cover doesn't trip mixed-content blocking on this app's HTTPS pages. +`ReferenceEnrichmentService.Books.cs` never hardcodes a provider name; every `ExternalIds`/person-reference lookup keys off the resolved client's `ProviderKey` instead, +so a further implementation only needs its own class (`GoogleBooksClient`/`OpenLibraryClient`/`BnfClient`-shaped: base address, optional settings/API-key class, `ProviderKey`, `DisplayName`) plus one registration block in `Program.cs` - no changes to the enrichment service, admin controller, registry, or the admin UI's provider picker (it lists whatever's registered). + +`BookModel.Language` (free text, same shape as `Genre`) is auto-filled on link/refresh from providers that report one - Google Books' `volumeInfo.language` (a clean ISO 639-1 code) and BnF's Dublin Core `dc:language` (a MARC-style code, e.g. "fre" - shown as-is, not translated to a friendly name) both populate `BookDetails.Language`; Open Library's client doesn't populate it (the value lives at the edition level, not the work level the current client fetches), left as a possible future enhancement rather than guessed at. +The reference-level `BookReferenceModel.Language`/`BookReferenceDto.Language` and `IBookRepository.SetReferenceLinkAsync`'s `canonicalLanguage` parameter follow the exact same propagation shape `Genre`/`canonicalGenre` already established: null (not overwritten) when the provider has none. ### Blazor app diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 40a88448..e182d68d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -86,7 +86,8 @@ Key | Description `Tmdb:ApiKey` | TMDB v3 API key, used to auto-match shows/movies to episode titles and synopses (see [Reference data](#reference-data-tmdb-open-library-rawg-discogs) below) `Rawg:ApiKey` | RAWG API key, used to auto-match video games to synopses/cover art/platforms (see [Reference data](#reference-data-tmdb-open-library-rawg-discogs) below) `Discogs:Token` | Discogs personal access token, used to auto-match albums to synopses/cover art/genres (see [Reference data](#reference-data-tmdb-open-library-rawg-discogs) below) -`ReferenceData:BookProvider` | Which `IBookReferenceClient` implementation to use for book matching (see [Reference data](#reference-data-tmdb-open-library-rawg-discogs) below). Default: `OpenLibrary` +`GoogleBooks:ApiKey` | Google Books API key, used to auto-match books to synopses/cover art/language/genres (see [Reference data](#reference-data-tmdb-open-library-rawg-discogs) below) +`ReferenceData:BookProvider` | Default `IBookReferenceClient` provider key used for automatic/background book matching - every registered provider (Google Books, Open Library, BnF) is always available for an admin to pick per search/link regardless of this setting (see [Reference data](#reference-data-tmdb-open-library-rawg-discogs) below). Default: `googlebooks` This values can be easily provided as environment variables (replace ":" by "__") or by configuration (json). @@ -153,15 +154,16 @@ Albums | [Discogs](https://www.discogs.com/developers) | `Discogs:Tok Set `Rawg:ApiKey` (or `Rawg__ApiKey`) to that key. 4. **Discogs**: create a free account, then generate a personal access token at [discogs.com/settings/developers](https://www.discogs.com/settings/developers). Set `Discogs:Token` (or `Discogs__Token`) to that token. +5. **Google Books**: create a project in [Google Cloud Console](https://console.cloud.google.com/), enable the Books API, then generate an API key. + Set `GoogleBooks:ApiKey` (or `GoogleBooks__ApiKey`) to that key. Without a key/token for a given provider, new items of that type simply stay unresolved (no synopsis, no cover art) instead of erroring. The app degrades gracefully per type - it just won't auto-match that type until the corresponding setting is provided. -Unlike the other three, books are resolved through a provider-agnostic `IBookReferenceClient` interface (`src/WebApi/ReferenceData/`). -Which book provider is active is itself a setting: `ReferenceData:BookProvider` (or the `ReferenceData__BookProvider` environment variable), defaulting to `OpenLibrary`. -`src/WebApi/Program.cs` switches on this value to decide which implementation to register - `OpenLibrary` is the only one that ships today. -To add a new book provider, implement `IBookReferenceClient` (a new client class alongside `OpenLibraryClient.cs`, plus its own settings class if it needs an API key, following `RawgSettings`/`DiscogsSettings`). -Also add a matching `case` to that switch. +Unlike the other three, books are resolved through a provider-agnostic `IBookReferenceClient` interface (`src/WebApi/ReferenceData/`), and it's the one reference domain with more than one provider registered at once: `GoogleBooksClient` (key `googlebooks`, the default - real synopses, cover art, language and the widest catalogue coverage of the three, including manga/comics), `OpenLibraryClient` (key `openlibrary`, no API key needed, kept as a fallback), and `BnfClient` (key `bnf`, BnF's free/keyless SRU Catalogue général, also kept as a fallback - in practice its records tend to have long library-catalogue-style titles, no cover art, and little to no synopsis, so it's rarely the best choice, but it can still surface a French title neither of the other two has). +`src/WebApi/Program.cs` registers every implemented provider unconditionally; `BookReferenceClientRegistry` resolves a provider by key, falling back to `ReferenceData:BookProvider` (or the `ReferenceData__BookProvider` environment variable, default `googlebooks`) when none is specified. +An admin can pick any registered provider per search/link action from the reference-data admin page or a book's own detail page, regardless of that default. +To add a new book provider, implement `IBookReferenceClient` (a new client class alongside `GoogleBooksClient.cs`/`OpenLibraryClient.cs`/`BnfClient.cs`, plus its own settings class if it needs an API key, following `RawgSettings`/`DiscogsSettings`) and add one registration block to `Program.cs` - no changes needed to the registry, enrichment service, admin controller, or admin UI. Nothing else in the app needs to change, since `ReferenceEnrichmentService`/`ReferenceDataAdminController` only depend on the interface and read the active provider's key from `IBookReferenceClient.ProviderKey`. ### Admin role @@ -376,6 +378,7 @@ Variable | Default | Purpose `E2E_TRACE` | `on-failure` | `off`, `on` or `on-failure`; traces/screenshots land in `bin//net10.0/e2e-diagnostics` `E2E_SCREENSHOTS` | `false` | Opt-in for `MobileScreenshotTest`, an assertion-free visual-review walkthrough: seeds representative data, captures every page at a phone viewport, cleans up after itself `E2E_SHOTS_DIR` | `bin//net10.0/mobile-shots` | Where `MobileScreenshotTest` writes its captures +`GOOGLE_BOOKS_SMOKE_ENABLED` | `false` | Opt-in for `GoogleBooksSmokeTest`, which links a real book through the actual Google Books provider (requires `GoogleBooks:ApiKey` to be configured) - kept opt-in rather than always-on since Google's API has been observed to occasionally return a transient 503 Integration mode (the common local/CI case) reuses the same MongoDB/Firebase variables as the integration tests above, pointed at a dedicated database (e.g. `keeptrack_e2e`), plus `E2E_ENABLED=true`: diff --git a/docs/code-quality-findings.md b/docs/code-quality-findings.md index 5d5cf71f..706a9a8c 100644 --- a/docs/code-quality-findings.md +++ b/docs/code-quality-findings.md @@ -6,6 +6,26 @@ Update this file as items are fixed or as new reviews are performed. ## Fixed +### BnF's own `"and (bib.author ...)"` CQL combination is not a strict intersection - candidates not actually matching the requested author silently leaked into search results + +Found on 2026-07-19 (real user report: "when I search BnF it doesn't consider the author") while BnF was the second registered book provider. +Confirmed directly against the real API: a query for title "La Peste" and author "Victor Hugo" (who never wrote a book by that title) returned several genuine Victor Hugo anthologies instead of zero results, none of them actually titled "La Peste". +The same query shape correctly narrows to 69 genuine matches when the *correct* author (Albert Camus) is used, so the server-side clause isn't useless, just not trustworthy as a hard filter on its own - it appears to fall back to relevance-ranked results for the author alone when no record actually satisfies both criteria, rather than returning an empty set. +Fixed by adding a client-side post-filter (`BnfClient.AuthorMatches`, a normalized word-presence check reusing `TitleNormalizer.Normalize`) that discards any parsed candidate whose own author text doesn't actually contain every word of the requested author, instead of trusting BnF's own filtering. +Covered by `BnfClientTest.SearchBooksAsync_FiltersOutCandidatesWhoseAuthorDoesNotActuallyMatch`. + +File: `src/WebApi/ReferenceData/BnfClient.cs` + +### `RefreshBookReferenceAsync` only ever checked the currently-configured default provider's key, not whichever provider a reference was actually linked through + +Found on 2026-07-19 while adding a second book reference provider (BnF, alongside Open Library) and letting an admin pick either one per search/link action instead of only a deployment-wide config switch. +`RefreshBookReferenceAsync` read `reference.ExternalIds.GetValueOrDefault(bookReferenceClient.ProviderKey)`, where `bookReferenceClient` was the single injected client for whichever provider `ReferenceData:BookProvider` currently names. +Once a book reference could be linked through a *different* registered provider than the current default (e.g. linked via BnF while the deployment default stays Open Library), the periodic/on-demand sync would find no id under the default's key and silently no-op that reference forever - it would never refresh again, with no error surfaced anywhere. +Fixed by resolving against every currently-registered provider's key (`BookReferenceClientRegistry.All.FirstOrDefault(c => reference.ExternalIds.ContainsKey(c.ProviderKey))`) instead of a single injected client's key. +Covered by `ReferenceEnrichmentServiceTest.RefreshBookReferenceAsync_RefreshesViaANonDefaultRegisteredProvider_WhenThatsTheOnlyOnePresent`. + +File: `src/WebApi/ReferenceData/ReferenceEnrichmentService.Books.cs` + Several findings below trace back to AutoMapper's profile-wide `AllowNullDestinationValues = false` (a null source string/collection/object silently substituted with `""`/an empty collection/a blank instance). AutoMapper itself was removed in favor of Riok.Mapperly (see `docs/automapper-removal-plan.md`), which preserves nulls by default. The entire class of gotcha these findings patched around is now structurally impossible, not just individually fixed. diff --git a/src/BlazorApp/Components/Inventory/Pages/BookDetail.razor b/src/BlazorApp/Components/Inventory/Pages/BookDetail.razor index 097af65e..1ae1af97 100644 --- a/src/BlazorApp/Components/Inventory/Pages/BookDetail.razor +++ b/src/BlazorApp/Components/Inventory/Pages/BookDetail.razor @@ -55,10 +55,11 @@ else @(Book.FirstReadAt is not null ? "✓ Read" : "Mark as read")
- @if (!string.IsNullOrEmpty(Reference?.ImageUrl)) + @{ var coverUrl = !string.IsNullOrEmpty(Book.CustomImageUrl) ? Book.CustomImageUrl : Reference?.ImageUrl; } + @if (!string.IsNullOrEmpty(coverUrl)) {
- @Book.Title cover + @Book.Title cover
}
@@ -91,6 +92,15 @@ else
+
+ + +
+
+ + +
@@ -236,6 +246,20 @@ else await BookApi.UpdateAsync(Book); } + private async Task SaveLanguageAsync(ChangeEventArgs e) + { + if (Book is null) return; + Book.Language = e.Value?.ToString(); + await BookApi.UpdateAsync(Book); + } + + private async Task SaveCustomImageUrlAsync(ChangeEventArgs e) + { + if (Book is null) return; + Book.CustomImageUrl = e.Value?.ToString(); + await BookApi.UpdateAsync(Book); + } + private async Task RefreshReferenceAsync() { if (Book?.Id is null) return; diff --git a/src/BlazorApp/Components/ReferenceDataAdmin/InlineReferenceLinker.razor b/src/BlazorApp/Components/ReferenceDataAdmin/InlineReferenceLinker.razor index f4dbdebc..abd593a0 100644 --- a/src/BlazorApp/Components/ReferenceDataAdmin/InlineReferenceLinker.razor +++ b/src/BlazorApp/Components/ReferenceDataAdmin/InlineReferenceLinker.razor @@ -5,16 +5,31 @@

Not yet matched to a reference source (admin only).

- @if (!_searched) + @if (Type == ReferenceItemType.Book && _bookProviders.Count > 0) { - @* disabled on an empty title: the API refuses (400) to hit a provider with nothing to search for *@ - +
+ @foreach (var bookProvider in _bookProviders) + { + + } +
} - else if (_searching) + + @* One persistent button for search/re-search, re-labeled rather than swapped out for a + differently-labeled button after the first search - it used to be replaced by a plain + "↻ Search again" button post-search, which looked like the original button had vanished. + Re-runs with the page's *current* title/year (and author/artist) field values - the + wrong-candidates fix is "edit the fields above, then search again". *@ + + + @if (_searching) {
Searching…
} - else if (_results.Count == 0) + else if (_searched && _results.Count == 0) {
No @ProviderName results for this title/year.
} @@ -44,15 +59,6 @@ } } - @if (_searched && !_searching) - { - @* Re-runs the search with the page's *current* title/year (and author/artist) field values - - the wrong-candidates fix is "edit the fields above, then search again", which previously - required a full page reload. *@ - - } - @if (_error is not null) {
@_error
@@ -81,22 +87,45 @@ private bool _linking; private List _results = []; private string? _error; + private List _bookProviders = []; + private string? _selectedProvider; private string ProviderName => Type switch { ReferenceItemType.TvShow or ReferenceItemType.Movie => "TMDB", - ReferenceItemType.Book => "Open Library", + ReferenceItemType.Book => _bookProviders.FirstOrDefault(p => p.Key == _selectedProvider)?.DisplayName ?? "Google Books", ReferenceItemType.VideoGame => "RAWG", ReferenceItemType.Album => "Discogs", _ => "reference" }; + protected override async Task OnInitializedAsync() + { + if (Type != ReferenceItemType.Book) return; + _bookProviders = await Api.GetBookProvidersAsync(); + _selectedProvider = _bookProviders.FirstOrDefault()?.Key; + } + private async Task SearchAsync() { _searched = true; _searching = true; - _results = await Api.SearchAsync(Type, Title, Year, Creator); - _searching = false; + _error = null; + try + { + _results = await Api.SearchAsync(Type, Title, Year, Creator, Type == ReferenceItemType.Book ? _selectedProvider : null); + } + catch (Exception ex) + { + // an uncaught exception here would crash the whole Blazor Server circuit (not just this + // component), forcing a full page reload - same reasoning as LinkAsync's own try/catch below. + _results = []; + _error = ex.Message; + } + finally + { + _searching = false; + } } private async Task LinkAsync(ReferenceSearchResultDto candidate) @@ -105,7 +134,14 @@ _error = null; try { - await Api.LinkAsync(new LinkReferenceRequestDto { Type = Type, Title = Title, Year = Year, ExternalId = candidate.ExternalId }); + await Api.LinkAsync(new LinkReferenceRequestDto + { + Type = Type, + Title = Title, + Year = Year, + ExternalId = candidate.ExternalId, + Provider = Type == ReferenceItemType.Book ? _selectedProvider : null + }); await OnLinked.InvokeAsync(); } catch (Exception ex) diff --git a/src/BlazorApp/Components/ReferenceDataAdmin/ReferenceDataAdminApiClient.cs b/src/BlazorApp/Components/ReferenceDataAdmin/ReferenceDataAdminApiClient.cs index f7a8b3e2..f0a8945e 100644 --- a/src/BlazorApp/Components/ReferenceDataAdmin/ReferenceDataAdminApiClient.cs +++ b/src/BlazorApp/Components/ReferenceDataAdmin/ReferenceDataAdminApiClient.cs @@ -11,16 +11,27 @@ public async Task> GetUnresolvedAsync(ReferenceItem return results ?? []; } - public async Task> SearchAsync(ReferenceItemType type, string title, int? year, string? creator = null) + public async Task> SearchAsync(ReferenceItemType type, string title, int? year, string? creator = null, string? provider = 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)}"; + if (!string.IsNullOrEmpty(provider)) query += $"&provider={Uri.EscapeDataString(provider)}"; var results = await http.GetFromJsonAsync>(query); return results ?? []; } + /// + /// Every registered book provider (see ReferenceDataAdminController.GetBookProviders) - Book is + /// the one reference domain with more than one, so this is the only per-type provider list needed. + /// + public async Task> GetBookProvidersAsync() + { + var results = await http.GetFromJsonAsync>("/api/reference-data/book-providers"); + return results ?? []; + } + public async Task LinkAsync(LinkReferenceRequestDto request) { var response = await http.PostAsJsonAsync("/api/reference-data/link", request); diff --git a/src/BlazorApp/Components/ReferenceDataAdmin/ReferenceDataAdminPage.razor b/src/BlazorApp/Components/ReferenceDataAdmin/ReferenceDataAdminPage.razor index f23e472c..309082fd 100644 --- a/src/BlazorApp/Components/ReferenceDataAdmin/ReferenceDataAdminPage.razor +++ b/src/BlazorApp/Components/ReferenceDataAdmin/ReferenceDataAdminPage.razor @@ -60,6 +60,14 @@ } + @if (_type == ReferenceItemType.Book) + { + @foreach (var bookProvider in _bookProviders) + { + + } + }
@@ -294,11 +302,13 @@ private string? _queryTitle; private int? _queryYear; private string? _creator; + private List _bookProviders = []; + private string? _selectedProvider; private string ProviderName => _type switch { ReferenceItemType.TvShow or ReferenceItemType.Movie => "TMDB", - ReferenceItemType.Book => "Open Library", + ReferenceItemType.Book => _bookProviders.FirstOrDefault(p => p.Key == _selectedProvider)?.DisplayName ?? "Google Books", ReferenceItemType.VideoGame => "RAWG", ReferenceItemType.Album => "Discogs", _ => "reference" @@ -428,6 +438,8 @@ protected override async Task OnInitializedAsync() { + _bookProviders = await Api.GetBookProvidersAsync(); + _selectedProvider = _bookProviders.FirstOrDefault()?.Key; await LoadUnresolvedAsync(); await LoadSystemStatusAsync(); } @@ -493,8 +505,21 @@ _searching = true; _error = null; - _searchResults = await Api.SearchAsync(_type, _queryTitle ?? _selected.Title, _queryYear, _creator); - _searching = false; + try + { + _searchResults = await Api.SearchAsync(_type, _queryTitle ?? _selected.Title, _queryYear, _creator, _type == ReferenceItemType.Book ? _selectedProvider : null); + } + catch (Exception ex) + { + // an uncaught exception here would crash the whole Blazor Server circuit (not just this + // component), forcing a full page reload - same reasoning as LinkAsync's own try/catch below. + _searchResults = []; + _error = ex.Message; + } + finally + { + _searching = false; + } } /// @@ -510,7 +535,14 @@ _error = null; try { - await Api.LinkAsync(new LinkReferenceRequestDto { Type = _type, Title = _selected.Title, Year = _selected.Year, ExternalId = candidate.ExternalId }); + await Api.LinkAsync(new LinkReferenceRequestDto + { + Type = _type, + Title = _selected.Title, + Year = _selected.Year, + ExternalId = candidate.ExternalId, + Provider = _type == ReferenceItemType.Book ? _selectedProvider : null + }); await LoadUnresolvedAsync(); } catch (Exception ex) diff --git a/src/Domain/Models/BookModel.cs b/src/Domain/Models/BookModel.cs index 58226bfa..80e1ffb5 100644 --- a/src/Domain/Models/BookModel.cs +++ b/src/Domain/Models/BookModel.cs @@ -22,12 +22,21 @@ public class BookModel : IHasIdAndOwnerId public string? Genre { get; set; } + public string? Language { get; set; } + public string? Notes { get; set; } public DateOnly? FirstReadAt { get; set; } public string? ReferenceId { get; set; } + /// + /// Tenant-owned cover image override - takes priority over the linked reference's own cover wherever + /// a cover is shown (list thumbnail, detail page). Null means "use the reference's cover, if any" - + /// the previous, only behavior. + /// + public string? CustomImageUrl { get; set; } + public bool IsFavorite { get; set; } public List OwnedVersions { get; set; } = []; diff --git a/src/Domain/Models/BookReferenceModel.cs b/src/Domain/Models/BookReferenceModel.cs index 053b8b3d..b41f746b 100644 --- a/src/Domain/Models/BookReferenceModel.cs +++ b/src/Domain/Models/BookReferenceModel.cs @@ -40,5 +40,11 @@ public class BookReferenceModel : IHasId public string? ImageUrl { get; set; } + /// + /// The book's language, when the linking provider reports one (BnF's Dublin Core reliably does; Open + /// Library's client doesn't populate this today - see ). + /// + public string? Language { get; set; } + public DateTime? LastEnrichedAt { get; set; } } diff --git a/src/Domain/Repositories/IBookRepository.cs b/src/Domain/Repositories/IBookRepository.cs index 92e2525b..921ff3b9 100644 --- a/src/Domain/Repositories/IBookRepository.cs +++ b/src/Domain/Repositories/IBookRepository.cs @@ -8,11 +8,12 @@ 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 - /// . + /// , 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); + Task SetReferenceLinkAsync(string title, int? year, string referenceId, string canonicalTitle, int? canonicalYear = null, string? canonicalAuthor = null, string? canonicalGenre = null, + string? canonicalLanguage = null); /// /// Distinct (title, year) pairs across every tenant's books that have no diff --git a/src/Infrastructure.MongoDb/Entities/Book.cs b/src/Infrastructure.MongoDb/Entities/Book.cs index 9a7e058f..6611b4cb 100644 --- a/src/Infrastructure.MongoDb/Entities/Book.cs +++ b/src/Infrastructure.MongoDb/Entities/Book.cs @@ -27,6 +27,8 @@ public class Book : IHasIdAndOwnerId public string? Genre { get; set; } + public string? Language { get; set; } + public string? Notes { get; set; } [BsonElement("first_read_at")] @@ -35,6 +37,9 @@ public class Book : IHasIdAndOwnerId [BsonElement("reference_id")] public string? ReferenceId { get; set; } + [BsonElement("custom_image_url")] + public string? CustomImageUrl { get; set; } + [BsonElement("is_favorite")] public bool IsFavorite { get; set; } diff --git a/src/Infrastructure.MongoDb/Entities/BookReference.cs b/src/Infrastructure.MongoDb/Entities/BookReference.cs index 5ff70d90..ff9ac35c 100644 --- a/src/Infrastructure.MongoDb/Entities/BookReference.cs +++ b/src/Infrastructure.MongoDb/Entities/BookReference.cs @@ -38,6 +38,8 @@ public class BookReference [BsonElement("image_url")] public string? ImageUrl { get; set; } + public string? Language { get; set; } + [BsonElement("last_enriched_at")] public DateTime? LastEnrichedAt { get; set; } } diff --git a/src/Infrastructure.MongoDb/Repositories/BookRepository.cs b/src/Infrastructure.MongoDb/Repositories/BookRepository.cs index 3a0bcda5..2dd928d5 100644 --- a/src/Infrastructure.MongoDb/Repositories/BookRepository.cs +++ b/src/Infrastructure.MongoDb/Repositories/BookRepository.cs @@ -42,7 +42,8 @@ protected override FilterDefinition GetFilter(string ownerId, string? sear return filter; } - public async Task SetReferenceLinkAsync(string title, int? year, string referenceId, string canonicalTitle, int? canonicalYear = null, string? canonicalAuthor = null, string? canonicalGenre = null) + public async Task SetReferenceLinkAsync(string title, int? year, string referenceId, string canonicalTitle, int? canonicalYear = null, string? canonicalAuthor = null, string? canonicalGenre = null, + string? canonicalLanguage = null) { var builder = Builders.Filter; var filter = builder.Regex(f => f.Title, new BsonRegularExpression($"^{Regex.Escape(title)}$", "i")) @@ -53,6 +54,7 @@ public async Task SetReferenceLinkAsync(string title, int? year, string re if (canonicalYear is not null) update = update.Set(f => f.Year, canonicalYear); if (canonicalAuthor is not null) update = update.Set(f => f.Author, canonicalAuthor); if (canonicalGenre is not null) update = update.Set(f => f.Genre, canonicalGenre); + if (canonicalLanguage is not null) update = update.Set(f => f.Language, canonicalLanguage); var result = await GetCollection().UpdateManyAsync(filter, update); return result.ModifiedCount; } diff --git a/src/WebApi.Contracts/Dto/BookDto.cs b/src/WebApi.Contracts/Dto/BookDto.cs index e9d31715..f839b32f 100644 --- a/src/WebApi.Contracts/Dto/BookDto.cs +++ b/src/WebApi.Contracts/Dto/BookDto.cs @@ -41,6 +41,12 @@ public class BookDto : IHasId, IReferenceLinkedDto public string? Genre { get; set; } + /// + /// Book language - free text, auto-filled on link/refresh from providers that report one (BnF does; + /// Open Library doesn't yet), always freely editable afterward. + /// + public string? Language { get; set; } + public string? Notes { get; set; } /// @@ -54,11 +60,18 @@ public class BookDto : IHasId, IReferenceLinkedDto public string? ReferenceId { get; set; } /// - /// Cover/poster image URL from the linked reference document - read-only, hydrated server-side on - /// list reads and never accepted from client input. + /// Cover image URL shown on the list page - when set, otherwise the linked + /// reference document's own cover. Read-only, hydrated server-side on list reads; never accepted from + /// client input (edit instead). /// public string? ImageUrl { get; set; } + /// + /// Tenant-owned cover image override, freely editable - takes priority over the linked reference's + /// cover wherever one is shown. Null means "use the reference's cover, if any". + /// + public string? CustomImageUrl { get; set; } + public bool IsFavorite { get; set; } /// diff --git a/src/WebApi.Contracts/Dto/BookProviderDto.cs b/src/WebApi.Contracts/Dto/BookProviderDto.cs new file mode 100644 index 00000000..2f971475 --- /dev/null +++ b/src/WebApi.Contracts/Dto/BookProviderDto.cs @@ -0,0 +1,14 @@ +namespace Keeptrack.WebApi.Contracts.Dto; + +/// +/// One registered book reference provider an admin can search/link with - see +/// GET /api/reference-data/book-providers. +/// +public class BookProviderDto +{ + /// The provider's key, e.g. "openlibrary"/"bnf" - pass back as . + public required string Key { get; set; } + + /// Human-readable name for display, e.g. "Open Library"/"BnF". + public required string DisplayName { get; set; } +} diff --git a/src/WebApi.Contracts/Dto/BookReferenceDto.cs b/src/WebApi.Contracts/Dto/BookReferenceDto.cs index 0b0d51b3..3eb99254 100644 --- a/src/WebApi.Contracts/Dto/BookReferenceDto.cs +++ b/src/WebApi.Contracts/Dto/BookReferenceDto.cs @@ -24,4 +24,7 @@ public class BookReferenceDto : IHasId public List Genres { get; set; } = []; public string? ImageUrl { get; set; } + + /// The book's language, when the linking provider reports one. + public string? Language { get; set; } } diff --git a/src/WebApi.Contracts/Dto/LinkReferenceRequestDto.cs b/src/WebApi.Contracts/Dto/LinkReferenceRequestDto.cs index 0effea4e..52db0f61 100644 --- a/src/WebApi.Contracts/Dto/LinkReferenceRequestDto.cs +++ b/src/WebApi.Contracts/Dto/LinkReferenceRequestDto.cs @@ -13,4 +13,11 @@ public class LinkReferenceRequestDto public int? Year { get; set; } public required string ExternalId { get; set; } + + /// + /// Which registered provider came from - only meaningful for + /// (the one domain with more than one registered provider); null + /// for every other type, and null here falls back to the deployment's default book provider. + /// + public string? Provider { get; set; } } diff --git a/src/WebApi/AppConfiguration.cs b/src/WebApi/AppConfiguration.cs index 6f38b57b..feb7095a 100644 --- a/src/WebApi/AppConfiguration.cs +++ b/src/WebApi/AppConfiguration.cs @@ -45,10 +45,14 @@ public static int GetFreeTierItemLimit(IConfiguration configuration) public DiscogsSettings DiscogsSettings { get; } = configuration.TryGetSection("Discogs"); + public GoogleBooksSettings GoogleBooksSettings { get; } = configuration.TryGetSection("GoogleBooks"); + /// - /// Selects which implementation Program.cs - /// registers - see the switch there for supported values. Overridable via the - /// ReferenceData__BookProvider environment variable, same convention as every other setting. + /// Which book provider () is used for + /// automatic/background resolution when an admin doesn't pick one explicitly - see + /// . Every registered provider stays available + /// to pick from regardless of this value. Overridable via the ReferenceData__BookProvider + /// environment variable, same convention as every other setting. /// public string BookReferenceProvider => configuration.TryGetSection("ReferenceData:BookProvider"); diff --git a/src/WebApi/Controllers/BookController.cs b/src/WebApi/Controllers/BookController.cs index b9fc31aa..ee55bd58 100644 --- a/src/WebApi/Controllers/BookController.cs +++ b/src/WebApi/Controllers/BookController.cs @@ -21,10 +21,19 @@ public class BookController( { /// /// Hydrates each page item's cover image from its linked reference document - one batched lookup per - /// page (see ), keyed by the id-bearing documents only. + /// page (see ), keyed by the id-bearing documents only. A book with + /// its own set overrides that afterward - this is Book-specific + /// (not shared via /, + /// which the other four reference-linked types also implement, with no equivalent override field). /// - protected override Task OnListMappedAsync(List dtos) - => ReferenceImageHydrator.HydrateAsync(dtos, referenceRepository.FindByIdsAsync, x => x.ImageUrl); + protected override async Task OnListMappedAsync(List dtos) + { + await ReferenceImageHydrator.HydrateAsync(dtos, referenceRepository.FindByIdsAsync, x => x.ImageUrl); + foreach (var dto in dtos.Where(d => !string.IsNullOrEmpty(d.CustomImageUrl))) + { + dto.ImageUrl = dto.CustomImageUrl; + } + } /// /// Fires a best-effort background Open Library match for the new book - see . diff --git a/src/WebApi/Controllers/SystemStatusController.cs b/src/WebApi/Controllers/SystemStatusController.cs index 3390075b..e497e86d 100644 --- a/src/WebApi/Controllers/SystemStatusController.cs +++ b/src/WebApi/Controllers/SystemStatusController.cs @@ -41,8 +41,9 @@ public async Task> Get() { InstanceName = Environment.MachineName, IsReferenceSyncEnabled = appConfiguration.IsReferenceSyncEnabled, - // mirrors Program.cs's provider switch default - update both if a second provider ships - BookProvider = string.IsNullOrEmpty(appConfiguration.BookReferenceProvider) ? "OpenLibrary" : appConfiguration.BookReferenceProvider, + // the *default* provider used for automatic/background resolution - see BookReferenceClientRegistry; + // an admin can search/link with any registered provider regardless of this value (GET book-providers) + BookProvider = string.IsNullOrEmpty(appConfiguration.BookReferenceProvider) ? "googlebooks" : appConfiguration.BookReferenceProvider, ReferenceSyncLease = lease is null ? null : new SystemLeaseDto { Holder = lease.Holder, ExpiresAt = lease.ExpiresAt, IsLive = lease.ExpiresAt > DateTime.UtcNow }, diff --git a/src/WebApi/Program.cs b/src/WebApi/Program.cs index cfeb8d60..6874aab8 100644 --- a/src/WebApi/Program.cs +++ b/src/WebApi/Program.cs @@ -52,21 +52,39 @@ // see https://github.com/dotnet/extensions/issues/4770 (confirmed against this exact symptom on Discogs). client.Timeout = Timeout.InfiniteTimeSpan; }).AddProviderResilienceHandler(); -// which IBookReferenceClient implementation is registered is a deployment-time choice -// (ReferenceData:BookProvider / ReferenceData__BookProvider) - add a case here for each new provider. -switch (configuration.BookReferenceProvider) +// every book provider is registered unconditionally (unlike the single-provider TMDB/RAWG/Discogs clients +// below) - an admin picks which one to search with at request time (see BookReferenceClientRegistry), +// ReferenceData:BookProvider only selects the *default* used for automatic/background resolution. +// Each is registered as itself via the typed-client pattern, then bridged to the shared interface with +// AddTransient (not AddSingleton - capturing a typed HttpClient in a singleton would pin its handler +// forever and defeat IHttpClientFactory's rotation) so IEnumerable resolves both. +// Registration order is also display/priority order in the admin UI's provider picker (BookReferenceClientRegistry.All +// preserves it) - Google Books first since it's the default (best synopsis/cover/language/catalogue +// coverage of the three), Open Library and BnF after as fallbacks. +builder.Services.AddSingleton(configuration.GoogleBooksSettings); +builder.Services.AddHttpClient(client => { - case "OpenLibrary": - builder.Services.AddHttpClient(client => - { - client.BaseAddress = new Uri("https://openlibrary.org/"); - client.DefaultRequestHeaders.Add("User-Agent", "Keeptrack/1.0 (+https://github.com/devpro/keeptrack)"); - client.Timeout = Timeout.InfiniteTimeSpan; - }).AddProviderResilienceHandler(); - break; - default: - throw new InvalidOperationException($"Unknown ReferenceData:BookProvider '{configuration.BookReferenceProvider}'. Supported providers: OpenLibrary."); -} + client.BaseAddress = new Uri("https://www.googleapis.com/books/v1/"); + client.Timeout = Timeout.InfiniteTimeSpan; +}).AddProviderResilienceHandler(); +builder.Services.AddTransient(sp => sp.GetRequiredService()); +builder.Services.AddHttpClient(client => +{ + client.BaseAddress = new Uri("https://openlibrary.org/"); + client.DefaultRequestHeaders.Add("User-Agent", "Keeptrack/1.0 (+https://github.com/devpro/keeptrack)"); + client.Timeout = Timeout.InfiniteTimeSpan; +}).AddProviderResilienceHandler(); +builder.Services.AddTransient(sp => sp.GetRequiredService()); +builder.Services.AddHttpClient(client => +{ + client.BaseAddress = new Uri("https://catalogue.bnf.fr/api/"); + client.Timeout = Timeout.InfiniteTimeSpan; +}).AddProviderResilienceHandler(); +builder.Services.AddTransient(sp => sp.GetRequiredService()); +// a factory (not a plain AddScoped) so configuration.BookReferenceProvider - +// a plain computed-on-access property, not cached - is read fresh on every scope, same "checked fresh" +// requirement IsReferenceSyncEnabled already has elsewhere, without exposing all of AppConfiguration here. +builder.Services.AddScoped(sp => new Keeptrack.WebApi.ReferenceData.BookReferenceClientRegistry(sp.GetServices(), configuration.BookReferenceProvider)); builder.Services.AddSingleton(configuration.RawgSettings); builder.Services.AddHttpClient(client => { diff --git a/src/WebApi/ReferenceData/BnfClient.cs b/src/WebApi/ReferenceData/BnfClient.cs new file mode 100644 index 00000000..63ced96a --- /dev/null +++ b/src/WebApi/ReferenceData/BnfClient.cs @@ -0,0 +1,176 @@ +using System.Globalization; +using System.Text.RegularExpressions; +using System.Xml.Linq; +using Keeptrack.Common.System; + +namespace Keeptrack.WebApi.ReferenceData; + +/// +/// BnF (Bibliothèque nationale de France) "Catalogue général" SRU client - free, keyless, public. +/// Unlike every other provider here (JSON), SRU responses are XML: each hit is a srw:record whose +/// srw:recordData embeds a Dublin Core (oai_dc:dc) document. Confirmed against the real API +/// (searching "Killing Floor" / Lee Child, then re-fetching by bib.persistentid) before writing this +/// parser - the exact element/namespace shape below is not guessed from documentation prose. +/// Deliberately never populates /: +/// BnF's ordinary catalogue records carry no cover-art field at all (only a digitized Gallica item would, +/// via a separate API this client doesn't call), unlike Open Library/RAWG/Discogs. +/// +public class BnfClient(HttpClient http) : IBookReferenceClient +{ + public string ProviderKey => "bnf"; + + public string DisplayName => "BnF"; + + private static readonly XNamespace s_srw = "http://www.loc.gov/zing/srw/"; + private static readonly XNamespace s_dc = "http://purl.org/dc/elements/1.1/"; + + private const int MaxGenres = 5; + + /// + /// is deliberately never sent to BnF as a query criterion, same choice + /// makes (see its own doc comment) though for a different reason here: + /// BnF's own "and" combination was confirmed unreliable for narrowing (see ), + /// so stacking a second server-side "and" clause on top of the author one would only compound that risk. + /// dc:date is still parsed and returned per candidate () for + /// the admin to use when picking, exactly like every other provider here. + /// + public async Task> SearchBooksAsync(string title, int? year, string? author = null, CancellationToken cancellationToken = default) + { + var results = await SearchBooksCoreAsync(title, author, cancellationToken); + if (results.Count == 0 && !string.IsNullOrEmpty(author)) + { + // Same "an optional narrowing parameter must never silently zero out results" lesson as + // OpenLibraryClient/DiscogsClient - a tenant's plain author text can fail to match BnF's own + // "LastName, FirstName (dates). Role" creator indexing even when the title alone would find it. + results = await SearchBooksCoreAsync(title, null, cancellationToken); + } + + return results; + } + + /// + /// , when given, is both sent to BnF as an "and (bib.author ...)" clause AND + /// re-checked client-side afterward - confirmed against the real API that BnF's own "and" combination + /// is not a strict intersection for every author: querying title "La Peste" and author "Victor Hugo" + /// (who never wrote a book by that title) returns several real Hugo anthologies instead of zero, none + /// of them actually titled "La Peste". The server-side clause still narrows the common, well-populated + /// case (confirmed correct for "Killing Floor"/Lee Child and "La Peste"/Albert Camus, both genuine + /// matches), but a candidate that slips through without a real author match must be filtered out here + /// rather than trusted - otherwise search results silently include titles that don't match the + /// requested author at all, which read as "the author was ignored". + /// + private async Task> SearchBooksCoreAsync(string title, string? author, CancellationToken cancellationToken) + { + var xml = await FetchAsync(BuildCqlQuery(title, author), cancellationToken); + var records = ParseRecords(xml); + if (!string.IsNullOrEmpty(author)) + { + records = records.Where(r => AuthorMatches(r.Author, author)); + } + + return records.Select(r => new BookSearchResult(r.ExternalId, r.Title, r.Year, r.Author, null)).ToList(); + } + + /// + /// True when every word of appears somewhere in + /// - a plain substring/word-presence check (same normalization, + /// , already used for title matching elsewhere), not an exact + /// match: already went through 's + /// reordering, but a multi-author record's dc:creator only ever contributes the FIRST credited + /// name to this parser, so this stays a loose contains-check rather than requiring exact equality. + /// + private static bool AuthorMatches(string? candidateAuthor, string requestedAuthor) + { + if (string.IsNullOrEmpty(candidateAuthor)) return false; + + var normalizedCandidate = TitleNormalizer.Normalize(candidateAuthor); + return requestedAuthor + .Split(' ', StringSplitOptions.RemoveEmptyEntries) + .All(word => normalizedCandidate.Contains(TitleNormalizer.Normalize(word))); + } + + public async Task GetBookDetailsAsync(string externalId, CancellationToken cancellationToken = default) + { + // externalId is the bare ARK (e.g. "ark:/12148/cb361713613") from srw:recordIdentifier - re-querying + // by bib.persistentid is the one exact-id search criterion confirmed to round-trip it back to the + // same single record. + var xml = await FetchAsync($"bib.persistentid all \"{externalId}\"", cancellationToken); + var record = ParseRecords(xml).FirstOrDefault(); + return record is null ? null : new BookDetails(record.ExternalId, record.Title, record.Year, record.Synopsis, record.Author, null, record.Genres, null, record.Language); + } + + private async Task FetchAsync(string cqlQuery, CancellationToken cancellationToken) + { + var query = $"SRU?version=1.2&operation=searchRetrieve&recordSchema=dublincore&maximumRecords=20&query={Uri.EscapeDataString(cqlQuery)}"; + return await http.GetStringAsync(query, cancellationToken); + } + + private static string BuildCqlQuery(string title, string? author) + { + var clause = $"bib.title all \"{EscapeCql(title)}\""; + return string.IsNullOrEmpty(author) ? clause : $"{clause} and (bib.author all \"{EscapeCql(author)}\")"; + } + + private static string EscapeCql(string value) => value.Replace("\"", "\\\""); + + private sealed record ParsedRecord(string ExternalId, string Title, int? Year, string? Author, string? Synopsis, string? Language, List Genres); + + /// + /// srw:recordData's only child is the oai_dc:dc wrapper - read it positionally rather than + /// by name, so this doesn't depend on assuming the oai_dc namespace prefix/URI stays exactly as observed. + /// A record missing an id or a title (shouldn't happen for a real bibliographic hit) is skipped rather + /// than surfaced as a broken candidate. + /// + private static IEnumerable ParseRecords(string xml) + { + var doc = XDocument.Parse(xml); + foreach (var record in doc.Descendants(s_srw + "record")) + { + var externalId = record.Element(s_srw + "recordIdentifier")?.Value; + var dc = record.Element(s_srw + "recordData")?.Elements().FirstOrDefault(); + var title = dc?.Element(s_dc + "title")?.Value; + if (string.IsNullOrEmpty(externalId) || string.IsNullOrEmpty(title)) continue; + + yield return new ParsedRecord( + externalId, + title, + ParseYear(dc!.Element(s_dc + "date")?.Value), + ExtractAuthorName(dc.Element(s_dc + "creator")?.Value), + dc.Element(s_dc + "description")?.Value, + dc.Element(s_dc + "language")?.Value, + dc.Elements(s_dc + "subject").Select(e => e.Value).Where(v => !string.IsNullOrEmpty(v)).Take(MaxGenres).ToList()); + } + } + + /// + /// BnF's dc:creator is formatted "LastName, FirstName (birth-death dates). Role" (e.g. "Child, + /// Lee (1954-....). Auteur du texte", confirmed against the real API) - strips the trailing role + /// sentence and the parenthetical dates, then reorders "LastName, FirstName" to "FirstName LastName" to + /// match the plain-name shape every other provider here returns. Left as-is (just parenthetical- + /// stripped) when there's no comma to reorder around (e.g. a corporate/collective author). + /// + private static string? ExtractAuthorName(string? raw) + { + if (string.IsNullOrWhiteSpace(raw)) return null; + + var namePart = raw.Split(". ", 2)[0]; + namePart = Regex.Replace(namePart, @"\s*\([^)]*\)", "").Trim(); + + var parts = namePart.Split(", ", 2); + return parts.Length == 2 ? $"{parts[1]} {parts[0]}".Trim() : namePart; + } + + /// + /// dc:date is usually a bare 4-digit year (confirmed "1997" for a real record) but library + /// catalogue dates can carry uncertainty markers or ranges - a defensive last-4-digit-token extraction, + /// same shape as 's own year parsing, rather than a bare int.Parse. + /// + private static readonly Regex s_yearRegex = new(@"\b\d{4}\b", RegexOptions.Compiled); + + private static int? ParseYear(string? date) + { + if (string.IsNullOrEmpty(date)) return null; + var matches = s_yearRegex.Matches(date); + return matches.Count > 0 && int.TryParse(matches[0].Value, NumberStyles.None, CultureInfo.InvariantCulture, out var year) ? year : null; + } +} diff --git a/src/WebApi/ReferenceData/BookReferenceClientRegistry.cs b/src/WebApi/ReferenceData/BookReferenceClientRegistry.cs new file mode 100644 index 00000000..4dfefed2 --- /dev/null +++ b/src/WebApi/ReferenceData/BookReferenceClientRegistry.cs @@ -0,0 +1,29 @@ +namespace Keeptrack.WebApi.ReferenceData; + +/// +/// Resolves which to use, by provider key or by falling back to the +/// deployment default (ReferenceData:BookProvider, passed in as +/// - see Program.cs). Exists because Book is the one reference domain with more than one registered +/// provider; every other domain (TMDB/RAWG/Discogs) still has exactly one client injected directly, with no +/// equivalent registry needed. Depends on just the one string it needs rather than the whole +/// , so a unit test can construct one without an unrelated config section +/// (JWT/TMDB/RAWG/Discogs settings) tripping 's own eager parsing. +/// +public class BookReferenceClientRegistry(IEnumerable clients, string defaultProviderKey) +{ + /// Every registered book provider, in DI registration order. + public IReadOnlyList All { get; } = clients.ToList(); + + /// + /// is matched case-insensitively so an existing deployment's + /// ReferenceData:BookProvider setting (historically the PascalCase switch-case label, e.g. + /// "OpenLibrary") keeps resolving against the lowercase + /// convention ("openlibrary") with no config migration needed. + /// + public IBookReferenceClient Resolve(string? providerKey) + { + var key = providerKey ?? defaultProviderKey; + return All.FirstOrDefault(c => string.Equals(c.ProviderKey, key, StringComparison.OrdinalIgnoreCase)) + ?? throw new ArgumentException($"Unknown book provider '{key}'.", nameof(providerKey)); + } +} diff --git a/src/WebApi/ReferenceData/GoogleBooksClient.cs b/src/WebApi/ReferenceData/GoogleBooksClient.cs new file mode 100644 index 00000000..910312e9 --- /dev/null +++ b/src/WebApi/ReferenceData/GoogleBooksClient.cs @@ -0,0 +1,161 @@ +using System.Globalization; +using System.Net; +using System.Text.Json.Serialization; +using System.Text.RegularExpressions; +using System.Web; + +namespace Keeptrack.WebApi.ReferenceData; + +/// +/// Google Books REST client - the default book provider (ReferenceData:BookProvider, see Program.cs) +/// once it's registered, chosen over Open Library/BnF for real synopses, cover art, language and much wider +/// catalogue coverage (including manga/comics), which the other two were both found lacking in practice. +/// Registered as a typed with the API key appended as a query parameter, same +/// convention as / - see Program.cs. +/// +public class GoogleBooksClient(HttpClient http, GoogleBooksSettings settings) : IBookReferenceClient +{ + public string ProviderKey => "googlebooks"; + + public string DisplayName => "Google Books"; + + private const int MaxResults = 20; + + private const int MaxGenres = 5; + + private string ApiKey => settings.ApiKey; + + public async Task> SearchBooksAsync(string title, int? year, string? author = null, CancellationToken cancellationToken = default) + { + var results = await SearchBooksCoreAsync(title, author, cancellationToken); + if (results.Count == 0 && !string.IsNullOrEmpty(author)) + { + // same "an optional narrowing parameter must never silently zero out results" lesson as + // OpenLibraryClient/DiscogsClient/BnfClient - an "inauthor:" qualifier that doesn't exactly + // match Google's own indexing can zero out results the title alone would find. + results = await SearchBooksCoreAsync(title, null, cancellationToken); + } + + return results; + } + + private async Task> SearchBooksCoreAsync(string title, string? author, CancellationToken cancellationToken) + { + var response = await http.GetFromJsonAsync( + $"volumes?q={Encode(BuildQuery(title, author))}&maxResults={MaxResults}&key={ApiKey}", cancellationToken); + + return response?.Items + .Where(i => !string.IsNullOrEmpty(i.Id) && !string.IsNullOrEmpty(i.VolumeInfo?.Title)) + .Select(i => new BookSearchResult(i.Id!, i.VolumeInfo!.Title!, ParseYear(i.VolumeInfo.PublishedDate), i.VolumeInfo.Authors.FirstOrDefault(), BuildImageUrl(i.VolumeInfo.ImageLinks))) + .ToList() ?? []; + } + + public async Task GetBookDetailsAsync(string externalId, CancellationToken cancellationToken = default) + { + var volume = await http.GetFromJsonAsync($"volumes/{externalId}?key={ApiKey}", cancellationToken); + var info = volume?.VolumeInfo; + if (info?.Title is null) return null; + + return new BookDetails( + externalId, + info.Title, + ParseYear(info.PublishedDate), + CleanDescription(info.Description), + info.Authors.FirstOrDefault(), + null, + info.Categories.Take(MaxGenres).ToList(), + BuildImageUrl(info.ImageLinks), + info.Language); + } + + private static string BuildQuery(string title, string? author) + { + var q = $"intitle:{title}"; + return string.IsNullOrEmpty(author) ? q : $"{q} inauthor:{author}"; + } + + private static string Encode(string value) => HttpUtility.UrlEncode(value); + + /// + /// Matches a standalone 4-digit token - volumeInfo.publishedDate is documented as a plain string + /// with no fixed format (seen in practice as a bare year, "YYYY-MM", or "YYYY-MM-DD"), same defensive + /// approach as 's own year parsing rather than a bare int.Parse. + /// + private static readonly Regex s_yearRegex = new(@"\b\d{4}\b", RegexOptions.Compiled); + + private static int? ParseYear(string? date) + { + if (string.IsNullOrEmpty(date)) return null; + var match = s_yearRegex.Match(date); + return match.Success && int.TryParse(match.Value, NumberStyles.None, CultureInfo.InvariantCulture, out var year) ? year : null; + } + + /// + /// volumeInfo.description is documented as HTML-formatted ("simple formatting elements, such as + /// b, i, and br tags"), not plain text - rendered as-is, an admin would see literal escaped tags in the + /// search results/synopsis instead of formatted text. Strips tags and decodes entities down to plain text. + /// + private static string? CleanDescription(string? html) + { + if (string.IsNullOrEmpty(html)) return null; + + var withoutBreaks = Regex.Replace(html, "", " ", RegexOptions.IgnoreCase); + var withoutTags = Regex.Replace(withoutBreaks, "<[^>]+>", ""); + var text = WebUtility.HtmlDecode(withoutTags).Trim(); + return text.Length == 0 ? null : text; + } + + /// + /// Google Books' own thumbnail URLs are widely documented (across its API consumer ecosystem) to come + /// back as plain http:// - upgraded to https:// here so embedding it in this app's own + /// HTTPS pages doesn't trip mixed-content blocking, the same reasoning TMDB/Open Library's own + /// always-https CDN URLs never need applied to them. + /// + private static string? BuildImageUrl(GoogleBooksImageLinks? imageLinks) => + string.IsNullOrEmpty(imageLinks?.Thumbnail) ? null : imageLinks.Thumbnail.Replace("http://", "https://", StringComparison.Ordinal); + + private sealed class GoogleBooksSearchResponse + { + [JsonPropertyName("items")] + public List Items { get; set; } = []; + } + + private sealed class GoogleBooksVolume + { + [JsonPropertyName("id")] + public string? Id { get; set; } + + [JsonPropertyName("volumeInfo")] + public GoogleBooksVolumeInfo? VolumeInfo { get; set; } + } + + private sealed class GoogleBooksVolumeInfo + { + [JsonPropertyName("title")] + public string? Title { get; set; } + + [JsonPropertyName("authors")] + public List Authors { get; set; } = []; + + [JsonPropertyName("publishedDate")] + public string? PublishedDate { get; set; } + + [JsonPropertyName("description")] + public string? Description { get; set; } + + [JsonPropertyName("categories")] + public List Categories { get; set; } = []; + + [JsonPropertyName("language")] + public string? Language { get; set; } + + [JsonPropertyName("imageLinks")] + public GoogleBooksImageLinks? ImageLinks { get; set; } + } + + private sealed class GoogleBooksImageLinks + { + [JsonPropertyName("thumbnail")] + public string? Thumbnail { get; set; } + } +} diff --git a/src/WebApi/ReferenceData/GoogleBooksSettings.cs b/src/WebApi/ReferenceData/GoogleBooksSettings.cs new file mode 100644 index 00000000..ed33b4de --- /dev/null +++ b/src/WebApi/ReferenceData/GoogleBooksSettings.cs @@ -0,0 +1,6 @@ +namespace Keeptrack.WebApi.ReferenceData; + +public class GoogleBooksSettings +{ + public required string ApiKey { get; set; } +} diff --git a/src/WebApi/ReferenceData/IBookReferenceClient.cs b/src/WebApi/ReferenceData/IBookReferenceClient.cs index 411ab0b3..378a94e5 100644 --- a/src/WebApi/ReferenceData/IBookReferenceClient.cs +++ b/src/WebApi/ReferenceData/IBookReferenceClient.cs @@ -6,7 +6,7 @@ namespace Keeptrack.WebApi.ReferenceData; /// public record BookSearchResult(string ExternalId, string Title, int? Year, string? Author, string? ImageUrl); -public record BookDetails(string ExternalId, string Title, int? Year, string? Synopsis, string? Author, string? AuthorExternalId, List Genres, string? ImageUrl); +public record BookDetails(string ExternalId, string Title, int? Year, string? Synopsis, string? Author, string? AuthorExternalId, List Genres, string? ImageUrl, string? Language = null); /// /// Provider-agnostic book lookup, backing 's book resolution/refresh @@ -26,6 +26,13 @@ public interface IBookReferenceClient /// string ProviderKey { get; } + /// + /// Human-readable name shown to an admin choosing a provider (e.g. "Open Library", "BnF") - the single + /// source of truth for that text, instead of a per- switch hardcoding it + /// (fine for the single-provider domains, but Book now has more than one). + /// + string DisplayName { get; } + /// /// narrows the query when known - without it, a common title can return /// dozens of unrelated results. is an optional hint; whether and how an diff --git a/src/WebApi/ReferenceData/OpenLibraryClient.cs b/src/WebApi/ReferenceData/OpenLibraryClient.cs index 3d81effe..4898babe 100644 --- a/src/WebApi/ReferenceData/OpenLibraryClient.cs +++ b/src/WebApi/ReferenceData/OpenLibraryClient.cs @@ -15,6 +15,8 @@ public class OpenLibraryClient(HttpClient http) : IBookReferenceClient { public string ProviderKey => "openlibrary"; + public string DisplayName => "Open Library"; + /// /// Deliberately does NOT filter server-side by : /// Open Library's first_publish_year is the work's ORIGINAL publication year, diff --git a/src/WebApi/ReferenceData/ReferenceDataAdminController.cs b/src/WebApi/ReferenceData/ReferenceDataAdminController.cs index 5b0f5ee1..8b276ee0 100644 --- a/src/WebApi/ReferenceData/ReferenceDataAdminController.cs +++ b/src/WebApi/ReferenceData/ReferenceDataAdminController.cs @@ -25,7 +25,7 @@ public class ReferenceDataAdminController( IVideoGameRepository videoGameRepository, IAlbumRepository albumRepository, ITmdbClient tmdbClient, - IBookReferenceClient bookReferenceClient, + BookReferenceClientRegistry bookReferenceClientRegistry, IRawgClient rawgClient, IDiscogsClient discogsClient, ReferenceEnrichmentService enrichmentService, @@ -217,6 +217,15 @@ private async Task RunSyncJobAsync(Guid jobId) } } + /// + /// Every registered book provider an admin can search/link with - Book is the one reference domain with + /// more than one (TMDB/RAWG/Discogs each have exactly one, so no equivalent listing endpoint exists for them). + /// + [HttpGet("book-providers")] + [ProducesResponseType(200)] + public ActionResult> GetBookProviders() => + Ok(bookReferenceClientRegistry.All.Select(c => new BookProviderDto { Key = c.ProviderKey, DisplayName = c.DisplayName }).ToList()); + /// /// Distinct (title, year) pairs, across every tenant, still missing a reference-data link. /// @@ -256,12 +265,14 @@ public async Task>> GetUnresolved([Fro /// (see /), /// since a common title alone often returns many unrelated candidates. /// Ignored for TV shows/movies/video games, which have no equivalent single-name creator field on this endpoint. + /// selects which registered book provider to search with (see + /// ); ignored for every other type. Null falls back to the deployment default. /// [HttpGet("search")] [ProducesResponseType(200)] [ProducesResponseType(400)] public async Task>> Search([FromQuery] ReferenceItemType type, [FromQuery] string title, [FromQuery] int? year, - [FromQuery] string? creator = null) + [FromQuery] string? creator = null, [FromQuery] string? provider = null) { // never hit a provider with an empty title - mapped to a 400 by ApiExceptionFilterAttribute ArgumentException.ThrowIfNullOrWhiteSpace(title); @@ -272,7 +283,7 @@ public async Task>> Search([FromQuer case ReferenceItemType.Movie: return Ok(await SearchTvShowOrMovieAsync(type, title, year)); case ReferenceItemType.Book: - var books = await bookReferenceClient.SearchBooksAsync(title, year, creator); + var books = await bookReferenceClientRegistry.Resolve(provider).SearchBooksAsync(title, year, creator); return Ok(books.Take(MaxEnrichedCandidates) .Select(r => new ReferenceSearchResultDto { @@ -348,7 +359,7 @@ public async Task Link([FromBody] LinkReferenceRequestDto request await enrichmentService.ResolveMovieAsync(request.Title, request.Year, request.ExternalId); break; case ReferenceItemType.Book: - await enrichmentService.ResolveBookAsync(request.Title, request.Year, request.ExternalId); + await enrichmentService.ResolveBookAsync(request.Title, request.Year, request.ExternalId, request.Provider); break; case ReferenceItemType.VideoGame: await enrichmentService.ResolveVideoGameAsync(request.Title, request.Year, request.ExternalId); diff --git a/src/WebApi/ReferenceData/ReferenceEnrichmentService.Books.cs b/src/WebApi/ReferenceData/ReferenceEnrichmentService.Books.cs index 06fa4d03..2eea5564 100644 --- a/src/WebApi/ReferenceData/ReferenceEnrichmentService.Books.cs +++ b/src/WebApi/ReferenceData/ReferenceEnrichmentService.Books.cs @@ -9,10 +9,11 @@ public partial class ReferenceEnrichmentService /// User-triggered "check for reference match" for books - see /// for the full rationale (this is the same local-only, /// no-HTTP-call lookup, just against book_reference). A successful match also sets - /// , and to the - /// reference's canonical values - the author's name is joined from via - /// , and Genre from - /// (joined into the same single free-text field the tenant can otherwise edit by hand). + /// , , and + /// to the reference's canonical values - the author's name is joined + /// from via , and + /// Genre from (joined into the same single free-text field the + /// tenant can otherwise edit by hand). /// public async Task TryLinkExistingBookReferenceAsync(BookModel model) { @@ -43,46 +44,53 @@ public async Task TryLinkExistingBookReferenceAsync(BookModel model) if (reference.Year is not null) model.Year = reference.Year; if (!string.IsNullOrEmpty(authorName)) model.Author = authorName; if (genre is not null) model.Genre = genre; + if (reference.Language is not null) model.Language = reference.Language; await bookRepository.UpdateAsync(model.Id!, model, model.OwnerId); - await bookRepository.SetReferenceLinkAsync(originalTitle, originalYear, reference.Id!, reference.Title, reference.Year, authorName, genre); + await bookRepository.SetReferenceLinkAsync(originalTitle, originalYear, reference.Id!, reference.Title, reference.Year, authorName, genre, reference.Language); return model; } /// - /// Best-effort automatic match for books - see . Passing - /// narrows the configured book provider's search considerably - without it, - /// a common title easily returns more than one candidate and the match is correctly left for the - /// admin queue. + /// Best-effort automatic match for books - see . Always searches + /// the deployment's *default* provider ( with a null + /// key) - this is the unattended background path, so there's no admin picking a provider here. Passing + /// narrows the search considerably - without it, a common title easily + /// returns more than one candidate and the match is correctly left for the admin queue. /// public async Task TryAutoResolveBookAsync(string title, int? year, string? author = null) { if (string.IsNullOrWhiteSpace(title)) return; // see TryAutoResolveTvShowAsync - var candidates = await bookReferenceClient.SearchBooksAsync(title, year, author); + var client = bookReferenceClientRegistry.Resolve(null); + var candidates = await client.SearchBooksAsync(title, year, author); if (candidates.Count != 1) return; - await ResolveBookAsync(title, year, candidates[0].ExternalId); + await ResolveBookAsync(title, year, candidates[0].ExternalId, client.ProviderKey); } /// /// Resolves a title+year to a specific book provider id, upserts the reference document, and - /// propagates the link - see . + /// propagates the link - see . is which + /// registered came from - required from + /// the admin's manual link action (an id is meaningless without knowing which provider issued it once + /// more than one is registered), defaults to the deployment default for the automatic path above. /// - public async Task ResolveBookAsync(string title, int? year, string externalId) + public async Task ResolveBookAsync(string title, int? year, string externalId, string? providerKey = null) { ArgumentException.ThrowIfNullOrWhiteSpace(title); - var details = await bookReferenceClient.GetBookDetailsAsync(externalId) - ?? throw new InvalidOperationException($"Book {externalId} could not be fetched from {bookReferenceClient.ProviderKey}."); + var client = bookReferenceClientRegistry.Resolve(providerKey); + var details = await client.GetBookDetailsAsync(externalId) + ?? throw new InvalidOperationException($"Book {externalId} could not be fetched from {client.ProviderKey}."); - var existing = await bookReferenceRepository.FindByExternalIdAsync(bookReferenceClient.ProviderKey, externalId) + var existing = await bookReferenceRepository.FindByExternalIdAsync(client.ProviderKey, externalId) ?? (details.Author is not null ? await bookReferenceRepository.FindByTitleYearAsync(title, year, details.Author) : null) ?? (details.Author is not null ? await bookReferenceRepository.FindByTitleAsync(title, details.Author) : null); var externalIds = existing?.ExternalIds ?? new Dictionary(); - externalIds[bookReferenceClient.ProviderKey] = externalId; + externalIds[client.ProviderKey] = externalId; var authorReferenceId = !string.IsNullOrEmpty(details.AuthorExternalId) - ? await ResolvePersonReferenceIdAsync(bookReferenceClient.ProviderKey, details.AuthorExternalId, details.Author ?? "Unknown", null) + ? await ResolvePersonReferenceIdAsync(client.ProviderKey, details.AuthorExternalId, details.Author ?? "Unknown", null) : existing?.AuthorReferenceId; var model = new BookReferenceModel @@ -97,28 +105,34 @@ public async Task ResolveBookAsync(string title, int? year, MatchedAliases = MergeMatchedAliases(existing?.MatchedAliases, (details.Title, details.Year ?? year, details.Author), (title, year, details.Author)), Genres = details.Genres, ImageUrl = details.ImageUrl, + Language = details.Language ?? existing?.Language, LastEnrichedAt = DateTime.UtcNow }; var saved = await bookReferenceRepository.UpsertAsync(model); - await bookRepository.SetReferenceLinkAsync(title, year, saved.Id!, details.Title, saved.Year, details.Author, JoinGenres(details.Genres)); + await bookRepository.SetReferenceLinkAsync(title, year, saved.Id!, details.Title, saved.Year, details.Author, JoinGenres(details.Genres), details.Language); return saved; } /// - /// Re-fetches a book reference from the configured book provider, always doing a full re-fetch when - /// called (unlike TMDB, none of the book providers currently supported expose a per-id "has this - /// changed" endpoint, so there's no cheap pre-check to skip it) - see - /// for the shared staleness-cutoff mechanism this is invoked - /// from. A no-op (returns unchanged) for a reference with no id from the currently configured provider, - /// or that the provider no longer has details for. + /// Re-fetches a book reference from whichever registered provider it was actually linked through, + /// always doing a full re-fetch when called (unlike TMDB, none of the book providers currently + /// supported expose a per-id "has this changed" endpoint, so there's no cheap pre-check to skip it) - + /// see for the shared staleness-cutoff mechanism this is + /// invoked from. Looks up against every *currently + /// registered* provider, not just the deployment default - a reference linked via a non-default + /// provider must keep refreshing even if the default later changes (this used to only ever check the + /// single configured client's key, so a reference linked through any other provider silently stopped + /// refreshing forever). A no-op (returns unchanged) when no registered provider's id is present, or the + /// provider no longer has details for it. /// public async Task<(BookReferenceModel Model, bool DataChanged)> RefreshBookReferenceAsync(BookReferenceModel reference, CancellationToken cancellationToken = default) { - var externalId = reference.ExternalIds.GetValueOrDefault(bookReferenceClient.ProviderKey); - if (string.IsNullOrEmpty(externalId)) return (reference, false); + var client = bookReferenceClientRegistry.All.FirstOrDefault(c => reference.ExternalIds.ContainsKey(c.ProviderKey)); + if (client is null) return (reference, false); - var details = await bookReferenceClient.GetBookDetailsAsync(externalId, cancellationToken); + var externalId = reference.ExternalIds[client.ProviderKey]; + var details = await client.GetBookDetailsAsync(externalId, cancellationToken); if (details is null) return (reference, false); reference.Title = details.Title; @@ -126,10 +140,11 @@ public async Task ResolveBookAsync(string title, int? year, reference.Synopsis = details.Synopsis; if (!string.IsNullOrEmpty(details.AuthorExternalId)) { - reference.AuthorReferenceId = await ResolvePersonReferenceIdAsync(bookReferenceClient.ProviderKey, details.AuthorExternalId, details.Author ?? "Unknown", null); + reference.AuthorReferenceId = await ResolvePersonReferenceIdAsync(client.ProviderKey, details.AuthorExternalId, details.Author ?? "Unknown", null); } reference.Genres = details.Genres; reference.ImageUrl = details.ImageUrl ?? reference.ImageUrl; + reference.Language = details.Language ?? reference.Language; reference.MatchedAliases = MergeMatchedAliases(reference.MatchedAliases, (details.Title, reference.Year, details.Author)); reference.LastEnrichedAt = DateTime.UtcNow; diff --git a/src/WebApi/ReferenceData/ReferenceEnrichmentService.cs b/src/WebApi/ReferenceData/ReferenceEnrichmentService.cs index bf9e3d0f..c4f052d6 100644 --- a/src/WebApi/ReferenceData/ReferenceEnrichmentService.cs +++ b/src/WebApi/ReferenceData/ReferenceEnrichmentService.cs @@ -15,7 +15,7 @@ namespace Keeptrack.WebApi.ReferenceData; /// public partial class ReferenceEnrichmentService( ITmdbClient tmdbClient, - IBookReferenceClient bookReferenceClient, + BookReferenceClientRegistry bookReferenceClientRegistry, IRawgClient rawgClient, IDiscogsClient discogsClient, ITvShowReferenceRepository tvShowReferenceRepository, diff --git a/src/WebApi/appsettings.json b/src/WebApi/appsettings.json index 1a666a4f..8091f505 100644 --- a/src/WebApi/appsettings.json +++ b/src/WebApi/appsettings.json @@ -32,8 +32,11 @@ "Discogs": { "Token": "" }, + "GoogleBooks": { + "ApiKey": "" + }, "ReferenceData": { - "BookProvider": "OpenLibrary" + "BookProvider": "googlebooks" }, "Logging": { "LogLevel": { diff --git a/test/BlazorApp.PlaywrightTests/Pages/AlbumDetailPage.cs b/test/BlazorApp.PlaywrightTests/Pages/AlbumDetailPage.cs index b8780f45..e563d304 100644 --- a/test/BlazorApp.PlaywrightTests/Pages/AlbumDetailPage.cs +++ b/test/BlazorApp.PlaywrightTests/Pages/AlbumDetailPage.cs @@ -2,7 +2,7 @@ namespace Keeptrack.BlazorApp.PlaywrightTests.Pages; -public sealed class AlbumDetailPage(IPage page) : ReferenceableDetailPageBase(page, "Discogs") +public sealed class AlbumDetailPage(IPage page) : ReferenceableDetailPageBase(page) { public ILocator ArtistInput => Page.GetByTestId("artist-input"); } diff --git a/test/BlazorApp.PlaywrightTests/Pages/BookDetailPage.cs b/test/BlazorApp.PlaywrightTests/Pages/BookDetailPage.cs index 23529206..17a24c1c 100644 --- a/test/BlazorApp.PlaywrightTests/Pages/BookDetailPage.cs +++ b/test/BlazorApp.PlaywrightTests/Pages/BookDetailPage.cs @@ -3,12 +3,20 @@ namespace Keeptrack.BlazorApp.PlaywrightTests.Pages; -public class BookDetailPage(IPage page) : ReferenceableDetailPageBase(page, "Open Library") +public class BookDetailPage(IPage page) : ReferenceableDetailPageBase(page) { public ILocator AuthorInput => Page.GetByTestId("author-input"); public ILocator SeriesInput => Page.GetByTestId("series-input"); + /// + /// Book is the one reference-linked type with more than one registered provider - selects one of the + /// provider buttons (e.g. "Google Books", "Open Library", "BnF") before searching. Exact match since + /// display names are short and otherwise unambiguous on this page. + /// + public async Task SelectProviderAsync(string displayName) => + await Page.GetByRole(AriaRole.Button, new PageGetByRoleOptions { Name = displayName, Exact = true }).ClickAsync(); + public static async Task SetFieldAsync(ILocator input, string value) { await input.FillAsync(value); diff --git a/test/BlazorApp.PlaywrightTests/Pages/MovieDetailPage.cs b/test/BlazorApp.PlaywrightTests/Pages/MovieDetailPage.cs index 8f4f6038..8e04da97 100644 --- a/test/BlazorApp.PlaywrightTests/Pages/MovieDetailPage.cs +++ b/test/BlazorApp.PlaywrightTests/Pages/MovieDetailPage.cs @@ -2,6 +2,6 @@ namespace Keeptrack.BlazorApp.PlaywrightTests.Pages; -public sealed class MovieDetailPage(IPage page) : ReferenceableDetailPageBase(page, "TMDB") +public sealed class MovieDetailPage(IPage page) : ReferenceableDetailPageBase(page) { } diff --git a/test/BlazorApp.PlaywrightTests/Pages/ReferenceableDetailPageBase.cs b/test/BlazorApp.PlaywrightTests/Pages/ReferenceableDetailPageBase.cs index 2764d3f2..c5a5396d 100644 --- a/test/BlazorApp.PlaywrightTests/Pages/ReferenceableDetailPageBase.cs +++ b/test/BlazorApp.PlaywrightTests/Pages/ReferenceableDetailPageBase.cs @@ -8,10 +8,8 @@ namespace Keeptrack.BlazorApp.PlaywrightTests.Pages; /// Shared shape for the five detail pages that carry a reference-data concept (Book/Movie/TvShow/VideoGame/Album) - /// all five render the exact same "check for reference match" icon button + toast, /// and the exact same admin-only InlineReferenceLinker (search the real provider, pick a candidate, click "Link"). -/// is the display name InlineReferenceLinker.razor shows for -/// ("TMDB", "Open Library", "RAWG", "Discogs"). /// -public abstract partial class ReferenceableDetailPageBase(IPage page, string providerName) : DetailPageBase(page) +public abstract partial class ReferenceableDetailPageBase(IPage page) : DetailPageBase(page) { [GeneratedRegex("(cover|poster)$")] private static partial Regex CoverRegex(); @@ -35,11 +33,13 @@ public async Task ClickCheckReferenceMatchAsync() /// The admin-only InlineReferenceLinker: a real, synchronous search against the actual external provider, /// then linking the first returned candidate. /// Only rendered while the item has no ReferenceId yet. + /// The button is re-labeled rather than swapped out after the first search ("Search" -> "↻ Search again"), + /// so this only ever needs to find the pre-search "Search" label - see InlineReferenceLinker.razor. /// A generous timeout is used for the search results since this is a genuine outbound network call, not a local lookup. /// public async Task SearchAndLinkFirstResultAsync() { - var searchButton = Page.GetByRole(AriaRole.Button, new PageGetByRoleOptions { Name = $"Search {providerName} to link" }); + var searchButton = Page.GetByRole(AriaRole.Button, new PageGetByRoleOptions { Name = "Search", Exact = true }); var firstLinkButton = Page.GetByRole(AriaRole.Button, new PageGetByRoleOptions { Name = "Link" }).First; await searchButton.ClickAsync(); diff --git a/test/BlazorApp.PlaywrightTests/Pages/TvShowDetailPage.cs b/test/BlazorApp.PlaywrightTests/Pages/TvShowDetailPage.cs index 469725eb..83a8bbe3 100644 --- a/test/BlazorApp.PlaywrightTests/Pages/TvShowDetailPage.cs +++ b/test/BlazorApp.PlaywrightTests/Pages/TvShowDetailPage.cs @@ -3,7 +3,7 @@ namespace Keeptrack.BlazorApp.PlaywrightTests.Pages; -public sealed class TvShowDetailPage(IPage page) : ReferenceableDetailPageBase(page, "TMDB") +public sealed class TvShowDetailPage(IPage page) : ReferenceableDetailPageBase(page) { /// /// The episode checklist only renders once the show is linked (_reference is not null), grouped by season with the lowest season selected by default - diff --git a/test/BlazorApp.PlaywrightTests/Pages/VideoGameDetailPage.cs b/test/BlazorApp.PlaywrightTests/Pages/VideoGameDetailPage.cs index 6bcaac1a..a79e229e 100644 --- a/test/BlazorApp.PlaywrightTests/Pages/VideoGameDetailPage.cs +++ b/test/BlazorApp.PlaywrightTests/Pages/VideoGameDetailPage.cs @@ -2,4 +2,4 @@ namespace Keeptrack.BlazorApp.PlaywrightTests.Pages; -public class VideoGameDetailPage(IPage page) : ReferenceableDetailPageBase(page, "RAWG"); +public class VideoGameDetailPage(IPage page) : ReferenceableDetailPageBase(page); diff --git a/test/BlazorApp.PlaywrightTests/Smoke/GoogleBooksSmokeTest.cs b/test/BlazorApp.PlaywrightTests/Smoke/GoogleBooksSmokeTest.cs new file mode 100644 index 00000000..c8391de0 --- /dev/null +++ b/test/BlazorApp.PlaywrightTests/Smoke/GoogleBooksSmokeTest.cs @@ -0,0 +1,57 @@ +using System; +using System.Threading.Tasks; +using Keeptrack.BlazorApp.PlaywrightTests.Hosting; +using Keeptrack.BlazorApp.PlaywrightTests.Pages; +using Microsoft.Playwright; +using Xunit; + +namespace Keeptrack.BlazorApp.PlaywrightTests.Smoke; + +/// +/// Verifies the Google Books provider specifically (Book's own add/edit/delete flow is already covered by +/// , which never links to any provider). Opt-in via +/// GOOGLE_BOOKS_SMOKE_ENABLED, unlike TMDB/RAWG/Discogs (hard-required for their own always-on smoke +/// tests) - Google Books has been observed to occasionally return a transient 503 (see +/// docs/code-quality-findings.md), and it's the newest/least-proven of the three registered book providers, +/// so this stays a deliberate, on-demand check rather than part of the default run. +/// +[Trait("Category", "E2eTests")] +[Trait("Mode", "Mutating")] +public class GoogleBooksSmokeTest(End2EndFixture fixture) : SmokeTestBase(fixture) +{ + private const string Title = "The Hobbit"; + private const string Author = "J.R.R. Tolkien"; + + [Fact] + public async Task AddLinkAndDelete_BookThroughGoogleBooks() + { + SkipIfReadOnly(); + Assert.SkipUnless(Environment.GetEnvironmentVariable("GOOGLE_BOOKS_SMOKE_ENABLED") == "true", + "GOOGLE_BOOKS_SMOKE_ENABLED is not set; the Google Books provider smoke test is opt-in."); + + var home = await new HomePage(Page).OpenAsync(); + var list = await home.OpenBooksAsync(); + await list.ClickAddAsync(); + await list.FillAsync("title-input", Title); + await list.FillAsync("author-input", Author); + await list.SaveNewAsync(); + + var detail = new BookDetailPage(Page); + await detail.WaitForReadyAsync(); + var id = ExtractIdFromUrl(Page.Url); + + try + { + // Google Books is already the registered default (first in Program.cs), but select it + // explicitly so this test still proves the right thing if that ever changes. + await detail.SelectProviderAsync("Google Books"); + await detail.SearchAndLinkFirstResultAsync(); + + await Assertions.Expect(detail.CoverImage.First).ToBeVisibleAsync(); + } + finally + { + await Fixture.DeleteItemAsync($"/api/books/{id}"); + } + } +} diff --git a/test/WebApi.IntegrationTests/Resources/BookProviderSearchAndLinkResourceTest.cs b/test/WebApi.IntegrationTests/Resources/BookProviderSearchAndLinkResourceTest.cs new file mode 100644 index 00000000..fe49a921 --- /dev/null +++ b/test/WebApi.IntegrationTests/Resources/BookProviderSearchAndLinkResourceTest.cs @@ -0,0 +1,58 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using AwesomeAssertions; +using Keeptrack.WebApi.Contracts.Dto; +using Keeptrack.WebApi.IntegrationTests.Hosting; +using Xunit; + +namespace Keeptrack.WebApi.IntegrationTests.Resources; + +/// +/// Exercises the actual multi-provider search+link HTTP flow (GET .../search, POST .../link) +/// against a real provider - Open Library specifically, since it's free/keyless and has proven reliable in +/// practice, unlike Google Books (observed to occasionally return a transient 503 - see +/// docs/code-quality-findings.md) or BnF (whose own "and" query combination needed a client-side correctness +/// fix - see BnfClientTest). deliberately only exercises +/// the local-only "check for reference match" lookup, never Search/Link, so this is the one place in this +/// suite that proves the registry/enrichment-service path actually reaches a live book provider end to end. +/// A fixed, well-known real title (not a GUID) is used so the search genuinely returns a match - the same +/// tradeoff the Playwright smoke tests already accept for Movie/TvShow/VideoGame/Album. +/// +public class BookProviderSearchAndLinkResourceTest(KestrelWebAppFactory factory) + : ResourceTestBase(factory) +{ + private const string Title = "The Hobbit"; + private const string Author = "J.R.R. Tolkien"; + + [Fact] + public async Task SearchThenLink_ResolvesARealBook_ThroughOpenLibrary() + { + await Authenticate(); + + var created = await PostAsync("/api/books", new BookDto { Title = Title, Author = Author }); + + try + { + var results = await GetAsync>( + $"/api/reference-data/search?type=Book&title={Uri.EscapeDataString(Title)}&creator={Uri.EscapeDataString(Author)}&provider=openlibrary"); + + results.Should().NotBeEmpty(); + + await PostNoContentAsync("/api/reference-data/link", new LinkReferenceRequestDto + { + Type = ReferenceItemType.Book, + Title = Title, + ExternalId = results[0].ExternalId, + Provider = "openlibrary" + }); + + var linked = await GetAsync($"/api/books/{created.Id}"); + linked.ReferenceId.Should().NotBeNullOrEmpty(); + } + finally + { + await DeleteAsync($"/api/books/{created.Id}"); + } + } +} diff --git a/test/WebApi.IntegrationTests/Resources/BookReferenceRepositoryTest.cs b/test/WebApi.IntegrationTests/Resources/BookReferenceRepositoryTest.cs index f552435f..1f27d62c 100644 --- a/test/WebApi.IntegrationTests/Resources/BookReferenceRepositoryTest.cs +++ b/test/WebApi.IntegrationTests/Resources/BookReferenceRepositoryTest.cs @@ -77,6 +77,47 @@ public async Task FindByTitleAsync_MatchesAnAlternateTitle_IgnoringYear() } } + /// + /// can carry more than one provider's id at once (e.g. a + /// reference first linked via Open Library, then also confirmed via BnF for a different tenant) - both + /// keys must keep resolving to the same document, which is what ReferenceEnrichmentService.RefreshBookReferenceAsync's + /// multi-provider refresh fix (see docs/code-quality-findings.md) depends on. + /// + [Fact] + public async Task FindByExternalIdAsync_FindsTheSameDocument_ByEitherOfTwoCoexistingProviderKeys() + { + using var scope = factory.Services.CreateScope(); + var repository = scope.ServiceProvider.GetRequiredService(); + var title = $"Multi Provider Book Title {Guid.NewGuid()}"; + // unique per run - other tests in this file already reuse the literal "OL1W" placeholder across + // several documents, so FindByExternalIdAsync could otherwise resolve to one of theirs instead of + // this test's own document (confirmed: this is exactly what happened before this fix). + var openLibraryId = $"OL-{Guid.NewGuid():N}"; + var bnfId = $"ark:/12148/{Guid.NewGuid():N}"; + + var created = await repository.UpsertAsync(new BookReferenceModel + { + Title = title, + TitleNormalized = title.ToLowerInvariant(), + ExternalIds = new Dictionary { ["openlibrary"] = openLibraryId, ["bnf"] = bnfId } + }); + + try + { + var foundByOpenLibrary = await repository.FindByExternalIdAsync("openlibrary", openLibraryId); + var foundByBnf = await repository.FindByExternalIdAsync("bnf", bnfId); + + foundByOpenLibrary.Should().NotBeNull(); + foundByBnf.Should().NotBeNull(); + foundByOpenLibrary!.Id.Should().Be(created.Id); + foundByBnf!.Id.Should().Be(created.Id); + } + finally + { + await DeleteAsync(scope, created.Id!); + } + } + [Fact] public async Task UpsertAsync_AlwaysIncludesTheCanonicalTitleAndYearInMatchedAliases_EvenIfTheCallerForgot() { diff --git a/test/WebApi.IntegrationTests/Resources/BookResourceTest.cs b/test/WebApi.IntegrationTests/Resources/BookResourceTest.cs index d365f369..3b42b93e 100644 --- a/test/WebApi.IntegrationTests/Resources/BookResourceTest.cs +++ b/test/WebApi.IntegrationTests/Resources/BookResourceTest.cs @@ -1,11 +1,18 @@ -using System.Linq; +using System; +using System.Collections.Generic; +using System.Linq; using System.Net; using System.Threading.Tasks; using AwesomeAssertions; using Bogus; using Keeptrack.Common.System; +using Keeptrack.Domain.Models; +using Keeptrack.Domain.Repositories; +using Keeptrack.Infrastructure.MongoDb.Entities; using Keeptrack.WebApi.Contracts.Dto; using Keeptrack.WebApi.IntegrationTests.Hosting; +using Microsoft.Extensions.DependencyInjection; +using MongoDB.Driver; using Xunit; namespace Keeptrack.WebApi.IntegrationTests.Resources; @@ -23,7 +30,16 @@ public async Task BookResourceFullCycle_IsOk() await Authenticate(); var input = new Faker() - .Rules((f, o) => { o.Author = f.Random.AlphaNumeric(8); o.Title = f.Random.AlphaNumeric(14); }) + .Rules((f, o) => + { + o.Author = f.Random.AlphaNumeric(8); + o.Title = f.Random.AlphaNumeric(14); + // round-trips Language/CustomImageUrl through the real Mapperly mappers + MongoDB - both + // are plain scalar fields with no special mapping, but a real integration test is what + // would catch a missed [BsonElement]/mapper ignore, not a mocked unit test. + o.Language = f.Random.AlphaNumeric(3); + o.CustomImageUrl = f.Internet.Url(); + }) .Generate(); var created = await PostAsync($"/{ResourceEndpoint}", input); created.Id.Should().NotBeNullOrEmpty(); @@ -79,4 +95,49 @@ public async Task BookResourceOwnedAndWishlistedFilters_OnlyReturnMatchingItems_ await DeleteAsync($"/{ResourceEndpoint}/{created.Id}"); } } + + /// + /// is Book-specific (not shared via the generic + /// image-hydration helper/, which the other four reference-linked types + /// also implement with no equivalent override) - BookController.OnListMappedAsync is expected to + /// apply it over the linked reference's own cover on every list read. + /// + [Fact] + public async Task BookResourceList_CustomImageUrlOverridesTheLinkedReferencesCover() + { + using var scope = Factory.Services.CreateScope(); + var referenceRepository = scope.ServiceProvider.GetRequiredService(); + var uniqueTitle = $"CustomImageOverrideTarget-{Guid.NewGuid():N}"; + + var reference = await referenceRepository.UpsertAsync(new BookReferenceModel + { + Title = "Some Reference Title", + TitleNormalized = "some reference title", + ExternalIds = new Dictionary { ["googlebooks"] = $"gb-{Guid.NewGuid():N}" }, + ImageUrl = "https://example.com/reference-cover.jpg" + }); + + await Authenticate(); + const string customImageUrl = "https://example.com/custom-cover.jpg"; + var created = await PostAsync($"/{ResourceEndpoint}", new BookDto + { + Title = uniqueTitle, + Author = "Some Author", + ReferenceId = reference.Id, + CustomImageUrl = customImageUrl + }); + + try + { + var list = await GetAsync>($"/{ResourceEndpoint}?search={uniqueTitle}"); + var item = list.Items.Should().ContainSingle(b => b.Id == created.Id).Subject; + item.ImageUrl.Should().Be(customImageUrl); + } + finally + { + await DeleteAsync($"/{ResourceEndpoint}/{created.Id}"); + var referenceCollection = scope.ServiceProvider.GetRequiredService().GetCollection("book_reference"); + await referenceCollection.DeleteOneAsync(Builders.Filter.Eq(x => x.Id, reference.Id), TestContext.Current.CancellationToken); + } + } } diff --git a/test/WebApi.IntegrationTests/Resources/ReferenceDataAdminResourceTest.cs b/test/WebApi.IntegrationTests/Resources/ReferenceDataAdminResourceTest.cs index 9845edca..a05c7b26 100644 --- a/test/WebApi.IntegrationTests/Resources/ReferenceDataAdminResourceTest.cs +++ b/test/WebApi.IntegrationTests/Resources/ReferenceDataAdminResourceTest.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Linq; using System.Net; using System.Threading.Tasks; using AwesomeAssertions; @@ -29,6 +30,25 @@ public async Task GetUnresolved_WithAdminRole_IsOk() await GetAsync>("/api/reference-data/unresolved?type=TvShow"); } + /// + /// Registration order in Program.cs doubles as the admin UI's provider-picker display/priority order + /// (BookReferenceClientRegistry.All preserves it) - Google Books is the default, Open Library and + /// BnF are fallbacks in that order. This pins the contract so a future reordering in Program.cs fails a + /// test instead of silently changing the picker's default. + /// + [Fact] + public async Task GetBookProviders_ReturnsRegisteredProvidersInPriorityOrder() + { + await Authenticate(); + + var providers = await GetAsync>("/api/reference-data/book-providers"); + + providers.Select(p => p.Key).Should().Equal("googlebooks", "openlibrary", "bnf"); + providers.Should().Contain(p => p.Key == "googlebooks" && p.DisplayName == "Google Books"); + providers.Should().Contain(p => p.Key == "openlibrary" && p.DisplayName == "Open Library"); + providers.Should().Contain(p => p.Key == "bnf" && p.DisplayName == "BnF"); + } + /// /// Exercises the "sync now" job start over HTTP: POST returns immediately (202 + job id) rather than /// blocking on every reference document - the actual fix for the timeout reported against this diff --git a/test/WebApi.IntegrationTests/Resources/ResourceTestBase.cs b/test/WebApi.IntegrationTests/Resources/ResourceTestBase.cs index 8def7e55..9094182a 100644 --- a/test/WebApi.IntegrationTests/Resources/ResourceTestBase.cs +++ b/test/WebApi.IntegrationTests/Resources/ResourceTestBase.cs @@ -93,6 +93,18 @@ protected async Task PutAsync(string url, T body, HttpStatusCode httpStatusCo response.StatusCode.Should().Be(httpStatusCode); } + /// + /// For POST endpoints that return no body (e.g. 204 No Content, like POST /api/reference-data/link) - + /// the other PostAsync overloads all assume a JSON response body and would throw trying to + /// deserialize an empty one. Same status-check-only shape as , just over POST. + /// + protected async Task PostNoContentAsync(string url, T body, HttpStatusCode httpStatusCode = HttpStatusCode.NoContent) + { + var bodyContent = new StringContent(JsonSerializer.Serialize(body, JsonSerializerOptions.Web), Encoding.UTF8, MediaTypeJson); + var response = await _httpClient.PostAsync(url, bodyContent); + response.StatusCode.Should().Be(httpStatusCode); + } + protected async Task PostFileAsync(string url, string fieldName, byte[] fileContent, string fileName, HttpStatusCode httpStatusCode = HttpStatusCode.OK) { using var content = new MultipartFormDataContent(); diff --git a/test/WebApi.UnitTests/ReferenceData/BnfClientTest.cs b/test/WebApi.UnitTests/ReferenceData/BnfClientTest.cs new file mode 100644 index 00000000..b0972f94 --- /dev/null +++ b/test/WebApi.UnitTests/ReferenceData/BnfClientTest.cs @@ -0,0 +1,153 @@ +using System; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using AwesomeAssertions; +using Keeptrack.WebApi.ReferenceData; +using Xunit; + +namespace Keeptrack.WebApi.UnitTests.ReferenceData; + +/// +/// parses SRU/XML (Dublin Core), unlike every other provider here (JSON) - the +/// sample response shapes below are trimmed but otherwise verbatim from the real API (searched "Killing +/// Floor" / Lee Child, then re-fetched by bib.persistentid), not guessed from documentation prose. +/// +[Trait("Category", "UnitTests")] +public class BnfClientTest +{ + private sealed class StubHttpMessageHandler(Func respond) : HttpMessageHandler + { + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) => + Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(respond(request), Encoding.UTF8, "application/xml") + }); + } + + private static BnfClient BuildClient(Func respond) + { + var http = new HttpClient(new StubHttpMessageHandler(respond)) { BaseAddress = new Uri("https://catalogue.bnf.fr/api/") }; + return new BnfClient(http); + } + + private static string OneRecordResponse(string externalId, string title, string creator, string date, string language) => + RecordsResponse((externalId, title, creator, date, language)); + + private static string RecordsResponse(params (string ExternalId, string Title, string Creator, string Date, string Language)[] records) + { + var recordsXml = string.Join("\n", records.Select(r => $""" + + + + http://catalogue.bnf.fr/{r.ExternalId} + {r.Title} + {r.Creator} + {r.Date} + {r.Language} + + + {r.ExternalId} + + """)); + + return $""" + + + {records.Length} + + {recordsXml} + + + """; + } + + [Fact] + public void ProviderKey_IsBnf() => BuildClient(_ => "").ProviderKey.Should().Be("bnf"); + + [Fact] + public void DisplayName_IsBnf() => BuildClient(_ => "").DisplayName.Should().Be("BnF"); + + [Fact] + public async Task GetBookDetailsAsync_ParsesTitleYearLanguageAndCleansUpTheCreatorName() + { + var client = BuildClient(_ => OneRecordResponse("ark:/12148/cb361713613", "Du fond de l'abîme", "Child, Lee (1954-....). Auteur du texte", "1997", "fre")); + + var details = await client.GetBookDetailsAsync("ark:/12148/cb361713613", TestContext.Current.CancellationToken); + + details!.Title.Should().Be("Du fond de l'abîme"); + details.Year.Should().Be(1997); + details.Language.Should().Be("fre"); + // "LastName, FirstName (dates). Role" -> "FirstName LastName" - confirmed against the real API's + // own creator formatting for this exact record. + details.Author.Should().Be("Lee Child"); + details.ImageUrl.Should().BeNull(); + } + + [Fact] + public async Task GetBookDetailsAsync_LeavesACreatorWithNoCommaUnchanged() + { + // a corporate/collective author has no "LastName, FirstName" shape to reorder around + var client = BuildClient(_ => OneRecordResponse("ark:/12148/cb2", "Some Title", "Bibliothèque nationale de France", "2001", "fre")); + + var details = await client.GetBookDetailsAsync("ark:/12148/cb2", TestContext.Current.CancellationToken); + + details!.Author.Should().Be("Bibliothèque nationale de France"); + } + + [Fact] + public async Task SearchBooksAsync_ParsesResultsWithNoImageUrl() + { + // BnF's ordinary catalogue records carry no cover-art field at all - confirmed against the real API. + var client = BuildClient(_ => OneRecordResponse("ark:/12148/cb361713613", "Du fond de l'abîme", "Child, Lee (1954-....). Auteur du texte", "1997", "fre")); + + var results = await client.SearchBooksAsync("Du fond de l'abime", 1997, cancellationToken: TestContext.Current.CancellationToken); + + results.Should().ContainSingle(); + results[0].ExternalId.Should().Be("ark:/12148/cb361713613"); + results[0].Author.Should().Be("Lee Child"); + results[0].ImageUrl.Should().BeNull(); + } + + [Fact] + public async Task SearchBooksAsync_RetriesWithoutTheAuthor_WhenTheNarrowedSearchReturnsNoResults() + { + var authorSearchAttempted = false; + var client = BuildClient(request => + { + if (request.RequestUri!.Query.Contains("bib.author")) + { + authorSearchAttempted = true; + return """0"""; + } + + return OneRecordResponse("ark:/12148/cb361713613", "Du fond de l'abîme", "Child, Lee (1954-....). Auteur du texte", "1997", "fre"); + }); + + var results = await client.SearchBooksAsync("Du fond de l'abime", 1997, "Some Mismatched Author Text", TestContext.Current.CancellationToken); + + authorSearchAttempted.Should().BeTrue(); + results.Should().ContainSingle(); + } + + [Fact] + public async Task SearchBooksAsync_FiltersOutCandidatesWhoseAuthorDoesNotActuallyMatch() + { + // regression: confirmed against the real API that BnF's own "and (bib.author ...)" combination is + // not a strict intersection - a query for title "La Peste" and author "Victor Hugo" (who never wrote + // a book by that title) returned several genuine Victor Hugo anthologies instead of zero, none of + // them actually titled "La Peste". The client must not trust the server's own filtering here. + var client = BuildClient(_ => RecordsResponse( + ("ark:/12148/cb1", "La peste", "Camus, Albert (1913-1960). Auteur du texte", "1947", "fre"), + ("ark:/12148/cb2", "Chemins de la poésie", "Hugo, Victor (1802-1885). Auteur du texte", "1956", "fre"))); + + var results = await client.SearchBooksAsync("La Peste", null, "Albert Camus", TestContext.Current.CancellationToken); + + results.Should().ContainSingle(); + results[0].ExternalId.Should().Be("ark:/12148/cb1"); + results[0].Author.Should().Be("Albert Camus"); + } +} diff --git a/test/WebApi.UnitTests/ReferenceData/FakeBnfClient.cs b/test/WebApi.UnitTests/ReferenceData/FakeBnfClient.cs new file mode 100644 index 00000000..a5d1a702 --- /dev/null +++ b/test/WebApi.UnitTests/ReferenceData/FakeBnfClient.cs @@ -0,0 +1,35 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Keeptrack.WebApi.ReferenceData; + +namespace Keeptrack.WebApi.UnitTests.ReferenceData; + +/// Same shape as , just the second registered provider. +internal sealed class FakeBnfClient : IBookReferenceClient +{ + private readonly List _searchResults; + + public string ProviderKey => "bnf"; + + public string DisplayName => "BnF"; + + public Dictionary Details { get; } = new(); + + public string? LastSearchAuthor { get; private set; } + + private FakeBnfClient(List searchResults) => _searchResults = searchResults; + + public static FakeBnfClient Empty() => new([]); + + public static FakeBnfClient WithSearchResults(params BookSearchResult[] results) => new([.. results]); + + public Task> SearchBooksAsync(string title, int? year, string? author = null, CancellationToken cancellationToken = default) + { + LastSearchAuthor = author; + return Task.FromResult>(_searchResults); + } + + public Task GetBookDetailsAsync(string externalId, CancellationToken cancellationToken = default) => + Task.FromResult(Details.GetValueOrDefault(externalId)); +} diff --git a/test/WebApi.UnitTests/ReferenceData/FakeBookReferenceClient.cs b/test/WebApi.UnitTests/ReferenceData/FakeBookReferenceClient.cs index 31c0efe0..e6dc8158 100644 --- a/test/WebApi.UnitTests/ReferenceData/FakeBookReferenceClient.cs +++ b/test/WebApi.UnitTests/ReferenceData/FakeBookReferenceClient.cs @@ -11,6 +11,8 @@ internal sealed class FakeBookReferenceClient : IBookReferenceClient public string ProviderKey => "openlibrary"; + public string DisplayName => "Open Library"; + public Dictionary Details { get; } = new(); /// The author passed to the most recent call, for assertions. diff --git a/test/WebApi.UnitTests/ReferenceData/GoogleBooksClientTest.cs b/test/WebApi.UnitTests/ReferenceData/GoogleBooksClientTest.cs new file mode 100644 index 00000000..e4cd93f4 --- /dev/null +++ b/test/WebApi.UnitTests/ReferenceData/GoogleBooksClientTest.cs @@ -0,0 +1,115 @@ +using System; +using System.Net; +using System.Net.Http; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using AwesomeAssertions; +using Keeptrack.WebApi.ReferenceData; +using Xunit; + +namespace Keeptrack.WebApi.UnitTests.ReferenceData; + +[Trait("Category", "UnitTests")] +public class GoogleBooksClientTest +{ + private sealed class StubHttpMessageHandler(Func respond) : HttpMessageHandler + { + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) => + Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(respond(request), Encoding.UTF8, "application/json") + }); + } + + private static GoogleBooksClient BuildClient(Func respond) + { + var http = new HttpClient(new StubHttpMessageHandler(respond)) { BaseAddress = new Uri("https://www.googleapis.com/books/v1/") }; + return new GoogleBooksClient(http, new GoogleBooksSettings { ApiKey = "test-key" }); + } + + [Fact] + public void ProviderKey_IsGoogleBooks() => BuildClient(_ => "").ProviderKey.Should().Be("googlebooks"); + + [Fact] + public void DisplayName_IsGoogleBooks() => BuildClient(_ => "").DisplayName.Should().Be("Google Books"); + + [Fact] + public async Task GetBookDetailsAsync_StripsHtmlFromTheDescriptionAndUpgradesTheThumbnailToHttps() + { + var client = BuildClient(_ => """ + { + "id": "abc123", + "volumeInfo": { + "title": "Killing Floor", + "authors": ["Lee Child"], + "publishedDate": "1997-03-01", + "description": "A gripping thriller.
The first Jack Reacher novel.", + "categories": ["Fiction / Thrillers"], + "language": "en", + "imageLinks": { "thumbnail": "http://books.google.com/books/content?id=abc123&printsec=frontcover" } + } + } + """); + + var details = await client.GetBookDetailsAsync("abc123", TestContext.Current.CancellationToken); + + details!.Title.Should().Be("Killing Floor"); + details.Year.Should().Be(1997); + details.Author.Should().Be("Lee Child"); + details.Language.Should().Be("en"); + details.Synopsis.Should().Be("A gripping thriller. The first Jack Reacher novel."); + details.ImageUrl.Should().StartWith("https://"); + details.Genres.Should().ContainSingle().Which.Should().Be("Fiction / Thrillers"); + } + + [Fact] + public async Task GetBookDetailsAsync_ReturnsNull_WhenTheVolumeHasNoTitle() + { + var client = BuildClient(_ => """{"id":"abc123","volumeInfo":{}}"""); + + var details = await client.GetBookDetailsAsync("abc123", TestContext.Current.CancellationToken); + + details.Should().BeNull(); + } + + [Fact] + public async Task SearchBooksAsync_ParsesMultipleResults() + { + var client = BuildClient(_ => """ + { + "items": [ + { "id": "id1", "volumeInfo": { "title": "Killing Floor", "authors": ["Lee Child"], "publishedDate": "1997" } }, + { "id": "id2", "volumeInfo": { "title": "Die Trying", "authors": ["Lee Child"], "publishedDate": "1998" } } + ] + } + """); + + var results = await client.SearchBooksAsync("Some Query", null, cancellationToken: TestContext.Current.CancellationToken); + + results.Should().HaveCount(2); + results[0].ExternalId.Should().Be("id1"); + results[1].Year.Should().Be(1998); + } + + [Fact] + public async Task SearchBooksAsync_RetriesWithoutTheAuthor_WhenTheNarrowedSearchReturnsNoResults() + { + var authorSearchAttempted = false; + var client = BuildClient(request => + { + if (request.RequestUri!.Query.Contains("inauthor")) + { + authorSearchAttempted = true; + return """{"items":[]}"""; + } + + return """{"items":[{"id":"id1","volumeInfo":{"title":"Killing Floor","authors":["Lee Child"],"publishedDate":"1997"}}]}"""; + }); + + var results = await client.SearchBooksAsync("Killing Floor", 1997, "Some Mismatched Author Text", TestContext.Current.CancellationToken); + + authorSearchAttempted.Should().BeTrue(); + results.Should().ContainSingle(); + } +} diff --git a/test/WebApi.UnitTests/ReferenceData/ReferenceEnrichmentServiceTest.cs b/test/WebApi.UnitTests/ReferenceData/ReferenceEnrichmentServiceTest.cs index 10984907..b6fe0c8f 100644 --- a/test/WebApi.UnitTests/ReferenceData/ReferenceEnrichmentServiceTest.cs +++ b/test/WebApi.UnitTests/ReferenceData/ReferenceEnrichmentServiceTest.cs @@ -27,12 +27,22 @@ public class ReferenceEnrichmentServiceTest private readonly Mock _videoGameRepository = new(); private readonly Mock _albumRepository = new(); + /// + /// The registry always has "openlibrary" as the deployment default - matches FakeBookReferenceClient's + /// hardcoded ProviderKey, so every existing test that never mentions a provider keeps resolving the + /// same fake it always did. + /// + private const string DefaultBookProvider = "openlibrary"; + private ReferenceEnrichmentService CreateService( FakeTmdbClient tmdbClient, FakeBookReferenceClient? bookReferenceClient = null, FakeRawgClient? rawgClient = null, - FakeDiscogsClient? discogsClient = null) => new( - tmdbClient, bookReferenceClient ?? FakeBookReferenceClient.Empty(), rawgClient ?? FakeRawgClient.Empty(), discogsClient ?? FakeDiscogsClient.Empty(), + FakeDiscogsClient? discogsClient = null, + FakeBnfClient? bnfClient = null) => new( + tmdbClient, + new BookReferenceClientRegistry([bookReferenceClient ?? FakeBookReferenceClient.Empty(), bnfClient ?? FakeBnfClient.Empty()], DefaultBookProvider), + rawgClient ?? FakeRawgClient.Empty(), discogsClient ?? FakeDiscogsClient.Empty(), _tvShowReferenceRepository.Object, _movieReferenceRepository.Object, _personReferenceRepository.Object, _bookReferenceRepository.Object, _videoGameReferenceRepository.Object, _albumReferenceRepository.Object, _tvShowRepository.Object, _movieRepository.Object, _bookRepository.Object, _videoGameRepository.Object, _albumRepository.Object); @@ -603,6 +613,47 @@ public async Task RefreshBookReferenceAsync_AlwaysRefetches_RegardlessOfLastEnri result.Genres.Should().Contain("Fiction"); } + [Fact] + public async Task RefreshBookReferenceAsync_RefreshesViaANonDefaultRegisteredProvider_WhenThatsTheOnlyOnePresent() + { + // regression: this used to only ever check the currently-configured DEFAULT provider's key, so a + // reference linked through any other registered provider (bnf here, openlibrary being the default) + // would silently stop refreshing forever. + var bnfClient = FakeBnfClient.Empty(); + bnfClient.Details["ark:/12148/cb1"] = new BookDetails("ark:/12148/cb1", "Some Book - Updated", 2020, "Synopsis", "Some Author", null, [], null, "fre"); + var reference = new BookReferenceModel + { + Id = "reference-1", + Title = "Some Book", + TitleNormalized = "some book", + ExternalIds = new Dictionary { ["bnf"] = "ark:/12148/cb1" }, + LastEnrichedAt = DateTime.UtcNow + }; + _bookReferenceRepository.Setup(r => r.UpsertAsync(It.IsAny())).ReturnsAsync((BookReferenceModel m) => m); + var service = CreateService(FakeTmdbClient.WithTvShowSearchResults(), bnfClient: bnfClient); + + var (result, changed) = await service.RefreshBookReferenceAsync(reference, TestContext.Current.CancellationToken); + + changed.Should().BeTrue(); + result.Title.Should().Be("Some Book - Updated"); + result.Language.Should().Be("fre"); + } + + [Fact] + public async Task ResolveBookAsync_UsesTheExplicitlyRequestedProvider_NotTheDefault() + { + var bnfClient = FakeBnfClient.Empty(); + bnfClient.Details["ark:/12148/cb1"] = new BookDetails("ark:/12148/cb1", "Some Book", 2020, "Synopsis", "Some Author", null, [], null, "fre"); + _bookReferenceRepository.Setup(r => r.UpsertAsync(It.IsAny())).ReturnsAsync((BookReferenceModel m) => { m.Id = "reference-1"; return m; }); + var service = CreateService(FakeTmdbClient.WithTvShowSearchResults(), bnfClient: bnfClient); + + var result = await service.ResolveBookAsync("Some Book", 2020, "ark:/12148/cb1", "bnf"); + + result.ExternalIds["bnf"].Should().Be("ark:/12148/cb1"); + result.ExternalIds.Should().NotContainKey("openlibrary"); + result.Language.Should().Be("fre"); + } + [Fact] public async Task TryAutoResolveVideoGameAsync_DoesNothing_WhenSearchIsAmbiguous() { @@ -955,7 +1006,7 @@ public async Task RefreshAlbumReferenceAsync_AlwaysRefetches_RegardlessOfLastEnr ///
private ReferenceEnrichmentService CreateServiceWithStrictClients() => new( new Mock(MockBehavior.Strict).Object, - new Mock(MockBehavior.Strict).Object, + new BookReferenceClientRegistry([new Mock(MockBehavior.Strict).Object], DefaultBookProvider), new Mock(MockBehavior.Strict).Object, new Mock(MockBehavior.Strict).Object, _tvShowReferenceRepository.Object, _movieReferenceRepository.Object, _personReferenceRepository.Object, diff --git a/test/WebApi.UnitTests/ReferenceData/ReferenceSyncServiceTest.cs b/test/WebApi.UnitTests/ReferenceData/ReferenceSyncServiceTest.cs index 8f5718fb..e0a9aa2c 100644 --- a/test/WebApi.UnitTests/ReferenceData/ReferenceSyncServiceTest.cs +++ b/test/WebApi.UnitTests/ReferenceData/ReferenceSyncServiceTest.cs @@ -35,7 +35,7 @@ private ReferenceSyncService CreateService(FakeTmdbClient tmdbClient) _albumReferenceRepository.Setup(r => r.FindAllAsync()).ReturnsAsync([]); var enrichmentService = new ReferenceEnrichmentService( - tmdbClient, FakeBookReferenceClient.Empty(), FakeRawgClient.Empty(), FakeDiscogsClient.Empty(), + tmdbClient, new BookReferenceClientRegistry([FakeBookReferenceClient.Empty()], "openlibrary"), FakeRawgClient.Empty(), FakeDiscogsClient.Empty(), _tvShowReferenceRepository.Object, _movieReferenceRepository.Object, _personReferenceRepository.Object, _bookReferenceRepository.Object, _videoGameReferenceRepository.Object, _albumReferenceRepository.Object, _tvShowRepository.Object, _movieRepository.Object, _bookRepository.Object, _videoGameRepository.Object, _albumRepository.Object); From 27a81282ae2e19538d6206b9756a0a5b6a88ce51 Mon Sep 17 00:00:00 2001 From: Bertrand THOMAS Date: Sun, 19 Jul 2026 03:18:03 +0200 Subject: [PATCH 27/32] Add ISBN MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit What was added - BookModel.Isbn (and Book/BookDto/BookReferenceModel/BookReference/BookReferenceDto) — edited only on BookDetail.razor, never the Add form. - Search input: threaded isbn through IBookReferenceClient.SearchBooksAsync and the whole admin search chain (ReferenceDataAdminController, ReferenceDataAdminPage.razor's ISBN field, InlineReferenceLinker's Isbn parameter bound from the book's own detail page). Only GoogleBooksClient actually uses it — as an exact isbn: lookup that supersedes title/author entirely when present; Open Library/BnF accept but ignore it. - Autofill on link/refresh: both Google Books (industryIdentifiers, preferring ISBN_13) and BnF (dc:identifier's "ISBN ..." value, confirmed against the real API) populate it; Open Library doesn't, same edition-vs-work-level reason as Language. - "Only fields actually used" for matched aliases: ReferenceMatchModel/ReferenceMatch gained an Isbn field, and the canonical alias (provider's own value) vs. the tenant-search alias (what was actually searched with) are now kept as genuinely separate entries — the search alias is never backfilled with the provider's ISBN when no ISBN was actually part of that search. --- CLAUDE.md | 7 +++ .../Inventory/Pages/BookDetail.razor | 13 ++++- .../InlineReferenceLinker.razor | 12 ++++- .../ReferenceDataAdminApiClient.cs | 3 +- .../ReferenceDataAdminPage.razor | 10 +++- src/Domain/Models/BookModel.cs | 7 +++ src/Domain/Models/BookReferenceModel.cs | 7 +++ src/Domain/Models/ReferenceMatchModel.cs | 8 +++ src/Domain/Repositories/IBookRepository.cs | 9 ++-- src/Infrastructure.MongoDb/Entities/Book.cs | 2 + .../Entities/BookReference.cs | 2 + .../Entities/ReferenceMatch.cs | 3 ++ .../Repositories/BookRepository.cs | 3 +- src/WebApi.Contracts/Dto/BookDto.cs | 7 +++ src/WebApi.Contracts/Dto/BookReferenceDto.cs | 3 ++ .../Dto/LinkReferenceRequestDto.cs | 7 +++ src/WebApi/ReferenceData/BnfClient.cs | 29 +++++++++-- src/WebApi/ReferenceData/GoogleBooksClient.cs | 34 ++++++++++--- .../ReferenceData/IBookReferenceClient.cs | 9 ++-- src/WebApi/ReferenceData/OpenLibraryClient.cs | 4 +- .../ReferenceDataAdminController.cs | 8 +-- .../ReferenceEnrichmentService.Albums.cs | 4 +- .../ReferenceEnrichmentService.Books.cs | 38 +++++++++----- ...renceEnrichmentService.TvShowsAndMovies.cs | 8 +-- .../ReferenceEnrichmentService.VideoGames.cs | 4 +- .../ReferenceEnrichmentService.cs | 26 ++++++---- .../Resources/BookResourceTest.cs | 7 +-- .../ReferenceData/BnfClientTest.cs | 46 ++++++++++++++++- .../ReferenceData/FakeBnfClient.cs | 5 +- .../ReferenceData/FakeBookReferenceClient.cs | 6 ++- .../ReferenceData/GoogleBooksClientTest.cs | 50 ++++++++++++++++++- .../ReferenceEnrichmentServiceTest.cs | 39 +++++++++++++++ 32 files changed, 352 insertions(+), 68 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 3720c4de..32357236 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -731,6 +731,13 @@ so a further implementation only needs its own class (`GoogleBooksClient`/`OpenL `BookModel.Language` (free text, same shape as `Genre`) is auto-filled on link/refresh from providers that report one - Google Books' `volumeInfo.language` (a clean ISO 639-1 code) and BnF's Dublin Core `dc:language` (a MARC-style code, e.g. "fre" - shown as-is, not translated to a friendly name) both populate `BookDetails.Language`; Open Library's client doesn't populate it (the value lives at the edition level, not the work level the current client fetches), left as a possible future enhancement rather than guessed at. The reference-level `BookReferenceModel.Language`/`BookReferenceDto.Language` and `IBookRepository.SetReferenceLinkAsync`'s `canonicalLanguage` parameter follow the exact same propagation shape `Genre`/`canonicalGenre` already established: null (not overwritten) when the provider has none. +`BookModel.Isbn` follows the same shape again, with two differences from Genre/Language. First, it's edited on `BookDetail.razor` only, never the Add form (`Books.razor`'s add card only carries title/author/year, per this document's own "Adding a new trackable item" convention). Second, it doubles as an optional *search input*: +`IBookReferenceClient.SearchBooksAsync` takes an `isbn` parameter, but only `GoogleBooksClient` actually uses it (as the sole query, `isbn:{isbn}`, superseding title/author entirely - an ISBN is an exact identifier, so combining it with a fuzzy title/author match would only reintroduce the kind of "and" narrowing risk `BnfClient`'s own author fix (above) had to work around). +Open Library/BnF accept the parameter (interface compliance) but ignore it as a search input; both `GoogleBooksClient` (via `volumeInfo.industryIdentifiers`, preferring ISBN_13 over ISBN_10 when a volume reports both) and `BnfClient` (via a `dc:identifier` value prefixed "ISBN ", confirmed against the real API) still populate `BookDetails.Isbn` for autofill on link/refresh - reporting one already-known and searching by one are different things. +The admin search UI (`ReferenceDataAdminPage.razor`'s ISBN field, and `InlineReferenceLinker`'s `Isbn` parameter bound from `BookDetail.razor`'s own field) is Book-only, same convention as the Author/Artist `creator` field - there was no push to generalize the parameter name here the way `creator` was, since only one domain needs it so far. + +**`ReferenceMatchModel`/`ReferenceMatch` gained an `Isbn` field (null for every domain but Book) specifically so a matched alias only ever records the identifier that actually drove that particular match** - the canonical alias (the provider's own reported ISBN, from `BookDetails.Isbn`) and the tenant-search alias (whatever ISBN, if any, was actually supplied as search input) are two separate entries, never merged, and the search alias's `Isbn` is never backfilled from the provider's own value when no ISBN was actually used to find the match. `MergeMatchedAliases`' shared tuple shape grew a 4th element for this (`(Title, Year, Creator, Isbn)`); every non-Book call site across `.TvShowsAndMovies.cs`/`.VideoGames.cs`/`.Albums.cs` passes a literal `null` for it, same as `Creator` already does for the domains with no creator dimension. + ### Blazor app `InventoryPageBase` (`BlazorApp/Components/Inventory/InventoryPageBase.cs`) centralizes list/paging/search/inline-edit state and calls into `InventoryApiClientBase`, diff --git a/src/BlazorApp/Components/Inventory/Pages/BookDetail.razor b/src/BlazorApp/Components/Inventory/Pages/BookDetail.razor index 1ae1af97..86915c59 100644 --- a/src/BlazorApp/Components/Inventory/Pages/BookDetail.razor +++ b/src/BlazorApp/Components/Inventory/Pages/BookDetail.razor @@ -45,7 +45,7 @@ else { - + } @@ -96,6 +96,10 @@ else
+
+ + +
[Parameter] public string? Creator { get; set; } + /// + /// The book's ISBN, when known - Book-only, and only actually used by Google Books (see + /// 's own doc comment). Edited on the detail page + /// itself (BookDetail.razor's own ISBN field), not here - same convention as . + /// + [Parameter] public string? Isbn { get; set; } + [Parameter] public EventCallback OnLinked { get; set; } private bool _searched; @@ -113,7 +120,7 @@ _error = null; try { - _results = await Api.SearchAsync(Type, Title, Year, Creator, Type == ReferenceItemType.Book ? _selectedProvider : null); + _results = await Api.SearchAsync(Type, Title, Year, Creator, Type == ReferenceItemType.Book ? _selectedProvider : null, Type == ReferenceItemType.Book ? Isbn : null); } catch (Exception ex) { @@ -140,7 +147,8 @@ Title = Title, Year = Year, ExternalId = candidate.ExternalId, - Provider = Type == ReferenceItemType.Book ? _selectedProvider : null + Provider = Type == ReferenceItemType.Book ? _selectedProvider : null, + Isbn = Type == ReferenceItemType.Book ? Isbn : null }); await OnLinked.InvokeAsync(); } diff --git a/src/BlazorApp/Components/ReferenceDataAdmin/ReferenceDataAdminApiClient.cs b/src/BlazorApp/Components/ReferenceDataAdmin/ReferenceDataAdminApiClient.cs index f0a8945e..e318a5f9 100644 --- a/src/BlazorApp/Components/ReferenceDataAdmin/ReferenceDataAdminApiClient.cs +++ b/src/BlazorApp/Components/ReferenceDataAdmin/ReferenceDataAdminApiClient.cs @@ -11,12 +11,13 @@ public async Task> GetUnresolvedAsync(ReferenceItem return results ?? []; } - public async Task> SearchAsync(ReferenceItemType type, string title, int? year, string? creator = null, string? provider = null) + public async Task> SearchAsync(ReferenceItemType type, string title, int? year, string? creator = null, string? provider = null, string? isbn = 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)}"; if (!string.IsNullOrEmpty(provider)) query += $"&provider={Uri.EscapeDataString(provider)}"; + if (!string.IsNullOrEmpty(isbn)) query += $"&isbn={Uri.EscapeDataString(isbn)}"; var results = await http.GetFromJsonAsync>(query); return results ?? []; diff --git a/src/BlazorApp/Components/ReferenceDataAdmin/ReferenceDataAdminPage.razor b/src/BlazorApp/Components/ReferenceDataAdmin/ReferenceDataAdminPage.razor index 309082fd..e1748875 100644 --- a/src/BlazorApp/Components/ReferenceDataAdmin/ReferenceDataAdminPage.razor +++ b/src/BlazorApp/Components/ReferenceDataAdmin/ReferenceDataAdminPage.razor @@ -62,6 +62,7 @@ } @if (_type == ReferenceItemType.Book) { + @foreach (var bookProvider in _bookProviders) {
diff --git a/src/BlazorApp/Components/ReferenceDataAdmin/ReferenceDataAdminPage.razor b/src/BlazorApp/Components/ReferenceDataAdmin/ReferenceDataAdminPage.razor index e1748875..89c070ac 100644 --- a/src/BlazorApp/Components/ReferenceDataAdmin/ReferenceDataAdminPage.razor +++ b/src/BlazorApp/Components/ReferenceDataAdmin/ReferenceDataAdminPage.razor @@ -493,7 +493,7 @@ _queryTitle = item.Title; _queryYear = item.Year; _creator = item.Creator; - _isbn = null; // no ISBN data in the unresolved queue itself - never carry one over from a previous selection + _isbn = item.Isbn; // prefilled from one of the matching tenant items' own ISBN, same convenience as Creator await SearchAsync(); } diff --git a/src/Domain/Repositories/IBookRepository.cs b/src/Domain/Repositories/IBookRepository.cs index 46fae077..7e5baf4d 100644 --- a/src/Domain/Repositories/IBookRepository.cs +++ b/src/Domain/Repositories/IBookRepository.cs @@ -18,7 +18,11 @@ Task SetReferenceLinkAsync(string title, int? year, string referenceId, st /// /// Distinct (title, year) pairs across every tenant's books that have no - /// yet - feeds the admin curation queue. + /// yet - feeds the admin curation queue. Isbn (like Creator) is a search-prefill + /// convenience only, taken from one of the matching tenant items - Book is the only + /// FindDistinctUnresolvedTitleYearsAsync that returns one, since it's the only domain with an + /// ISBN concept; the admin controller's GetUnresolved action handles Book separately from the + /// other four reference domains for exactly this reason. /// - Task> FindDistinctUnresolvedTitleYearsAsync(); + Task> FindDistinctUnresolvedTitleYearsAsync(); } diff --git a/src/Infrastructure.MongoDb/Repositories/BookRepository.cs b/src/Infrastructure.MongoDb/Repositories/BookRepository.cs index 5090856b..16d23df3 100644 --- a/src/Infrastructure.MongoDb/Repositories/BookRepository.cs +++ b/src/Infrastructure.MongoDb/Repositories/BookRepository.cs @@ -60,15 +60,15 @@ public async Task SetReferenceLinkAsync(string title, int? year, string re return result.ModifiedCount; } - public async Task> FindDistinctUnresolvedTitleYearsAsync() + public async Task> FindDistinctUnresolvedTitleYearsAsync() { - // any one tenant's author works as the queue entry's creator - it only prefills the admin's - // search field, it is never persisted anywhere (see ReferenceDataAdminPage's SearchAsync). + // any one tenant's author/ISBN works as the queue entry's creator/isbn - both only prefill the + // admin's search fields, neither is ever persisted anywhere (see ReferenceDataAdminPage's SearchAsync). var groups = await GetCollection().Aggregate() .Match(UnresolvedFilter()) - .Group(f => new { f.Title, f.Year }, g => new { g.Key, Creator = g.First().Author }) + .Group(f => new { f.Title, f.Year }, g => new { g.Key, Creator = g.First().Author, Isbn = g.First().Isbn }) .ToListAsync(); - return groups.Select(g => (g.Key.Title, g.Key.Year, (string?)g.Creator)).ToList(); + return groups.Select(g => (g.Key.Title, g.Key.Year, (string?)g.Creator, g.Isbn)).ToList(); } /// diff --git a/src/WebApi.Contracts/Dto/UnresolvedReferenceDto.cs b/src/WebApi.Contracts/Dto/UnresolvedReferenceDto.cs index 4e7155de..2800dec4 100644 --- a/src/WebApi.Contracts/Dto/UnresolvedReferenceDto.cs +++ b/src/WebApi.Contracts/Dto/UnresolvedReferenceDto.cs @@ -17,4 +17,11 @@ public class UnresolvedReferenceDto /// (title, year) pair - a search-prefill convenience only, null for types with no creator dimension. /// public string? Creator { get; set; } + + /// + /// A book's own ISBN, when one of the unresolved tenant items sharing this (title, year) pair already + /// has one recorded - a search-prefill convenience only, same role as . Book-only, + /// null for every other type. + /// + public string? Isbn { get; set; } } diff --git a/src/WebApi/ReferenceData/GoogleBooksClient.cs b/src/WebApi/ReferenceData/GoogleBooksClient.cs index a824ee55..33c2078c 100644 --- a/src/WebApi/ReferenceData/GoogleBooksClient.cs +++ b/src/WebApi/ReferenceData/GoogleBooksClient.cs @@ -102,16 +102,43 @@ private static string BuildQuery(string title, string? author) /// /// volumeInfo.description is documented as HTML-formatted ("simple formatting elements, such as - /// b, i, and br tags"), not plain text - rendered as-is, an admin would see literal escaped tags in the - /// search results/synopsis instead of formatted text. Strips tags and decodes entities down to plain text. + /// b, i, and br tags") - decodes entities first (so an entity-encoded tag, e.g. &lt;script&gt;, + /// can't survive the strip below and only turn into a real tag afterward), then keeps only bare, + /// attribute-free <b>/<i>/<br/> tags - reconstructed from just the + /// tag name, discarding any attributes the original tag carried - and removes every other tag entirely. + /// This fixed allowlist-and-reconstruct approach (not a general sanitizer) is specifically what makes it + /// safe to render the result as MarkupString on BookDetail.razor: nothing but those three + /// bare tags can ever survive, so there's no attribute-based injection vector (e.g. a stray + /// onclick) to worry about. /// + private static readonly Regex s_tagRegex = new(@"]*>", RegexOptions.Compiled); + + /// + /// Real descriptions confirmed to also use plain newline characters for paragraph breaks, not just + /// <br> tags - a description with only bold/italic markup and no actual <br> + /// tags otherwise renders as one massive paragraph, since HTML collapses bare newlines to whitespace. + /// Converted to real <br/> tags before the tag pass above (rather than a separate step + /// after), so it goes through the exact same allowlist reconstruction as any other <br>. + /// + private static readonly Regex s_newlineRegex = new(@"\r\n|\r|\n", RegexOptions.Compiled); + private static string? CleanDescription(string? html) { if (string.IsNullOrEmpty(html)) return null; - var withoutBreaks = Regex.Replace(html, "", " ", RegexOptions.IgnoreCase); - var withoutTags = Regex.Replace(withoutBreaks, "<[^>]+>", ""); - var text = WebUtility.HtmlDecode(withoutTags).Trim(); + var decoded = s_newlineRegex.Replace(WebUtility.HtmlDecode(html), "
"); + var text = s_tagRegex.Replace(decoded, m => + { + var isClosing = m.Value.StartsWith(" isClosing ? "
" : "", + "i" => isClosing ? "" : "", + "br" => "
", + _ => "" + }; + }).Trim(); + return text.Length == 0 ? null : text; } diff --git a/src/WebApi/ReferenceData/ReferenceDataAdminController.cs b/src/WebApi/ReferenceData/ReferenceDataAdminController.cs index 003a5474..f7288202 100644 --- a/src/WebApi/ReferenceData/ReferenceDataAdminController.cs +++ b/src/WebApi/ReferenceData/ReferenceDataAdminController.cs @@ -227,17 +227,26 @@ public ActionResult> GetBookProviders() => Ok(bookReferenceClientRegistry.All.Select(c => new BookProviderDto { Key = c.ProviderKey, DisplayName = c.DisplayName }).ToList()); /// - /// Distinct (title, year) pairs, across every tenant, still missing a reference-data link. + /// Distinct (title, year) pairs, across every tenant, still missing a reference-data link. Book is + /// handled separately since it's the only domain whose FindDistinctUnresolvedTitleYearsAsync + /// also surfaces a prefill Isbn (see ) - + /// forcing that onto the other four's shared tuple shape for one field only they'd never populate + /// wasn't worth it. /// [HttpGet("unresolved")] [ProducesResponseType(200)] public async Task>> GetUnresolved([FromQuery] ReferenceItemType type) { + if (type == ReferenceItemType.Book) + { + var bookPairs = await bookRepository.FindDistinctUnresolvedTitleYearsAsync(); + return Ok(bookPairs.Select(p => new UnresolvedReferenceDto { Type = type, Title = p.Title, Year = p.Year, Creator = p.Creator, Isbn = p.Isbn }).ToList()); + } + var pairs = type switch { ReferenceItemType.TvShow => await tvShowRepository.FindDistinctUnresolvedTitleYearsAsync(), ReferenceItemType.Movie => await movieRepository.FindDistinctUnresolvedTitleYearsAsync(), - ReferenceItemType.Book => await bookRepository.FindDistinctUnresolvedTitleYearsAsync(), ReferenceItemType.VideoGame => await videoGameRepository.FindDistinctUnresolvedTitleYearsAsync(), ReferenceItemType.Album => await albumRepository.FindDistinctUnresolvedTitleYearsAsync(), _ => throw new ArgumentOutOfRangeException(nameof(type)) diff --git a/test/WebApi.IntegrationTests/Resources/BookUnresolvedQueueTest.cs b/test/WebApi.IntegrationTests/Resources/BookUnresolvedQueueTest.cs index 956e3279..c03692a9 100644 --- a/test/WebApi.IntegrationTests/Resources/BookUnresolvedQueueTest.cs +++ b/test/WebApi.IntegrationTests/Resources/BookUnresolvedQueueTest.cs @@ -41,4 +41,32 @@ public async Task FindDistinctUnresolvedTitleYearsAsync_CarriesATenantsAuthorAsT await repository.DeleteAsync(bookB.Id!, "unresolved-book-tenant-b"); } } + + /// + /// Same search-prefill role as Creator, just for ISBN - regression: the admin unresolved-queue page + /// used to always force its ISBN search field back to null on selecting an item, discarding a tenant's + /// own already-recorded ISBN instead of prefilling with it. + /// + [Fact] + public async Task FindDistinctUnresolvedTitleYearsAsync_CarriesATenantsIsbn() + { + using var scope = factory.Services.CreateScope(); + var repository = scope.ServiceProvider.GetRequiredService(); + var title = $"Unresolved Queue Isbn Test Book {Guid.NewGuid()}"; + const string isbn = "9780000000042"; + + var book = await repository.CreateAsync(new BookModel { OwnerId = "unresolved-book-isbn-tenant", Title = title, Author = "Some Author", Year = 2003, Isbn = isbn }); + + try + { + var unresolved = await repository.FindDistinctUnresolvedTitleYearsAsync(); + + unresolved.Should().ContainSingle(p => p.Title == title && p.Year == 2003) + .Which.Isbn.Should().Be(isbn); + } + finally + { + await repository.DeleteAsync(book.Id!, "unresolved-book-isbn-tenant"); + } + } } diff --git a/test/WebApi.UnitTests/ReferenceData/GoogleBooksClientTest.cs b/test/WebApi.UnitTests/ReferenceData/GoogleBooksClientTest.cs index 848deb25..3ade638f 100644 --- a/test/WebApi.UnitTests/ReferenceData/GoogleBooksClientTest.cs +++ b/test/WebApi.UnitTests/ReferenceData/GoogleBooksClientTest.cs @@ -35,7 +35,7 @@ private static GoogleBooksClient BuildClient(Func re public void DisplayName_IsGoogleBooks() => BuildClient(_ => "").DisplayName.Should().Be("Google Books"); [Fact] - public async Task GetBookDetailsAsync_StripsHtmlFromTheDescriptionAndUpgradesTheThumbnailToHttps() + public async Task GetBookDetailsAsync_KeepsBoldItalicAndBreakFormattingInTheDescriptionAndUpgradesTheThumbnailToHttps() { var client = BuildClient(_ => """ { @@ -58,11 +58,60 @@ public async Task GetBookDetailsAsync_StripsHtmlFromTheDescriptionAndUpgradesThe details.Year.Should().Be(1997); details.Author.Should().Be("Lee Child"); details.Language.Should().Be("en"); - details.Synopsis.Should().Be("A gripping thriller. The first Jack Reacher novel."); + details.Synopsis.Should().Be("A gripping thriller.
The first Jack Reacher novel."); details.ImageUrl.Should().StartWith("https://"); details.Genres.Should().ContainSingle().Which.Should().Be("Fiction / Thrillers"); } + [Fact] + public async Task GetBookDetailsAsync_ConvertsPlainNewlinesToBreakTags() + { + // confirmed against a real description: paragraph breaks are sometimes plain "\n" characters, not + //
tags - HTML collapses bare newlines to whitespace, so without this a description with only + // bold/italic markup and no actual
tags renders as one massive undivided paragraph. + var client = BuildClient(_ => """{"id":"abc123","volumeInfo":{"title":"The Hobbit","description":"Bilbo Baggins is a hobbit.\r\n\r\nA wizard visits."}}"""); + + var details = await client.GetBookDetailsAsync("abc123", TestContext.Current.CancellationToken); + + details!.Synopsis.Should().Be("Bilbo Baggins is a hobbit.

A wizard visits."); + } + + [Fact] + public async Task GetBookDetailsAsync_StripsAnyTagOutsideTheBoldItalicBreakAllowlist_IncludingAttributesOnAllowedTags() + { + // the fixed allowlist-and-reconstruct approach (not a general sanitizer) is what makes it safe to + // render the result as MarkupString: an attribute on an otherwise-allowed tag (a plausible injection + // vector, e.g. onclick/onmouseover) must never survive, and neither should any other tag/script. + var client = BuildClient(_ => """ + { + "id": "abc123", + "volumeInfo": { + "title": "Killing Floor", + "description": "bold

para

" + } + } + """); + + var details = await client.GetBookDetailsAsync("abc123", TestContext.Current.CancellationToken); + + // the