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();