Refactor insights handling and error encoding in presentation layer
All checks were successful
CI / Check / Test (push) Successful in 1h12m41s
CI / Check / Test (pull_request) Successful in 1h6m20s

- 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:
2026-10-12 20:44:54 +02:00
parent ee06f4794c
commit 5cb6fd5fbb
28 changed files with 510 additions and 176 deletions

View File

@@ -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

View File

@@ -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.

View File

@@ -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 { .. }))