dashboard redesign

This commit is contained in:
nvrl committed 2026-06-15 21:20:58 +02:00
1 parent cc916e472a
commit 8aaf4fc3d7
23 files changed
+2326 -1044

No files matched your search

+29
View File
@@ -74,6 +74,35 @@ Verify mutations `invalidateQueries`; consider optimistic insert so reviews appe
--- ---
## Implementation status ## 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 - [x] Q4 — dropped the `as object` cast. Exported `RetriableConfig` from
`lib/api.ts`; `AuthContext.refetchUser` now passes `{ skipAuthRedirect: true } `lib/api.ts`; `AuthContext.refetchUser` now passes `{ skipAuthRedirect: true }
as RetriableConfig` (the field is part of that type) instead of the as RetriableConfig` (the field is part of that type) instead of the
+64 -1
View File
@@ -159,14 +159,77 @@ pub struct PublicProfileOut {
pub following: Vec<UserOut>, pub following: Vec<UserOut>,
} }
/// 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<f64>,
/// Count of the user's ratings per score, keyed `"1".."10"` (always all 10 keys).
pub score_distribution: std::collections::BTreeMap<String, i64>,
/// The category the user has rated most (`None` when they have no ratings).
pub favorite_category: Option<FavoriteCategory>,
/// 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. /// Custom payload of the `users/dashboard` action.
#[derive(Serialize, ToSchema)] #[derive(Serialize, ToSchema)]
pub struct DashboardOut { pub struct DashboardOut {
pub user: UserOut, pub user: UserOut,
pub rated_count: i64, pub rated_count: i64,
pub missing_count: i64, pub missing_count: i64,
pub missing_flavors: Vec<FlavorOut>,
pub my_ratings: Vec<RatingOut>, pub my_ratings: Vec<RatingOut>,
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<String>,
pub category_name: String,
pub category_slug: String,
pub average_rating: Option<f64>,
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<String>,
pub category_name: String,
pub category_slug: String,
pub average_rating: Option<f64>,
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)] #[derive(Serialize, ToSchema)]
+1
View File
@@ -17,6 +17,7 @@ mod openapi;
mod pagination; mod pagination;
mod password; mod password;
mod password_policy; mod password_policy;
mod recommend;
mod routes; mod routes;
mod search; mod search;
mod service; mod service;
+446
View File
@@ -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<Recommendation> {
if limit == 0 {
return Vec::new();
}
// user -> (flavor -> score)
let mut by_user: HashMap<i32, HashMap<i32, f64>> = 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<i32> = 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<i32, f64> = 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<i32, f64> = 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<i32> = 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<i32, (f64, f64, i64)> = 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<Recommendation> = 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<i32>,
ratings: &[RatingInput],
limit: usize,
) -> Vec<Recommendation> {
let global_mean = mean(ratings.iter().map(|r| r.score));
let mut agg: HashMap<i32, (f64, i64)> = 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<Recommendation> = 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<Item = f64>) -> 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<SimilarFlavor> {
if limit == 0 {
return Vec::new();
}
// user -> mean of their ratings (for mean-centring)
let mut by_user: HashMap<i32, Vec<f64>> = HashMap::new();
for r in ratings {
by_user.entry(r.user_id).or_default().push(r.score);
}
let user_mean: HashMap<i32, f64> = by_user
.iter()
.map(|(&u, vs)| (u, mean(vs.iter().copied())))
.collect();
// flavor -> (user -> mean-centred score)
let mut by_flavor: HashMap<i32, HashMap<i32, f64>> = 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<SimilarFlavor> = 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());
}
}
+69 -1
View File
@@ -9,17 +9,21 @@ use sea_orm::{
use serde::Deserialize; use serde::Deserialize;
use crate::auth::{AuthUser, OptionalUser}; use crate::auth::{AuthUser, OptionalUser};
use crate::dto::{FlavorOut, SearchHit}; use crate::dto::{FlavorOut, SearchHit, SimilarFlavorOut};
use crate::entities::prelude::*; use crate::entities::prelude::*;
use crate::entities::{category, flavor, rating}; use crate::entities::{category, flavor, rating};
use crate::error::{ApiError, ApiResult}; use crate::error::{ApiError, ApiResult};
use crate::media::image_field_url; use crate::media::image_field_url;
use crate::pagination::{parse_page, Paginated, DEFAULT_PAGE_SIZE}; 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::search::{extract_category_slug, query_words, score_relevance};
use crate::service::build_flavors; use crate::service::build_flavors;
use crate::state::AppState; use crate::state::AppState;
use crate::web::RequestCtx; use crate::web::RequestCtx;
/// Max "similar flavors" returned by `/flavors/{id}/similar/`.
const SIMILAR_LIMIT: usize = 8;
pub fn router() -> Router<AppState> { pub fn router() -> Router<AppState> {
Router::new() Router::new()
.route("/flavors/", get(list)) .route("/flavors/", get(list))
@@ -28,6 +32,7 @@ pub fn router() -> Router<AppState> {
.route("/flavors/newest/", get(newest)) .route("/flavors/newest/", get(newest))
.route("/flavors/followed_top/", get(followed_top)) .route("/flavors/followed_top/", get(followed_top))
.route("/flavors/{id}/", get(retrieve)) .route("/flavors/{id}/", get(retrieve))
.route("/flavors/{id}/similar/", get(similar))
} }
#[derive(Deserialize)] #[derive(Deserialize)]
@@ -122,6 +127,69 @@ async fn retrieve(
dtos.pop().map(Json).ok_or(ApiError::NotFound) 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<AppState>,
ctx: RequestCtx,
OptionalUser(viewer): OptionalUser,
Path(id): Path<i32>,
) -> ApiResult<Json<Vec<SimilarFlavorOut>>> {
// 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<RatingInput> = 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<i32> = 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<i32, _> = flavors.into_iter().map(|f| (f.id, f)).collect();
let out: Vec<SimilarFlavorOut> = 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)] #[derive(Deserialize)]
struct CategoryParam { struct CategoryParam {
category: Option<String>, category: Option<String>,
+206 -44
View File
@@ -16,6 +16,7 @@ use axum::extract::{ConnectInfo, Multipart, Path, Query, State};
use axum::http::{HeaderMap, StatusCode}; use axum::http::{HeaderMap, StatusCode};
use axum::routing::{get, patch, post}; use axum::routing::{get, patch, post};
use axum::{Json, Router}; use axum::{Json, Router};
use chrono::Datelike;
use sea_orm::ActiveValue::Set; use sea_orm::ActiveValue::Set;
use sea_orm::{ use sea_orm::{
ActiveModelTrait, ColumnTrait, ConnectionTrait, DbBackend, EntityTrait, PaginatorTrait, ActiveModelTrait, ColumnTrait, ConnectionTrait, DbBackend, EntityTrait, PaginatorTrait,
@@ -26,11 +27,15 @@ use serde_json::{json, Value};
use subtle::ConstantTimeEq; use subtle::ConstantTimeEq;
use crate::auth::{AuthUser, OptionalUser}; 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::prelude::*;
use crate::entities::{flavor, notification, profile_comment, rating, user, user_ip}; use crate::entities::{flavor, notification, profile_comment, rating, user, user_ip};
use crate::error::{ApiError, ApiResult}; use crate::error::{ApiError, ApiResult};
use crate::pagination::{parse_page, Paginated, DEFAULT_PAGE_SIZE}; use crate::pagination::{parse_page, Paginated, DEFAULT_PAGE_SIZE};
use crate::recommend::{recommend, RatingInput};
use crate::service::{ use crate::service::{
build_flavors, build_profile_comments, build_ratings, build_user, build_users, build_flavors, build_profile_comments, build_ratings, build_user, build_users,
}; };
@@ -91,6 +96,7 @@ pub fn router() -> Router<AppState> {
post(confirm_account_deletion), post(confirm_account_deletion),
) )
.route("/users/dashboard/", get(dashboard)) .route("/users/dashboard/", get(dashboard))
.route("/users/recommendations/", get(recommendations))
.route("/users/{id}/", get(retrieve)) .route("/users/{id}/", get(retrieve))
.route("/users/{id}/follow/", post(follow)) .route("/users/{id}/follow/", post(follow))
.route("/users/{id}/unfollow/", post(unfollow)) .route("/users/{id}/unfollow/", post(unfollow))
@@ -1315,7 +1321,6 @@ async fn dashboard(
AuthUser(uid): AuthUser, AuthUser(uid): AuthUser,
) -> ApiResult<Json<DashboardOut>> { ) -> ApiResult<Json<DashboardOut>> {
let me = load_user(&state, uid).await?; let me = load_user(&state, uid).await?;
let followed = following_ids(&state, uid).await?;
let my_ratings_models = Rating::find() let my_ratings_models = Rating::find()
.filter(rating::Column::UserId.eq(uid)) .filter(rating::Column::UserId.eq(uid))
@@ -1325,10 +1330,24 @@ async fn dashboard(
let rated_count = my_ratings_models.len() as i64; let rated_count = my_ratings_models.len() as i64;
let rated_flavor_ids: std::collections::HashSet<i32> = let rated_flavor_ids: std::collections::HashSet<i32> =
my_ratings_models.iter().map(|r| r.flavor_id).collect(); 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, // Ratings created in the current UTC month — computed from the models while we
// ordered by category name then flavor name. // 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() let packs = crate::entities::category::Entity::find()
.filter(crate::entities::category::Column::Name.eq("Packs and other")) .filter(crate::entities::category::Column::Name.eq("Packs and other"))
.one(&state.db) .one(&state.db)
@@ -1337,52 +1356,195 @@ async fn dashboard(
if let Some(p) = packs { if let Some(p) = packs {
q = q.filter(flavor::Column::CategoryId.ne(p.id)); q = q.filter(flavor::Column::CategoryId.ne(p.id));
} }
let mut all_flavors = q.all(&state.db).await?; let missing_count = q
all_flavors.retain(|f| !rated_flavor_ids.contains(&f.id)); .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<i32> = all_flavors.iter().map(|f| f.category_id).collect();
let cat_names: std::collections::HashMap<i32, String> =
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?; let user = build_user(&state, &ctx, me, Some(uid)).await?;
Ok(Json(DashboardOut { Ok(Json(DashboardOut {
user, user,
rated_count, rated_count,
missing_count, missing_count,
missing_flavors,
my_ratings, 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<AppState>,
ctx: RequestCtx,
AuthUser(uid): AuthUser,
) -> ApiResult<Json<Vec<RecommendationOut>>> {
// Whole rating matrix (hundreds of rows at current scale) → in-process scoring.
let all = Rating::find().all(&state.db).await?;
let inputs: Vec<RatingInput> = 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<i32> = 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<i32, _> = flavors.into_iter().map(|f| (f.id, f)).collect();
let out: Vec<RecommendationOut> = 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<String, i64> =
(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<String, (String, i64)> =
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::<std::collections::HashSet<_>>()
.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");
}
}
+227
View File
@@ -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<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.
+2
View File
@@ -6,11 +6,13 @@
export const queryKeys = { export const queryKeys = {
categories: () => ['categories'] as const, categories: () => ['categories'] as const,
dashboard: () => ['dashboard'] as const, dashboard: () => ['dashboard'] as const,
recommendations: () => ['recommendations'] as const,
publicProfile: (username: string) => ['publicProfile', username] as const, publicProfile: (username: string) => ['publicProfile', username] as const,
publicProfileAll: () => ['publicProfile'] as const, publicProfileAll: () => ['publicProfile'] as const,
flavors: (params: Record<string, unknown>) => ['flavors', params] as const, flavors: (params: Record<string, unknown>) => ['flavors', params] as const,
flavorsAll: () => ['flavors'] as const, flavorsAll: () => ['flavors'] as const,
flavorDetail: (id: number | string) => ['flavorDetail', String(id)] 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, activeBanner: (username: string) => ['activeBanner', username] as const,
notifications: () => ['notifications'] as const, notifications: () => ['notifications'] as const,
communityFeed: (params: Record<string, unknown>) => ['communityFeed', params] as const, communityFeed: (params: Record<string, unknown>) => ['communityFeed', params] as const,
@@ -1,6 +1,11 @@
import { useMutation, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQueryClient } from '@tanstack/react-query';
import api from '../../lib/api'; import api from '../../lib/api';
import { queryKeys } from '../keys'; import { queryKeys } from '../keys';
import type {
FlavorDetailData,
FlavorDetailRating,
FlavorDetailReply,
} from '../queries/useFlavorDetail';
const invalidateRatingViews = (qc: ReturnType<typeof useQueryClient>) => { const invalidateRatingViews = (qc: ReturnType<typeof useQueryClient>) => {
qc.invalidateQueries({ queryKey: queryKeys.dashboard() }); qc.invalidateQueries({ queryKey: queryKeys.dashboard() });
@@ -37,19 +42,53 @@ export interface CreateRatingInput {
flavor: number; flavor: number;
score: number; score: number;
comment?: string | null; 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 = () => { export const useCreateRating = () => {
const qc = useQueryClient(); const qc = useQueryClient();
return useMutation({ return useMutation({
mutationFn: (input: CreateRatingInput) => api.post('ratings/', input), mutationFn: ({ flavor, score, comment }: CreateRatingInput) =>
onSuccess: () => invalidateRatingViews(qc), 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<FlavorDetailData>(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<FlavorDetailData>(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 { export interface ReplyInput {
ratingId: number; ratingId: number;
text: string; 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 = () => { export const useCreateReply = () => {
@@ -57,7 +96,31 @@ export const useCreateReply = () => {
return useMutation({ return useMutation({
mutationFn: ({ ratingId, text }: ReplyInput) => mutationFn: ({ ratingId, text }: ReplyInput) =>
api.post(`ratings/${ratingId}/reply/`, { text }), 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<FlavorDetailData>(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<FlavorDetailData>(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),
}); });
}; };
@@ -1,5 +1,5 @@
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import api from '../../lib/api'; import api, { type RetriableConfig } from '../../lib/api';
import { useAuth } from '../../hooks/useAuth'; import { useAuth } from '../../hooks/useAuth';
export interface CurrentUserLite { export interface CurrentUserLite {
@@ -19,8 +19,8 @@ export const useCurrentUserLite = () => {
const res = await api.get<CurrentUserLite>('users/me/', { const res = await api.get<CurrentUserLite>('users/me/', {
// Stale access tokens are common; never bounce a public page reader // Stale access tokens are common; never bounce a public page reader
// to /login because of a background "am I logged in?" check. // to /login because of a background "am I logged in?" check.
...({ skipAuthRedirect: true } as object), skipAuthRedirect: true,
}); } as RetriableConfig);
return res.data; return res.data;
}, },
enabled: !!user, enabled: !!user,
@@ -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<Recommendation[]>('users/recommendations/');
return res.data;
},
enabled,
staleTime: 5 * 60_000,
});
@@ -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<SimilarFlavor[]>(`flavors/${flavorId}/similar/`);
return res.data;
},
enabled: enabled && flavorId != null,
staleTime: 5 * 60_000,
});
+48 -1
View File
@@ -32,10 +32,57 @@ export interface MissingFlavor {
is_available?: boolean; 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<string, number>;
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 { export interface DashboardData {
user: { username: string; avatar: string | null }; user: { username: string; avatar: string | null };
rated_count: number; rated_count: number;
missing_count: number; missing_count: number;
missing_flavors: MissingFlavor[];
my_ratings: RatedItem[]; 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;
} }
+22
View File
@@ -29,6 +29,14 @@ const resources = {
welcome: 'Welcome back, {{username}}!', welcome: 'Welcome back, {{username}}!',
shareProfile: 'Share Profile', shareProfile: 'Share Profile',
copySuccess: 'Link copied to clipboard!', 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', myRatings: 'My Ratings',
missing: 'Missing', missing: 'Missing',
noRatings: "You haven't rated any flavors yet.", noRatings: "You haven't rated any flavors yet.",
@@ -141,6 +149,9 @@ const resources = {
outOfStock: 'Out of Stock', outOfStock: 'Out of Stock',
}, },
flavorDetail: { 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', rateThisFlavor: 'Rate this flavor',
scoreLabel: 'Your score, 1 to 10', scoreLabel: 'Your score, 1 to 10',
submitReview: 'Submit Review', submitReview: 'Submit Review',
@@ -336,6 +347,14 @@ const resources = {
welcome: 'Willkommen zurück, {{username}}!', welcome: 'Willkommen zurück, {{username}}!',
shareProfile: 'Profil teilen', shareProfile: 'Profil teilen',
copySuccess: 'Link in die Zwischenablage kopiert!', 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', myRatings: 'Meine Bewertungen',
missing: 'Fehlend', missing: 'Fehlend',
noRatings: 'Du hast noch keine Sorten bewertet.', noRatings: 'Du hast noch keine Sorten bewertet.',
@@ -448,6 +467,9 @@ const resources = {
outOfStock: 'Ausverkauft', outOfStock: 'Ausverkauft',
}, },
flavorDetail: { 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', rateThisFlavor: 'Diese Sorte bewerten',
scoreLabel: 'Deine Bewertung, 1 bis 10', scoreLabel: 'Deine Bewertung, 1 bis 10',
submitReview: 'Bewertung abschicken', submitReview: 'Bewertung abschicken',
File diff suppressed because it is too large. Load diff
@@ -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<DashboardHeaderProps> = ({
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 */}
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
{isXs ? (
<IconButton
onClick={onBack}
aria-label={t('common.back')}
sx={{ border: '1px solid', borderColor: 'divider', borderRadius: 2 }}
>
<ArrowBackIcon />
</IconButton>
) : (
<Button
variant="outlined"
onClick={onBack}
startIcon={<ArrowBackIcon />}
sx={{ borderRadius: 2, textTransform: 'none', fontWeight: 'bold' }}
>
{t('common.back')}
</Button>
)}
<Typography variant="h5" sx={{ fontWeight: '900', display: { xs: 'none', md: 'block' } }}>
{t('dashboard.title')}
</Typography>
</Box>
{isXs ? (
<Tooltip title={t('dashboard.shareProfile')}>
<IconButton
color="primary"
onClick={onShare}
aria-label={t('dashboard.shareProfile')}
sx={{ bgcolor: 'primary.main', color: 'primary.contrastText', borderRadius: 2 }}
>
<ShareIcon />
</IconButton>
</Tooltip>
) : (
<Button
variant="contained"
onClick={onShare}
startIcon={<ShareIcon />}
sx={{ borderRadius: 2, textTransform: 'none', fontWeight: 'bold' }}
>
{t('dashboard.shareProfile')}
</Button>
)}
</Box>
{/* Profile Header — theme-derived gradient (ColorThief dropped in the redesign) */}
<GlassCard intensity="strong" sx={{ overflow: 'hidden' }}>
{!isXs && (
<Box sx={{ height: 100, position: 'relative', overflow: 'hidden' }}>
<Box
sx={{
position: 'absolute',
inset: 0,
background: `
radial-gradient(at 0% 0%, ${alpha(grad[0], 0.6)} 0px, transparent 55%),
radial-gradient(at 100% 0%, ${alpha(grad[1], 0.5)} 0px, transparent 55%),
radial-gradient(at 50% 100%, ${alpha(theme.palette.primary.main, 0.3)} 0px, transparent 55%),
linear-gradient(135deg, ${alpha(grad[0], 0.1)} 0%, ${alpha(grad[1], 0.1)} 100%)
`,
}}
/>
</Box>
)}
<CardContent
sx={{
pt: { xs: 2, sm: 0 },
px: { xs: 2, sm: 4 },
pb: { xs: 2, sm: 4 },
position: 'relative',
}}
>
<Box
sx={{
display: 'flex',
flexDirection: { xs: 'row', sm: 'row' },
alignItems: { xs: 'center', sm: 'flex-end' },
gap: { xs: 2, sm: 4 },
}}
>
<Box
sx={{
position: 'relative',
mt: { xs: 0, sm: -6 },
p: 0.5,
borderRadius: '50%',
background: `linear-gradient(135deg, ${grad[0]}, ${grad[1]})`,
boxShadow: { xs: 'none', sm: '0 12px 48px rgba(0,0,0,0.25)' },
display: 'flex',
flexShrink: 0,
}}
>
<Avatar
src={user.avatar || undefined}
sx={{
width: { xs: 56, sm: 110 },
height: { xs: 56, sm: 110 },
border: { xs: '2px solid', sm: '4px solid' },
borderColor: theme.palette.background.paper,
fontSize: { xs: '1.5rem', sm: '3rem' },
bgcolor: theme.palette.background.paper,
color: 'primary.main',
}}
>
{!user.avatar && user.username.charAt(0).toUpperCase()}
</Avatar>
</Box>
<Box
sx={{
flex: 1,
minWidth: 0,
textAlign: { xs: 'left', sm: 'left' },
mt: { xs: 0, sm: 4 },
}}
>
<Typography
variant={isXs ? 'h5' : 'h3'}
sx={{
fontWeight: '900',
mb: 0.25,
letterSpacing: -1,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{user.username}
</Typography>
{!isXs && (
<Typography variant="subtitle1" color="text.secondary" gutterBottom>
{t('dashboard.welcome', { username: user.username })}
</Typography>
)}
{fav && (
<Typography variant="body2" color="text.secondary" sx={{ mt: { xs: 0.5, sm: 0 } }}>
{t('dashboard.favoriteCategory', {
category: t(`categories.${fav.slug}`, { defaultValue: fav.name }),
count: fav.count,
})}
</Typography>
)}
<Box sx={{ mt: { xs: 1, sm: 2 } }}>
<GlassSurface
intensity="subtle"
sx={{
display: 'inline-flex',
borderRadius: 1,
overflow: 'hidden',
flexWrap: 'wrap',
}}
>
{statTiles.map((stat, i) => (
<React.Fragment key={stat.label}>
<Box
sx={{
py: { xs: 0.5, sm: 1 },
width: { xs: 80, sm: 140 },
textAlign: 'center',
}}
>
<Typography
variant="h6"
sx={{ fontWeight: '900', lineHeight: 1, color: 'text.primary' }}
>
{stat.val}
</Typography>
<Typography
variant="caption"
sx={{
fontWeight: '900',
textTransform: 'uppercase',
opacity: 0.5,
fontSize: '0.6rem',
letterSpacing: 1,
}}
>
{stat.label}
</Typography>
</Box>
{i < statTiles.length - 1 && (
<Divider
orientation="vertical"
flexItem
sx={{ borderStyle: 'solid', opacity: 0.1 }}
/>
)}
</React.Fragment>
))}
</GlassSurface>
</Box>
</Box>
</Box>
</CardContent>
</GlassCard>
</>
);
};
export default DashboardHeader;
@@ -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<MyReviewCardProps> = ({ 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 (
<GlassCard>
<CardContent sx={{ p: { xs: 2, sm: 3 } }}>
{isEditing ? (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 'bold' }}>
{t('dashboard.editingReview', { flavor: rating.flavor_name })}
</Typography>
<Stack direction="row" spacing={2} alignItems="center">
<Typography variant="body2">{t('dashboard.newScore')}</Typography>
<MuiRating max={10} value={editScore} onChange={(_, val) => setEditScore(val || 0)} />
</Stack>
<MentionTextField multiline rows={3} value={editComment} onChange={setEditComment} />
<Stack direction="row" spacing={1}>
<Button variant="contained" size="small" onClick={handleUpdate}>
{t('common.save')}
</Button>
<Button variant="outlined" size="small" onClick={() => setIsEditing(false)}>
{t('common.cancel')}
</Button>
</Stack>
</Box>
) : (
<>
<Box
sx={{
display: 'flex',
flexDirection: { xs: 'column', sm: 'row' },
justifyContent: 'space-between',
alignItems: { xs: 'stretch', sm: 'flex-start' },
gap: { xs: 1.5, sm: 2 },
mb: 2,
}}
>
<Box sx={{ display: 'flex', gap: 2, minWidth: 0, flex: 1 }}>
<Box
sx={{
width: { xs: 56, sm: 64 },
height: { xs: 56, sm: 64 },
bgcolor: 'background.default',
border: '1px solid',
borderColor: 'divider',
borderRadius: 1.5,
overflow: 'hidden',
flexShrink: 0,
}}
>
<Box
component="img"
src={rating.flavor_image || undefined}
alt={rating.flavor_name}
sx={{ width: '100%', height: '100%', objectFit: 'cover' }}
/>
</Box>
<Box sx={{ minWidth: 0, flex: 1 }}>
<Typography
variant={isXs ? 'subtitle1' : 'h6'}
sx={{ fontWeight: 'bold', lineHeight: 1.2, wordBreak: 'break-word' }}
>
<Link
to={`/flavor/${rating.flavor}`}
style={{ color: 'inherit', textDecoration: 'none' }}
>
{rating.flavor_name}
</Link>
</Typography>
<Stack
direction="row"
spacing={1}
alignItems="center"
flexWrap="wrap"
sx={{ mt: 0.5, rowGap: 0.5 }}
>
<Typography variant="caption" color="text.secondary">
{t(`categories.${rating.category_slug}`, {
defaultValue: rating.category_name,
})}{' '}
• {formatDate(rating.created_at)}
</Typography>
<StatusBadge
isLegacy={rating.is_legacy}
isAvailable={rating.is_available}
size="small"
/>
</Stack>
</Box>
</Box>
<Box sx={{ alignSelf: { xs: 'flex-start', sm: 'auto' }, flexShrink: 0 }}>
<RatingBadge
score={rating.score}
size={isXs ? 'medium' : isMobile ? 'medium' : 'large'}
/>
</Box>
</Box>
<Box
sx={{
mb: 2,
p: 2,
bgcolor: alpha(theme.palette.text.primary, 0.03),
borderRadius: 2,
}}
>
{rating.comment ? (
<RichText text={rating.comment} />
) : (
<Typography variant="body2" color="text.secondary" sx={{ fontStyle: 'italic' }}>
{t('dashboard.noComment')}
</Typography>
)}
</Box>
<Stack direction="row" spacing={2} alignItems="center">
<Button
size="small"
startIcon={<CommentIcon fontSize="small" />}
onClick={() => setExpanded((v) => !v)}
sx={{ textTransform: 'none', fontWeight: 'bold', color: 'text.secondary' }}
>
{rating.replies.length} {t('common.replies')}
</Button>
<Box sx={{ flexGrow: 1 }} />
<Button size="small" sx={{ fontWeight: 'bold' }} onClick={startEdit}>
{t('common.edit')}
</Button>
<Button size="small" color="error" sx={{ fontWeight: 'bold' }} onClick={handleDelete}>
{t('common.delete')}
</Button>
</Stack>
<Collapse in={expanded}>
<Box sx={{ mt: 2, pt: 2, borderTop: '1px solid', borderColor: 'divider' }}>
{rating.replies.map((reply) => (
<Box
key={reply.id}
sx={{ mb: 2, pl: 2, borderLeft: '3px solid', borderColor: 'primary.main' }}
>
<Box
sx={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
mb: 0.5,
}}
>
<Typography
variant="subtitle2"
sx={{ fontWeight: 'bold', fontSize: '0.85rem' }}
>
{reply.user}
{reply.user === currentUsername && (
<VerifiedIcon
sx={{ fontSize: '0.8rem', color: 'primary.main', ml: 0.5 }}
/>
)}
</Typography>
<Stack direction="row" spacing={1} alignItems="center">
<Typography variant="caption" color="text.secondary">
{formatDate(reply.created_at)}
</Typography>
{reply.user === currentUsername && (
<Button
size="small"
color="error"
sx={{ minWidth: 0, p: 0, fontSize: '0.7rem' }}
onClick={() => handleDeleteReply(reply.id)}
>
{t('common.delete')}
</Button>
)}
</Stack>
</Box>
<Typography variant="body2">
<RichText text={reply.text} />
</Typography>
</Box>
))}
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={1} sx={{ mt: 2 }}>
<Box sx={{ flex: 1, minWidth: 0 }}>
<MentionTextField
placeholder={t('community.writeReply')}
value={replyInput}
onChange={setReplyInput}
onKeyDown={(e) => e.key === 'Enter' && !e.shiftKey && handleReplySubmit()}
/>
</Box>
<Button
variant="contained"
size="small"
onClick={handleReplySubmit}
disabled={!replyInput}
sx={{ height: 40, px: 3, alignSelf: { xs: 'flex-end', sm: 'auto' } }}
>
{t('common.reply')}
</Button>
</Stack>
</Box>
</Collapse>
</>
)}
</CardContent>
</GlassCard>
);
};
export default MyReviewCard;
@@ -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<MyReviewsTabProps> = ({ ratings, currentUsername, onExplore }) => {
const { t } = useTranslation();
const [category, setCategory] = useState('All');
const [search, setSearch] = useState('');
const [sort, setSort] = useState<RatedSort>('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 (
<Box>
<GlassSurface intensity="subtle" sx={{ p: { xs: 1.5, sm: 2 }, mb: 3 }}>
<Grid container spacing={{ xs: 1.5, lg: 1.5 }} alignItems="center">
<Grid size={{ xs: 8, sm: 12, lg: 4, xl: 5 }}>
<TextField
fullWidth
size="small"
placeholder={t('dashboard.searchRated')}
value={search}
onChange={(e) => setSearch(e.target.value)}
slotProps={{
input: {
startAdornment: (
<InputAdornment position="start">
<SearchIcon fontSize="small" />
</InputAdornment>
),
},
}}
/>
</Grid>
<Grid size={{ xs: 4, sm: 12, lg: 'auto' }} order={{ xs: 2, sm: 3, lg: 3 }}>
<Select
size="small"
fullWidth
value={sort}
onChange={(e) => setSort(e.target.value as RatedSort)}
startAdornment={
<InputAdornment position="start">
<SortIcon fontSize="small" />
</InputAdornment>
}
sx={{ minWidth: { lg: 140 }, fontWeight: 'bold' }}
>
<MenuItem value="date">{t('dashboard.date')}</MenuItem>
<MenuItem value="rating">{t('dashboard.rating')}</MenuItem>
</Select>
</Grid>
<Grid
size={{ xs: 12, lg: 'auto' }}
order={{ xs: 3, sm: 2, lg: 2 }}
sx={{ flexGrow: 1, overflow: 'hidden' }}
>
<Box
sx={{
display: 'flex',
gap: 1,
overflowX: 'auto',
py: 0.5,
'&::-webkit-scrollbar': { display: 'none' },
}}
>
{categories.map((cat) => (
<Chip
key={cat}
label={
cat === 'All'
? t('common.all')
: t(
`categories.${ratings.find((r) => 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 }}
/>
))}
</Box>
</Grid>
</Grid>
</GlassSurface>
<Stack spacing={3}>
{filtered.length === 0 ? (
<EmptyState
title={t('dashboard.noRatings')}
action={
<Button onClick={onExplore} variant="contained" sx={{ borderRadius: 2 }}>
{t('dashboard.exploreFlavors')}
</Button>
}
/>
) : (
filtered.map((rating) => (
<MyReviewCard key={rating.id} rating={rating} currentUsername={currentUsername} />
))
)}
</Stack>
</Box>
);
};
export default MyReviewsTab;
@@ -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<RecommendationCardProps> = ({ rec }) => {
const { t } = useTranslation();
const reasonLabel =
rec.reason === 'cf'
? t('dashboard.recReasonCf', { count: rec.contributing_neighbours })
: t('dashboard.recReasonPopular');
return (
<GlassCard
intensity="subtle"
sx={{
height: '100%',
transition: 'all 0.2s ease',
'&:hover': {
transform: 'translateY(-4px)',
borderColor: 'primary.main',
boxShadow: (th) => th.tokens.elevation.md,
},
overflow: 'hidden',
}}
>
<Link to={`/flavor/${rec.id}`} style={{ textDecoration: 'none', color: 'inherit' }}>
<Box
sx={{
position: 'relative',
aspectRatio: '1/1',
bgcolor: 'background.default',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
borderBottom: '1px solid',
borderColor: 'divider',
overflow: 'hidden',
}}
>
<Box
component="img"
src={rec.image_url || undefined}
alt={rec.name}
loading="lazy"
sx={{
width: '100%',
height: '100%',
objectFit: 'cover',
transition: 'transform 0.5s ease',
'&:hover': { transform: 'scale(1.1)' },
}}
/>
<Box
sx={{
position: 'absolute',
top: 10,
left: 10,
zIndex: 2,
display: 'flex',
flexDirection: 'column',
gap: 1,
}}
>
<Tooltip title={t('dashboard.predictedForYou')}>
<Box>
<RatingBadge
score={rec.predicted_score}
size="small"
sx={{ height: 24, bgcolor: 'primary.main' }}
/>
</Box>
</Tooltip>
<StatusBadge isLegacy={rec.is_legacy} isAvailable={rec.is_available} size="small" />
</Box>
</Box>
<CardContent sx={{ p: 2 }}>
<Typography
variant="subtitle2"
sx={{
fontWeight: 'bold',
mb: 0.5,
display: '-webkit-box',
WebkitLineClamp: 1,
WebkitBoxOrient: 'vertical',
overflow: 'hidden',
}}
>
{rec.name}
</Typography>
<Typography variant="caption" color="text.secondary" display="block" sx={{ mb: 1 }}>
{t(`categories.${rec.category_slug}`, { defaultValue: rec.category_name })}
</Typography>
<Chip
size="small"
icon={rec.reason === 'cf' ? <GroupsIcon /> : <AutoAwesomeIcon />}
label={reasonLabel}
color={rec.reason === 'cf' ? 'primary' : 'default'}
variant="outlined"
sx={{ fontWeight: 'bold', maxWidth: '100%' }}
/>
</CardContent>
</Link>
</GlassCard>
);
};
export default RecommendationCard;
@@ -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<RecommendationsSectionProps> = ({ enabled }) => {
const { t } = useTranslation();
const { data, isLoading } = useRecommendations(enabled);
if (isLoading) {
return (
<Box>
<Skeleton variant="text" width={240} height={36} sx={{ mb: 2 }} />
<Grid container spacing={2}>
{Array.from({ length: 4 }).map((_, i) => (
<Grid key={i} size={{ xs: 12, sm: 6, lg: 3 }}>
<Skeleton variant="rounded" height={260} sx={{ borderRadius: 2 }} />
</Grid>
))}
</Grid>
</Box>
);
}
if (!data || data.length === 0) return null;
return (
<Box>
<Stack direction="row" spacing={1} alignItems="center" sx={{ mb: 2 }}>
<AutoAwesomeIcon color="primary" />
<Typography variant="h6" sx={{ fontWeight: '900' }}>
{t('dashboard.recHeading')}
</Typography>
</Stack>
<Grid container spacing={2}>
{data.map((rec) => (
<Grid key={rec.id} size={{ xs: 12, sm: 6, lg: 3 }}>
<RecommendationCard rec={rec} />
</Grid>
))}
</Grid>
</Box>
);
};
export default RecommendationsSection;
+9 -1
View File
@@ -53,6 +53,7 @@ import {
RatingDistribution, RatingDistribution,
} from '../../components/ui'; } from '../../components/ui';
import RatingForm from './components/RatingForm'; import RatingForm from './components/RatingForm';
import SimilarFlavorsSection from './components/SimilarFlavorsSection';
import { useToast } from '../../hooks/useToast'; import { useToast } from '../../hooks/useToast';
import { useConfirm } from '../../hooks/useConfirm'; import { useConfirm } from '../../hooks/useConfirm';
@@ -143,7 +144,12 @@ const FlavorDetail: React.FC = () => {
const text = replyInputs[ratingId]; const text = replyInputs[ratingId];
if (!text) return; if (!text) return;
try { try {
await createReply.mutateAsync({ ratingId, text }); await createReply.mutateAsync({
ratingId,
text,
flavorId: flavor?.id,
optimisticUser: currentUser ?? undefined,
});
setReplyInputs({ ...replyInputs, [ratingId]: '' }); setReplyInputs({ ...replyInputs, [ratingId]: '' });
setExpandedReplies((prev) => ({ ...prev, [ratingId]: true })); setExpandedReplies((prev) => ({ ...prev, [ratingId]: true }));
} catch { } catch {
@@ -772,6 +778,8 @@ const FlavorDetail: React.FC = () => {
</Stack> </Stack>
</Grid> </Grid>
</Grid> </Grid>
<SimilarFlavorsSection flavorId={flavor.id} />
</PageShell> </PageShell>
); );
}; };
@@ -6,6 +6,7 @@ import ScoreInput from '../../../components/ui/ScoreInput';
import MentionTextField from '../../../components/MentionTextField'; import MentionTextField from '../../../components/MentionTextField';
import { useCreateRating } from '../../../api/mutations/useRatingMutations'; import { useCreateRating } from '../../../api/mutations/useRatingMutations';
import { useToast } from '../../../hooks/useToast'; import { useToast } from '../../../hooks/useToast';
import { useAuth } from '../../../hooks/useAuth';
export interface RatingFormProps { export interface RatingFormProps {
flavorId: number; flavorId: number;
@@ -14,6 +15,7 @@ export interface RatingFormProps {
const RatingForm: React.FC<RatingFormProps> = ({ flavorId }) => { const RatingForm: React.FC<RatingFormProps> = ({ flavorId }) => {
const { t } = useTranslation(); const { t } = useTranslation();
const { notify } = useToast(); const { notify } = useToast();
const { user } = useAuth();
const createRating = useCreateRating(); const createRating = useCreateRating();
const [score, setScore] = useState<number | null>(null); const [score, setScore] = useState<number | null>(null);
const [comment, setComment] = useState(''); const [comment, setComment] = useState('');
@@ -25,7 +27,12 @@ const RatingForm: React.FC<RatingFormProps> = ({ flavorId }) => {
return; return;
} }
try { 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); setScore(null);
setComment(''); setComment('');
} catch (err) { } catch (err) {
@@ -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 (
<GlassCard
intensity="subtle"
sx={{
height: '100%',
transition: 'all 0.2s ease',
'&:hover': {
transform: 'translateY(-4px)',
borderColor: 'primary.main',
boxShadow: (th) => th.tokens.elevation.md,
},
overflow: 'hidden',
}}
>
<Link to={`/flavor/${item.id}`} style={{ textDecoration: 'none', color: 'inherit' }}>
<Box
sx={{
position: 'relative',
aspectRatio: '1/1',
bgcolor: 'background.default',
borderBottom: '1px solid',
borderColor: 'divider',
overflow: 'hidden',
}}
>
<Box
component="img"
src={item.image_url || undefined}
alt={item.name}
loading="lazy"
sx={{
width: '100%',
height: '100%',
objectFit: 'cover',
transition: 'transform 0.5s ease',
'&:hover': { transform: 'scale(1.1)' },
}}
/>
{item.average_rating != null && (
<Box sx={{ position: 'absolute', top: 10, left: 10, zIndex: 2 }}>
<RatingBadge score={item.average_rating} size="small" sx={{ height: 24 }} />
</Box>
)}
<Box sx={{ position: 'absolute', top: 10, right: 10, zIndex: 2 }}>
<StatusBadge isLegacy={item.is_legacy} isAvailable={item.is_available} size="small" />
</Box>
</Box>
<CardContent sx={{ p: 2 }}>
<Typography
variant="subtitle2"
sx={{
fontWeight: 'bold',
mb: 0.5,
display: '-webkit-box',
WebkitLineClamp: 1,
WebkitBoxOrient: 'vertical',
overflow: 'hidden',
}}
>
{item.name}
</Typography>
<Typography variant="caption" color="text.secondary" display="block" sx={{ mb: 1 }}>
{t(`categories.${item.category_slug}`, { defaultValue: item.category_name })}
</Typography>
<Tooltip title={t('flavorDetail.similarTooltip')}>
<Chip
size="small"
icon={<GroupsIcon />}
label={t('flavorDetail.similarCoRaters', { count: item.co_raters })}
color="primary"
variant="outlined"
sx={{ fontWeight: 'bold', maxWidth: '100%' }}
/>
</Tooltip>
</CardContent>
</Link>
</GlassCard>
);
};
/**
* "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<SimilarFlavorsSectionProps> = ({ flavorId }) => {
const { t } = useTranslation();
const { data, isLoading } = useSimilarFlavors(flavorId);
if (isLoading) {
return (
<Box sx={{ mt: 4 }}>
<Skeleton variant="text" width={280} height={36} sx={{ mb: 2 }} />
<Grid container spacing={2}>
{Array.from({ length: 4 }).map((_, i) => (
<Grid key={i} size={{ xs: 6, sm: 4, md: 3 }}>
<Skeleton variant="rounded" height={240} sx={{ borderRadius: 2 }} />
</Grid>
))}
</Grid>
</Box>
);
}
if (!data || data.length === 0) return null;
return (
<Box sx={{ mt: 4 }}>
<Stack direction="row" spacing={1} alignItems="center" sx={{ mb: 2 }}>
<RecommendIcon color="primary" />
<Typography variant="h6" sx={{ fontWeight: '900' }}>
{t('flavorDetail.similarHeading')}
</Typography>
</Stack>
<Grid container spacing={2}>
{data.map((item) => (
<Grid key={item.id} size={{ xs: 6, sm: 4, md: 3 }}>
<SimilarCard item={item} />
</Grid>
))}
</Grid>
</Box>
);
};
export default SimilarFlavorsSection;