14 KiB
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.tsxis 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 whatFlavorDetail.tsxalready does. Same bug surface in two places (the Q1 notif-menu dedup pattern applies here too). - A3. ColorThief on every load.
Dashboard.tsx:109-146fetches the avatar as a CORSImage, runscolorthief.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_flavorsfrom 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 viainclude_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)
- Load the full rating matrix (331 cells — trivial) into memory:
user → {flavor: score}. - For the target user
u, compute similarity to every other uservover their co-rated flavors using Pearson correlation (mean-centred, so it cancels out the fact that some users rate high and some low). - Min-overlap guard: ignore any
vsharing fewer than ~3 co-rated flavors — correlation over 1–2 points is noise. Apply significance weightingsim *= min(overlap, K)/K(K≈5) to down-weight thin overlaps. - For each candidate flavor
fthe target hasn't rated, predict:pred(u,f) = mean_u + Σ_v sim(u,v)·(r_vf − mean_v) / Σ |sim(u,v)|over neighboursvwho ratedf. Require ≥2 contributing neighbours for confidence. - 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
statsblock toDashboardOut(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 thedashboardhandler (users.rs) by a puredashboard_stats()helper over the already-loaded ratings — no new queries. 3 unit tests (empty / aggregate / tie-break).cargo fmt+clippyclean, 23 backend tests pass. - Frontend: extended
DashboardDatawithDashboardStats/FavoriteCategory;DashboardHeadernow 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 weightingmin(overlap,5)/5, positive-correlation-only neighbours,MIN_NEIGHBOURS=2, prediction clamped to 1–10. Cold start (<5own 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 viabuild_flavors(include_ratings=false).cargo fmt/clippyclean, 26 backend tests pass. - Frontend plumbing:
Recommendationtype +useRecommendations()query hook (keyrecommendations, 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):
DashboardOutno longer shipsmissing_flavors(the full unratedVec<FlavorOut>with per-flavor aggregates). The handler now computesmissing_countfrom flavor ids alone — nobuild_flavorspass over ~200 rows, no extra category/rating/user queries.cargo fmt/clippyclean, 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. DeletedExploreTab.tsx. - Dropped ColorThief from the dashboard (A3):
DashboardHeaderuses a theme-derived gradient; deletedusePalette.ts. (The npm dep stays —PublicProfilestill uses ColorThief; that's out of dashboard scope.) - New components:
RecommendationCard.tsx(image + predicted-score badge + reason chip),RecommendationsSection.tsx(wrapsuseRecommendations). - i18n EN+DE:
recHeading,recReasonCf,recReasonPopular,predictedForYou(plus the earlierloginRequired/avgScore/thisMonth/favoriteCategory). tsc+ ESLint (0 warnings — the oldusePalettesetState-in-effect warning is gone with the file), 27 tests,vite buildclean.
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()tobackend-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). ReturnsSimilarFlavor { 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 viabuild_flavors(no nested ratings/replies).cargo fmt/clippyclean, 29 backend tests pass. - Frontend:
SimilarFlavortype +queryKeys.similarFlavors(id)+useSimilarFlavors()hook (public, 5 min staleTime). Newpages/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 intoFlavorDetail.tsxbelow the ratings grid. i18n EN+DE (similarHeading,similarCoRaters,similarTooltip).tsc+ ESLint (0 warnings), 27 tests,vite buildclean.
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_neighboursas 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_flavorsdump 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.