diff --git a/docs/tables_frontend_overhaul_implementation_plan.md b/docs/tables_frontend_overhaul_implementation_plan.md index cfdd4d1..773db81 100644 --- a/docs/tables_frontend_overhaul_implementation_plan.md +++ b/docs/tables_frontend_overhaul_implementation_plan.md @@ -52,6 +52,7 @@ It is intentionally implementation-focused: | 2026-03-21 | Post-P1 fix 4 | Completed | Added early theme bootstrapping in `App.razor` and `theme.js` so the stored mode is applied before hydration and remains visible after refresh. | | 2026-03-21 | P2.1 | Completed | Added canonical `/tools/diagnostics` and `/tools/api` routes with dedicated tooling page components, extracted the diagnostics and API content into shared tool components, and updated the `Tools` landing page to link to the new route structure. | | 2026-03-21 | P2.2 | Completed | Replaced the old `/diagnostics` and `/api` pages with lightweight compatibility routes that reuse a shared redirect component, preserve query strings and fragments, and replace browser history while forwarding into the canonical `Tools` routes. | +| 2026-03-21 | P2.3 | Completed | Added a shared `RecentTablesState` service backed by browser storage, centralized the recents storage key, and started recording successful table visits from the `Tables` page so later omnibox and rail work has real shared data. | ### Lessons Learned @@ -73,6 +74,7 @@ It is intentionally implementation-focused: - Persisted theme state should be applied before Blazor hydrates, not only after layout initialization. Otherwise refresh can look broken even when storage writes succeed. - For route migration in Blazor, extracting the destination UI into shared components keeps canonical routes and temporary compatibility routes from drifting while the redirect phase is still pending. - Compatibility routes should stay declarative and shared. A single redirect component is enough for route forwarding and avoids copy-pasted `NavigationManager` lifecycle logic in page files. +- Shared browser-backed state becomes more useful once one real page writes to it immediately. Recording recents from `Tables` now keeps later omnibox work from being blocked on synthetic placeholder data. ## Target Outcomes @@ -362,7 +364,7 @@ Establish the shared shell, tokens, typography, and theme system that every dest | --- | --- | --- | | `P2.1` | Completed | Canonical `Tools` child routes now exist, backed by dedicated route pages and shared tooling content components. | | `P2.2` | Completed | Old `/diagnostics` and `/api` routes now forward into the canonical `Tools` routes with shared redirect behavior. | -| `P2.3` | Pending | Recent tables state has not been introduced yet. | +| `P2.3` | Completed | Recent critical-table visits now persist through a shared app-state service and are recorded from the `Tables` page. | | `P2.4` | Pending | Pinned tables state has not been introduced yet. | | `P2.5` | Pending | Table-context URL parsing and serialization still needs a shared model. | | `P2.6` | Pending | The shell omnibox is still a placeholder trigger. | diff --git a/src/RolemasterDb.App/Components/Pages/Tables.razor b/src/RolemasterDb.App/Components/Pages/Tables.razor index 20c5e7c..0bff37b 100644 --- a/src/RolemasterDb.App/Components/Pages/Tables.razor +++ b/src/RolemasterDb.App/Components/Pages/Tables.razor @@ -6,6 +6,7 @@ @using System.Linq @inject IJSRuntime JSRuntime @inject LookupService LookupService +@inject RolemasterDb.App.Frontend.AppState.RecentTablesState RecentTablesState Critical Tables @@ -292,7 +293,10 @@ if (tableDetail is null) { detailError = "The selected table could not be loaded."; + return; } + + await RecordRecentTableVisitAsync(); } catch (Exception exception) { @@ -769,6 +773,20 @@ private Task PersistSelectedTableAsync(string tableSlug) => JSRuntime.InvokeVoidAsync("localStorage.setItem", SelectedTableStorageKey, tableSlug).AsTask(); + private Task RecordRecentTableVisitAsync() + { + if (SelectedTableReference is not { } selectedTable) + { + return Task.CompletedTask; + } + + return RecentTablesState.RecordVisitAsync( + selectedTable.Key, + selectedTable.Label, + selectedTable.Family, + selectedTable.CurationPercentage); + } + private string GetTableOptionCssClass(CriticalTableReference table) { var classes = new List(); diff --git a/src/RolemasterDb.App/Frontend/AppState/BrowserStorageKeys.cs b/src/RolemasterDb.App/Frontend/AppState/BrowserStorageKeys.cs index eda8783..e45ea0a 100644 --- a/src/RolemasterDb.App/Frontend/AppState/BrowserStorageKeys.cs +++ b/src/RolemasterDb.App/Frontend/AppState/BrowserStorageKeys.cs @@ -3,4 +3,5 @@ namespace RolemasterDb.App.Frontend.AppState; public static class BrowserStorageKeys { public const string ThemeMode = "rolemaster.theme.mode"; + public const string RecentTables = "rolemaster.tables.recent"; } diff --git a/src/RolemasterDb.App/Frontend/AppState/RecentTableEntry.cs b/src/RolemasterDb.App/Frontend/AppState/RecentTableEntry.cs new file mode 100644 index 0000000..74131ae --- /dev/null +++ b/src/RolemasterDb.App/Frontend/AppState/RecentTableEntry.cs @@ -0,0 +1,8 @@ +namespace RolemasterDb.App.Frontend.AppState; + +public sealed record RecentTableEntry( + string Slug, + string Label, + string Family, + int CurationPercentage, + DateTimeOffset ViewedAtUtc); diff --git a/src/RolemasterDb.App/Frontend/AppState/RecentTablesState.cs b/src/RolemasterDb.App/Frontend/AppState/RecentTablesState.cs new file mode 100644 index 0000000..59004a1 --- /dev/null +++ b/src/RolemasterDb.App/Frontend/AppState/RecentTablesState.cs @@ -0,0 +1,108 @@ +using System.Text.Json; + +namespace RolemasterDb.App.Frontend.AppState; + +public sealed class RecentTablesState(BrowserStorageService browserStorage) +{ + private const int MaxItems = 8; + private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web); + private bool isInitialized; + + public IReadOnlyList Items { get; private set; } = []; + + public event Action? Changed; + + public async Task InitializeAsync() + { + if (isInitialized) + { + return; + } + + try + { + var storedValue = await browserStorage.GetItemAsync(BrowserStorageKeys.RecentTables); + Items = DeserializeItems(storedValue); + isInitialized = true; + Changed?.Invoke(); + } + catch (JsonException) + { + Items = []; + isInitialized = true; + Changed?.Invoke(); + } + catch (InvalidOperationException) + { + // JS interop is unavailable during prerender. Retry on the next interactive render. + } + } + + public async Task RecordVisitAsync(string slug, string label, string family, int curationPercentage) + { + if (string.IsNullOrWhiteSpace(slug) || string.IsNullOrWhiteSpace(label)) + { + return; + } + + await InitializeAsync(); + + var updatedItems = new List + { + new( + slug.Trim(), + label.Trim(), + family.Trim(), + curationPercentage, + DateTimeOffset.UtcNow) + }; + + foreach (var item in Items) + { + if (string.Equals(item.Slug, slug, StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + updatedItems.Add(item); + if (updatedItems.Count == MaxItems) + { + break; + } + } + + Items = updatedItems; + await PersistAsync(); + Changed?.Invoke(); + } + + private async Task PersistAsync() + { + var serialized = JsonSerializer.Serialize(Items, SerializerOptions); + await browserStorage.SetItemAsync(BrowserStorageKeys.RecentTables, serialized); + } + + private static IReadOnlyList DeserializeItems(string? storedValue) + { + if (string.IsNullOrWhiteSpace(storedValue)) + { + return []; + } + + var items = JsonSerializer.Deserialize>(storedValue, SerializerOptions); + if (items is null || items.Count == 0) + { + return []; + } + + return items + .Where(item => !string.IsNullOrWhiteSpace(item.Slug) && !string.IsNullOrWhiteSpace(item.Label)) + .GroupBy(item => item.Slug, StringComparer.OrdinalIgnoreCase) + .Select(group => group + .OrderByDescending(item => item.ViewedAtUtc) + .First()) + .OrderByDescending(item => item.ViewedAtUtc) + .Take(MaxItems) + .ToList(); + } +} diff --git a/src/RolemasterDb.App/Program.cs b/src/RolemasterDb.App/Program.cs index b4c6d35..1c9c952 100644 --- a/src/RolemasterDb.App/Program.cs +++ b/src/RolemasterDb.App/Program.cs @@ -13,6 +13,7 @@ builder.Services.AddDbContextFactory(options => options.Use builder.Services.AddSingleton(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddScoped(); var app = builder.Build();