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

@@ -375,8 +375,6 @@ pub fn parse_csv(content: &str) -> Vec<ParsedRow> {
rows
}
/// Returns a mutable reference to whichever text field currently has focus,
/// or `None` if the active widget is non-textual (e.g. a rating spinner).
fn focused_input(app: &mut App) -> Option<&mut String> {
match &mut app.screen {
Screen::Setup(s) => Some(&mut s.api_url),

View File

@@ -8,7 +8,6 @@ use tui::app::{self, Action, App, BulkImportStage, Command, Screen, SettingsFiel
use tui::client::{ApiClient, ApiError};
use tui::config::Config;
/// Convert an API error into either `AuthExpired` (for 401s) or a `ShowError`.
fn api_err_action(e: ApiError) -> Action {
match e {
ApiError::Unauthorized => Action::AuthExpired,
@@ -27,7 +26,6 @@ fn main() -> anyhow::Result<()> {
async fn run() -> anyhow::Result<()> {
let mut config = Config::load();
// env var override
if let Ok(url) = std::env::var("MOVIES_API_URL") {
match &mut config {
Some(c) => c.api_url = url,
@@ -48,7 +46,6 @@ async fn run() -> anyhow::Result<()> {
let (tx, mut rx) = mpsc::channel::<Action>(64);
let mut terminal = ratatui::init();
// If we start directly in Main (saved token), trigger an initial diary load
if matches!(app.screen, Screen::Main(_))
&& let Some(token) = &saved_token
{
@@ -71,7 +68,6 @@ async fn run() -> anyhow::Result<()> {
loop {
terminal.draw(|f| tui::ui::render(f, &app))?;
// Poll keyboard — non-blocking with short timeout
if event::poll(Duration::from_millis(50))?
&& let Event::Key(key) = event::read()?
{
@@ -89,7 +85,6 @@ async fn run() -> anyhow::Result<()> {
}
}
// Drain async results
while let Ok(action) = rx.try_recv() {
let cmds = app::update(&mut app, action);
for cmd in cmds {
@@ -273,7 +268,6 @@ fn tab_shortcut(code: KeyCode) -> Option<Action> {
}
fn key_to_action(app: &App, key: ratatui::crossterm::event::KeyEvent) -> Option<Action> {
// Ctrl+C always quits
if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('c') {
return Some(Action::Quit);
}

View File

@@ -501,9 +501,6 @@ fn auth_ok_uses_app_api_url_for_main_state() {
// ── parse_csv ─────────────────────────────────────────────────────────────
// CSV column order matches the export format:
// title,year,director,rating,comment,watched_at,external_metadata_id
#[test]
fn parse_csv_valid_row_with_title() {
let csv = "title,year,director,rating,comment,watched_at,external_metadata_id\nThe Matrix,1999,Wachowski,5,,1999-03-31T00:00:00,\n";