mirror of
https://github.com/narrrl/holy-flavor-rater.git
synced 2026-11-03 16:41:27 +01:00
228 lines
14 KiB
Markdown
228 lines
14 KiB
Markdown
# 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.
|