@@ -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=="],
|
||||
|
||||
@@ -8,6 +8,7 @@ import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover
|
||||
import { Calendar } from "@/components/ui/calendar"
|
||||
import { StarRating } from "@/components/star-rating"
|
||||
import { WatchMediumPicker } from "@/components/watch-medium-picker"
|
||||
import { withPickedDay } from "@/lib/date"
|
||||
|
||||
type ReviewFormFieldsProps = {
|
||||
rating: number
|
||||
@@ -55,7 +56,7 @@ export function ReviewFormFields({
|
||||
mode="single"
|
||||
fixedWeeks
|
||||
selected={watchedAt}
|
||||
onSelect={(d) => d && onWatchedAtChange(d)}
|
||||
onSelect={(d) => d && onWatchedAtChange(withPickedDay(watchedAt, d))}
|
||||
disabled={(d) => d > new Date()}
|
||||
autoFocus
|
||||
/>
|
||||
|
||||
@@ -12,7 +12,7 @@ import { Trash2 } from "lucide-react"
|
||||
import { toast } from "sonner"
|
||||
import { posterUrl } from "@/lib/api/client"
|
||||
import { hapticMedium } from "@/lib/haptics"
|
||||
import { parseLocalDate, formatLocalDateTime } from "@/lib/date"
|
||||
import { formatWatchedAt, isDateOnly, parseWatchedAt } from "@/lib/date"
|
||||
import type { EditReviewRequest } from "@/features/diary"
|
||||
import type { MovieDto, ReviewDto } from "@/lib/api/common"
|
||||
|
||||
@@ -77,7 +77,7 @@ function LogMode({ open, onOpenChange }: { open: boolean; onOpenChange: (open: b
|
||||
manual_director: movie.director,
|
||||
rating,
|
||||
comment: comment || undefined,
|
||||
watched_at: watchedAt.toISOString().replace("Z", "").split(".")[0]!,
|
||||
watched_at: formatWatchedAt(watchedAt),
|
||||
watch_medium: watchMedium,
|
||||
},
|
||||
{
|
||||
@@ -145,7 +145,7 @@ function EditMode({
|
||||
const { t } = useTranslation()
|
||||
const [rating, setRating] = useState(review.rating)
|
||||
const [comment, setComment] = useState(review.comment ?? "")
|
||||
const [watchedAt, setWatchedAt] = useState<Date>(() => parseLocalDate(review.watched_at))
|
||||
const [watchedAt, setWatchedAt] = useState<Date>(() => parseWatchedAt(review.watched_at))
|
||||
const [dateChanged, setDateChanged] = useState(false)
|
||||
const [watchMedium, setWatchMedium] = useState<string | undefined>(review.watch_medium)
|
||||
const [confirmDelete, setConfirmDelete] = useState(false)
|
||||
@@ -164,7 +164,7 @@ function EditMode({
|
||||
if (rating !== review.rating) data.rating = rating
|
||||
const newComment = comment || null
|
||||
if (newComment !== (review.comment ?? null)) data.comment = newComment
|
||||
if (dateChanged) data.watched_at = formatLocalDateTime(watchedAt)
|
||||
if (dateChanged) data.watched_at = formatWatchedAt(watchedAt, isDateOnly(review.watched_at))
|
||||
if (watchMedium !== review.watch_medium) data.watch_medium = watchMedium ?? null
|
||||
|
||||
if (Object.keys(data).length === 0) {
|
||||
|
||||
69
spa/src/features/insights.ts
Normal file
69
spa/src/features/insights.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
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,
|
||||
forZone: (tz: string) => [...insightsKeys.all, tz] as const,
|
||||
}
|
||||
|
||||
// Months, weekdays and hours are bucketed server-side in this zone, so the
|
||||
// rhythm card's hours read as the viewer's local time.
|
||||
function browserTimeZone(): string {
|
||||
return Intl.DateTimeFormat().resolvedOptions().timeZone
|
||||
}
|
||||
|
||||
function getInsights(tz: string) {
|
||||
return get<InsightsResponse>("/insights", { tz })
|
||||
}
|
||||
|
||||
export function useInsights() {
|
||||
const tz = browserTimeZone()
|
||||
return useQuery({
|
||||
queryKey: insightsKeys.forZone(tz),
|
||||
queryFn: () => getInsights(tz),
|
||||
})
|
||||
}
|
||||
@@ -18,18 +18,34 @@ export function shortDate(dateStr: string): string {
|
||||
}
|
||||
}
|
||||
|
||||
export function parseLocalDate(s: string): Date {
|
||||
const [datePart, timePart] = s.split("T")
|
||||
if (!datePart) return new Date()
|
||||
const [y, m, d] = datePart.split("-").map(Number)
|
||||
if (timePart) {
|
||||
const [h, min, sec] = timePart.split(":").map(Number)
|
||||
return new Date(y!, m! - 1, d!, h, min, sec)
|
||||
}
|
||||
return new Date(y!, m! - 1, d!)
|
||||
// `watched_at` is stored as naive UTC ("YYYY-MM-DDTHH:MM:SS", no zone).
|
||||
// Date-only imports carry no time and are stored at exactly 00:00:00; those
|
||||
// are calendar dates, not instants, and must not be shifted between zones.
|
||||
|
||||
export function isDateOnly(s: string): boolean {
|
||||
return /^\d{4}-\d{2}-\d{2}([T ]00:00(:00)?)?$/.test(s)
|
||||
}
|
||||
|
||||
export function formatLocalDateTime(d: Date): string {
|
||||
const pad = (n: number) => n.toString().padStart(2, "0")
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
|
||||
export function parseWatchedAt(s: string): Date {
|
||||
const [y, m, d] = s.slice(0, 10).split("-").map(Number)
|
||||
if (isDateOnly(s)) return new Date(y!, m! - 1, d!)
|
||||
const parsed = new Date(`${s.replace(" ", "T")}Z`)
|
||||
return Number.isNaN(parsed.getTime()) ? new Date() : parsed
|
||||
}
|
||||
|
||||
export function formatWatchedAt(d: Date, dateOnly = false): string {
|
||||
const pad = (n: number) => n.toString().padStart(2, "0")
|
||||
if (dateOnly) {
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T00:00:00`
|
||||
}
|
||||
return d.toISOString().split(".")[0]!
|
||||
}
|
||||
|
||||
// Swap the calendar day of `current` for `picked` (the date picker yields local
|
||||
// midnight), keeping the time of day so the stored UTC instant stays on the
|
||||
// chosen day.
|
||||
export function withPickedDay(current: Date, picked: Date): Date {
|
||||
const next = new Date(current)
|
||||
next.setFullYear(picked.getFullYear(), picked.getMonth(), picked.getDate())
|
||||
return next
|
||||
}
|
||||
|
||||
@@ -258,6 +258,28 @@
|
||||
"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 <hl>{{genre}}</hl> higher than your own average — <hl>{{genreAvg}}</hl>★ vs <hl>{{userAvg}}</hl>★",
|
||||
"ratingLiftDown": "You rate <hl>{{genre}}</hl> lower than your own average — <hl>{{genreAvg}}</hl>★ vs <hl>{{userAvg}}</hl>★",
|
||||
"yourAverage": "your average",
|
||||
"seasonality": "<hl>{{genre}}</hl> is your <hl>{{month}}</hl> genre — <hl>{{multiplier}}</hl>× your yearly baseline",
|
||||
"mediumTiming": "You watch <hl>{{medium}}</hl> on weekends <hl>{{multiplier}}</hl>× more than your baseline",
|
||||
"weekends": "weekends",
|
||||
"yourBaseline": "your baseline",
|
||||
"rhythmBody": "You watch mostly <hl>{{start}}</hl>–<hl>{{end}}</hl> on <hl>{{days}}</hl>",
|
||||
"filmsSample_one": "{{count}} film",
|
||||
"filmsSample_other": "{{count}} films",
|
||||
"watchesSample_one": "{{count}} watch",
|
||||
"watchesSample_other": "{{count}} watches"
|
||||
},
|
||||
"wrapup": {
|
||||
"title": "Year Wrap-Up",
|
||||
"noWrapUps": "No wrap-ups",
|
||||
|
||||
@@ -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,
|
||||
|
||||
299
spa/src/routes/_app/insights.tsx
Normal file
299
spa/src/routes/_app/insights.tsx
Normal file
@@ -0,0 +1,299 @@
|
||||
import { createFileRoute } from "@tanstack/react-router"
|
||||
import type { ReactNode } from "react"
|
||||
import { Trans, useTranslation } from "react-i18next"
|
||||
import {
|
||||
AlertTriangle,
|
||||
CalendarDays,
|
||||
Clapperboard,
|
||||
Clock,
|
||||
Lightbulb,
|
||||
Sparkles,
|
||||
TrendingDown,
|
||||
TrendingUp,
|
||||
} from "lucide-react"
|
||||
import { BackButton } from "@/components/back-button"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button } from "@/components/ui/button"
|
||||
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
|
||||
|
||||
const hl = <span className="text-primary" />
|
||||
|
||||
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, isError, refetch } = 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>
|
||||
|
||||
{isError ? (
|
||||
<div className="flex flex-col items-center">
|
||||
<EmptyState icon={AlertTriangle} title={t("errors.somethingWrong")} />
|
||||
<Button variant="outline" size="sm" onClick={() => refetch()}>
|
||||
{t("common.tryAgain")}
|
||||
</Button>
|
||||
</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"}`} />
|
||||
<span>
|
||||
<Trans
|
||||
i18nKey={up ? "insights.ratingLiftUp" : "insights.ratingLiftDown"}
|
||||
values={{
|
||||
genre: insight.genre,
|
||||
genreAvg: insight.genre_avg.toFixed(1),
|
||||
userAvg: insight.user_avg.toFixed(1),
|
||||
}}
|
||||
components={{ hl }}
|
||||
/>
|
||||
</span>
|
||||
</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 unit="films" count={insight.sample_count} />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
function SeasonalityCard({ insight }: SeasonalityCardProps) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="flex items-center gap-2 text-sm">
|
||||
<CalendarDays className="size-4 text-primary" />
|
||||
<span>
|
||||
<Trans
|
||||
i18nKey="insights.seasonality"
|
||||
values={{
|
||||
genre: insight.genre,
|
||||
multiplier: insight.multiplier.toFixed(1),
|
||||
month: monthName(insight.month),
|
||||
}}
|
||||
components={{ hl }}
|
||||
/>
|
||||
</span>
|
||||
</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 unit="watches" 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" />
|
||||
<span>
|
||||
<Trans
|
||||
i18nKey="insights.mediumTiming"
|
||||
values={{
|
||||
medium: mediumLabel(insight.medium, t),
|
||||
multiplier: insight.multiplier.toFixed(1),
|
||||
}}
|
||||
components={{ hl }}
|
||||
/>
|
||||
</span>
|
||||
</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 unit="watches" count={insight.sample_count} />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
function RhythmCard({ insight }: RhythmCardProps) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="flex items-center gap-2 text-sm">
|
||||
<Clock className="size-4 text-primary" />
|
||||
<span>
|
||||
<Trans
|
||||
i18nKey="insights.rhythmBody"
|
||||
values={{
|
||||
start: hourLabel(insight.peak_hour_start),
|
||||
end: hourLabel(insight.peak_hour_end),
|
||||
days: dayNames(insight.top_days).join(", "),
|
||||
}}
|
||||
components={{ hl }}
|
||||
/>
|
||||
</span>
|
||||
</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 unit="watches" count={insight.sample_count} />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
// Rating lift counts distinct films (first watches only); every other
|
||||
// category counts diary entries, rewatches included.
|
||||
function SampleBadge({ unit, count }: { unit: "films" | "watches"; count: number }) {
|
||||
const { t } = useTranslation()
|
||||
const key = unit === "films" ? "insights.filmsSample" : "insights.watchesSample"
|
||||
return <Badge variant="outline">{t(key, { 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user