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

@@ -1,5 +1,6 @@
{
"lockfileVersion": 1,
"configVersion": 0,
"workspaces": {
"": {
"name": "vite-app",
@@ -18,6 +19,7 @@
"cmdk": "^1.1.1",
"date-fns": "^4.4.0",
"embla-carousel-react": "^8.6.0",
"html2canvas-pro": "^2.0.4",
"i18next": "^26.3.1",
"input-otp": "^1.4.2",
"lucide-react": "^1.17.0",
@@ -844,6 +846,8 @@
"html-parse-stringify": ["html-parse-stringify@3.0.1", "", { "dependencies": { "void-elements": "3.1.0" } }, "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg=="],
"html2canvas-pro": ["html2canvas-pro@2.5.2", "", {}, "sha512-uka+0oVG/yB4B7U9M/+aJFF0aKZVmMWZKi4unemkQh537cLu3jXPvZtXakTuPl3taOI+O43RhuV6kn2igk1Z6g=="],
"http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="],
"https-proxy-agent": ["https-proxy-agent@5.0.1", "", { "dependencies": { "agent-base": "6", "debug": "4" } }, "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA=="],

View File

@@ -0,0 +1,61 @@
import { useQuery } from "@tanstack/react-query"
import { get } from "@/lib/api/client"
export type RatingLiftInsight = {
kind: "rating_lift"
genre: string
genre_avg: number
user_avg: number
delta: number
sample_count: number
}
export type SeasonalityInsight = {
kind: "seasonality"
genre: string
month: number
multiplier: number
sample_count: number
}
export type MediumTimingInsight = {
kind: "medium_timing"
medium: string
multiplier: number
weekend_share: number
baseline_share: number
sample_count: number
}
export type RhythmInsight = {
kind: "rhythm"
peak_hour_start: number
peak_hour_end: number
top_days: number[]
sample_count: number
}
export type Insight =
| RatingLiftInsight
| SeasonalityInsight
| MediumTimingInsight
| RhythmInsight
export type InsightsResponse = {
insights: Insight[]
}
export const insightsKeys = {
all: ["insights"] as const,
}
function getInsights() {
return get<InsightsResponse>("/insights")
}
export function useInsights() {
return useQuery({
queryKey: insightsKeys.all,
queryFn: getInsights,
})
}

View File

@@ -258,6 +258,25 @@
"plexStep2": "Add the URL above, replacing YOUR_TOKEN with your generated token",
"plexStep3": "Plex sends scrobble events when a movie is watched to 90%+ (requires Plex Pass)"
},
"insights": {
"title": "Insights",
"subtitle": "Patterns your diary reveals",
"emptyTitle": "No insights yet",
"emptyDesc": "Insights appear once there is enough data to say something honest — keep logging films.",
"taste": "Your taste",
"seasons": "Seasons",
"mediums": "How you watch",
"rhythm": "When you watch",
"ratingLiftUp": "You rate {{genre}} higher than your own average — {{genreAvg}}★ vs {{userAvg}}★",
"ratingLiftDown": "You rate {{genre}} lower than your own average — {{genreAvg}}★ vs {{userAvg}}★",
"yourAverage": "your average",
"seasonality": "{{genre}} is your {{month}} genre — {{multiplier}}× your yearly baseline",
"mediumTiming": "You watch {{medium}} on weekends {{multiplier}}× more than your baseline",
"weekends": "weekends",
"yourBaseline": "your baseline",
"rhythm": "You watch mostly {{start}}–{{end}} on {{days}}",
"filmsSample": "{{count}} films"
},
"wrapup": {
"title": "Year Wrap-Up",
"noWrapUps": "No wrap-ups",

View File

@@ -16,6 +16,7 @@ import { Route as AppIndexRouteImport } from './routes/_app/index'
import { Route as AppSocialRouteImport } from './routes/_app/social'
import { Route as AppSearchRouteImport } from './routes/_app/search'
import { Route as AppProfileRouteImport } from './routes/_app/profile'
import { Route as AppInsightsRouteImport } from './routes/_app/insights'
import { Route as AppDiaryRouteImport } from './routes/_app/diary'
import { Route as AppSettingsIndexRouteImport } from './routes/_app/settings/index'
import { Route as AppWrapupIdRouteImport } from './routes/_app/wrapup.$id'
@@ -63,6 +64,11 @@ const AppProfileRoute = AppProfileRouteImport.update({
path: '/profile',
getParentRoute: () => AppRoute,
} as any)
const AppInsightsRoute = AppInsightsRouteImport.update({
id: '/insights',
path: '/insights',
getParentRoute: () => AppRoute,
} as any)
const AppDiaryRoute = AppDiaryRouteImport.update({
id: '/diary',
path: '/diary',
@@ -129,6 +135,7 @@ export interface FileRoutesByFullPath {
'/login': typeof LoginRoute
'/register': typeof RegisterRoute
'/diary': typeof AppDiaryRoute
'/insights': typeof AppInsightsRoute
'/profile': typeof AppProfileRoute
'/search': typeof AppSearchRoute
'/social': typeof AppSocialRoute
@@ -148,6 +155,7 @@ export interface FileRoutesByTo {
'/login': typeof LoginRoute
'/register': typeof RegisterRoute
'/diary': typeof AppDiaryRoute
'/insights': typeof AppInsightsRoute
'/profile': typeof AppProfileRoute
'/search': typeof AppSearchRoute
'/social': typeof AppSocialRoute
@@ -170,6 +178,7 @@ export interface FileRoutesById {
'/login': typeof LoginRoute
'/register': typeof RegisterRoute
'/_app/diary': typeof AppDiaryRoute
'/_app/insights': typeof AppInsightsRoute
'/_app/profile': typeof AppProfileRoute
'/_app/search': typeof AppSearchRoute
'/_app/social': typeof AppSocialRoute
@@ -193,6 +202,7 @@ export interface FileRouteTypes {
| '/login'
| '/register'
| '/diary'
| '/insights'
| '/profile'
| '/search'
| '/social'
@@ -212,6 +222,7 @@ export interface FileRouteTypes {
| '/login'
| '/register'
| '/diary'
| '/insights'
| '/profile'
| '/search'
| '/social'
@@ -233,6 +244,7 @@ export interface FileRouteTypes {
| '/login'
| '/register'
| '/_app/diary'
| '/_app/insights'
| '/_app/profile'
| '/_app/search'
| '/_app/social'
@@ -307,6 +319,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AppProfileRouteImport
parentRoute: typeof AppRoute
}
'/_app/insights': {
id: '/_app/insights'
path: '/insights'
fullPath: '/insights'
preLoaderRoute: typeof AppInsightsRouteImport
parentRoute: typeof AppRoute
}
'/_app/diary': {
id: '/_app/diary'
path: '/diary'
@@ -396,6 +415,7 @@ declare module '@tanstack/react-router' {
interface AppRouteChildren {
AppDiaryRoute: typeof AppDiaryRoute
AppInsightsRoute: typeof AppInsightsRoute
AppProfileRoute: typeof AppProfileRoute
AppSearchRoute: typeof AppSearchRoute
AppSocialRoute: typeof AppSocialRoute
@@ -415,6 +435,7 @@ interface AppRouteChildren {
const AppRouteChildren: AppRouteChildren = {
AppDiaryRoute: AppDiaryRoute,
AppInsightsRoute: AppInsightsRoute,
AppProfileRoute: AppProfileRoute,
AppSearchRoute: AppSearchRoute,
AppSocialRoute: AppSocialRoute,

View File

@@ -0,0 +1,263 @@
import { createFileRoute } from "@tanstack/react-router"
import type { ReactNode } from "react"
import { useTranslation } from "react-i18next"
import {
CalendarDays,
Clapperboard,
Clock,
Lightbulb,
Sparkles,
TrendingDown,
TrendingUp,
} from "lucide-react"
import { BackButton } from "@/components/back-button"
import { Badge } from "@/components/ui/badge"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { EmptyState } from "@/components/empty-state"
import { Skeleton } from "@/components/ui/skeleton"
import { WATCH_MEDIUMS } from "@/lib/watch-mediums"
import { useInsights, type Insight } from "@/features/insights"
import { useDocumentTitle } from "@/hooks/use-document-title"
export const Route = createFileRoute("/_app/insights")({
component: InsightsPage,
})
const MONTH_BASE = new Date(2024, 0, 1) // any year; only the month name matters
const MONDAY_BASE = new Date(2024, 2, 4) // 2024-03-04 is a Monday
function isKind<K extends Insight["kind"]>(kind: K) {
return (insight: Insight): insight is Extract<Insight, { kind: K }> => insight.kind === kind
}
function monthName(month: number): string {
return new Intl.DateTimeFormat(undefined, { month: "long" }).format(
new Date(MONTH_BASE.getFullYear(), month - 1, 1),
)
}
function dayNames(days: number[]): string[] {
const fmt = new Intl.DateTimeFormat(undefined, { weekday: "short" })
return days.map((d) => fmt.format(new Date(MONDAY_BASE.getFullYear(), MONDAY_BASE.getMonth(), MONDAY_BASE.getDate() + d - 1)))
}
function mediumLabel(medium: string, t: (key: string) => string): string {
const def = WATCH_MEDIUMS.find((m) => m.value === medium)
return def ? t(def.labelKey) : medium
}
function hourLabel(hour: number): string {
return `${String(hour).padStart(2, "0")}:00`
}
function InsightsPage() {
const { t } = useTranslation()
const { data, isPending } = useInsights()
useDocumentTitle(t("insights.title"))
if (isPending) return <InsightsSkeleton />
const insights = data?.insights ?? []
return (
<div className="space-y-4 p-4">
<BackButton />
<div>
<h1 className="flex items-center gap-2 text-lg font-bold">
<Sparkles className="size-5 text-primary" />
{t("insights.title")}
</h1>
<p className="text-sm text-muted-foreground">{t("insights.subtitle")}</p>
</div>
{insights.length === 0 ? (
<EmptyState
icon={Lightbulb}
title={t("insights.emptyTitle")}
description={t("insights.emptyDesc")}
/>
) : (
<>
<InsightGroup
title={t("insights.taste")}
items={insights.filter(isKind("rating_lift"))}
render={(insight) => <RatingLiftCard insight={insight} />}
/>
<InsightGroup
title={t("insights.seasons")}
items={insights.filter(isKind("seasonality"))}
render={(insight) => <SeasonalityCard insight={insight} />}
/>
<InsightGroup
title={t("insights.mediums")}
items={insights.filter(isKind("medium_timing"))}
render={(insight) => <MediumTimingCard insight={insight} />}
/>
<InsightGroup
title={t("insights.rhythm")}
items={insights.filter(isKind("rhythm"))}
render={(insight) => <RhythmCard insight={insight} />}
/>
</>
)}
</div>
)
}
function InsightGroup<T extends Insight>({
title,
items,
render,
}: {
title: string
items: T[]
render: (insight: T) => ReactNode
}) {
if (items.length === 0) return null
return (
<section className="space-y-2">
<h2 className="text-sm font-semibold text-muted-foreground">{title}</h2>
{items.map((insight, i) => (
<div key={i}>{render(insight)}</div>
))}
</section>
)
}
type RatingLiftCardProps = { insight: Extract<Insight, { kind: "rating_lift" }> }
type SeasonalityCardProps = { insight: Extract<Insight, { kind: "seasonality" }> }
type MediumTimingCardProps = { insight: Extract<Insight, { kind: "medium_timing" }> }
type RhythmCardProps = { insight: Extract<Insight, { kind: "rhythm" }> }
function RatingLiftCard({ insight }: RatingLiftCardProps) {
const { t } = useTranslation()
const up = insight.delta > 0
const Trend = up ? TrendingUp : TrendingDown
return (
<Card>
<CardHeader className="pb-2">
<CardTitle className="flex items-center gap-2 text-sm">
<Trend className={`size-4 ${up ? "text-emerald-500" : "text-rose-500"}`} />
{t(up ? "insights.ratingLiftUp" : "insights.ratingLiftDown", {
genre: insight.genre,
genreAvg: insight.genre_avg.toFixed(1),
userAvg: insight.user_avg.toFixed(1),
})}
</CardTitle>
</CardHeader>
<CardContent>
<div className="flex flex-wrap gap-2">
<Badge variant="secondary">
{insight.genre_avg.toFixed(1)}★ · {insight.genre}
</Badge>
<Badge variant="outline">
{t("insights.yourAverage")}: {insight.user_avg.toFixed(1)}★
</Badge>
<SampleBadge count={insight.sample_count} />
</div>
</CardContent>
</Card>
)
}
function SeasonalityCard({ insight }: SeasonalityCardProps) {
const { t } = useTranslation()
return (
<Card>
<CardHeader className="pb-2">
<CardTitle className="flex items-center gap-2 text-sm">
<CalendarDays className="size-4 text-primary" />
{t("insights.seasonality", {
genre: insight.genre,
multiplier: insight.multiplier.toFixed(1),
month: monthName(insight.month),
})}
</CardTitle>
</CardHeader>
<CardContent>
<div className="flex flex-wrap gap-2">
<Badge variant="secondary">
{insight.multiplier.toFixed(1)}× · {monthName(insight.month)}
</Badge>
<Badge variant="outline">{insight.genre}</Badge>
<SampleBadge count={insight.sample_count} />
</div>
</CardContent>
</Card>
)
}
function MediumTimingCard({ insight }: MediumTimingCardProps) {
const { t } = useTranslation()
return (
<Card>
<CardHeader className="pb-2">
<CardTitle className="flex items-center gap-2 text-sm">
<Clapperboard className="size-4 text-primary" />
{t("insights.mediumTiming", {
medium: mediumLabel(insight.medium, t),
multiplier: insight.multiplier.toFixed(1),
})}
</CardTitle>
</CardHeader>
<CardContent>
<div className="flex flex-wrap gap-2">
<Badge variant="secondary">
{Math.round(insight.weekend_share * 100)}% · {t("insights.weekends")}
</Badge>
<Badge variant="outline">
{t("insights.yourBaseline")}: {Math.round(insight.baseline_share * 100)}%
</Badge>
<SampleBadge count={insight.sample_count} />
</div>
</CardContent>
</Card>
)
}
function RhythmCard({ insight }: RhythmCardProps) {
const { t } = useTranslation()
return (
<Card>
<CardHeader className="pb-2">
<CardTitle className="flex items-center gap-2 text-sm">
<Clock className="size-4 text-primary" />
{t("insights.rhythm", {
start: hourLabel(insight.peak_hour_start),
end: hourLabel(insight.peak_hour_end),
days: dayNames(insight.top_days).join(", "),
})}
</CardTitle>
</CardHeader>
<CardContent>
<div className="flex flex-wrap gap-2">
<Badge variant="secondary">
{hourLabel(insight.peak_hour_start)}–{hourLabel(insight.peak_hour_end)}
</Badge>
<Badge variant="outline">{dayNames(insight.top_days).join(", ")}</Badge>
<SampleBadge count={insight.sample_count} />
</div>
</CardContent>
</Card>
)
}
function SampleBadge({ count }: { count: number }) {
const { t } = useTranslation()
return <Badge variant="outline">{t("insights.filmsSample", { count })}</Badge>
}
function InsightsSkeleton() {
return (
<div className="space-y-4 p-4">
<BackButton />
<Skeleton className="h-6 w-40" />
<Skeleton className="h-4 w-64" />
<div className="space-y-2">
{[0, 1, 2].map((i) => (
<Skeleton key={i} className="h-28 w-full" />
))}
</div>
</div>
)
}

View File

@@ -1,7 +1,7 @@
import { createFileRoute, Link } from "@tanstack/react-router"
import { useState } from "react"
import { useTranslation } from "react-i18next"
import { ChevronDown, ChevronRight, Plus, Settings, Sparkles } from "lucide-react"
import { ChevronDown, ChevronRight, Lightbulb, Plus, Settings, Sparkles } from "lucide-react"
import { Button } from "@/components/ui/button"
import { ProfileView, ProfileSkeleton } from "@/components/profile-view"
import { useAuth } from "@/components/auth-provider"
@@ -51,6 +51,15 @@ function ProfilePage() {
actions={
<>
<GoalSection goals={data.goals ?? []} />
<Link to="/insights" className="block">
<Button variant="outline" size="sm" className="w-full justify-between">
<span className="flex items-center gap-2">
<Lightbulb className="size-4 text-primary" />
{t("insights.title")}
</span>
<ChevronRight className="size-4 text-muted-foreground" />
</Button>
</Link>
<Link to="/social" className="block">
<Button variant="outline" size="sm" className="w-full justify-between">
<span>{t("profile.followingFollowers")}</span>