diff --git a/audit.md b/audit.md index 58376a6..e0c121a 100644 --- a/audit.md +++ b/audit.md @@ -74,6 +74,35 @@ Verify mutations `invalidateQueries`; consider optimistic insert so reviews appe --- ## Implementation status +- [x] P2 — investigated, **no code change** (no measurable win post-P1). + `avg_score_expr()` is used only in `ORDER BY` (`flavors.rs:97/145/199`), never + selected as a column — it's *required* for sorting+paginating by rating and + can't be dropped. The Rust avg is computed inside the single rating loop that + also builds distribution/followed-avg/viewer-score (`service.rs:341-400`), + which runs regardless; the avg is `sum/n` accumulated in that same loop, so + removing it saves one division per flavor (≤50/page) — negligible. The real + cost (loading all ratings for `list`) is intentional: `FlavorList.tsx:144-171` + reads `flavor.ratings` for the comment count + previews, so `list` keeps + `include_ratings=true`. Slimming that is a P1-class payload change needing a + frontend refactor, out of P2 scope. Closed as wontfix. +- [x] Q4 follow-up — `useCurrentUserLite.ts` had the same `...({ skipAuthRedirect: + true } as object)` cast as the (now-fixed) `AuthContext`; switched it to + `{ skipAuthRedirect: true } as RetriableConfig` using the type exported in Q4. + `tsc` + ESLint (0 errors) clean. +- [x] U1 — optimistic UI on rating + reply submit. `useCreateRating` / + `useCreateReply` (`useRatingMutations.ts`) now run `onMutate`/`onError`/`onSettled` + against the `flavorDetail` cache: insert a temp row (negative id, replaced on + refetch), roll back on error from the snapshot, and invalidate on settle. Author + info is passed through the input — `RatingForm` supplies `optimistic` from + `useAuth().user`; `FlavorDetail.handleReplySubmit` supplies `flavorId` + + `optimisticUser`. Both optimistic paths are opt-in (skip if author info absent), + so other callers are unaffected. New review/reply appears instantly, reconciles + on the round-trip. `tsc`, ESLint (0 errors), 27 tests clean. +- [x] Q3 — fixed stale CLAUDE.md frontend Auth paragraph. It described the + retired localStorage `access`/`refresh` JWT scheme; rewritten to match + `lib/api.ts`: httpOnly `access_token`/`refresh_token` cookies, `withCredentials`, + 401 auto-refresh via `auth/token/refresh/`, `skipAuthRedirect`/`RetriableConfig` + escape hatch, and `clearLegacyToken()` purging all three retired localStorage keys. - [x] Q4 — dropped the `as object` cast. Exported `RetriableConfig` from `lib/api.ts`; `AuthContext.refetchUser` now passes `{ skipAuthRedirect: true } as RetriableConfig` (the field is part of that type) instead of the diff --git a/backend-rs/src/dto.rs b/backend-rs/src/dto.rs index ac09861..c61d170 100644 --- a/backend-rs/src/dto.rs +++ b/backend-rs/src/dto.rs @@ -159,14 +159,77 @@ pub struct PublicProfileOut { pub following: Vec, } +/// The user's most-rated category. +#[derive(Serialize, ToSchema)] +pub struct FavoriteCategory { + pub name: String, + pub slug: String, + pub count: i64, +} + +/// Aggregate stats over the current user's own ratings, surfaced on the dashboard. +#[derive(Serialize, ToSchema)] +pub struct DashboardStats { + /// Mean of the scores the user has given (`None` when they have no ratings). + pub average_score: Option, + /// Count of the user's ratings per score, keyed `"1".."10"` (always all 10 keys). + pub score_distribution: std::collections::BTreeMap, + /// The category the user has rated most (`None` when they have no ratings). + pub favorite_category: Option, + /// Ratings the user created in the current calendar month (UTC). + pub ratings_this_month: i64, + /// Number of distinct categories the user has rated in. + pub category_count: i64, +} + /// Custom payload of the `users/dashboard` action. #[derive(Serialize, ToSchema)] pub struct DashboardOut { pub user: UserOut, pub rated_count: i64, pub missing_count: i64, - pub missing_flavors: Vec, pub my_ratings: Vec, + pub stats: DashboardStats, +} + +/// One recommended flavor (card payload + the recommendation metadata). Mirrors the +/// fields the frontend flavor card reads, plus the prediction + reason. +#[derive(Serialize, ToSchema)] +pub struct RecommendationOut { + pub id: i32, + pub name: String, + pub image_url: Option, + pub category_name: String, + pub category_slug: String, + pub average_rating: Option, + pub is_legacy: bool, + pub is_available: bool, + /// Predicted score for this user (CF) or the flavor's damped community score + /// (popularity fallback). + pub predicted_score: f64, + /// "N tasters like you" count (CF) or the flavor's rating count (popularity). + pub contributing_neighbours: i64, + /// `"cf"` or `"popular"` — frontend picks the reason copy. Names are never + /// surfaced (privacy decision). + pub reason: String, +} + +/// Item-based "people who liked this flavor also liked…" card, served on +/// `GET /api/flavors/{id}/similar/`. Flavor card fields plus the similarity metadata. +#[derive(Serialize, ToSchema)] +pub struct SimilarFlavorOut { + pub id: i32, + pub name: String, + pub image_url: Option, + pub category_name: String, + pub category_slug: String, + pub average_rating: Option, + pub is_legacy: bool, + pub is_available: bool, + /// Adjusted-cosine similarity to the target flavor, in (0, 1]. + pub similarity: f64, + /// Users who rated both this flavor and the target (the confidence count). + pub co_raters: i64, } #[derive(Serialize, ToSchema)] diff --git a/backend-rs/src/main.rs b/backend-rs/src/main.rs index e338e3d..ce205bc 100644 --- a/backend-rs/src/main.rs +++ b/backend-rs/src/main.rs @@ -17,6 +17,7 @@ mod openapi; mod pagination; mod password; mod password_policy; +mod recommend; mod routes; mod search; mod service; diff --git a/backend-rs/src/recommend.rs b/backend-rs/src/recommend.rs new file mode 100644 index 0000000..1a8b7e2 --- /dev/null +++ b/backend-rs/src/recommend.rs @@ -0,0 +1,446 @@ +//! Flavor recommendations: "tasters like you also liked X". +//! +//! User-based collaborative filtering (CF) with a Bayesian-popularity fallback for +//! cold-start users. Kept pure (no DB) so the scoring math is unit-testable; the +//! route handler loads the rating matrix and calls [`recommend`]. +//! +//! At the current scale (tens of users, hundreds of ratings) this runs in-process +//! per request in well under a millisecond. If the dataset grows by orders of +//! magnitude, move the call behind a scheduled job that precomputes per-user results +//! — the math here is unchanged. + +use std::collections::{HashMap, HashSet}; + +/// One cell of the user×flavor rating matrix. +#[derive(Clone, Copy, Debug)] +pub struct RatingInput { + pub user_id: i32, + pub flavor_id: i32, + pub score: f64, +} + +/// Where a recommendation came from — drives the frontend copy ("cf" → "tasters like +/// you", "popular" → "popular in the community"). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum RecSource { + /// Collaborative filtering over similar users. + Cf, + /// Cold-start / fallback Bayesian-popularity ranking. + Popular, +} + +impl RecSource { + pub fn as_str(self) -> &'static str { + match self { + RecSource::Cf => "cf", + RecSource::Popular => "popular", + } + } +} + +#[derive(Clone, Debug)] +pub struct Recommendation { + pub flavor_id: i32, + pub predicted_score: f64, + /// Number of similar users whose ratings backed this (CF), or the flavor's total + /// rating count (popularity). Surfaced as the "N tasters" reason. + pub contributing_neighbours: i64, + pub source: RecSource, +} + +// --- Tunables ---------------------------------------------------------------- +/// Below this many of their own ratings a user is "cold" → popularity fallback. +const MIN_PROFILE: usize = 5; +/// A neighbour must share at least this many co-rated flavors to be trusted. +const MIN_OVERLAP: usize = 3; +/// Significance-weighting cap: similarity is scaled by `min(overlap, K)/K`. +const SIG_K: f64 = 5.0; +/// A CF candidate needs at least this many contributing neighbours. +const MIN_NEIGHBOURS: i64 = 2; +/// Bayesian prior strength for the popularity fallback. +const BAYES_C: f64 = 5.0; + +/// Recommend up to `limit` flavors for `target`, given the full rating matrix. +pub fn recommend(target: i32, ratings: &[RatingInput], limit: usize) -> Vec { + if limit == 0 { + return Vec::new(); + } + + // user -> (flavor -> score) + let mut by_user: HashMap> = HashMap::new(); + for r in ratings { + by_user + .entry(r.user_id) + .or_default() + .insert(r.flavor_id, r.score); + } + + let target_ratings = by_user.get(&target).cloned().unwrap_or_default(); + let target_rated: HashSet = target_ratings.keys().copied().collect(); + + // Cold start: too thin a profile to trust similarity → popularity. + if target_ratings.len() < MIN_PROFILE { + return popularity(&target_rated, ratings, limit); + } + + let means: HashMap = by_user + .iter() + .map(|(&u, rs)| (u, mean(rs.values().copied()))) + .collect(); + let target_mean = *means.get(&target).unwrap_or(&0.0); + + // Similarity (positive Pearson, significance-weighted) to each other user. + let mut sims: HashMap = HashMap::new(); + for (&v, v_ratings) in &by_user { + if v == target { + continue; + } + let v_mean = *means.get(&v).unwrap_or(&0.0); + let common: Vec = target_ratings + .keys() + .filter(|f| v_ratings.contains_key(f)) + .copied() + .collect(); + if common.len() < MIN_OVERLAP { + continue; + } + let (mut num, mut du, mut dv) = (0.0, 0.0, 0.0); + for f in &common { + let a = target_ratings[f] - target_mean; + let b = v_ratings[f] - v_mean; + num += a * b; + du += a * a; + dv += b * b; + } + let den = du.sqrt() * dv.sqrt(); + if den == 0.0 { + continue; + } + let mut sim = num / den; + if sim <= 0.0 { + continue; // only positively-correlated tasters + } + sim *= (common.len().min(SIG_K as usize) as f64) / SIG_K; + sims.insert(v, sim); + } + + // Predict a score for every flavor the target hasn't rated, from its neighbours. + let mut acc: HashMap = HashMap::new(); // flavor -> (num, |sim| sum, count) + for (&v, &sim) in &sims { + let v_mean = *means.get(&v).unwrap_or(&0.0); + for (&f, &score) in &by_user[&v] { + if target_rated.contains(&f) { + continue; + } + let e = acc.entry(f).or_insert((0.0, 0.0, 0)); + e.0 += sim * (score - v_mean); + e.1 += sim.abs(); + e.2 += 1; + } + } + + let mut recs: Vec = acc + .into_iter() + .filter(|(_, (_, den, count))| *count >= MIN_NEIGHBOURS && *den > 0.0) + .map(|(flavor_id, (num, den, count))| Recommendation { + flavor_id, + predicted_score: (target_mean + num / den).clamp(1.0, 10.0), + contributing_neighbours: count, + source: RecSource::Cf, + }) + .collect(); + + // No confident CF candidates → fall back to popularity. + if recs.is_empty() { + return popularity(&target_rated, ratings, limit); + } + + sort_recs(&mut recs); + recs.truncate(limit); + recs +} + +/// Bayesian-average popularity ranking over flavors the target hasn't rated. Damps +/// flavors with few ratings toward the global mean so one 10/10 can't top the chart. +fn popularity( + target_rated: &HashSet, + ratings: &[RatingInput], + limit: usize, +) -> Vec { + let global_mean = mean(ratings.iter().map(|r| r.score)); + let mut agg: HashMap = HashMap::new(); // flavor -> (sum, count) + for r in ratings { + if target_rated.contains(&r.flavor_id) { + continue; + } + let e = agg.entry(r.flavor_id).or_insert((0.0, 0)); + e.0 += r.score; + e.1 += 1; + } + + let mut recs: Vec = agg + .into_iter() + .map(|(flavor_id, (sum, count))| Recommendation { + flavor_id, + predicted_score: (BAYES_C * global_mean + sum) / (BAYES_C + count as f64), + contributing_neighbours: count, + source: RecSource::Popular, + }) + .collect(); + sort_recs(&mut recs); + recs.truncate(limit); + recs +} + +/// Deterministic ordering: predicted score desc, then more neighbours, then flavor id. +fn sort_recs(recs: &mut [Recommendation]) { + recs.sort_by(|a, b| { + b.predicted_score + .partial_cmp(&a.predicted_score) + .unwrap_or(std::cmp::Ordering::Equal) + .then(b.contributing_neighbours.cmp(&a.contributing_neighbours)) + .then(a.flavor_id.cmp(&b.flavor_id)) + }); +} + +fn mean(values: impl Iterator) -> f64 { + let (sum, n) = values.fold((0.0, 0i64), |(s, n), v| (s + v, n + 1)); + if n == 0 { + 0.0 + } else { + sum / n as f64 + } +} + +// --- Item-based CF: "people who liked this flavor also liked…" --------------- +/// A flavor co-rated needs at least this many shared raters with the target to be +/// trusted — similarity over 1 shared user is noise. +const MIN_CORATERS: usize = 2; + +/// One flavor similar to a target flavor. +#[derive(Clone, Debug)] +pub struct SimilarFlavor { + pub flavor_id: i32, + /// Adjusted-cosine similarity in (0, 1]. Higher = more alike. + pub similarity: f64, + /// How many users rated both this flavor and the target (the confidence count). + pub co_raters: i64, +} + +/// Flavors most similar to `target_flavor`, by **adjusted cosine** over the user +/// rating vectors (each user's ratings mean-centred to cancel rater bias). Pure (no +/// DB) and computed on demand; at current scale this is sub-millisecond. +/// +/// Returns only positively-correlated flavors sharing at least [`MIN_CORATERS`] +/// raters with the target, ranked by similarity. Empty if the target has too few +/// raters to compare against. +pub fn similar_flavors( + target_flavor: i32, + ratings: &[RatingInput], + limit: usize, +) -> Vec { + if limit == 0 { + return Vec::new(); + } + + // user -> mean of their ratings (for mean-centring) + let mut by_user: HashMap> = HashMap::new(); + for r in ratings { + by_user.entry(r.user_id).or_default().push(r.score); + } + let user_mean: HashMap = by_user + .iter() + .map(|(&u, vs)| (u, mean(vs.iter().copied()))) + .collect(); + + // flavor -> (user -> mean-centred score) + let mut by_flavor: HashMap> = HashMap::new(); + for r in ratings { + let centred = r.score - user_mean.get(&r.user_id).copied().unwrap_or(0.0); + by_flavor + .entry(r.flavor_id) + .or_default() + .insert(r.user_id, centred); + } + + let Some(target_vec) = by_flavor.get(&target_flavor) else { + return Vec::new(); + }; + + let mut sims: Vec = Vec::new(); + for (&f, f_vec) in &by_flavor { + if f == target_flavor { + continue; + } + // co-rated users + let (mut num, mut dt, mut df, mut co) = (0.0, 0.0, 0.0, 0i64); + for (&u, &a) in target_vec { + if let Some(&b) = f_vec.get(&u) { + num += a * b; + dt += a * a; + df += b * b; + co += 1; + } + } + if (co as usize) < MIN_CORATERS { + continue; + } + let den = dt.sqrt() * df.sqrt(); + if den == 0.0 { + continue; + } + let sim = num / den; + if sim <= 0.0 { + continue; // only positively-correlated flavors + } + sims.push(SimilarFlavor { + flavor_id: f, + similarity: sim, + co_raters: co, + }); + } + + // Deterministic: similarity desc, then more co-raters, then flavor id. + sims.sort_by(|a, b| { + b.similarity + .partial_cmp(&a.similarity) + .unwrap_or(std::cmp::Ordering::Equal) + .then(b.co_raters.cmp(&a.co_raters)) + .then(a.flavor_id.cmp(&b.flavor_id)) + }); + sims.truncate(limit); + sims +} + +#[cfg(test)] +mod tests { + use super::*; + + fn r(user_id: i32, flavor_id: i32, score: f64) -> RatingInput { + RatingInput { + user_id, + flavor_id, + score, + } + } + + #[test] + fn cold_start_user_gets_popularity() { + // Target (1) has only 1 rating → cold. Others rate flavors 10/20. + let ratings = vec![ + r(1, 100, 9.0), + r(2, 10, 9.0), + r(2, 20, 4.0), + r(3, 10, 8.0), + r(3, 20, 5.0), + ]; + let recs = recommend(1, &ratings, 10); + assert!(!recs.is_empty()); + assert!(recs.iter().all(|x| x.source == RecSource::Popular)); + // Flavor 10 (avg ~8.5) should outrank flavor 20 (avg ~4.5). + assert_eq!(recs[0].flavor_id, 10); + // Already-rated flavor 100 is excluded. + assert!(recs.iter().all(|x| x.flavor_id != 100)); + } + + #[test] + fn cf_recommends_what_similar_tasters_liked() { + // Target (1) has a full profile (>= MIN_PROFILE ratings). Users 2 & 4 share + // its taste and both loved flavor 99, which the target hasn't tried. User 3 + // is an opposite-taste decoy who rated 99 low — must be filtered out. + let mut ratings = vec![ + // target's profile + r(1, 1, 9.0), + r(1, 2, 8.0), + r(1, 3, 7.0), + r(1, 4, 9.0), + r(1, 5, 6.0), + // similar taster 2 (agrees, plus loves 99) + r(2, 1, 9.0), + r(2, 2, 8.0), + r(2, 3, 7.0), + r(2, 4, 9.0), + r(2, 99, 10.0), + // similar taster 4 (>= MIN_OVERLAP, also loves 99) → 2nd neighbour for 99 + r(4, 1, 9.0), + r(4, 2, 8.0), + r(4, 3, 7.0), + r(4, 99, 10.0), + ]; + // opposite-taste user rates 99 low — negative correlation, must be filtered + ratings.push(r(3, 1, 1.0)); + ratings.push(r(3, 2, 2.0)); + ratings.push(r(3, 3, 3.0)); + ratings.push(r(3, 99, 1.0)); + + let recs = recommend(1, &ratings, 10); + assert!(!recs.is_empty(), "expected CF recommendations"); + assert!(recs.iter().any(|x| x.source == RecSource::Cf)); + // The standout shared pick (99) should be recommended. + let top = &recs[0]; + assert_eq!(top.flavor_id, 99); + assert!(top.contributing_neighbours >= MIN_NEIGHBOURS); + assert!(top.predicted_score > 7.0); + } + + #[test] + fn excludes_already_rated_and_respects_limit() { + let ratings = vec![ + r(1, 1, 9.0), + r(1, 2, 8.0), + r(1, 3, 7.0), + r(1, 4, 6.0), + r(1, 5, 5.0), + r(2, 1, 9.0), + r(2, 2, 8.0), + r(2, 3, 7.0), + r(2, 6, 9.0), + r(2, 7, 8.0), + r(3, 1, 9.0), + r(3, 2, 8.0), + r(3, 3, 7.0), + r(3, 6, 9.0), + r(3, 7, 8.0), + ]; + let recs = recommend(1, &ratings, 1); + assert_eq!(recs.len(), 1); + // never recommend a flavor the target already rated + assert!(recs.iter().all(|x| ![1, 2, 3, 4, 5].contains(&x.flavor_id))); + } + + #[test] + fn similar_flavors_finds_co_liked() { + // Flavors 1 & 2 move together (users who like one like the other); flavor 3 + // moves opposite. Target = flavor 1 → expect 2 ranked above (or instead of) 3. + let ratings = vec![ + r(10, 1, 9.0), + r(10, 2, 9.0), + r(10, 3, 2.0), + r(11, 1, 8.0), + r(11, 2, 8.0), + r(11, 3, 3.0), + r(12, 1, 3.0), + r(12, 2, 2.0), + r(12, 3, 9.0), + ]; + let sims = similar_flavors(1, &ratings, 10); + assert!(!sims.is_empty(), "expected similar flavors"); + assert_eq!(sims[0].flavor_id, 2, "flavor 2 moves with flavor 1"); + assert!(sims[0].co_raters >= MIN_CORATERS as i64); + // The anti-correlated flavor 3 is filtered out (negative similarity). + assert!(sims.iter().all(|s| s.flavor_id != 3)); + } + + #[test] + fn similar_flavors_needs_min_coraters() { + // Only one shared rater between flavor 1 and 2 → below MIN_CORATERS → empty. + let ratings = vec![r(10, 1, 9.0), r(10, 2, 9.0), r(11, 1, 8.0), r(12, 2, 7.0)]; + assert!(similar_flavors(1, &ratings, 10).is_empty()); + } + + #[test] + fn similar_flavors_unknown_target_is_empty() { + let ratings = vec![r(10, 1, 9.0), r(11, 1, 8.0)]; + assert!(similar_flavors(999, &ratings, 10).is_empty()); + } +} diff --git a/backend-rs/src/routes/flavors.rs b/backend-rs/src/routes/flavors.rs index 8541c99..69decac 100644 --- a/backend-rs/src/routes/flavors.rs +++ b/backend-rs/src/routes/flavors.rs @@ -9,17 +9,21 @@ use sea_orm::{ use serde::Deserialize; use crate::auth::{AuthUser, OptionalUser}; -use crate::dto::{FlavorOut, SearchHit}; +use crate::dto::{FlavorOut, SearchHit, SimilarFlavorOut}; use crate::entities::prelude::*; use crate::entities::{category, flavor, rating}; use crate::error::{ApiError, ApiResult}; use crate::media::image_field_url; use crate::pagination::{parse_page, Paginated, DEFAULT_PAGE_SIZE}; +use crate::recommend::{similar_flavors, RatingInput}; use crate::search::{extract_category_slug, query_words, score_relevance}; use crate::service::build_flavors; use crate::state::AppState; use crate::web::RequestCtx; +/// Max "similar flavors" returned by `/flavors/{id}/similar/`. +const SIMILAR_LIMIT: usize = 8; + pub fn router() -> Router { Router::new() .route("/flavors/", get(list)) @@ -28,6 +32,7 @@ pub fn router() -> Router { .route("/flavors/newest/", get(newest)) .route("/flavors/followed_top/", get(followed_top)) .route("/flavors/{id}/", get(retrieve)) + .route("/flavors/{id}/similar/", get(similar)) } #[derive(Deserialize)] @@ -122,6 +127,69 @@ async fn retrieve( dtos.pop().map(Json).ok_or(ApiError::NotFound) } +/// GET /api/flavors/{id}/similar/ +/// +/// Item-based collaborative filtering: "people who liked this flavor also liked…". +/// Adjusted-cosine over the rating matrix (see `crate::recommend::similar_flavors`). +/// Empty (not 404) when the catalog has too few co-ratings to compare. +async fn similar( + State(state): State, + ctx: RequestCtx, + OptionalUser(viewer): OptionalUser, + Path(id): Path, +) -> ApiResult>> { + // Target must exist. + Flavor::find_by_id(id) + .one(&state.db) + .await? + .ok_or(ApiError::NotFound)?; + + // Whole rating matrix (hundreds of rows at current scale) → in-process scoring. + let all = Rating::find().all(&state.db).await?; + let inputs: Vec = all + .iter() + .map(|r| RatingInput { + user_id: r.user_id, + flavor_id: r.flavor_id, + score: r.score as f64, + }) + .collect(); + + let sims = similar_flavors(id, &inputs, SIMILAR_LIMIT); + if sims.is_empty() { + return Ok(Json(Vec::new())); + } + + // Resolve ids to card payloads, then re-zip in similarity-ranked order. + let sim_ids: Vec = sims.iter().map(|s| s.flavor_id).collect(); + let models = Flavor::find() + .filter(flavor::Column::Id.is_in(sim_ids)) + .all(&state.db) + .await?; + let flavors = build_flavors(&state, &ctx, models, viewer, &[], false, false).await?; + let by_id: std::collections::HashMap = flavors.into_iter().map(|f| (f.id, f)).collect(); + + let out: Vec = sims + .into_iter() + .filter_map(|s| { + by_id.get(&s.flavor_id).map(|f| SimilarFlavorOut { + id: f.id, + name: f.name.clone(), + image_url: f.image_url.clone(), + category_name: f.category_name.clone(), + category_slug: f.category_slug.clone(), + average_rating: f.average_rating, + is_legacy: f.is_legacy, + is_available: f.is_available, + similarity: s.similarity, + co_raters: s.co_raters, + }) + }) + .collect(); + + Ok(Json(out)) +} + #[derive(Deserialize)] struct CategoryParam { category: Option, diff --git a/backend-rs/src/routes/users.rs b/backend-rs/src/routes/users.rs index ab6f6a5..b8bc8fe 100644 --- a/backend-rs/src/routes/users.rs +++ b/backend-rs/src/routes/users.rs @@ -16,6 +16,7 @@ use axum::extract::{ConnectInfo, Multipart, Path, Query, State}; use axum::http::{HeaderMap, StatusCode}; use axum::routing::{get, patch, post}; use axum::{Json, Router}; +use chrono::Datelike; use sea_orm::ActiveValue::Set; use sea_orm::{ ActiveModelTrait, ColumnTrait, ConnectionTrait, DbBackend, EntityTrait, PaginatorTrait, @@ -26,11 +27,15 @@ use serde_json::{json, Value}; use subtle::ConstantTimeEq; use crate::auth::{AuthUser, OptionalUser}; -use crate::dto::{DashboardOut, ProfileCommentOut, PublicProfileOut, UserOut}; +use crate::dto::{ + DashboardOut, DashboardStats, FavoriteCategory, ProfileCommentOut, PublicProfileOut, + RecommendationOut, UserOut, +}; use crate::entities::prelude::*; use crate::entities::{flavor, notification, profile_comment, rating, user, user_ip}; use crate::error::{ApiError, ApiResult}; use crate::pagination::{parse_page, Paginated, DEFAULT_PAGE_SIZE}; +use crate::recommend::{recommend, RatingInput}; use crate::service::{ build_flavors, build_profile_comments, build_ratings, build_user, build_users, }; @@ -91,6 +96,7 @@ pub fn router() -> Router { post(confirm_account_deletion), ) .route("/users/dashboard/", get(dashboard)) + .route("/users/recommendations/", get(recommendations)) .route("/users/{id}/", get(retrieve)) .route("/users/{id}/follow/", post(follow)) .route("/users/{id}/unfollow/", post(unfollow)) @@ -1315,7 +1321,6 @@ async fn dashboard( AuthUser(uid): AuthUser, ) -> ApiResult> { let me = load_user(&state, uid).await?; - let followed = following_ids(&state, uid).await?; let my_ratings_models = Rating::find() .filter(rating::Column::UserId.eq(uid)) @@ -1325,10 +1330,24 @@ async fn dashboard( let rated_count = my_ratings_models.len() as i64; let rated_flavor_ids: std::collections::HashSet = my_ratings_models.iter().map(|r| r.flavor_id).collect(); - let my_ratings = build_ratings(&state, &ctx, my_ratings_models).await?; - // Missing flavors: not rated by me, excluding the "Packs and other" category, - // ordered by category name then flavor name. + // Ratings created in the current UTC month — computed from the models while we + // still have proper datetimes (RatingOut.created_at is a formatted string). + let now = chrono::Utc::now().naive_utc(); + let (this_year, this_month) = (now.year(), now.month()); + let ratings_this_month = my_ratings_models + .iter() + .filter(|r| r.created_at.year() == this_year && r.created_at.month() == this_month) + .count() as i64; + + let my_ratings = build_ratings(&state, &ctx, my_ratings_models).await?; + let stats = dashboard_stats(&my_ratings, ratings_this_month); + + // "To try" count: flavors the user hasn't rated, excluding the "Packs and other" + // category. We no longer ship the full unrated flavor list — the dashboard's old + // "explore" grid is gone (browsing lives on the main flavor list, discovery on the + // recommendations endpoint), which drops a large per-request payload + the + // per-flavor aggregate pass. Only the count is needed now, so load ids alone. let packs = crate::entities::category::Entity::find() .filter(crate::entities::category::Column::Name.eq("Packs and other")) .one(&state.db) @@ -1337,52 +1356,195 @@ async fn dashboard( if let Some(p) = packs { q = q.filter(flavor::Column::CategoryId.ne(p.id)); } - let mut all_flavors = q.all(&state.db).await?; - all_flavors.retain(|f| !rated_flavor_ids.contains(&f.id)); + let missing_count = q + .all(&state.db) + .await? + .iter() + .filter(|f| !rated_flavor_ids.contains(&f.id)) + .count() as i64; - // Order by category name then flavor name (resolve category names first). - let cat_ids: Vec = all_flavors.iter().map(|f| f.category_id).collect(); - let cat_names: std::collections::HashMap = - crate::entities::category::Entity::find() - .filter(crate::entities::category::Column::Id.is_in(cat_ids)) - .all(&state.db) - .await? - .into_iter() - .map(|c| (c.id, c.name)) - .collect(); - all_flavors.sort_by(|a, b| { - let ca = cat_names - .get(&a.category_id) - .map(String::as_str) - .unwrap_or(""); - let cb = cat_names - .get(&b.category_id) - .map(String::as_str) - .unwrap_or(""); - ca.cmp(cb).then_with(|| a.name.cmp(&b.name)) - }); - - let missing_count = all_flavors.len() as i64; - // The dashboard "explore" grid sorts these by community / followed average - // but never reads the nested ratings, and this query loads *every* unrated - // flavor — so skip building a RatingOut per rating (large payload win). - let missing_flavors = build_flavors( - &state, - &ctx, - all_flavors, - Some(uid), - &followed, - false, - false, - ) - .await?; let user = build_user(&state, &ctx, me, Some(uid)).await?; Ok(Json(DashboardOut { user, rated_count, missing_count, - missing_flavors, my_ratings, + stats, })) } + +/// Max recommendations returned by `/users/recommendations/`. +const RECOMMENDATION_LIMIT: usize = 12; + +/// GET /api/users/recommendations/ +/// +/// "Tasters like you also liked…" — user-based collaborative filtering with a +/// Bayesian-popularity cold-start fallback (see `crate::recommend`). +async fn recommendations( + State(state): State, + ctx: RequestCtx, + AuthUser(uid): AuthUser, +) -> ApiResult>> { + // Whole rating matrix (hundreds of rows at current scale) → in-process scoring. + let all = Rating::find().all(&state.db).await?; + let inputs: Vec = all + .iter() + .map(|r| RatingInput { + user_id: r.user_id, + flavor_id: r.flavor_id, + score: r.score as f64, + }) + .collect(); + + let recs = recommend(uid, &inputs, RECOMMENDATION_LIMIT); + if recs.is_empty() { + return Ok(Json(Vec::new())); + } + + // Resolve the recommended flavor ids to card payloads, then re-zip with the + // recommendation metadata in the engine's ranked order. + let rec_ids: Vec = recs.iter().map(|r| r.flavor_id).collect(); + let models = Flavor::find() + .filter(flavor::Column::Id.is_in(rec_ids.clone())) + .all(&state.db) + .await?; + let followed = following_ids(&state, uid).await?; + let flavors = build_flavors(&state, &ctx, models, Some(uid), &followed, false, false).await?; + let by_id: std::collections::HashMap = flavors.into_iter().map(|f| (f.id, f)).collect(); + + let out: Vec = recs + .into_iter() + .filter_map(|rec| { + by_id.get(&rec.flavor_id).map(|f| RecommendationOut { + id: f.id, + name: f.name.clone(), + image_url: f.image_url.clone(), + category_name: f.category_name.clone(), + category_slug: f.category_slug.clone(), + average_rating: f.average_rating, + is_legacy: f.is_legacy, + is_available: f.is_available, + predicted_score: rec.predicted_score, + contributing_neighbours: rec.contributing_neighbours, + reason: rec.source.as_str().to_string(), + }) + }) + .collect(); + + Ok(Json(out)) +} + +/// Aggregate the dashboard stats block from the user's own ratings. `this_month` is +/// passed in because it's computed from the rating models (which carry real +/// datetimes) before they're turned into the string-timestamped `RatingOut`s. +fn dashboard_stats(my_ratings: &[crate::dto::RatingOut], this_month: i64) -> DashboardStats { + let mut score_distribution: std::collections::BTreeMap = + (1..=10).map(|s| (s.to_string(), 0)).collect(); + let mut sum: i64 = 0; + // category slug -> (display name, count) + let mut cat_counts: std::collections::HashMap = + std::collections::HashMap::new(); + + for r in my_ratings { + if (1..=10).contains(&r.score) { + *score_distribution.get_mut(&r.score.to_string()).unwrap() += 1; + } + sum += r.score as i64; + let entry = cat_counts + .entry(r.category_slug.clone()) + .or_insert((r.category_name.clone(), 0)); + entry.1 += 1; + } + + let n = my_ratings.len() as i64; + let average_score = (n > 0).then(|| sum as f64 / n as f64); + // Highest count wins; ties broken by slug so the result is deterministic + // (HashMap iteration order isn't). + let favorite_category = cat_counts + .into_iter() + .max_by(|(slug_a, (_, count_a)), (slug_b, (_, count_b))| { + count_a.cmp(count_b).then_with(|| slug_b.cmp(slug_a)) + }) + .map(|(slug, (name, count))| FavoriteCategory { name, slug, count }); + + let category_count = my_ratings + .iter() + .map(|r| r.category_slug.as_str()) + .collect::>() + .len() as i64; + + DashboardStats { + average_score, + score_distribution, + favorite_category, + ratings_this_month: this_month, + category_count, + } +} + +#[cfg(test)] +mod dashboard_stats_tests { + use super::*; + use crate::dto::RatingOut; + + fn rating(score: i32, cat_slug: &str, cat_name: &str) -> RatingOut { + RatingOut { + id: 0, + user: String::new(), + user_id: 0, + user_avatar: None, + flavor: 0, + flavor_name: String::new(), + flavor_image: None, + category_name: cat_name.into(), + category_slug: cat_slug.into(), + is_available: true, + is_legacy: false, + score, + comment: None, + created_at: String::new(), + replies: vec![], + } + } + + #[test] + fn empty_ratings_yield_zeroed_stats() { + let s = dashboard_stats(&[], 0); + assert_eq!(s.average_score, None); + assert!(s.favorite_category.is_none()); + assert_eq!(s.category_count, 0); + assert_eq!(s.ratings_this_month, 0); + // Distribution still carries all 10 keys, all zero. + assert_eq!(s.score_distribution.len(), 10); + assert!(s.score_distribution.values().all(|&v| v == 0)); + } + + #[test] + fn aggregates_average_distribution_and_favorite() { + let ratings = vec![ + rating(10, "fruity", "Fruity"), + rating(8, "fruity", "Fruity"), + rating(6, "minty", "Minty"), + ]; + let s = dashboard_stats(&ratings, 2); + assert_eq!(s.average_score, Some(8.0)); + assert_eq!(s.score_distribution["10"], 1); + assert_eq!(s.score_distribution["8"], 1); + assert_eq!(s.score_distribution["6"], 1); + assert_eq!(s.score_distribution["1"], 0); + assert_eq!(s.category_count, 2); + assert_eq!(s.ratings_this_month, 2); + let fav = s.favorite_category.unwrap(); + assert_eq!(fav.slug, "fruity"); + assert_eq!(fav.count, 2); + } + + #[test] + fn favorite_category_tie_breaks_by_slug_deterministically() { + // Equal counts (1 each) → smaller slug ("apple") wins, stable across runs. + let ratings = vec![rating(5, "banana", "Banana"), rating(5, "apple", "Apple")]; + let fav = dashboard_stats(&ratings, 0).favorite_category.unwrap(); + assert_eq!(fav.slug, "apple"); + } +} diff --git a/dashboard-redesign.md b/dashboard-redesign.md new file mode 100644 index 0000000..45e1c02 --- /dev/null +++ b/dashboard-redesign.md @@ -0,0 +1,227 @@ +# 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` + (`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` 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` (`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. diff --git a/frontend/src/api/keys.ts b/frontend/src/api/keys.ts index 28d7164..f78c951 100644 --- a/frontend/src/api/keys.ts +++ b/frontend/src/api/keys.ts @@ -6,11 +6,13 @@ export const queryKeys = { categories: () => ['categories'] as const, dashboard: () => ['dashboard'] as const, + recommendations: () => ['recommendations'] as const, publicProfile: (username: string) => ['publicProfile', username] as const, publicProfileAll: () => ['publicProfile'] as const, flavors: (params: Record) => ['flavors', params] as const, flavorsAll: () => ['flavors'] as const, flavorDetail: (id: number | string) => ['flavorDetail', String(id)] as const, + similarFlavors: (id: number | string) => ['similarFlavors', String(id)] as const, activeBanner: (username: string) => ['activeBanner', username] as const, notifications: () => ['notifications'] as const, communityFeed: (params: Record) => ['communityFeed', params] as const, diff --git a/frontend/src/api/mutations/useRatingMutations.ts b/frontend/src/api/mutations/useRatingMutations.ts index 3531ed1..706f24b 100644 --- a/frontend/src/api/mutations/useRatingMutations.ts +++ b/frontend/src/api/mutations/useRatingMutations.ts @@ -1,6 +1,11 @@ import { useMutation, useQueryClient } from '@tanstack/react-query'; import api from '../../lib/api'; import { queryKeys } from '../keys'; +import type { + FlavorDetailData, + FlavorDetailRating, + FlavorDetailReply, +} from '../queries/useFlavorDetail'; const invalidateRatingViews = (qc: ReturnType) => { qc.invalidateQueries({ queryKey: queryKeys.dashboard() }); @@ -37,19 +42,53 @@ export interface CreateRatingInput { flavor: number; score: number; comment?: string | null; + /** Author info for the optimistic row shown on the flavor-detail page before + * the server round-trip lands. Omit to skip the optimistic insert. */ + optimistic?: { user: string; user_avatar: string | null }; } export const useCreateRating = () => { const qc = useQueryClient(); return useMutation({ - mutationFn: (input: CreateRatingInput) => api.post('ratings/', input), - onSuccess: () => invalidateRatingViews(qc), + mutationFn: ({ flavor, score, comment }: CreateRatingInput) => + api.post('ratings/', { flavor, score, comment }), + onMutate: async ({ flavor, score, comment, optimistic }) => { + if (!optimistic) return; + const key = queryKeys.flavorDetail(flavor); + await qc.cancelQueries({ queryKey: key }); + const prev = qc.getQueryData(key); + if (prev) { + const temp: FlavorDetailRating = { + id: -Date.now(), // negative temp id; replaced on refetch + user: optimistic.user, + user_avatar: optimistic.user_avatar, + score, + comment: comment ?? '', + created_at: new Date().toISOString(), + replies: [], + }; + qc.setQueryData(key, { + ...prev, + ratings: [temp, ...prev.ratings], + user_rating: score, + }); + } + return { key, prev }; + }, + onError: (_e, _v, ctx) => { + if (ctx?.prev) qc.setQueryData(ctx.key, ctx.prev); + }, + onSettled: () => invalidateRatingViews(qc), }); }; export interface ReplyInput { ratingId: number; text: string; + /** Flavor id + author for the optimistic reply on the flavor-detail page. + * Omit either to skip the optimistic insert. */ + flavorId?: number; + optimisticUser?: string; } export const useCreateReply = () => { @@ -57,7 +96,31 @@ export const useCreateReply = () => { return useMutation({ mutationFn: ({ ratingId, text }: ReplyInput) => api.post(`ratings/${ratingId}/reply/`, { text }), - onSuccess: () => invalidateRatingViews(qc), + onMutate: async ({ ratingId, text, flavorId, optimisticUser }) => { + if (!flavorId || !optimisticUser) return; + const key = queryKeys.flavorDetail(flavorId); + await qc.cancelQueries({ queryKey: key }); + const prev = qc.getQueryData(key); + if (prev) { + const temp: FlavorDetailReply = { + id: -Date.now(), // negative temp id; replaced on refetch + user: optimisticUser, + text, + created_at: new Date().toISOString(), + }; + qc.setQueryData(key, { + ...prev, + ratings: prev.ratings.map((r) => + r.id === ratingId ? { ...r, replies: [...r.replies, temp] } : r, + ), + }); + } + return { key, prev }; + }, + onError: (_e, _v, ctx) => { + if (ctx?.prev) qc.setQueryData(ctx.key, ctx.prev); + }, + onSettled: () => invalidateRatingViews(qc), }); }; diff --git a/frontend/src/api/queries/useCurrentUserLite.ts b/frontend/src/api/queries/useCurrentUserLite.ts index e56f4b8..ebf9360 100644 --- a/frontend/src/api/queries/useCurrentUserLite.ts +++ b/frontend/src/api/queries/useCurrentUserLite.ts @@ -1,5 +1,5 @@ import { useQuery } from '@tanstack/react-query'; -import api from '../../lib/api'; +import api, { type RetriableConfig } from '../../lib/api'; import { useAuth } from '../../hooks/useAuth'; export interface CurrentUserLite { @@ -19,8 +19,8 @@ export const useCurrentUserLite = () => { const res = await api.get('users/me/', { // Stale access tokens are common; never bounce a public page reader // to /login because of a background "am I logged in?" check. - ...({ skipAuthRedirect: true } as object), - }); + skipAuthRedirect: true, + } as RetriableConfig); return res.data; }, enabled: !!user, diff --git a/frontend/src/api/queries/useRecommendations.ts b/frontend/src/api/queries/useRecommendations.ts new file mode 100644 index 0000000..b68bed5 --- /dev/null +++ b/frontend/src/api/queries/useRecommendations.ts @@ -0,0 +1,20 @@ +import { useQuery } from '@tanstack/react-query'; +import api from '../../lib/api'; +import { queryKeys } from '../keys'; +import type { Recommendation } from '../types'; + +/** + * "Tasters like you also liked…" — user-based collaborative filtering with a + * popularity cold-start fallback (backend `GET /users/recommendations/`). Enabled + * only when logged in; recommendations shift slowly, so a long staleTime is fine. + */ +export const useRecommendations = (enabled = true) => + useQuery({ + queryKey: queryKeys.recommendations(), + queryFn: async () => { + const res = await api.get('users/recommendations/'); + return res.data; + }, + enabled, + staleTime: 5 * 60_000, + }); diff --git a/frontend/src/api/queries/useSimilarFlavors.ts b/frontend/src/api/queries/useSimilarFlavors.ts new file mode 100644 index 0000000..0f2e95e --- /dev/null +++ b/frontend/src/api/queries/useSimilarFlavors.ts @@ -0,0 +1,20 @@ +import { useQuery } from '@tanstack/react-query'; +import api from '../../lib/api'; +import { queryKeys } from '../keys'; +import type { SimilarFlavor } from '../types'; + +/** + * "People who liked this flavor also liked…" — item-based collaborative filtering + * (backend `GET /flavors/{id}/similar/`). Public (no auth needed); the result moves + * slowly with the rating matrix, so a long staleTime is fine. + */ +export const useSimilarFlavors = (flavorId: number | undefined, enabled = true) => + useQuery({ + queryKey: queryKeys.similarFlavors(flavorId ?? 0), + queryFn: async () => { + const res = await api.get(`flavors/${flavorId}/similar/`); + return res.data; + }, + enabled: enabled && flavorId != null, + staleTime: 5 * 60_000, + }); diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index c4bc484..a6734c4 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -32,10 +32,57 @@ export interface MissingFlavor { is_available?: boolean; } +export interface FavoriteCategory { + name: string; + slug: string; + count: number; +} + +export interface DashboardStats { + average_score: number | null; + /** Count per score, keyed "1".."10". */ + score_distribution: Record; + favorite_category: FavoriteCategory | null; + ratings_this_month: number; + category_count: number; +} + +export interface Recommendation { + id: number; + name: string; + image_url: string | null; + category_name: string; + category_slug: string; + average_rating: number | null; + is_legacy: boolean; + is_available: boolean; + predicted_score: number; + /** "N tasters like you" count (cf) or rating count (popular). */ + contributing_neighbours: number; + /** "cf" | "popular" — frontend picks the reason copy. */ + reason: 'cf' | 'popular'; +} + export interface DashboardData { user: { username: string; avatar: string | null }; rated_count: number; missing_count: number; - missing_flavors: MissingFlavor[]; my_ratings: RatedItem[]; + stats: DashboardStats; +} + +/** Item-based "people who liked this flavor also liked…" card. */ +export interface SimilarFlavor { + id: number; + name: string; + image_url: string | null; + category_name: string; + category_slug: string; + average_rating: number | null; + is_legacy: boolean; + is_available: boolean; + /** Adjusted-cosine similarity to the target flavor, in (0, 1]. */ + similarity: number; + /** Users who rated both this flavor and the target. */ + co_raters: number; } diff --git a/frontend/src/i18n.ts b/frontend/src/i18n.ts index 63f98b2..8276d38 100644 --- a/frontend/src/i18n.ts +++ b/frontend/src/i18n.ts @@ -29,6 +29,14 @@ const resources = { welcome: 'Welcome back, {{username}}!', shareProfile: 'Share Profile', copySuccess: 'Link copied to clipboard!', + loginRequired: 'Please log in to view your dashboard.', + avgScore: 'Avg Score', + thisMonth: 'This Month', + favoriteCategory: 'Top category: {{category}} ({{count}})', + recHeading: 'Tasters like you loved', + recReasonCf: '{{count}} tasters like you', + recReasonPopular: 'Popular pick', + predictedForYou: 'Predicted for you', myRatings: 'My Ratings', missing: 'Missing', noRatings: "You haven't rated any flavors yet.", @@ -141,6 +149,9 @@ const resources = { outOfStock: 'Out of Stock', }, flavorDetail: { + similarHeading: 'People who liked this also liked', + similarCoRaters: '{{count}} rated both', + similarTooltip: 'Tasters who rated both this flavor and the one above', rateThisFlavor: 'Rate this flavor', scoreLabel: 'Your score, 1 to 10', submitReview: 'Submit Review', @@ -336,6 +347,14 @@ const resources = { welcome: 'Willkommen zurück, {{username}}!', shareProfile: 'Profil teilen', copySuccess: 'Link in die Zwischenablage kopiert!', + loginRequired: 'Bitte melde dich an, um dein Dashboard zu sehen.', + avgScore: 'Ø Bewertung', + thisMonth: 'Diesen Monat', + favoriteCategory: 'Top-Kategorie: {{category}} ({{count}})', + recHeading: 'Tester wie du mochten', + recReasonCf: '{{count}} Tester wie du', + recReasonPopular: 'Beliebte Wahl', + predictedForYou: 'Für dich vorhergesagt', myRatings: 'Meine Bewertungen', missing: 'Fehlend', noRatings: 'Du hast noch keine Sorten bewertet.', @@ -448,6 +467,9 @@ const resources = { outOfStock: 'Ausverkauft', }, flavorDetail: { + similarHeading: 'Wer das mochte, mochte auch', + similarCoRaters: '{{count}} bewerteten beide', + similarTooltip: 'Tester, die diese Sorte und die obige bewertet haben', rateThisFlavor: 'Diese Sorte bewerten', scoreLabel: 'Deine Bewertung, 1 bis 10', submitReview: 'Bewertung abschicken', diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index 1ad2d19..3c49865 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -1,103 +1,27 @@ -import React, { useEffect, useMemo, useState } from 'react'; -import { - Typography, - CardContent, - Box, - Button, - IconButton, - Skeleton, - Avatar, - Tabs, - Tab, - Grid, - alpha, - useTheme, - Divider, - Stack, - Chip, - Collapse, - TextField, - Rating as MuiRating, - InputAdornment, - Tooltip, - useMediaQuery, - Select, - MenuItem, -} from '@mui/material'; -import ArrowBackIcon from '@mui/icons-material/ArrowBack'; -import SortIcon from '@mui/icons-material/Sort'; -import ShareIcon from '@mui/icons-material/Share'; -import CommentIcon from '@mui/icons-material/Comment'; -import VerifiedIcon from '@mui/icons-material/Verified'; -import SearchIcon from '@mui/icons-material/Search'; -import ColorThief from 'colorthief'; -import { useNavigate, useSearchParams, Link } from 'react-router-dom'; +import React from 'react'; +import { Box, Skeleton, Stack, Typography, useMediaQuery, useTheme } from '@mui/material'; +import { useNavigate } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import { useDashboard } from '../api/queries/useDashboard'; -import { - useDeleteRating, - useDeleteReply, - useCreateReply, - useUpdateRating, -} from '../api/mutations/useRatingMutations'; import { useTitle } from '../hooks/useTitle'; -import RatingBadge from '../components/RatingBadge'; -import StatusBadge from '../components/StatusBadge'; -import MentionTextField from '../components/MentionTextField'; -import RichText from '../components/RichText'; -import { formatDate } from '../utils/date'; -import { PageShell, GlassCard, GlassSurface, EmptyState } from '../components/ui'; import { useToast } from '../hooks/useToast'; -import { useConfirm } from '../hooks/useConfirm'; +import { PageShell } from '../components/ui'; +import DashboardHeader from './dashboard/DashboardHeader'; +import MyReviewsTab from './dashboard/MyReviewsTab'; +import RecommendationsSection from './dashboard/RecommendationsSection'; const Dashboard: React.FC = () => { const { t } = useTranslation(); const navigate = useNavigate(); const theme = useTheme(); - const isMobile = useMediaQuery(theme.breakpoints.down('lg')); const isXs = useMediaQuery(theme.breakpoints.down('sm')); useTitle(t('dashboard.title')); const { data, isLoading: loading } = useDashboard(); - const updateRating = useUpdateRating(); - const deleteRating = useDeleteRating(); - const createReply = useCreateReply(); - const deleteReply = useDeleteReply(); - const [searchParams, setSearchParams] = useSearchParams(); - const activeTab = searchParams.get('tab') === 'explore' ? 1 : 0; - const setActiveTab = (v: number) => { - setSearchParams( - (prev) => { - const next = new URLSearchParams(prev); - if (v === 1) next.set('tab', 'explore'); - else next.delete('tab'); - return next; - }, - { replace: true }, - ); - }; - const [palette, setPalette] = useState([]); - - const [exploreCategory, setExploreCategory] = useState('All'); - const [exploreSearch, setExploreSearch] = useState(''); - const [exploreSort, setExploreSort] = useState<'community' | 'circle'>('community'); - - const [ratedCategory, setRatedCategory] = useState('All'); - const [ratedSearch, setRatedSearch] = useState(''); - const [ratedSort, setRatedSort] = useState<'date' | 'rating'>('date'); - - const [editingRatingId, setEditingRatingId] = useState(null); - const [editScore, setEditScore] = useState(0); - const [editComment, setEditComment] = useState(''); - - const [replyInputs, setReplyInputs] = useState>({}); - const [expandedReplies, setExpandedReplies] = useState>({}); const { notify } = useToast(); - const { confirm } = useConfirm(); - - const shareUrl = data ? `${window.location.origin}/profile/${data.user.username}` : ''; const handleCopyLink = () => { - navigator.clipboard.writeText(shareUrl); + if (!data) return; + navigator.clipboard.writeText(`${window.location.origin}/profile/${data.user.username}`); notify({ message: t('dashboard.copySuccess'), severity: 'success' }); }; @@ -106,143 +30,12 @@ const Dashboard: React.FC = () => { else navigate('/'); }; - useEffect(() => { - if (isXs) { - setPalette([]); - return; - } - if (data?.user?.avatar) { - const img = new Image(); - img.crossOrigin = 'Anonymous'; - img.src = data.user.avatar; - img.onload = () => { - const colorThief = new ColorThief(); - try { - const rawPalette = colorThief.getPalette(img, 10); - const processed = rawPalette.map((c) => { - const r = c[0] / 255, - g = c[1] / 255, - b = c[2] / 255; - const max = Math.max(r, g, b), - min = Math.min(r, g, b); - const l = (max + min) / 2; - const s = - max === min ? 0 : l > 0.5 ? (max - min) / (2 - max - min) : (max - min) / (max + min); - return { rgb: `rgb(${c[0]}, ${c[1]}, ${c[2]})`, l, s }; - }); - const vibrant = processed - .filter((c) => c.l > 0.2 && c.l < 0.85) - .sort((a, b) => b.s - a.s); - if (vibrant.length >= 2) setPalette([vibrant[0].rgb, vibrant[1].rgb]); - else if (vibrant.length === 1) setPalette([vibrant[0].rgb, vibrant[0].rgb]); - else setPalette([]); - } catch { - setPalette([]); - } - }; - } else { - setPalette([]); - } - }, [data?.user?.avatar, isXs]); - - const handleUpdateRating = async (ratingId: number) => { - try { - await updateRating.mutateAsync({ id: ratingId, score: editScore, comment: editComment }); - setEditingRatingId(null); - } catch { - notify({ message: 'Failed to update review', severity: 'error' }); - } - }; - - const handleDeleteRating = async (ratingId: number) => { - if (!(await confirm({ message: 'Delete this review?', danger: true }))) return; - try { - await deleteRating.mutateAsync(ratingId); - } catch { - notify({ message: 'Failed to delete review', severity: 'error' }); - } - }; - - const handleReplySubmit = async (ratingId: number) => { - const text = replyInputs[ratingId]; - if (!text) return; - try { - await createReply.mutateAsync({ ratingId, text }); - setReplyInputs({ ...replyInputs, [ratingId]: '' }); - setExpandedReplies({ ...expandedReplies, [ratingId]: true }); - } catch { - notify({ message: 'Failed to send reply', severity: 'error' }); - } - }; - - const handleDeleteReply = async (replyId: number) => { - if (!(await confirm({ message: 'Delete this reply?', danger: true }))) return; - try { - await deleteReply.mutateAsync(replyId); - } catch { - notify({ message: 'Failed to delete reply', severity: 'error' }); - } - }; - - const exploreCategories = useMemo(() => { - if (!data) return ['All']; - const cats = new Set( - data.missing_flavors - .filter((f) => f.category_name !== 'Packs and other') - .map((f) => f.category_name), - ); - return ['All', ...Array.from(cats).sort()]; - }, [data]); - - const ratedCategories = useMemo(() => { - if (!data) return ['All']; - const cats = new Set(data.my_ratings.map((r) => r.category_name)); - return ['All', ...Array.from(cats).sort()]; - }, [data]); - - const filteredMissing = useMemo(() => { - if (!data) return []; - const items = data.missing_flavors.filter((f) => { - const isNotPack = f.category_name !== 'Packs and other'; - const matchCat = exploreCategory === 'All' || f.category_name === exploreCategory; - const matchSearch = f.name.toLowerCase().includes(exploreSearch.toLowerCase()); - return isNotPack && matchCat && matchSearch; - }); - - if (exploreSort === 'community') { - items.sort((a, b) => (b.average_rating || 0) - (a.average_rating || 0)); - } else { - items.sort((a, b) => (b.followed_average_rating || 0) - (a.followed_average_rating || 0)); - } - return items; - }, [data, exploreCategory, exploreSearch, exploreSort]); - - const filteredAndSortedRated = useMemo(() => { - if (!data) return []; - const items = [...data.my_ratings].filter((r) => { - const matchCat = ratedCategory === 'All' || r.category_name === ratedCategory; - const matchSearch = r.flavor_name.toLowerCase().includes(ratedSearch.toLowerCase()); - return matchCat && matchSearch; - }); - - if (ratedSort === 'rating') { - items.sort( - (a, b) => - b.score - a.score || new Date(b.created_at).getTime() - new Date(a.created_at).getTime(), - ); - } else { - items.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime()); - } - return items; - }, [data, ratedCategory, ratedSearch, ratedSort]); - if (loading) return ( - {Array.from({ length: 3 }).map((_, i) => ( ))} @@ -252,786 +45,33 @@ const Dashboard: React.FC = () => { if (!data) return ( - Please login to view dashboard. + {t('dashboard.loginRequired')} ); return ( - {/* Action Bar */} - - - {isXs ? ( - - - - ) : ( - - )} - - {t('dashboard.title')} - - + - {isXs ? ( - - - - - - ) : ( - - )} + + + + + {t('dashboard.myReviews')} + + navigate('/')} + /> - - {/* Profile Header — keeps inline ColorThief gradient per Phase-2 risk fallback */} - - {!isXs && ( - - - - )} - - - - - - {!data.user.avatar && data.user.username.charAt(0).toUpperCase()} - - - - - - {data.user.username} - - {!isXs && ( - - {t('dashboard.welcome', { username: data.user.username })} - - )} - - - - {[ - { label: t('dashboard.myRatings'), val: data.rated_count }, - { label: t('dashboard.missing'), val: data.missing_count }, - ].map((stat, i) => ( - - - - {stat.val} - - - {stat.label} - - - {i < 1 && ( - - )} - - ))} - - - - - - - - {/* Tabs */} - - setActiveTab(v)} - textColor="primary" - indicatorColor="primary" - variant="fullWidth" - > - - - - - - {/* TAB 0: MY REVIEWS */} - {activeTab === 0 && ( - - - - - setRatedSearch(e.target.value)} - slotProps={{ - input: { - startAdornment: ( - - - - ), - }, - }} - /> - - - - - - - - - {ratedCategories.map((cat) => ( - r.category_name === cat)?.category_slug}`, - { defaultValue: cat }, - ) - } - onClick={() => setRatedCategory(cat)} - color={ratedCategory === cat ? 'primary' : 'default'} - variant={ratedCategory === cat ? 'filled' : 'outlined'} - sx={{ fontWeight: 'bold', flexShrink: 0 }} - /> - ))} - - - - - - - {filteredAndSortedRated.length === 0 ? ( - setActiveTab(1)} - variant="contained" - sx={{ borderRadius: 2 }} - > - {t('dashboard.exploreFlavors')} - - } - /> - ) : ( - filteredAndSortedRated.map((rating) => ( - - - {editingRatingId === rating.id ? ( - - - {t('dashboard.editingReview', { flavor: rating.flavor_name })} - - - {t('dashboard.newScore')} - setEditScore(val || 0)} - /> - - - - - - - - ) : ( - <> - - - - - - - - - {rating.flavor_name} - - - - - {t(`categories.${rating.category_slug}`, { - defaultValue: rating.category_name, - })}{' '} - • {formatDate(rating.created_at)} - - - - - - - - - - - - {rating.comment ? ( - - ) : ( - - {t('dashboard.noComment')} - - )} - - - - - - - - - - - - {rating.replies.map((reply) => ( - - - - {reply.user} - {reply.user === data.user.username && ( - - )} - - - - {formatDate(reply.created_at)} - - {reply.user === data.user.username && ( - - )} - - - - - - - ))} - - - - setReplyInputs({ ...replyInputs, [rating.id]: val }) - } - onKeyDown={(e) => - e.key === 'Enter' && !e.shiftKey && handleReplySubmit(rating.id) - } - /> - - - - - - - )} - - - )) - )} - - - )} - - {/* TAB 1: EXPLORE NEW */} - {activeTab === 1 && ( - - - - - setExploreSearch(e.target.value)} - slotProps={{ - input: { - startAdornment: ( - - - - ), - }, - }} - /> - - - - - - - - - {exploreCategories.map((cat) => ( - f.category_name === cat)?.category_slug}`, - { defaultValue: cat }, - ) - } - onClick={() => setExploreCategory(cat)} - color={exploreCategory === cat ? 'primary' : 'default'} - variant={exploreCategory === cat ? 'filled' : 'outlined'} - sx={{ fontWeight: 'bold', flexShrink: 0 }} - /> - ))} - - - - - - {filteredMissing.length === 0 ? ( - - ) : ( - - {filteredMissing.map((flavor) => ( - - th.tokens.elevation.md, - }, - overflow: 'hidden', - }} - > - - - - - - - - - - - - - - - {flavor.name} - - - {t(`categories.${flavor.category_slug}`, { - defaultValue: flavor.category_name, - })} - - - - - - ))} - - )} - - )} ); }; diff --git a/frontend/src/pages/dashboard/DashboardHeader.tsx b/frontend/src/pages/dashboard/DashboardHeader.tsx new file mode 100644 index 0000000..b238b4a --- /dev/null +++ b/frontend/src/pages/dashboard/DashboardHeader.tsx @@ -0,0 +1,261 @@ +import React from 'react'; +import { + Avatar, + Box, + Button, + CardContent, + Divider, + IconButton, + Tooltip, + Typography, + alpha, + useMediaQuery, + useTheme, +} from '@mui/material'; +import ArrowBackIcon from '@mui/icons-material/ArrowBack'; +import ShareIcon from '@mui/icons-material/Share'; +import { useTranslation } from 'react-i18next'; +import type { DashboardStats } from '../../api/types'; +import { GlassCard, GlassSurface } from '../../components/ui'; + +export interface DashboardHeaderProps { + user: { username: string; avatar: string | null }; + ratedCount: number; + missingCount: number; + stats: DashboardStats; + onBack: () => void; + onShare: () => void; +} + +const DashboardHeader: React.FC = ({ + user, + ratedCount, + missingCount, + stats, + onBack, + onShare, +}) => { + const { t } = useTranslation(); + const theme = useTheme(); + const isXs = useMediaQuery(theme.breakpoints.down('sm')); + // Theme-derived gradient (replaced the avatar-sampled ColorThief palette). + const grad = [theme.palette.primary.main, theme.palette.secondary.main]; + + const statTiles = [ + { label: t('dashboard.myRatings'), val: ratedCount }, + { + label: t('dashboard.avgScore'), + val: stats.average_score != null ? stats.average_score.toFixed(1) : '–', + }, + { label: t('dashboard.thisMonth'), val: stats.ratings_this_month }, + { label: t('dashboard.missing'), val: missingCount }, + ]; + const fav = stats.favorite_category; + + return ( + <> + {/* Action Bar */} + + + {isXs ? ( + + + + ) : ( + + )} + + {t('dashboard.title')} + + + + {isXs ? ( + + + + + + ) : ( + + )} + + + {/* Profile Header — theme-derived gradient (ColorThief dropped in the redesign) */} + + {!isXs && ( + + + + )} + + + + + + {!user.avatar && user.username.charAt(0).toUpperCase()} + + + + + + {user.username} + + {!isXs && ( + + {t('dashboard.welcome', { username: user.username })} + + )} + {fav && ( + + {t('dashboard.favoriteCategory', { + category: t(`categories.${fav.slug}`, { defaultValue: fav.name }), + count: fav.count, + })} + + )} + + + + {statTiles.map((stat, i) => ( + + + + {stat.val} + + + {stat.label} + + + {i < statTiles.length - 1 && ( + + )} + + ))} + + + + + + + + ); +}; + +export default DashboardHeader; diff --git a/frontend/src/pages/dashboard/MyReviewCard.tsx b/frontend/src/pages/dashboard/MyReviewCard.tsx new file mode 100644 index 0000000..b6a5648 --- /dev/null +++ b/frontend/src/pages/dashboard/MyReviewCard.tsx @@ -0,0 +1,314 @@ +import React, { useState } from 'react'; +import { + Box, + Button, + CardContent, + Collapse, + Rating as MuiRating, + Stack, + Typography, + alpha, + useMediaQuery, + useTheme, +} from '@mui/material'; +import CommentIcon from '@mui/icons-material/Comment'; +import VerifiedIcon from '@mui/icons-material/Verified'; +import { Link } from 'react-router-dom'; +import { useTranslation } from 'react-i18next'; +import type { RatedItem } from '../../api/types'; +import { + useCreateReply, + useDeleteRating, + useDeleteReply, + useUpdateRating, +} from '../../api/mutations/useRatingMutations'; +import RatingBadge from '../../components/RatingBadge'; +import StatusBadge from '../../components/StatusBadge'; +import MentionTextField from '../../components/MentionTextField'; +import RichText from '../../components/RichText'; +import { formatDate } from '../../utils/date'; +import { GlassCard } from '../../components/ui'; +import { useToast } from '../../hooks/useToast'; +import { useConfirm } from '../../hooks/useConfirm'; + +export interface MyReviewCardProps { + rating: RatedItem; + /** Current user's username — drives the "this is mine" verified badge + reply delete. */ + currentUsername: string; +} + +/** + * One of the current user's own reviews on the dashboard: flavor-centric header, + * inline score/comment edit, and a collapsible reply thread. Owns its own edit / + * reply / expand state (previously hoisted into the Dashboard monolith as shared + * maps keyed by rating id). + */ +const MyReviewCard: React.FC = ({ rating, currentUsername }) => { + const { t } = useTranslation(); + const theme = useTheme(); + const isXs = useMediaQuery(theme.breakpoints.down('sm')); + const isMobile = useMediaQuery(theme.breakpoints.down('lg')); + const { notify } = useToast(); + const { confirm } = useConfirm(); + + const updateRating = useUpdateRating(); + const deleteRating = useDeleteRating(); + const createReply = useCreateReply(); + const deleteReply = useDeleteReply(); + + const [isEditing, setIsEditing] = useState(false); + const [editScore, setEditScore] = useState(rating.score); + const [editComment, setEditComment] = useState(rating.comment || ''); + const [replyInput, setReplyInput] = useState(''); + const [expanded, setExpanded] = useState(false); + + const startEdit = () => { + setEditScore(rating.score); + setEditComment(rating.comment || ''); + setIsEditing(true); + }; + + const handleUpdate = async () => { + try { + await updateRating.mutateAsync({ id: rating.id, score: editScore, comment: editComment }); + setIsEditing(false); + } catch { + notify({ message: 'Failed to update review', severity: 'error' }); + } + }; + + const handleDelete = async () => { + if (!(await confirm({ message: 'Delete this review?', danger: true }))) return; + try { + await deleteRating.mutateAsync(rating.id); + } catch { + notify({ message: 'Failed to delete review', severity: 'error' }); + } + }; + + const handleReplySubmit = async () => { + if (!replyInput) return; + try { + await createReply.mutateAsync({ ratingId: rating.id, text: replyInput }); + setReplyInput(''); + setExpanded(true); + } catch { + notify({ message: 'Failed to send reply', severity: 'error' }); + } + }; + + const handleDeleteReply = async (replyId: number) => { + if (!(await confirm({ message: 'Delete this reply?', danger: true }))) return; + try { + await deleteReply.mutateAsync(replyId); + } catch { + notify({ message: 'Failed to delete reply', severity: 'error' }); + } + }; + + return ( + + + {isEditing ? ( + + + {t('dashboard.editingReview', { flavor: rating.flavor_name })} + + + {t('dashboard.newScore')} + setEditScore(val || 0)} /> + + + + + + + + ) : ( + <> + + + + + + + + + {rating.flavor_name} + + + + + {t(`categories.${rating.category_slug}`, { + defaultValue: rating.category_name, + })}{' '} + • {formatDate(rating.created_at)} + + + + + + + + + + + + {rating.comment ? ( + + ) : ( + + {t('dashboard.noComment')} + + )} + + + + + + + + + + + + {rating.replies.map((reply) => ( + + + + {reply.user} + {reply.user === currentUsername && ( + + )} + + + + {formatDate(reply.created_at)} + + {reply.user === currentUsername && ( + + )} + + + + + + + ))} + + + e.key === 'Enter' && !e.shiftKey && handleReplySubmit()} + /> + + + + + + + )} + + + ); +}; + +export default MyReviewCard; diff --git a/frontend/src/pages/dashboard/MyReviewsTab.tsx b/frontend/src/pages/dashboard/MyReviewsTab.tsx new file mode 100644 index 0000000..002cbe0 --- /dev/null +++ b/frontend/src/pages/dashboard/MyReviewsTab.tsx @@ -0,0 +1,156 @@ +import React, { useMemo, useState } from 'react'; +import { + Box, + Button, + Chip, + Grid, + InputAdornment, + MenuItem, + Select, + Stack, + TextField, +} from '@mui/material'; +import SortIcon from '@mui/icons-material/Sort'; +import SearchIcon from '@mui/icons-material/Search'; +import { useTranslation } from 'react-i18next'; +import type { RatedItem } from '../../api/types'; +import { GlassSurface, EmptyState } from '../../components/ui'; +import MyReviewCard from './MyReviewCard'; + +export interface MyReviewsTabProps { + ratings: RatedItem[]; + currentUsername: string; + /** Empty-state CTA: jump the user to the explore/discover view. */ + onExplore: () => void; +} + +type RatedSort = 'date' | 'rating'; + +const MyReviewsTab: React.FC = ({ ratings, currentUsername, onExplore }) => { + const { t } = useTranslation(); + const [category, setCategory] = useState('All'); + const [search, setSearch] = useState(''); + const [sort, setSort] = useState('date'); + + const categories = useMemo(() => { + const cats = new Set(ratings.map((r) => r.category_name)); + return ['All', ...Array.from(cats).sort()]; + }, [ratings]); + + const filtered = useMemo(() => { + const items = ratings.filter((r) => { + const matchCat = category === 'All' || r.category_name === category; + const matchSearch = r.flavor_name.toLowerCase().includes(search.toLowerCase()); + return matchCat && matchSearch; + }); + if (sort === 'rating') { + items.sort( + (a, b) => + b.score - a.score || new Date(b.created_at).getTime() - new Date(a.created_at).getTime(), + ); + } else { + items.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime()); + } + return items; + }, [ratings, category, search, sort]); + + return ( + + + + + setSearch(e.target.value)} + slotProps={{ + input: { + startAdornment: ( + + + + ), + }, + }} + /> + + + + + + + + + {categories.map((cat) => ( + r.category_name === cat)?.category_slug}`, + { + defaultValue: cat, + }, + ) + } + onClick={() => setCategory(cat)} + color={category === cat ? 'primary' : 'default'} + variant={category === cat ? 'filled' : 'outlined'} + sx={{ fontWeight: 'bold', flexShrink: 0 }} + /> + ))} + + + + + + + {filtered.length === 0 ? ( + + {t('dashboard.exploreFlavors')} + + } + /> + ) : ( + filtered.map((rating) => ( + + )) + )} + + + ); +}; + +export default MyReviewsTab; diff --git a/frontend/src/pages/dashboard/RecommendationCard.tsx b/frontend/src/pages/dashboard/RecommendationCard.tsx new file mode 100644 index 0000000..40e5b22 --- /dev/null +++ b/frontend/src/pages/dashboard/RecommendationCard.tsx @@ -0,0 +1,123 @@ +import React from 'react'; +import { Box, CardContent, Chip, Tooltip, Typography } from '@mui/material'; +import AutoAwesomeIcon from '@mui/icons-material/AutoAwesome'; +import GroupsIcon from '@mui/icons-material/Groups'; +import { Link } from 'react-router-dom'; +import { useTranslation } from 'react-i18next'; +import type { Recommendation } from '../../api/types'; +import RatingBadge from '../../components/RatingBadge'; +import StatusBadge from '../../components/StatusBadge'; +import { GlassCard } from '../../components/ui'; + +export interface RecommendationCardProps { + rec: Recommendation; +} + +/** + * A single recommended flavor. The reason chip is **count-only** (no neighbour + * names, per product decision): CF → "N tasters like you", popularity → "Popular". + */ +const RecommendationCard: React.FC = ({ rec }) => { + const { t } = useTranslation(); + + const reasonLabel = + rec.reason === 'cf' + ? t('dashboard.recReasonCf', { count: rec.contributing_neighbours }) + : t('dashboard.recReasonPopular'); + + return ( + th.tokens.elevation.md, + }, + overflow: 'hidden', + }} + > + + + + + + + + + + + + + + + {rec.name} + + + {t(`categories.${rec.category_slug}`, { defaultValue: rec.category_name })} + + : } + label={reasonLabel} + color={rec.reason === 'cf' ? 'primary' : 'default'} + variant="outlined" + sx={{ fontWeight: 'bold', maxWidth: '100%' }} + /> + + + + ); +}; + +export default RecommendationCard; diff --git a/frontend/src/pages/dashboard/RecommendationsSection.tsx b/frontend/src/pages/dashboard/RecommendationsSection.tsx new file mode 100644 index 0000000..030af7a --- /dev/null +++ b/frontend/src/pages/dashboard/RecommendationsSection.tsx @@ -0,0 +1,58 @@ +import React from 'react'; +import { Box, Grid, Skeleton, Stack, Typography } from '@mui/material'; +import AutoAwesomeIcon from '@mui/icons-material/AutoAwesome'; +import { useTranslation } from 'react-i18next'; +import { useRecommendations } from '../../api/queries/useRecommendations'; +import RecommendationCard from './RecommendationCard'; + +export interface RecommendationsSectionProps { + /** Gate the query on auth (the dashboard only renders when logged in). */ + enabled: boolean; +} + +/** + * "Tasters like you loved…" — the personalized discovery row. Hidden entirely when + * the engine returns nothing (e.g. the user has rated everything); a thin profile + * still yields popularity-fallback picks, so this is rarely empty. + */ +const RecommendationsSection: React.FC = ({ enabled }) => { + const { t } = useTranslation(); + const { data, isLoading } = useRecommendations(enabled); + + if (isLoading) { + return ( + + + + {Array.from({ length: 4 }).map((_, i) => ( + + + + ))} + + + ); + } + + if (!data || data.length === 0) return null; + + return ( + + + + + {t('dashboard.recHeading')} + + + + {data.map((rec) => ( + + + + ))} + + + ); +}; + +export default RecommendationsSection; diff --git a/frontend/src/pages/flavors/FlavorDetail.tsx b/frontend/src/pages/flavors/FlavorDetail.tsx index e03d926..24c9be8 100644 --- a/frontend/src/pages/flavors/FlavorDetail.tsx +++ b/frontend/src/pages/flavors/FlavorDetail.tsx @@ -53,6 +53,7 @@ import { RatingDistribution, } from '../../components/ui'; import RatingForm from './components/RatingForm'; +import SimilarFlavorsSection from './components/SimilarFlavorsSection'; import { useToast } from '../../hooks/useToast'; import { useConfirm } from '../../hooks/useConfirm'; @@ -143,7 +144,12 @@ const FlavorDetail: React.FC = () => { const text = replyInputs[ratingId]; if (!text) return; try { - await createReply.mutateAsync({ ratingId, text }); + await createReply.mutateAsync({ + ratingId, + text, + flavorId: flavor?.id, + optimisticUser: currentUser ?? undefined, + }); setReplyInputs({ ...replyInputs, [ratingId]: '' }); setExpandedReplies((prev) => ({ ...prev, [ratingId]: true })); } catch { @@ -772,6 +778,8 @@ const FlavorDetail: React.FC = () => { + + ); }; diff --git a/frontend/src/pages/flavors/components/RatingForm.tsx b/frontend/src/pages/flavors/components/RatingForm.tsx index 543947a..acb3734 100644 --- a/frontend/src/pages/flavors/components/RatingForm.tsx +++ b/frontend/src/pages/flavors/components/RatingForm.tsx @@ -6,6 +6,7 @@ import ScoreInput from '../../../components/ui/ScoreInput'; import MentionTextField from '../../../components/MentionTextField'; import { useCreateRating } from '../../../api/mutations/useRatingMutations'; import { useToast } from '../../../hooks/useToast'; +import { useAuth } from '../../../hooks/useAuth'; export interface RatingFormProps { flavorId: number; @@ -14,6 +15,7 @@ export interface RatingFormProps { const RatingForm: React.FC = ({ flavorId }) => { const { t } = useTranslation(); const { notify } = useToast(); + const { user } = useAuth(); const createRating = useCreateRating(); const [score, setScore] = useState(null); const [comment, setComment] = useState(''); @@ -25,7 +27,12 @@ const RatingForm: React.FC = ({ flavorId }) => { return; } try { - await createRating.mutateAsync({ flavor: flavorId, score, comment }); + await createRating.mutateAsync({ + flavor: flavorId, + score, + comment, + optimistic: user ? { user: user.username, user_avatar: user.avatar } : undefined, + }); setScore(null); setComment(''); } catch (err) { diff --git a/frontend/src/pages/flavors/components/SimilarFlavorsSection.tsx b/frontend/src/pages/flavors/components/SimilarFlavorsSection.tsx new file mode 100644 index 0000000..2f907cd --- /dev/null +++ b/frontend/src/pages/flavors/components/SimilarFlavorsSection.tsx @@ -0,0 +1,145 @@ +import React from 'react'; +import { Box, CardContent, Chip, Grid, Skeleton, Stack, Tooltip, Typography } from '@mui/material'; +import RecommendIcon from '@mui/icons-material/Recommend'; +import GroupsIcon from '@mui/icons-material/Groups'; +import { Link } from 'react-router-dom'; +import { useTranslation } from 'react-i18next'; +import { useSimilarFlavors } from '../../../api/queries/useSimilarFlavors'; +import type { SimilarFlavor } from '../../../api/types'; +import RatingBadge from '../../../components/RatingBadge'; +import StatusBadge from '../../../components/StatusBadge'; +import { GlassCard } from '../../../components/ui'; + +export interface SimilarFlavorsSectionProps { + flavorId: number | undefined; +} + +/** One compact "similar flavor" card: image, score badge, name, co-rater count. */ +const SimilarCard: React.FC<{ item: SimilarFlavor }> = ({ item }) => { + const { t } = useTranslation(); + return ( + th.tokens.elevation.md, + }, + overflow: 'hidden', + }} + > + + + + {item.average_rating != null && ( + + + + )} + + + + + + + {item.name} + + + {t(`categories.${item.category_slug}`, { defaultValue: item.category_name })} + + + } + label={t('flavorDetail.similarCoRaters', { count: item.co_raters })} + color="primary" + variant="outlined" + sx={{ fontWeight: 'bold', maxWidth: '100%' }} + /> + + + + + ); +}; + +/** + * "People who liked this also liked…" — item-based collaborative filtering on the + * flavor detail page. Hidden entirely when the catalog has too few co-ratings to + * compute a confident neighbourhood (cold flavors return an empty list). + */ +const SimilarFlavorsSection: React.FC = ({ flavorId }) => { + const { t } = useTranslation(); + const { data, isLoading } = useSimilarFlavors(flavorId); + + if (isLoading) { + return ( + + + + {Array.from({ length: 4 }).map((_, i) => ( + + + + ))} + + + ); + } + + if (!data || data.length === 0) return null; + + return ( + + + + + {t('flavorDetail.similarHeading')} + + + + {data.map((item) => ( + + + + ))} + + + ); +}; + +export default SimilarFlavorsSection;