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