Files
mal/.codex/audits/frontend-performance-2026-07-03/fixes/07-recommendation-refresh-state.md

150 lines
5.2 KiB
Markdown

# Fix 07: represent recommendation refresh state
Priority: P1
Risk: Medium
Primary benefit: Honest background behavior and automatic completion
## Issue
Recommendation computation already runs in the background, which is the right architecture. The cache contract loses an important distinction, however:
- The user has no usable watchlist signal.
- Recommendations are currently being computed.
- Computation failed.
- Stale recommendations exist while refresh runs.
On a cold cache, `getCachedTopPicksForYou` starts a goroutine and immediately returns an empty anime slice. The UI therefore renders “No top picks yet—add a few anime” even when the user just added an anime and a roughly ten-second computation is active. Results appear only after a manual reload.
## Goal
Preserve asynchronous computation while returning an explicit state that lets the UI show the truth and update automatically.
## Proposed domain model
Add a typed state to recommendation results:
```text
empty no usable signals after a completed computation
refreshing no result yet; computation in progress
ready current result available
stale previous result shown while refresh runs
failed no result and the latest computation failed
```
Include only the minimum supporting metadata needed by the UI, such as `retryAfter` or `updatedAt`. Do not expose internal provider errors.
## Cache behavior
### First request, no entry
1. Create an entry with `refreshing=true`.
2. Start the background computation.
3. Return `refreshing` with no items.
### Fresh data
Return `ready` immediately.
### Expired data
1. Preserve the old data.
2. Mark refresh in progress.
3. Return `stale` with old items.
4. Refresh in the background.
### Watchlist mutation
Do not delete the cache entry. Mark it stale and start refresh immediately. This preserves existing recommendations while the new profile is computed.
### Completed empty result
Store a completed `empty` result so every request does not restart computation.
### Failure
- With stale data: keep serving stale and record refresh failure.
- Without data: return `failed` with Retry.
- Use bounded retry/backoff; do not restart on every poll.
## HTMX update flow
The simplest UI is an outer fragment that polls only while state is `refreshing`:
```html
<section
hx-get="/api/catalog/top-pick"
hx-trigger="every 2s"
hx-swap="outerHTML"
aria-busy="true">
<p role="status">Preparing recommendations…</p>
</section>
```
When the server returns `ready`, `stale`, `empty`, or `failed`, render markup without the polling trigger. Replacing the polling element naturally stops polling. HTTP status `286` can also stop polling, but removing the trigger from the final fragment is easier to inspect and does not depend on status-specific behavior.
Use a two-second interval rather than aggressive sub-second polling. The job takes seconds and does not need real-time granularity.
For the full Top Picks page, use the same state-aware fragment or a small page-specific status endpoint. Avoid duplicating state rules between home and `/top-picks`.
## Request synchronization
Ensure one client poll is active at a time. If a poll response can exceed the interval, use an HTMX synchronization policy or a longer interval. Server-side cache locking/singleflight remains authoritative; client synchronization is only a traffic guard.
## Copy
- Refreshing with no data: “Preparing recommendations…”
- Stale: keep cards visible; optional subtle “Updating…” text.
- Completed empty: current “Add a few anime…” guidance.
- Failed with no data: “Recommendations could not be prepared.” plus Retry.
Do not show provider or scoring implementation details to the user.
## Affected files
- `internal/anime/recommendations.go`
- Recommendation/cache domain types
- Recommendation invalidator interface and watchlist integration
- `internal/anime/catalog_handler.go`
- `templates/index.gohtml`
- `templates/top_picks.gohtml`
- Recommendation and handler tests
## Tests
- Cold cache returns `refreshing`, not `empty`.
- Completed empty computation returns `empty` and is cached.
- Fresh data returns `ready`.
- Expired data returns stale cards and starts exactly one refresh.
- Watchlist mutation preserves stale cards and starts refresh immediately.
- Failure preserves stale data when available.
- Final fragments do not continue polling.
- Home and Top Picks use the same state rules.
## Observability
Track:
- Refresh started/completed/failed.
- Refresh duration.
- Ready/stale/refreshing/empty responses.
- Poll count per refresh.
- Number of refreshes joined or suppressed.
## Rollout
Ship the state contract, server rendering, and templates together. During rollout, tolerate missing/zero state as `empty` only for compatibility with old tests; remove that fallback after all call sites are migrated.
## Acceptance criteria
- A cold recommendation build never displays the completed-empty message.
- Results appear without manual page reload.
- Existing cards remain visible during refresh.
- Exactly one computation runs per cache key.
- Polling stops after a terminal state.
- Failure has a visible bounded Retry path.
## Reference
- [HTMX polling and progress pattern](https://github.com/bigskysoftware/htmx/blob/v2.0.4/www/content/examples/progress-bar.md)