feat: always-on Insights — correlation analysis over viewing habits
Some checks failed
CI / Check / Test (push) Failing after 9m16s
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:
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
49
crates/api-types/src/insights.rs
Normal file
49
crates/api-types/src/insights.rs
Normal 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>,
|
||||
}
|
||||
@@ -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::*;
|
||||
|
||||
@@ -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>,
|
||||
}
|
||||
|
||||
|
||||
@@ -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>,
|
||||
}
|
||||
|
||||
|
||||
@@ -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!(
|
||||
|
||||
@@ -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>,
|
||||
|
||||
Reference in New Issue
Block a user