diff --git a/apps/web/app/api/moments/route.ts b/apps/web/app/api/moments/route.ts new file mode 100644 index 0000000..cb0f65d --- /dev/null +++ b/apps/web/app/api/moments/route.ts @@ -0,0 +1,66 @@ +import { z } from "zod"; + +import { getDb } from "@tline/db"; +import { clusterMoments } from "../../lib/moments"; + +export const runtime = "nodejs"; + +const querySchema = z + .object({ + start: z.string().datetime().optional(), + end: z.string().datetime().optional(), + limit: z.coerce.number().int().positive().max(2000).default(1000), + }) + .strict(); + +export async function GET(request: Request): Promise { + const url = new URL(request.url); + const parsed = querySchema.safeParse({ + start: url.searchParams.get("start") ?? undefined, + end: url.searchParams.get("end") ?? undefined, + limit: url.searchParams.get("limit") ?? undefined, + }); + + if (!parsed.success) { + return Response.json( + { error: "invalid_query", issues: parsed.error.issues }, + { status: 400 }, + ); + } + + const query = parsed.data; + const start = query.start ? new Date(query.start) : null; + const end = query.end ? new Date(query.end) : null; + + const db = getDb(); + const rows = await db< + { + id: string; + capture_ts_utc: string | null; + }[] + >` + select id, capture_ts_utc + from assets + where capture_ts_utc is not null + and (${start}::timestamptz is null or capture_ts_utc >= ${start}::timestamptz) + and (${end}::timestamptz is null or capture_ts_utc < ${end}::timestamptz) + and status <> 'failed' + order by capture_ts_utc asc, id asc + limit ${query.limit} + `; + + const clusters = clusterMoments( + rows + .filter((row) => Boolean(row.capture_ts_utc)) + .map((row) => ({ + id: row.id, + capture_ts_utc: row.capture_ts_utc as string, + })), + ); + + return Response.json({ + start: start ? start.toISOString() : null, + end: end ? end.toISOString() : null, + clusters, + }); +} diff --git a/apps/web/app/components/TimelineTree.tsx b/apps/web/app/components/TimelineTree.tsx index 5f594cd..8310628 100644 --- a/apps/web/app/components/TimelineTree.tsx +++ b/apps/web/app/components/TimelineTree.tsx @@ -27,6 +27,17 @@ type ApiTreeResponse = { nodes: ApiTreeRow[]; }; +type MomentCluster = { + day: string; + count: number; +}; + +type MomentsResponse = { + start: string | null; + end: string | null; + clusters: MomentCluster[]; +}; + type Orientation = "vertical" | "horizontal"; type ExpandedState = Record; @@ -147,6 +158,9 @@ export function TimelineTree(props: { const [expanded, setExpanded] = useState({}); const [rows, setRows] = useState(null); const [error, setError] = useState(null); + const [showMoments, setShowMoments] = useState(false); + const [moments, setMoments] = useState(null); + const [momentsError, setMomentsError] = useState(null); // simple pan/zoom via viewBox const svgRef = useRef(null); @@ -182,6 +196,34 @@ export function TimelineTree(props: { }; }, []); + useEffect(() => { + if (!showMoments || !rows) return; + let cancelled = false; + async function loadMoments() { + try { + setMomentsError(null); + if (!rows || rows.length === 0) return; + const start = rows[0]?.group_ts ?? null; + const end = rows[rows.length - 1]?.group_ts ?? null; + const params = new URLSearchParams(); + if (start) params.set("start", start); + if (end) params.set("end", end); + const res = await fetch(`/api/moments?${params.toString()}`, { + cache: "no-store", + }); + if (!res.ok) throw new Error(`moments_fetch_failed:${res.status}`); + const json = (await res.json()) as MomentsResponse; + if (!cancelled) setMoments(json); + } catch (e) { + if (!cancelled) setMomentsError(e instanceof Error ? e.message : String(e)); + } + } + void loadMoments(); + return () => { + cancelled = true; + }; + }, [showMoments, rows]); + const roots = useMemo(() => (rows ? buildHierarchy(rows) : []), [rows]); const visible = useMemo( () => gatherVisible(roots, expanded), @@ -315,12 +357,18 @@ export function TimelineTree(props: { > Reset view + {rows ? ( {rows.length} day nodes ) : null} {error ?
Error: {error}
: null} + {momentsError ? ( +
Moments error: {momentsError}
+ ) : null} {!rows && !error ? (
c.day === dayKey) ?? [] + : []; + const momentsCount = dayMoments.reduce( + (sum, c) => sum + c.count, + 0, + ); + return ( {node.label} ({node.countReady}/{node.countTotal}) {hasChildren ? (isExpanded ? " ▼" : " ▶") : ""} + {showMoments && isDay ? ` · ${momentsCount} moment assets` : ""} ); diff --git a/apps/web/app/lib/moments.ts b/apps/web/app/lib/moments.ts new file mode 100644 index 0000000..755d6ef --- /dev/null +++ b/apps/web/app/lib/moments.ts @@ -0,0 +1,84 @@ +export type MomentAsset = { + id: string; + capture_ts_utc: string; +}; + +export type MomentCluster = { + day: string; + start: string; + end: string; + count: number; + assets: MomentAsset[]; +}; + +const MOMENT_WINDOW_MINUTES = 30; +const MOMENT_WINDOW_MS = MOMENT_WINDOW_MINUTES * 60 * 1000; + +function dayKeyFromIso(iso: string) { + const d = new Date(iso); + const yyyy = d.getUTCFullYear(); + const mm = String(d.getUTCMonth() + 1).padStart(2, "0"); + const dd = String(d.getUTCDate()).padStart(2, "0"); + return `${yyyy}-${mm}-${dd}`; +} + +export function clusterMoments(input: MomentAsset[]): MomentCluster[] { + const byDay = new Map(); + + for (const asset of input) { + if (!asset.capture_ts_utc) continue; + const key = dayKeyFromIso(asset.capture_ts_utc); + const list = byDay.get(key); + if (list) list.push(asset); + else byDay.set(key, [asset]); + } + + const clusters: MomentCluster[] = []; + + for (const [day, assets] of byDay) { + const sorted = [...assets].sort((a, b) => + a.capture_ts_utc.localeCompare(b.capture_ts_utc), + ); + + let current: MomentAsset[] = []; + let lastTs: number | null = null; + + for (const asset of sorted) { + const ts = new Date(asset.capture_ts_utc).getTime(); + if (!Number.isFinite(ts)) continue; + + if (lastTs === null || ts - lastTs <= MOMENT_WINDOW_MS) { + current.push(asset); + } else { + const start = current[0]?.capture_ts_utc; + const end = current[current.length - 1]?.capture_ts_utc; + if (start && end) { + clusters.push({ + day, + start, + end, + count: current.length, + assets: current, + }); + } + current = [asset]; + } + + lastTs = ts; + } + + if (current.length) { + const start = current[0].capture_ts_utc; + const end = current[current.length - 1].capture_ts_utc; + clusters.push({ + day, + start, + end, + count: current.length, + assets: current, + }); + } + } + + return clusters; +} diff --git a/apps/web/src/__tests__/moments.test.ts b/apps/web/src/__tests__/moments.test.ts new file mode 100644 index 0000000..c9c1e63 --- /dev/null +++ b/apps/web/src/__tests__/moments.test.ts @@ -0,0 +1,47 @@ +import { test, expect } from "bun:test"; + +import { clusterMoments } from "../../app/lib/moments"; + +test("clusterMoments groups assets within 30 minutes", () => { + const clusters = clusterMoments([ + { id: "a", capture_ts_utc: "2026-02-01T10:00:00.000Z" }, + { id: "b", capture_ts_utc: "2026-02-01T10:20:00.000Z" }, + { id: "c", capture_ts_utc: "2026-02-01T10:49:00.000Z" }, + ]); + + expect(clusters).toHaveLength(1); + expect(clusters[0]?.count).toBe(3); +}); + +test("clusterMoments splits after window gap", () => { + const clusters = clusterMoments([ + { id: "a", capture_ts_utc: "2026-02-01T10:00:00.000Z" }, + { id: "b", capture_ts_utc: "2026-02-01T11:05:00.000Z" }, + ]); + + expect(clusters).toHaveLength(2); + expect(clusters[0]?.count).toBe(1); + expect(clusters[1]?.count).toBe(1); +}); + +test("clusterMoments sorts inputs per day", () => { + const clusters = clusterMoments([ + { id: "b", capture_ts_utc: "2026-02-01T10:20:00.000Z" }, + { id: "a", capture_ts_utc: "2026-02-01T10:00:00.000Z" }, + { id: "c", capture_ts_utc: "2026-02-01T10:40:00.000Z" }, + ]); + + expect(clusters).toHaveLength(1); + expect(clusters[0]?.assets.map((a) => a.id)).toEqual(["a", "b", "c"]); +}); + +test("clusterMoments splits by day", () => { + const clusters = clusterMoments([ + { id: "a", capture_ts_utc: "2026-02-01T23:50:00.000Z" }, + { id: "b", capture_ts_utc: "2026-02-02T00:10:00.000Z" }, + ]); + + expect(clusters).toHaveLength(2); + expect(clusters[0]?.day).toBe("2026-02-01"); + expect(clusters[1]?.day).toBe("2026-02-02"); +});