Files
holy-flavor-rater/dashboard-redesign.md
T
2026-06-15 21:20:58 +02:00

228 lines
14 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Dashboard Redesign — Audit & Plan
Scope: the authenticated user dashboard (`frontend/src/pages/Dashboard.tsx`, backend
`GET /api/users/dashboard/` in `backend-rs/src/routes/users.rs:1311`). Goal: cleaner,
more intuitive, more robust UX, plus algorithmic recommendations ("tasters like you
also liked X").
Data scale (current, `backend/db.sqlite3`): **13 users · 219 flavors · 331 ratings ·
~25 ratings/user**. Sparse and small — everything fits in memory; recommendations can
be computed in-process per request. No Redis/precompute needed yet.
---
## Part 1 — Audit of the current dashboard
### Architecture / code quality
- **A1. 1039-line monolith.** `Dashboard.tsx` is one component holding the profile
header, stats, two tabs, two filter toolbars, two card lists, and inline
rating-edit + reply editors. Nothing is extracted, nothing is unit-testable in
isolation.
- **A2. Duplicated rating-edit/reply logic.** The edit-score, edit-comment, reply
submit, delete-reply handlers (`Dashboard.tsx:148-185`) re-implement what
`FlavorDetail.tsx` already does. Same bug surface in two places (the Q1 notif-menu
dedup pattern applies here too).
- **A3. ColorThief on every load.** `Dashboard.tsx:109-146` fetches the avatar as a
CORS `Image`, runs `colorthief.getPalette(...,10)` on the main thread, and derives
HSL just to tint a decorative header gradient. Extra dependency, main-thread work,
CORS dependency — all for cosmetics. Already skipped on mobile (`isXs`).
### Backend / performance
- **A4. Loads every unrated flavor, every hit.** The dashboard handler builds
`missing_flavors` from *all* flavors the user hasn't rated (~200 rows), with
per-flavor aggregate rollups, on every dashboard request
(`users.rs:1340-1378`). No pagination. (P1 already trimmed the *per-rating* payload
here via `include_ratings=false`, but the row count itself is unbounded.)
- **A5. All filter/sort is client-side** over the full arrays (`Dashboard.tsx:187-237`).
Fine at 200 items; breaks down as the catalog grows and blocks server-side
pagination.
### UX / product
- **A6. Thin stats.** Only two numbers surfaced: `rated_count`, `missing_count`
(`Dashboard.tsx:405-408`). No average score the user gives, no rating distribution,
no favorite category, no recent-activity sense.
- **A7. "Explore" is an undifferentiated dump.** Tab 2 is the full unrated list sorted
by community or circle average. No curation, no "for you", overwhelming, no
pagination, no recommendations.
- **A8. Flat two-tab IA** (My Reviews / Explore New). No personalized landing; the
user's *taste* is never reflected back to them.
- **A9. Rough edges.** `"Please login to view dashboard."` is a hardcoded,
untranslated string (`Dashboard.tsx:255`); several toast messages are hardcoded
English (`'Failed to update review'`, etc.) instead of i18n keys.
- **A10. No recommendations exist** anywhere in the product today.
---
## Part 2 — Recommendation engine (the headline feature)
"Users with the same taste also like X" = **user-based collaborative filtering (CF)**,
with a popularity/quality fallback for cold-start users.
### Algorithm (user-based CF)
1. Load the full rating matrix (331 cells — trivial) into memory: `user → {flavor: score}`.
2. For the target user `u`, compute similarity to every other user `v` over their
**co-rated** flavors using **Pearson correlation** (mean-centred, so it cancels out
the fact that some users rate high and some low).
3. **Min-overlap guard:** ignore any `v` sharing fewer than ~3 co-rated flavors —
correlation over 1–2 points is noise. Apply **significance weighting**
`sim *= min(overlap, K)/K` (K≈5) to down-weight thin overlaps.
4. For each candidate flavor `f` the target hasn't rated, predict:
`pred(u,f) = mean_u + Σ_v sim(u,v)·(r_vf − mean_v) / Σ |sim(u,v)|`
over neighbours `v` who rated `f`. Require ≥2 contributing neighbours for confidence.
5. Rank candidates by `pred`, return top-N with metadata: `predicted_score`,
`contributing_neighbours` (the "based on N tasters like you" count), and the
neighbour usernames (optional, for "because Alice & Bob loved it").
### Cold-start fallback (user with < ~5 ratings, or no confident neighbours)
Rank unrated flavors by a **Bayesian average** to stop a single 10/10 from topping the
chart: `score = (C·m + Σr) / (C + n)` where `m` = global mean score, `n` = that
flavor's rating count, `C` ≈ 5 (confidence prior). Optionally bias toward categories the
user has already engaged with.
### Why in-process, on-demand
At 13 users / 331 ratings the whole computation is sub-millisecond. Compute it inside
the recommendations endpoint; add a short per-user cache only if profiling says so.
If the dataset grows orders of magnitude, promote it to a **background job**
(`src/jobs/` already exists) that precomputes per-user recommendations into a table on a
schedule — the engine code stays the same, only the trigger changes.
### Optional companion: item-based CF
Flavor↔flavor cosine over rating vectors powers "people who liked *this* flavor also
liked…" — useful on `FlavorDetail` too, not just the dashboard. Separate, later phase.
---
## Part 3 — Phased implementation plan
Each phase is independently shippable and verified (`tsc` + ESLint + vitest on the
frontend; `cargo fmt`/`clippy`/`test` on the backend).
### Phase 0 — Decompose (no behaviour change) — ✅ DONE
Split the 1039-line `Dashboard.tsx` (now ~115 lines, thin orchestrator) into
`src/pages/dashboard/`:
- `DashboardHeader.tsx` — action bar + profile + stats.
- `MyReviewsTab.tsx` — filter toolbar + list; owns its filter/sort/category state.
- `MyReviewCard.tsx` — one own-review card; **owns its own** edit/reply/expand state
+ mutation calls (replaces the Dashboard-level state maps keyed by rating id).
- `ExploreTab.tsx` — filter toolbar + grid; owns its own filter state.
- `usePalette.ts` — the ColorThief effect, isolated (deleted in Phase 3).
Also fixed A9's hardcoded login string → `t('dashboard.loginRequired')` (EN+DE).
`tsc` clean, ESLint 0 errors (1 pre-existing setState-in-effect warning in the
soon-to-be-deleted `usePalette`), 27 tests pass, `vite build` clean.
**Deviation from original plan:** did *not* force a single shared `ReviewCard`
across Dashboard + `FlavorDetail`. On inspection the two cards are structurally
different — the dashboard card is flavor-centric (one user, many flavors), the
detail card is user-centric (one flavor, many users). Unifying would over-couple two
genuinely different layouts. The cross-file A2 dedup is reduced to a possible future
extraction of just the shared reply-thread atom, tracked but not forced.
### Phase 1 — Enrich the backend payload — ✅ DONE (stats); payload-slim deferred
- Added a **`stats` block** to `DashboardOut` (`dto.rs`): `average_score`,
`score_distribution` (1–10 histogram, always all 10 keys), `favorite_category`
(`{name, slug, count}`, deterministic tie-break by slug), `ratings_this_month`
(current UTC month), `category_count`. Computed in the `dashboard` handler
(`users.rs`) by a pure `dashboard_stats()` helper over the already-loaded ratings —
**no new queries**. 3 unit tests (empty / aggregate / tie-break). `cargo fmt` +
`clippy` clean, 23 backend tests pass.
- Frontend: extended `DashboardData` with `DashboardStats`/`FavoriteCategory`;
`DashboardHeader` now shows Avg Score + This Month tiles and a "Top category" line
(i18n EN+DE). `tsc` + ESLint (0 errors) clean, 27 tests, build clean.
**Sequencing change (deliberate):** the planned payload slim — dropping the
all-unrated `missing_flavors` dump (A4) — is **deferred to Phase 3**, where the
Explore tab that still consumes it is removed. Dropping it now would break a live UI
mid-refactor. Phase 1 stayed purely additive.
### Phase 2 — Recommendations engine — ✅ DONE
- New pure module `backend-rs/src/recommend.rs` (no DB → unit-testable): user-based CF
with Pearson similarity (mean-centred), `MIN_OVERLAP=3`, significance weighting
`min(overlap,5)/5`, positive-correlation-only neighbours, `MIN_NEIGHBOURS=2`,
prediction clamped to 1–10. Cold start (`<5` own ratings, or no confident CF
candidates) → Bayesian-popularity fallback (`C=5`). Deterministic ordering (score →
neighbours → id). 3 unit tests: cold-start, CF-finds-shared-pick (with opposite-taste
decoy filtered), exclude-already-rated + limit.
- New endpoint `GET /api/users/recommendations/` (`users.rs`) → `Vec<RecommendationOut>`
(`dto.rs`): card fields + `predicted_score`, `contributing_neighbours`, `reason`
(`"cf"`/`"popular"`). **Count only — no neighbour names** (locked decision). Loads
the full rating matrix once, scores in-process, resolves ids via `build_flavors`
(`include_ratings=false`). `cargo fmt`/`clippy` clean, 26 backend tests pass.
- Frontend plumbing: `Recommendation` type + `useRecommendations()` query hook
(key `recommendations`, 5 min staleTime) + `queryKeys.recommendations()`. UI wiring
is Phase 3. `tsc` + ESLint (0 errors) clean, 27 tests pass.
### Phase 3 — UI redesign ("For You" first) — ✅ DONE
- **Backend payload slim (the deferred A4):** `DashboardOut` no longer ships
`missing_flavors` (the full unrated `Vec<FlavorOut>` with per-flavor aggregates).
The handler now computes `missing_count` from flavor ids alone — no `build_flavors`
pass over ~200 rows, no extra category/rating/user queries. `cargo fmt`/`clippy`
clean, 26 backend tests pass.
- **Frontend IA:** removed the two-tab layout. Dashboard is now a single page —
`DashboardHeader` (hero stats) → `RecommendationsSection` ("Tasters like you
loved", count-only reason chips, skeleton while loading, hidden when empty) → "My
Reviews" list. Deleted `ExploreTab.tsx`.
- **Dropped ColorThief from the dashboard (A3):** `DashboardHeader` uses a
theme-derived gradient; deleted `usePalette.ts`. (The npm dep stays — `PublicProfile`
still uses ColorThief; that's out of dashboard scope.)
- New components: `RecommendationCard.tsx` (image + predicted-score badge + reason
chip), `RecommendationsSection.tsx` (wraps `useRecommendations`).
- i18n EN+DE: `recHeading`, `recReasonCf`, `recReasonPopular`, `predictedForYou` (plus
the earlier `loginRequired`/`avgScore`/`thisMonth`/`favoriteCategory`).
- `tsc` + ESLint (0 warnings — the old `usePalette` setState-in-effect warning is gone
with the file), 27 tests, `vite build` clean.
**Scope note:** the plan's separate "curated rows" (top community / newest) are
covered by the recommendation engine's popularity fallback — a cold or
rated-everything user still gets popularity-ranked picks in the same carousel — so a
distinct curated row was not added. Browsing the full catalog now lives on the main
flavor list, as decided. Leftover unused i18n keys from the old Explore tab
(`searchMissing`, `communityRating`, `circleRating`, `allRated`, `exploreNew`) were
left in place (harmless); can be swept later.
### Phase 4 — Item-based "similar flavors" — ✅ DONE
- **Engine:** added `similar_flavors()` to `backend-rs/src/recommend.rs` — **adjusted
cosine** over the rating matrix (each user's scores mean-centred to cancel rater
bias), positive-correlation-only, `MIN_CORATERS=2`, deterministic ordering
(similarity → co-raters → id). Returns `SimilarFlavor { flavor_id, similarity,
co_raters }`. 3 unit tests (co-liked / min-coraters guard / unknown target).
- **Endpoint:** `GET /api/flavors/{id}/similar/` (`routes/flavors.rs`, `SIMILAR_LIMIT=8`)
→ `Vec<SimilarFlavorOut>` (`dto.rs`: card fields + `similarity` + `co_raters`).
404 if the flavor doesn't exist; **empty list** (not 404) when too few co-ratings to
compute. Loads the matrix once, resolves ids via `build_flavors` (no nested
ratings/replies). `cargo fmt`/`clippy` clean, **29 backend tests pass**.
- **Frontend:** `SimilarFlavor` type + `queryKeys.similarFlavors(id)` +
`useSimilarFlavors()` hook (public, 5 min staleTime). New
`pages/flavors/components/SimilarFlavorsSection.tsx` — "People who liked this also
liked" grid (compact cards: image + avg-rating badge + status + "N rated both"
chip), skeleton while loading, **hidden when empty**. Wired into `FlavorDetail.tsx`
below the ratings grid. i18n EN+DE (`similarHeading`, `similarCoRaters`,
`similarTooltip`). `tsc` + ESLint (0 warnings), 27 tests, `vite build` clean.
**Deferred (not built):** promoting recommendations to a scheduled precompute job —
unnecessary at current scale (sub-ms in-process); the engine code is unchanged when
that day comes, only the trigger moves. Tracked, not done.
---
## Resolved decisions (user)
- **Recommendation reason = count only.** Show "N tasters like you loved this"; do not
name neighbours (no privacy exposure). The endpoint still returns
`contributing_neighbours` as a number; neighbour usernames are not surfaced.
- **Drop ColorThief.** The avatar-derived header gradient is replaced by a
theme-derived gradient — removes the dependency and the main-thread work (A3). No
Web Worker needed.
- **Collapse "Explore".** The dashboard no longer hosts a full browsable unrated
catalog. Dashboard = "For You" (recommendations) + curated rows + My Reviews.
General browsing lives on the main flavor list. This drops the all-unrated-flavors
load (A4) entirely from the dashboard payload — a large simplification.
### Plan adjustments from these decisions
- **Phase 1** no longer needs a paginated "browse unrated" view in the dashboard; it
just adds the stats block and the bounded curated rows. The `missing_flavors` dump
is removed from the dashboard response outright.
- **Phase 2** endpoint shape: `[{ ...flavor fields, predicted_score,
contributing_neighbours, reason_key }]` — no neighbour usernames.
- **Phase 3** IA becomes: hero stats → "Tasters like you loved…" carousel → curated
rows → My Reviews. The second tab ("Explore New") is removed.