Refactor insights handling and error encoding in presentation layer
- Updated insights analyzer tests to use Utc for date handling. - Added timezone support for insights retrieval, allowing insights to be bucketed by the user's local time. - Introduced `encode_domain_error` for better error handling in API responses. - Modified various handlers to utilize the new error encoding function. - Enhanced insights API to reject unknown time zones and return appropriate errors. - Updated frontend components to handle insights data with timezone awareness. - Improved localization for insights sample counts.
This commit is contained in:
@@ -35,7 +35,7 @@ pub enum Insight {
|
||||
sample_count: u32,
|
||||
},
|
||||
/// The genre's share of watches in one month of the year is `multiplier`
|
||||
/// times the uniform 1/12 baseline.
|
||||
/// times the user's own share of watches in that month.
|
||||
Seasonality {
|
||||
genre: String,
|
||||
/// 1–12
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use chrono::{Datelike, Timelike, Weekday};
|
||||
use chrono::{Datelike, NaiveDateTime, TimeZone, Timelike, Weekday};
|
||||
|
||||
use crate::models::{Insight, WatchRow};
|
||||
|
||||
@@ -21,8 +21,11 @@ const MIN_BASELINE_RATINGS: usize = 10;
|
||||
const MIN_RATING_DELTA: f64 = 0.3;
|
||||
/// Rows a genre needs before month-of-year distribution is more than noise.
|
||||
const MIN_GENRE_SEASON_ROWS: usize = 15;
|
||||
/// Minimum lift over the uniform 1/12 month baseline.
|
||||
/// Minimum lift over the user's own share of watches in that month.
|
||||
const MIN_SEASON_MULTIPLIER: f64 = 1.5;
|
||||
/// Genre watches the peak month needs — a lift built on one or two rows in a
|
||||
/// quiet month is noise, however large the ratio.
|
||||
const MIN_SEASON_PEAK_ROWS: u32 = 4;
|
||||
/// Rows a medium needs before its weekend share means anything.
|
||||
const MIN_MEDIUM_ROWS: usize = 5;
|
||||
/// Minimum lift over the user's own weekend share.
|
||||
@@ -40,15 +43,21 @@ const MAX_PER_CATEGORY: usize = 3;
|
||||
/// Width of the peak-hour window in the rhythm insight.
|
||||
const RHYTHM_WINDOW_HOURS: u32 = 3;
|
||||
|
||||
pub fn build_insights(rows: &[WatchRow]) -> Vec<Insight> {
|
||||
/// `rows` carry `watched_at` in UTC as stored; `tz` is the viewer's zone, so
|
||||
/// months, weekdays and hours are bucketed as the user lived them.
|
||||
pub fn build_insights<Tz: TimeZone>(rows: &[WatchRow], tz: &Tz) -> Vec<Insight> {
|
||||
let mut insights = Vec::new();
|
||||
insights.extend(rating_lifts(rows));
|
||||
insights.extend(seasonalities(rows));
|
||||
insights.extend(medium_timings(rows));
|
||||
insights.extend(rhythm(rows));
|
||||
insights.extend(seasonalities(rows, tz));
|
||||
insights.extend(medium_timings(rows, tz));
|
||||
insights.extend(rhythm(rows, tz));
|
||||
insights
|
||||
}
|
||||
|
||||
fn local<Tz: TimeZone>(tz: &Tz, utc: &NaiveDateTime) -> NaiveDateTime {
|
||||
tz.from_utc_datetime(utc).naive_local()
|
||||
}
|
||||
|
||||
// ── Rating lift ──────────────────────────────────────────────────────────────
|
||||
|
||||
fn rating_lifts(rows: &[WatchRow]) -> Vec<Insight> {
|
||||
@@ -75,20 +84,13 @@ fn rating_lifts(rows: &[WatchRow]) -> Vec<Insight> {
|
||||
if delta.abs() < MIN_RATING_DELTA {
|
||||
return None;
|
||||
}
|
||||
Some((
|
||||
strength(delta.abs(), ratings.len()),
|
||||
genre,
|
||||
genre_avg,
|
||||
Some(Insight::RatingLift {
|
||||
genre: genre.to_string(),
|
||||
genre_avg: round2(genre_avg),
|
||||
user_avg: round2(user_avg),
|
||||
delta,
|
||||
ratings.len(),
|
||||
))
|
||||
})
|
||||
.map(|(_, genre, genre_avg, delta, n)| Insight::RatingLift {
|
||||
genre: genre.to_string(),
|
||||
genre_avg: round2(genre_avg),
|
||||
user_avg: round2(user_avg),
|
||||
delta,
|
||||
sample_count: n as u32,
|
||||
sample_count: ratings.len() as u32,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
@@ -128,50 +130,55 @@ fn avg_rating(rows: &[&WatchRow]) -> f64 {
|
||||
|
||||
// ── Seasonality ──────────────────────────────────────────────────────────────
|
||||
|
||||
fn seasonalities(rows: &[WatchRow]) -> Vec<Insight> {
|
||||
let mut by_genre: HashMap<&str, Vec<u32>> = HashMap::new();
|
||||
fn seasonalities<Tz: TimeZone>(rows: &[WatchRow], tz: &Tz) -> Vec<Insight> {
|
||||
if rows.is_empty() {
|
||||
return vec![];
|
||||
}
|
||||
// The user's own month-of-year distribution is the baseline: a diary
|
||||
// started in October, or a user who simply watches more in winter, must
|
||||
// not make every big genre look seasonal.
|
||||
let mut user_months = [0u32; 12];
|
||||
let mut by_genre: HashMap<&str, [u32; 12]> = HashMap::new();
|
||||
for row in rows {
|
||||
let m = local(tz, &row.watched_at).month0() as usize;
|
||||
user_months[m] += 1;
|
||||
for genre in &row.genres {
|
||||
by_genre
|
||||
.entry(genre)
|
||||
.or_default()
|
||||
.push(row.watched_at.month());
|
||||
by_genre.entry(genre).or_insert([0; 12])[m] += 1;
|
||||
}
|
||||
}
|
||||
let user_total = rows.len() as f64;
|
||||
|
||||
let mut candidates: Vec<Insight> = by_genre
|
||||
.into_iter()
|
||||
.filter(|(_, months)| months.len() >= MIN_GENRE_SEASON_ROWS)
|
||||
.filter_map(|(genre, months)| {
|
||||
let total = months.len() as f64;
|
||||
let mut histogram = [0u32; 12];
|
||||
for &m in &months {
|
||||
histogram[(m - 1) as usize] += 1;
|
||||
.filter_map(|(genre, histogram)| {
|
||||
let genre_total: u32 = histogram.iter().sum();
|
||||
if (genre_total as usize) < MIN_GENRE_SEASON_ROWS {
|
||||
return None;
|
||||
}
|
||||
// Smallest month number wins ties — deterministic output.
|
||||
let (peak_month, peak_count) = histogram
|
||||
.into_iter()
|
||||
// Month with the largest lift over the user's share for that
|
||||
// month; smallest month number wins ties — deterministic output.
|
||||
let (peak_month, multiplier) = histogram
|
||||
.iter()
|
||||
.enumerate()
|
||||
.max_by_key(|(i, c)| (*c, std::cmp::Reverse(*i)))
|
||||
.map(|(i, c)| (i as u32 + 1, c))?;
|
||||
// Baseline is a uniform year: each month expects total/12 watches.
|
||||
let multiplier = round2(f64::from(peak_count) * 12.0 / total);
|
||||
.filter(|(_, c)| **c >= MIN_SEASON_PEAK_ROWS)
|
||||
.map(|(i, &c)| {
|
||||
let genre_share = f64::from(c) / f64::from(genre_total);
|
||||
let user_share = f64::from(user_months[i]) / user_total;
|
||||
(i as u32 + 1, round2(genre_share / user_share))
|
||||
})
|
||||
.fold(None, |best: Option<(u32, f64)>, cur| match best {
|
||||
Some(b) if b.1 >= cur.1 => Some(b),
|
||||
_ => Some(cur),
|
||||
})?;
|
||||
if multiplier < MIN_SEASON_MULTIPLIER {
|
||||
return None;
|
||||
}
|
||||
Some((
|
||||
strength(multiplier - 1.0, months.len()),
|
||||
genre,
|
||||
peak_month,
|
||||
Some(Insight::Seasonality {
|
||||
genre: genre.to_string(),
|
||||
month: peak_month,
|
||||
multiplier,
|
||||
months.len(),
|
||||
))
|
||||
})
|
||||
.map(|(_, genre, month, multiplier, n)| Insight::Seasonality {
|
||||
genre: genre.to_string(),
|
||||
month,
|
||||
multiplier,
|
||||
sample_count: n as u32,
|
||||
sample_count: genre_total,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
@@ -182,7 +189,7 @@ fn seasonalities(rows: &[WatchRow]) -> Vec<Insight> {
|
||||
|
||||
// ── Medium timing ────────────────────────────────────────────────────────────
|
||||
|
||||
fn medium_timings(rows: &[WatchRow]) -> Vec<Insight> {
|
||||
fn medium_timings<Tz: TimeZone>(rows: &[WatchRow], tz: &Tz) -> Vec<Insight> {
|
||||
if rows.is_empty() {
|
||||
return vec![];
|
||||
}
|
||||
@@ -196,7 +203,10 @@ fn medium_timings(rows: &[WatchRow]) -> Vec<Insight> {
|
||||
let mut by_medium: HashMap<&str, (usize, usize)> = HashMap::new();
|
||||
let mut weekend_total = 0usize;
|
||||
for row in rows.iter().filter(|r| r.watch_medium.is_some()) {
|
||||
let weekend = matches!(row.watched_at.weekday(), Weekday::Sat | Weekday::Sun);
|
||||
let weekend = matches!(
|
||||
local(tz, &row.watched_at).weekday(),
|
||||
Weekday::Sat | Weekday::Sun
|
||||
);
|
||||
weekend_total += usize::from(weekend);
|
||||
let entry = by_medium
|
||||
.entry(row.watch_medium.as_deref().expect("filtered above"))
|
||||
@@ -220,23 +230,14 @@ fn medium_timings(rows: &[WatchRow]) -> Vec<Insight> {
|
||||
if multiplier < MIN_MEDIUM_MULTIPLIER {
|
||||
return None;
|
||||
}
|
||||
Some((
|
||||
strength(multiplier - 1.0, count),
|
||||
medium,
|
||||
weekend_share,
|
||||
multiplier,
|
||||
count,
|
||||
))
|
||||
})
|
||||
.map(
|
||||
|(_, medium, weekend_share, multiplier, n)| Insight::MediumTiming {
|
||||
Some(Insight::MediumTiming {
|
||||
medium: medium.to_string(),
|
||||
multiplier,
|
||||
weekend_share: round2(weekend_share),
|
||||
baseline_share: round2(baseline_share),
|
||||
sample_count: n as u32,
|
||||
},
|
||||
)
|
||||
sample_count: count as u32,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
candidates.sort_by(strongest_first);
|
||||
@@ -246,10 +247,14 @@ fn medium_timings(rows: &[WatchRow]) -> Vec<Insight> {
|
||||
|
||||
// ── Rhythm ───────────────────────────────────────────────────────────────────
|
||||
|
||||
fn rhythm(rows: &[WatchRow]) -> Vec<Insight> {
|
||||
let real: Vec<&WatchRow> = rows
|
||||
fn rhythm<Tz: TimeZone>(rows: &[WatchRow], tz: &Tz) -> Vec<Insight> {
|
||||
// Import noise is detected on the stored value: date-only imports land at
|
||||
// exactly 00:00 as stored, which no longer reads as midnight once shifted
|
||||
// into the viewer's zone.
|
||||
let real: Vec<NaiveDateTime> = rows
|
||||
.iter()
|
||||
.filter(|r| !(r.watched_at.hour() == 0 && r.watched_at.minute() == 0))
|
||||
.map(|r| local(tz, &r.watched_at))
|
||||
.collect();
|
||||
let midnight_share = 1.0 - real.len() as f64 / rows.len().max(1) as f64;
|
||||
if rows.is_empty() || real.len() < MIN_RHYTHM_ROWS || midnight_share > MAX_MIDNIGHT_SHARE {
|
||||
@@ -258,9 +263,9 @@ fn rhythm(rows: &[WatchRow]) -> Vec<Insight> {
|
||||
|
||||
let mut hours = [0u32; 24];
|
||||
let mut days = [0u32; 7]; // index 0 = Monday (chrono weekday number − 1)
|
||||
for row in &real {
|
||||
hours[row.watched_at.hour() as usize] += 1;
|
||||
days[row.watched_at.weekday().number_from_monday() as usize - 1] += 1;
|
||||
for at in &real {
|
||||
hours[at.hour() as usize] += 1;
|
||||
days[at.weekday().number_from_monday() as usize - 1] += 1;
|
||||
}
|
||||
|
||||
// Circular 3-hour window with the most watches; smallest start hour wins ties.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use chrono::{Datelike, NaiveDate, Weekday};
|
||||
use chrono::{Datelike, FixedOffset, NaiveDate, Utc, Weekday};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::models::{Insight, WatchRow};
|
||||
@@ -41,7 +41,7 @@ fn rating_lift_compares_genre_to_personal_average() {
|
||||
.collect();
|
||||
rows.extend((0..12).map(|i| watch(&["Comedy"], 2, date(2024, 3, 1 + i), 20)));
|
||||
|
||||
let insights = build_insights(&rows);
|
||||
let insights = build_insights(&rows, &Utc);
|
||||
let thrill = insights.iter().find_map(|i| match i {
|
||||
Insight::RatingLift {
|
||||
genre,
|
||||
@@ -81,7 +81,7 @@ fn rating_lift_ignores_rewatches() {
|
||||
rows.push(rewatch);
|
||||
}
|
||||
|
||||
let insights = build_insights(&rows);
|
||||
let insights = build_insights(&rows, &Utc);
|
||||
let thrill = insights.iter().find_map(|i| match i {
|
||||
Insight::RatingLift {
|
||||
genre,
|
||||
@@ -107,7 +107,7 @@ fn rating_lift_below_sample_threshold_is_absent() {
|
||||
// high they are rated, the genre must not yield an insight.
|
||||
rows.extend((0..4).map(|i| watch(&["Thriller"], 5, date(2024, 2, 1 + i), 20)));
|
||||
|
||||
let insights = build_insights(&rows);
|
||||
let insights = build_insights(&rows, &Utc);
|
||||
assert!(
|
||||
!insights
|
||||
.iter()
|
||||
@@ -126,14 +126,14 @@ fn rating_lift_below_delta_threshold_is_absent() {
|
||||
rows.extend((0..3).map(|i| watch(&["Drama"], 3, date(2024, 1, 13 + i), 20)));
|
||||
rows.extend((0..5).map(|i| watch(&["Thriller"], 4, date(2024, 2, 1 + i), 20)));
|
||||
|
||||
let insights = build_insights(&rows);
|
||||
let insights = build_insights(&rows, &Utc);
|
||||
assert!(!kinds(&insights).contains(&"rating_lift"));
|
||||
}
|
||||
|
||||
// ── Seasonality ──────────────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn seasonality_detects_month_peak_against_uniform_baseline() {
|
||||
fn seasonality_detects_month_peak_against_own_monthly_baseline() {
|
||||
// 16 horrors: 10 in October across two years, 6 scattered.
|
||||
let mut rows: Vec<WatchRow> = Vec::new();
|
||||
for i in 0..10u32 {
|
||||
@@ -146,7 +146,7 @@ fn seasonality_detects_month_peak_against_uniform_baseline() {
|
||||
// A perfectly spread genre (2 watches per month) must NOT appear.
|
||||
rows.extend((0..24).map(|i| watch(&["Drama"], 3, date(2024, 1 + i % 12, 5), 21)));
|
||||
|
||||
let insights = build_insights(&rows);
|
||||
let insights = build_insights(&rows, &Utc);
|
||||
let horror = insights.iter().find_map(|i| match i {
|
||||
Insight::Seasonality {
|
||||
genre,
|
||||
@@ -159,9 +159,10 @@ fn seasonality_detects_month_peak_against_uniform_baseline() {
|
||||
});
|
||||
let (month, multiplier, count) = horror.expect("Horror seasonality expected");
|
||||
assert_eq!(month, 10);
|
||||
// 10 of 16 watches in one month → (10/16)·12 = 7.5× the uniform baseline.
|
||||
// Horror: 10 of 16 watches in October (62.5%); the whole diary puts
|
||||
// 12 of 40 watches there (30%) → 0.625 / 0.3 ≈ 2.08× the user's own share.
|
||||
assert!(
|
||||
(multiplier - 7.5).abs() < 1e-9,
|
||||
(multiplier - 2.08).abs() < 1e-9,
|
||||
"multiplier was {multiplier}"
|
||||
);
|
||||
assert_eq!(count, 16);
|
||||
@@ -174,12 +175,26 @@ fn seasonality_detects_month_peak_against_uniform_baseline() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn seasonality_ignores_when_the_diary_itself_is_concentrated() {
|
||||
// A diary started in October: every genre peaks in Oct/Nov only because
|
||||
// the user logged nothing else. Relative to their own months, nothing is
|
||||
// seasonal.
|
||||
let mut rows: Vec<WatchRow> = Vec::new();
|
||||
for i in 0..20u32 {
|
||||
let month = if i % 2 == 0 { 10 } else { 11 };
|
||||
rows.push(watch(&["Drama"], 3, date(2024, month, 1 + i), 21));
|
||||
rows.push(watch(&["Comedy"], 3, date(2024, month, 1 + i), 21));
|
||||
}
|
||||
assert!(!kinds(&build_insights(&rows, &Utc)).contains(&"seasonality"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn seasonality_below_sample_threshold_is_absent() {
|
||||
let rows: Vec<WatchRow> = (0..14)
|
||||
.map(|i| watch(&["Horror"], 4, date(2024, 10, 1 + i), 21))
|
||||
.collect();
|
||||
assert!(!kinds(&build_insights(&rows)).contains(&"seasonality"));
|
||||
assert!(!kinds(&build_insights(&rows, &Utc)).contains(&"seasonality"));
|
||||
}
|
||||
|
||||
// ── Medium timing ────────────────────────────────────────────────────────────
|
||||
@@ -216,7 +231,7 @@ fn medium_timing_detects_weekend_lift() {
|
||||
rows.push(row);
|
||||
}
|
||||
|
||||
let insights = build_insights(&rows);
|
||||
let insights = build_insights(&rows, &Utc);
|
||||
let cinema = insights.iter().find_map(|i| match i {
|
||||
Insight::MediumTiming {
|
||||
medium,
|
||||
@@ -255,7 +270,7 @@ fn medium_timing_skipped_when_medium_mostly_missing() {
|
||||
rows.push(row);
|
||||
}
|
||||
|
||||
assert!(!kinds(&build_insights(&rows)).contains(&"medium_timing"));
|
||||
assert!(!kinds(&build_insights(&rows, &Utc)).contains(&"medium_timing"));
|
||||
}
|
||||
|
||||
// ── Rhythm ───────────────────────────────────────────────────────────────────
|
||||
@@ -280,7 +295,7 @@ fn rhythm_finds_peak_window_and_days() {
|
||||
}
|
||||
}
|
||||
|
||||
let insights = build_insights(&rows);
|
||||
let insights = build_insights(&rows, &Utc);
|
||||
let rhythm = insights.iter().find_map(|i| match i {
|
||||
Insight::Rhythm {
|
||||
peak_hour_start,
|
||||
@@ -305,6 +320,61 @@ fn rhythm_finds_peak_window_and_days() {
|
||||
assert_eq!(count, 12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rhythm_buckets_in_the_viewers_time_zone() {
|
||||
// 12 watches stored at 22:30 UTC on Tue/Wed/Thu are 00:30 the next day
|
||||
// for a viewer at UTC+2.
|
||||
let first_tuesday = date(2024, 3, 5);
|
||||
let mut rows: Vec<WatchRow> = Vec::new();
|
||||
for day in 0..3u32 {
|
||||
for week in 0..(5 - day) {
|
||||
rows.push(watch(
|
||||
&["Drama"],
|
||||
4,
|
||||
first_tuesday
|
||||
+ chrono::Duration::days(i64::from(day))
|
||||
+ chrono::Duration::weeks(i64::from(week)),
|
||||
22,
|
||||
));
|
||||
}
|
||||
}
|
||||
let plus_two = FixedOffset::east_opt(2 * 3600).unwrap();
|
||||
|
||||
let insights = build_insights(&rows, &plus_two);
|
||||
let (start, end, days) = insights
|
||||
.iter()
|
||||
.find_map(|i| match i {
|
||||
Insight::Rhythm {
|
||||
peak_hour_start,
|
||||
peak_hour_end,
|
||||
top_days,
|
||||
..
|
||||
} => Some((*peak_hour_start, *peak_hour_end, top_days.clone())),
|
||||
_ => None,
|
||||
})
|
||||
.expect("rhythm expected");
|
||||
// Local hour 0; the scan starts at 0, so the first window covering it is 0–3.
|
||||
assert_eq!((start, end), (0, 3));
|
||||
assert_eq!(days, vec![3, 4, 5], "Wed=3, Thu=4, Fri=5 locally");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rhythm_import_noise_detected_before_zone_shift() {
|
||||
// Date-only imports are stored at 00:00 UTC; at UTC+2 they read 02:00,
|
||||
// but they are still noise.
|
||||
let rows: Vec<WatchRow> = (0..12u32)
|
||||
.map(|i| WatchRow {
|
||||
movie_id: Uuid::new_v4(),
|
||||
rating: 4,
|
||||
watched_at: date(2024, 1, 1 + i).and_hms_opt(0, 0, 0).unwrap(),
|
||||
watch_medium: None,
|
||||
genres: vec!["Drama".to_string()],
|
||||
})
|
||||
.collect();
|
||||
let plus_two = FixedOffset::east_opt(2 * 3600).unwrap();
|
||||
assert!(!kinds(&build_insights(&rows, &plus_two)).contains(&"rhythm"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rhythm_skipped_when_timestamps_are_import_noise() {
|
||||
let mut rows: Vec<WatchRow> = Vec::new();
|
||||
@@ -320,14 +390,14 @@ fn rhythm_skipped_when_timestamps_are_import_noise() {
|
||||
rows.push(row);
|
||||
}
|
||||
|
||||
assert!(!kinds(&build_insights(&rows)).contains(&"rhythm"));
|
||||
assert!(!kinds(&build_insights(&rows, &Utc)).contains(&"rhythm"));
|
||||
}
|
||||
|
||||
// ── Overall ──────────────────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn empty_diary_yields_nothing() {
|
||||
assert!(build_insights(&[]).is_empty());
|
||||
assert!(build_insights(&[], &Utc).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -335,7 +405,7 @@ fn small_diary_yields_nothing() {
|
||||
let rows: Vec<WatchRow> = (0..4)
|
||||
.map(|i| watch(&["Drama"], 4, date(2024, 1, 1 + i), 20))
|
||||
.collect();
|
||||
assert!(build_insights(&rows).is_empty());
|
||||
assert!(build_insights(&rows, &Utc).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -356,7 +426,7 @@ fn max_three_insights_per_category() {
|
||||
// Baseline dramas rated 1 make every lift large.
|
||||
rows.extend((0..10).map(|i| watch(&["Drama"], 1, date(2024, 3, 1 + i), 20)));
|
||||
|
||||
let insights = build_insights(&rows);
|
||||
let insights = build_insights(&rows, &Utc);
|
||||
let rating_lifts = insights
|
||||
.iter()
|
||||
.filter(|i| matches!(i, Insight::RatingLift { .. }))
|
||||
|
||||
Reference in New Issue
Block a user