Add shared recent tables state
This commit is contained in:
@@ -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 | 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.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.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
|
### 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.
|
- 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.
|
- 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.
|
- 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
|
## 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.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.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.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.5` | Pending | Table-context URL parsing and serialization still needs a shared model. |
|
||||||
| `P2.6` | Pending | The shell omnibox is still a placeholder trigger. |
|
| `P2.6` | Pending | The shell omnibox is still a placeholder trigger. |
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
@using System.Linq
|
@using System.Linq
|
||||||
@inject IJSRuntime JSRuntime
|
@inject IJSRuntime JSRuntime
|
||||||
@inject LookupService LookupService
|
@inject LookupService LookupService
|
||||||
|
@inject RolemasterDb.App.Frontend.AppState.RecentTablesState RecentTablesState
|
||||||
|
|
||||||
<PageTitle>Critical Tables</PageTitle>
|
<PageTitle>Critical Tables</PageTitle>
|
||||||
|
|
||||||
@@ -292,7 +293,10 @@
|
|||||||
if (tableDetail is null)
|
if (tableDetail is null)
|
||||||
{
|
{
|
||||||
detailError = "The selected table could not be loaded.";
|
detailError = "The selected table could not be loaded.";
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await RecordRecentTableVisitAsync();
|
||||||
}
|
}
|
||||||
catch (Exception exception)
|
catch (Exception exception)
|
||||||
{
|
{
|
||||||
@@ -769,6 +773,20 @@
|
|||||||
private Task PersistSelectedTableAsync(string tableSlug) =>
|
private Task PersistSelectedTableAsync(string tableSlug) =>
|
||||||
JSRuntime.InvokeVoidAsync("localStorage.setItem", SelectedTableStorageKey, tableSlug).AsTask();
|
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)
|
private string GetTableOptionCssClass(CriticalTableReference table)
|
||||||
{
|
{
|
||||||
var classes = new List<string>();
|
var classes = new List<string>();
|
||||||
|
|||||||
@@ -3,4 +3,5 @@ namespace RolemasterDb.App.Frontend.AppState;
|
|||||||
public static class BrowserStorageKeys
|
public static class BrowserStorageKeys
|
||||||
{
|
{
|
||||||
public const string ThemeMode = "rolemaster.theme.mode";
|
public const string ThemeMode = "rolemaster.theme.mode";
|
||||||
|
public const string RecentTables = "rolemaster.tables.recent";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
namespace RolemasterDb.App.Frontend.AppState;
|
||||||
|
|
||||||
|
public sealed record RecentTableEntry(
|
||||||
|
string Slug,
|
||||||
|
string Label,
|
||||||
|
string Family,
|
||||||
|
int CurationPercentage,
|
||||||
|
DateTimeOffset ViewedAtUtc);
|
||||||
108
src/RolemasterDb.App/Frontend/AppState/RecentTablesState.cs
Normal file
108
src/RolemasterDb.App/Frontend/AppState/RecentTablesState.cs
Normal file
@@ -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<RecentTableEntry> 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<RecentTableEntry>
|
||||||
|
{
|
||||||
|
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<RecentTableEntry> DeserializeItems(string? storedValue)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(storedValue))
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
var items = JsonSerializer.Deserialize<List<RecentTableEntry>>(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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ builder.Services.AddDbContextFactory<RolemasterDbContext>(options => options.Use
|
|||||||
builder.Services.AddSingleton<CriticalImportArtifactLocator>();
|
builder.Services.AddSingleton<CriticalImportArtifactLocator>();
|
||||||
builder.Services.AddScoped<LookupService>();
|
builder.Services.AddScoped<LookupService>();
|
||||||
builder.Services.AddScoped<BrowserStorageService>();
|
builder.Services.AddScoped<BrowserStorageService>();
|
||||||
|
builder.Services.AddScoped<RecentTablesState>();
|
||||||
builder.Services.AddScoped<ThemeState>();
|
builder.Services.AddScoped<ThemeState>();
|
||||||
|
|
||||||
var app = builder.Build();
|
var app = builder.Build();
|
||||||
|
|||||||
Reference in New Issue
Block a user