# 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

Preparing recommendations…

``` 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)