5.2 KiB
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:
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
- Create an entry with
refreshing=true. - Start the background computation.
- Return
refreshingwith no items.
Fresh data
Return ready immediately.
Expired data
- Preserve the old data.
- Mark refresh in progress.
- Return
stalewith old items. - 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
failedwith 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:
<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.gotemplates/index.gohtmltemplates/top_picks.gohtml- Recommendation and handler tests
Tests
- Cold cache returns
refreshing, notempty. - Completed empty computation returns
emptyand 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.