fix: improve moments clustering
This commit is contained in:
66
apps/web/app/api/moments/route.ts
Normal file
66
apps/web/app/api/moments/route.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
84
apps/web/app/lib/moments.ts
Normal file
84
apps/web/app/lib/moments.ts
Normal 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;
|
||||
}
|
||||
47
apps/web/src/__tests__/moments.test.ts
Normal file
47
apps/web/src/__tests__/moments.test.ts
Normal 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");
|
||||
});
|
||||
Reference in New Issue
Block a user