fix: improve moments clustering

This commit is contained in:
William Valentin
2026-02-05 09:14:45 -08:00
parent fdd1c932fd
commit 523460f639
4 changed files with 255 additions and 0 deletions

View File

@@ -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<Response> {
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,
});
}

View File

@@ -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<string, boolean>;
@@ -147,6 +158,9 @@ export function TimelineTree(props: {
const [expanded, setExpanded] = useState<ExpandedState>({});
const [rows, setRows] = useState<ApiTreeRow[] | null>(null);
const [error, setError] = useState<string | null>(null);
const [showMoments, setShowMoments] = useState(false);
const [moments, setMoments] = useState<MomentsResponse | null>(null);
const [momentsError, setMomentsError] = useState<string | null>(null);
// simple pan/zoom via viewBox
const svgRef = useRef<SVGSVGElement | null>(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
</button>
<button type="button" onClick={() => setShowMoments((v) => !v)}>
{showMoments ? "Hide moments" : "Show moments"}
</button>
{rows ? (
<span style={{ color: "#666" }}>{rows.length} day nodes</span>
) : null}
</div>
{error ? <div style={{ color: "#b00" }}>Error: {error}</div> : null}
{momentsError ? (
<div style={{ color: "#b00" }}>Moments error: {momentsError}</div>
) : null}
{!rows && !error ? (
<div
style={{
@@ -390,6 +438,15 @@ export function TimelineTree(props: {
const isDay = node.id.startsWith("d:");
const clickCursor = hasChildren || isDay ? "pointer" : "default";
const dayKey = node.label;
const dayMoments = showMoments
? moments?.clusters.filter((c) => c.day === dayKey) ?? []
: [];
const momentsCount = dayMoments.reduce(
(sum, c) => sum + c.count,
0,
);
return (
<g
key={node.id}
@@ -404,6 +461,7 @@ export function TimelineTree(props: {
<text x={20} y={5} fontSize={14} fill="#111">
{node.label} ({node.countReady}/{node.countTotal})
{hasChildren ? (isExpanded ? " ▼" : " ▶") : ""}
{showMoments && isDay ? ` · ${momentsCount} moment assets` : ""}
</text>
</g>
);

View File

@@ -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<string, MomentAsset[]>();
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;
}

View File

@@ -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");
});