feat: always-on Insights — correlation analysis over viewing habits
Some checks failed
CI / Check / Test (push) Failing after 9m16s

Conservative, live insights (ADR-0010) derived from each user's diary:
rating lift by genre (first-time watches only), genre seasonality vs a
uniform 1/12 month baseline, medium weekend-lift, and watch-time rhythm.

- domain: WatchRow model, InsightsQuery port, pure insights_analyzer with
  all honesty thresholds (sample minimums, strength cutoffs, suppressions
  for import noise), unit-tested
- adapters: thin Sqlite/Postgres fetch twins, reusing WrapUp's genre fetch
- application: get_insights use case, InsightsGroup in Deps
- composition/server: wiring through Repositories/DatabaseOutput
- presentation: GET /api/v1/insights handler, typed serde-tagged DTOs,
  openapi doc
- spa: /insights route (shadcn cards), insights feature hook, profile
  entry link, en locale strings
- server test: end-to-end seed sqlite → HTTP → rating lift + rhythm

Also records ADR-0010, defines "Insight" in the glossary, and includes
pre-existing worktree comment-stripping cleanups.
This commit is contained in:
2026-10-12 19:29:54 +02:00
parent 4d221e60de
commit 020fbb891e
126 changed files with 1922 additions and 639 deletions

View File

@@ -60,7 +60,6 @@ pub struct FeedEntryDto {
#[derive(Debug, Clone, Deserialize, utoipa::IntoParams)]
#[into_params(parameter_in = Query)]
pub struct ExportQueryParams {
/// Output format: `csv` (default) or `json`
#[serde(default = "default_export_format")]
pub format: String,
}

View File

@@ -17,13 +17,9 @@ pub struct SessionStateResponse {
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct ApiFieldMapping {
/// Column name in the source file
pub source_column: String,
/// Domain field: title | release_year | director | rating | watched_at | comment | external_metadata_id
pub domain_field: String,
/// For rating fields: multiply raw value by this factor (e.g. 0.5 for 10-point → 5-point scale)
pub rating_scale: Option<f64>,
/// For watched_at fields: strftime format hint (e.g. "%d/%m/%Y")
pub date_format: Option<String>,
}
@@ -34,15 +30,12 @@ pub struct ApplyMappingRequest {
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct ConfirmRequest {
/// Indices (0-based) of rows from the mapping preview to import
pub confirmed_indices: Vec<usize>,
}
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct SaveProfileRequest {
/// Session UUID whose current field_mappings to save
pub session_id: String,
/// Human-readable profile name (e.g. "Letterboxd")
pub name: String,
}

View File

@@ -0,0 +1,49 @@
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
/// One conservative, baseline-relative statement about the user's viewing
/// behavior. Tagged by `kind` so the SPA can switch on it; every variant
/// carries `sample_count` so the UI can explain a thin sample.
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum InsightDto {
/// `genre`'s average rating sits `delta` stars from the user's own
/// average (first-time watches only).
RatingLift {
genre: String,
genre_avg: f64,
user_avg: f64,
delta: f64,
sample_count: u32,
},
/// `genre`'s share of watches in `month` (1–12) is `multiplier` times the
/// uniform 1/12 baseline.
Seasonality {
genre: String,
month: u32,
multiplier: f64,
sample_count: u32,
},
/// `medium`'s weekend share of watches is `multiplier` times the user's
/// overall weekend share.
MediumTiming {
medium: String,
multiplier: f64,
weekend_share: f64,
baseline_share: f64,
sample_count: u32,
},
/// Watching concentrates in a 3-hour window starting `peak_hour_start`
/// (0–23) on `top_days` (Mon=1 … Sun=7).
Rhythm {
peak_hour_start: u32,
peak_hour_end: u32,
top_days: Vec<u32>,
sample_count: u32,
},
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct InsightsResponse {
pub insights: Vec<InsightDto>,
}

View File

@@ -3,6 +3,7 @@ pub mod common;
pub mod diary;
pub mod goals;
pub mod import;
pub mod insights;
pub mod movies;
pub mod rendering;
pub mod search;
@@ -17,6 +18,7 @@ pub use common::*;
pub use diary::*;
pub use goals::*;
pub use import::*;
pub use insights::*;
pub use movies::*;
pub use rendering::*;
pub use social::*;

View File

@@ -10,11 +10,8 @@ use crate::common::SocialFeedResponse;
pub struct MoviesQueryParams {
pub limit: Option<u32>,
pub offset: Option<u32>,
/// Optional title filter (case-insensitive substring match)
pub search: Option<String>,
/// Filter by genre name (case-insensitive)
pub genre: Option<String>,
/// Filter by ISO language code (e.g. "en")
pub language: Option<String>,
}

View File

@@ -6,21 +6,13 @@ pub use crate::common::{PaginatedMovieHits, PaginatedPersonHits};
#[derive(Debug, Deserialize, IntoParams)]
pub struct SearchQueryParams {
/// Free-text query matched across title, cast, crew, genres and keywords.
pub q: Option<String>,
/// Filter by genre name (exact match, case-sensitive).
pub genre: Option<String>,
/// Filter by release year.
pub year: Option<u16>,
/// Filter by person ID (UUID).
pub person_id: Option<Uuid>,
/// Filter crew results by department (e.g. "Directing", "Writing").
pub department: Option<String>,
/// Filter by original language code (e.g. "en", "fr").
pub language: Option<String>,
/// Max results to return (default 20).
pub limit: Option<u32>,
/// Offset for pagination (default 0).
pub offset: Option<u32>,
}

View File

@@ -16,7 +16,6 @@ pub struct RemoteActorDto {
pub handle: String,
pub display_name: Option<String>,
pub url: String,
/// `Some` for local actors, so the SPA can link internally to `/users/{id}`.
pub user_id: Option<Uuid>,
pub avatar_url: Option<String>,
}
@@ -79,12 +78,11 @@ pub struct PendingCountResponse {
pub count: usize,
}
//todo: move out to separate file
#[cfg(test)]
mod tests {
use super::*;
/// Task 7's SPA zod schema parses these exact lowercase literals —
/// a casing or naming drift here breaks the SPA at runtime.
#[test]
fn follow_state_dto_serializes_to_lowercase_strings() {
assert_eq!(

View File

@@ -23,7 +23,6 @@ pub struct UsersResponse {
#[derive(Debug, Clone, Deserialize, utoipa::IntoParams)]
#[into_params(parameter_in = Query)]
pub struct UserProfileQueryParams {
/// One of: `recent` (default), `ratings`, `history`, `trends`
pub view: Option<String>,
pub limit: Option<u32>,
pub offset: Option<u32>,