import { useCallback, useEffect, useState } from "react"; import { api, exportProjectUrl, exportVideoUrl, fmtChainageRange, fmtDuration, fmtWhen, passDeltas, setAuthToken, statusLabel, type Activity, type Member, type ProjectSummary, type RoadType, type Stats, type UserRow, type VideoRow, } from "./api"; import { RemarkCell } from "./RemarkCell"; import { AdminPanel } from "./AdminPanel"; import { MembersPicker } from "./MembersPicker"; import { FoldersPicker } from "./FoldersPicker"; import { SignIn } from "./SignIn"; import { MapView } from "./MapView"; import { ChainagePanel } from "./ChainagePanel"; const TOKEN_KEY = "central-token"; const POLL_MS = 10_000; const IDLE_MS = 10 * 60_000; // auto-logout after 10 minutes of inactivity type Auth = { username: string; role: string }; export function App() { // ---- auth gate ---- const [token, setToken] = useState(() => localStorage.getItem(TOKEN_KEY) ?? ""); const [auth, setAuth] = useState(null); const [authChecked, setAuthChecked] = useState(false); const [expiredReason, setExpiredReason] = useState(null); const isAdmin = auth?.role === "admin"; const signOut = useCallback((reason?: string) => { setAuth(null); setToken(""); setAuthToken(""); localStorage.removeItem(TOKEN_KEY); setExpiredReason(reason ?? null); }, []); const onSignedIn = useCallback((t: string, me: Auth) => { setAuthToken(t); localStorage.setItem(TOKEN_KEY, t); setToken(t); setAuth(me); setExpiredReason(null); }, []); // Restore a persisted session on load (silent whoami — no audit spam). useEffect(() => { let cancel = false; (async () => { if (token) { setAuthToken(token); try { const me = await api.whoami(token); if (!cancel) setAuth(me); } catch { if (!cancel) { setAuthToken(""); localStorage.removeItem(TOKEN_KEY); } } } if (!cancel) setAuthChecked(true); })(); return () => { cancel = true; }; // eslint-disable-next-line react-hooks/exhaustive-deps }, []); // Idle auto-logout: any activity resets a 10-minute timer. useEffect(() => { if (!auth) return; let timer = 0; const reset = () => { clearTimeout(timer); timer = window.setTimeout( () => signOut("Your session expired because of inactivity. Please sign in again."), IDLE_MS, ); }; const events = ["mousemove", "mousedown", "keydown", "scroll", "touchstart", "click"]; events.forEach((e) => window.addEventListener(e, reset, { passive: true })); reset(); return () => { clearTimeout(timer); events.forEach((e) => window.removeEventListener(e, reset)); }; }, [auth, signOut]); // ---- dashboard data ---- const [view, setView] = useState<"dashboard" | "admin">("dashboard"); const [projects, setProjects] = useState([]); const [projectId, setProjectId] = useState(null); const [stats, setStats] = useState(null); const [activity, setActivity] = useState(null); const [videos, setVideos] = useState([]); const [error, setError] = useState(null); const [busy, setBusy] = useState(false); // Assignment (admin): selected video ids; the dropdown lists the project's members. const [selected, setSelected] = useState>(new Set()); const [members, setMembers] = useState([]); const [assignee, setAssignee] = useState(""); // Videos panel: table ↔ map toggle + road types (labels for assignment/calibration). const [videoView, setVideoView] = useState<"table" | "map">("table"); const [roadTypes, setRoadTypes] = useState([]); const [rtAssign, setRtAssign] = useState(""); // All ml_support users (admin only) — the pool the members picker chooses from. const [users, setUsers] = useState([]); const mlUsers = users.filter((u) => u.role === "ml_support"); useEffect(() => { if (!isAdmin) { setUsers([]); return; } api.listUsers().then(setUsers).catch(() => setUsers([])); }, [isAdmin]); const toggleSel = (id: number) => setSelected((s) => { const n = new Set(s); n.has(id) ? n.delete(id) : n.add(id); return n; }); const loadProject = useCallback(async (id: number) => { try { const [s, v, a, m, rt] = await Promise.all([ api.stats(id), api.videos(id), api.activity(id), api.members(id).catch(() => [] as Member[]), api.roadTypes(id).catch(() => [] as RoadType[]), ]); setStats(s); setVideos(v); setActivity(a); setMembers(m); setRoadTypes(rt); setError(null); } catch (e) { setError(String(e)); } }, []); const loadProjects = useCallback(async () => { try { const p = await api.projects(); setProjects(p); setProjectId((cur) => cur ?? (p[0]?.id ?? null)); setError(null); } catch (e) { setError(String(e)); } }, []); const assignSelected = useCallback(async () => { if (!isAdmin || selected.size === 0 || projectId == null) return; setBusy(true); try { await api.assign(projectId, [...selected], assignee); setSelected(new Set()); await loadProject(projectId); setError(null); } catch (e) { setError(String(e)); } finally { setBusy(false); } }, [isAdmin, selected, assignee, projectId, loadProject]); // Bulk-assign the picked road type to the selected videos (admin; "" clears). const applyRoadType = useCallback(async () => { if (!isAdmin || selected.size === 0 || projectId == null) return; setBusy(true); try { await api.assignRoadType(projectId, [...selected], rtAssign); setSelected(new Set()); await loadProject(projectId); setError(null); } catch (e) { setError(String(e)); } finally { setBusy(false); } }, [isAdmin, selected, rtAssign, projectId, loadProject]); // A deleted road-type label can leave a stale dropdown selection: the select // LOOKS like "(clear road type)" but still sends the dead label, which the server // rejects — making un-assign impossible. Snap back to '' whenever that happens. useEffect(() => { if (rtAssign !== "" && !roadTypes.some((r) => r.name === rtAssign)) setRtAssign(""); }, [roadTypes, rtAssign]); useEffect(() => { if (auth) void loadProjects(); }, [auth, loadProjects]); useEffect(() => { if (!auth || projectId == null) return; setSelected(new Set()); // clear selection when switching projects void loadProject(projectId); const t = setInterval(() => { void loadProject(projectId); void loadProjects(); }, POLL_MS); return () => clearInterval(t); }, [auth, projectId, loadProject, loadProjects]); const timer = useCallback(async (action: "start" | "stop" | "reset") => { if (projectId == null) return; if (action === "reset" && !window.confirm("Reset the project timer back to zero?")) return; try { await api.setTimer(projectId, action); await loadProject(projectId); } catch (e) { setError(String(e)); } }, [projectId, loadProject]); const toggleIgnore = useCallback(async (v: VideoRow) => { if (!isAdmin || projectId == null) return; try { await api.setIgnore(v.id, !v.ignored); await loadProject(projectId); } catch (e) { setError(String(e)); } }, [isAdmin, projectId, loadProject]); const reingest = useCallback(async () => { if (projectId == null) return; setBusy(true); try { await api.ingest(projectId); await loadProject(projectId); await loadProjects(); } catch (e) { setError(String(e)); } finally { setBusy(false); } }, [projectId, loadProject, loadProjects]); if (!authChecked) { return
Loading…
; } if (!auth) { return ; } const project = projects.find((p) => p.id === projectId) ?? null; const ov = stats?.overview; const groups = groupByClient(projects); return (
Verification Dashboard
{view === "dashboard" && ( <> )} {view === "dashboard" && project && ( {project.client_name ? `${project.client_name} · ` : ""}{project.source_path} )} {auth.username} · {auth.role}
{error &&
⚠ {error}
} {view === "admin" && } {view === "dashboard" && ( <> {/* Overview */} {ov && (
Verified progress
{ov.pct_done.toFixed(1)}% {stats?.timeline.eta_ms != null && ( · ETA ~{fmtDuration(stats.timeline.eta_ms)} )}
)} {/* Project time & estimate (driven by the assigned users) */} {stats && ov && (

Project time & estimate

{isAdmin && projectId != null && (
{stats.timeline.time_running ? "● running" : "⏸ stopped"} {stats.timeline.time_running ? : }
)}
Total project time {fmtDuration(stats.timeline.elapsed_ms)} {stats.timeline.all_verified ? "✓" : ""} inception → {stats.timeline.all_verified ? "all verified" : "now (ongoing)"}
Total hours — all users {fmtDuration(stats.timeline.total_spent_ms)} annotation + verify, summed
Assigned users {stats.timeline.active_workers} {stats.timeline.active_workers === 0 ? "assign users to estimate" : "drives the estimate"}
Avg per video {fmtDuration(stats.timeline.avg_video_ms)} over verified videos
Estimated time to finish {stats.timeline.eta_ms != null ? fmtDuration(stats.timeline.eta_ms) : "—"} {ov.total - ov.verified} left ÷ {Math.max(1, stats.timeline.active_workers)} user(s) @ {fmtDuration(stats.timeline.avg_video_ms)}/video
)}
{/* Leaderboard + project members picker */}

Per-user leaderboard

{isAdmin && projectId != null && ( loadProject(projectId)} /> )}
{isAdmin && members.length === 0 && (
No users assigned to this project yet — use + Assign users to pick ml_support, then assign them videos.
)} {stats?.leaderboard.map((r) => ( ))} {(!stats || stats.leaderboard.length === 0) && ( )}
UserVideosAnnotationsTotal timeAvg / video
{r.user} {r.videos} {r.annotations} {fmtDuration(r.total_time_ms)} {fmtDuration(r.avg_time_ms)}
no annotators yet
{/* Verification pace (rate-based, replaces the old per-day bars) */}

Verification pace

{stats && ov ? (() => { const t = stats.timeline; const perDay = t.recent_window_days > 0 ? t.recent_verified / t.recent_window_days : 0; const remaining = ov.total - ov.verified; const paceDays = perDay > 0 ? Math.ceil(remaining / perDay) : null; return (
Verified / day {perDay.toFixed(1)} last {t.recent_window_days} days ({t.recent_verified} done)
Avg per video {fmtDuration(t.avg_video_ms)} over verified videos
Finish at recent pace {paceDays != null ? `~${paceDays} day${paceDays === 1 ? "" : "s"}` : "—"} {perDay > 0 ? `${remaining} left ÷ ${perDay.toFixed(1)}/day (all users' actual output)` : "no recent activity"}
Est. to finish · {Math.max(1, t.active_workers)} user{t.active_workers === 1 ? "" : "s"} {t.eta_ms != null ? fmtDuration(t.eta_ms) : "—"} {remaining} left × {fmtDuration(t.avg_video_ms)} ÷ {Math.max(1, t.active_workers)} user{t.active_workers === 1 ? "" : "s"}
); })() :
no data yet
}
{/* Verification leaderboard — assigned users ranked by annotation throughput (most annotations in the least time), not by video count. */}

Verification leaderboard · most annotations / min

{stats?.verifiers.map((r) => ( ))} {(!stats || stats.verifiers.length === 0) && ( )}
RankUserAnnotationsAnno/minVerifiedTotal time
{r.rank > 0 ? (r.rank === 1 ? "🥇 1" : `#${r.rank}`) : } {r.user} {r.annotations > 0 ? r.annotations : 0} {r.annotations_per_min > 0 ? r.annotations_per_min.toFixed(1) : } {r.videos > 0 ? r.videos : 0} {r.total_time_ms > 0 ? fmtDuration(r.total_time_ms) : }
no assigned users yet
{/* Recent activity (live claims + event feed) */}

Recent activity

{activity && activity.active_claims.length > 0 && (
{activity.active_claims.map((c) => (
🔒 {c.claimed_by} has {c.file_name} · since {fmtWhen(c.claimed_at)}
))}
)}
{activity?.recent_events.map((e, i) => (
{e.event} {e.file_name} {e.username} · {fmtWhen(e.at)}
))} {(!activity || activity.recent_events.length === 0) &&
no activity yet
}
{/* Per-video table / map */}

Videos ({videos.length})

{isAdmin && projectId != null && ( loadProject(projectId)} /> )} {isAdmin && videoView === "table" && ( {selected.size} selected → )} {projectId != null && videos.some((v) => v.annotation_count > 0) && ( ⬇ Export all )}
{videoView === "map" && projectId != null && ( loadProject(projectId)} /> )} {videoView === "table" && (
{isAdmin && } {videos.map((v, idx) => ( {isAdmin && } ))} {videos.length === 0 && }
0 && selected.size === videos.length} onChange={(e) => setSelected(e.target.checked ? new Set(videos.map((v) => v.id)) : new Set())} />#FolderFileStatusRoad · ChainageAnnotatorAnnotations ReviewVerify timeResolutionAnnotatedRemarks
toggleSel(v.id)} />{idx + 1} {subfolder(v.rel_path) || "—"} {v.file_name} {statusLabel(v.workflow_status)} {v.ignored && ignored} {isAdmin && ( )} {v.claimed_by ? (
🔒 claimed by {v.claimed_by}{v.claimed_at ? ` · ${fmtWhen(v.claimed_at)}` : ""}
) : v.workflow_status === "assigned" && v.assigned_to ? (
→ assigned to {v.assigned_to}
) : v.completed_at ? (
{v.verifiers || v.completed_by} · {fmtWhen(v.completed_at)}
) : null}
{v.road_type ? {v.road_type} : }
{v.chainage_start_m != null ? fmtChainageRange(v.chainage_start_m, v.chainage_end_m) : {v.gps_status === "ok" ? "gps ✓" : v.gps_status === "pending" ? "gps …" : `gps ${v.gps_status}`}}
{v.verifiers || v.primary_annotator || (v.assigned_to ? assigned: {v.assigned_to} : "—")} {v.annotation_count > 0 ? {v.annotation_count} ⬇ : 0} {(v.imported_count > 0 || (v.passes?.length ?? 0) > 0) && (() => { const deltas = passDeltas(v.imported_count, v.passes ?? []); return (
imported {v.imported_count} {deltas.length > 0 ? deltas.map((d, i) => ( = 0 ? "ann-add" : "ann-del"} title={`${d.label} by ${d.username} → ${d.total} total`}> {" · "}{d.label} {d.delta >= 0 ? "+" : ""}{d.delta} )) : <> {v.annotation_count < v.imported_count && · −{v.imported_count - v.annotation_count}} {v.annotation_count > v.imported_count && · +{v.annotation_count - v.imported_count}} }
); })()}
{v.review_count > 0 ? 🚩 {v.review_count} : } {fmtDuration(v.verify_time_ms)} {v.width && v.height ? `${v.width}×${v.height}` : "—"} {v.annotated_at ? v.annotated_at.slice(0, 10) : "—"}
no videos ingested
)}
{/* Chainage calibration + road-type labels (admin) */} {isAdmin && projectId != null && ( loadProject(projectId)} /> )} )}
); } /** The subfolder portion of a rel_path (everything before the last '/'), or "" at the root. */ function subfolder(rel: string): string { const i = rel.lastIndexOf("/"); return i >= 0 ? rel.slice(0, i) : ""; } /** Group projects under their client name, preserving the server's ordering. */ function groupByClient(projects: ProjectSummary[]): [string, ProjectSummary[]][] { const order: string[] = []; const map = new Map(); for (const p of projects) { const key = p.client_name || "—"; if (!map.has(key)) { map.set(key, []); order.push(key); } map.get(key)!.push(p); } return order.map((k) => [k, map.get(k)!]); } /** GPS fix count vs the ~1 fix/s expected for the video's duration. A mismatch gets * a ⚠ that expands (on click) into a plain-language explanation of how the missing/ * extra data is handled — interpolation, start-stitching, end clamping. */ function GpsPoints({ v }: { v: VideoRow }) { const [open, setOpen] = useState(false); if (v.gps_status !== "ok" || v.gps_sample_count == null) return null; const durS = v.duration_s ?? (v.frame_count && v.fps ? v.frame_count / v.fps : null); const expected = durS ? Math.round(durS) : null; const diff = expected != null ? v.gps_sample_count - expected : 0; const off = expected != null && Math.abs(diff) > Math.max(3, expected * 0.05); const bigGap = (v.gps_max_gap_s ?? 0) > 5; return (
{v.gps_sample_count} gps pts {expected != null && / ~{expected}} {(off || bigGap) && ( )} {open && (off || bigGap) && (
setOpen(false)}> {diff < 0 || (!off && bigGap) ? ( <> Expected ~{expected ?? "?"} fixes (1 per second of video), found {v.gps_sample_count} {bigGap && <> — largest dropout {v.gps_max_gap_s!.toFixed(0)} s}. Positions inside a dropout are time-interpolated: each frame is placed proportionally on a straight line between the two nearest real fixes (constant speed assumed), and its chainage follows the same interpolation. A missing{" "} start is stitched from the previous video via GPS timestamps when possible; a missing end clamps to the last fix. The longer the dropout, the rougher the approximation. ) : diff > 0 ? ( <> Found {v.gps_sample_count} fixes but only ~{expected} expected — the camera wrote more than one fix per second (or duplicated samples). Extra fixes are used as-is; nothing is approximated. ) : null}
)}
); } function Card({ label, value, tone }: { label: string; value: number; tone?: string }) { return (
{label}
{value}
); }