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

+64 -1
View File
@@ -159,14 +159,77 @@ pub struct PublicProfileOut {
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.
#[derive(Serialize, ToSchema)]
pub struct DashboardOut {
pub user: UserOut,
pub rated_count: i64,
pub missing_count: i64,
pub missing_flavors: Vec<FlavorOut>,
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)]
+1
View File
@@ -17,6 +17,7 @@ mod openapi;
mod pagination;
mod password;
mod password_policy;
mod recommend;
mod routes;
mod search;
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 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<AppState> {
Router::new()
.route("/flavors/", get(list))
@@ -28,6 +32,7 @@ pub fn router() -> Router<AppState> {
.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<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)]
struct CategoryParam {
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::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<AppState> {
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<Json<DashboardOut>> {
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<i32> =
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<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?;
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<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");
}
}