diff --git a/dashboard/src/App.tsx b/dashboard/src/App.tsx index c7be576..ac48a10 100644 --- a/dashboard/src/App.tsx +++ b/dashboard/src/App.tsx @@ -1,6 +1,6 @@ import { useCallback, useEffect, useState } from "react"; import { - api, exportProjectUrl, exportVideoUrl, fmtDuration, fmtWhen, setAuthToken, + api, exportProjectUrl, exportVideoUrl, fmtDuration, fmtWhen, passDeltas, setAuthToken, type Activity, type Member, type ProjectSummary, type Stats, type UserRow, type VideoRow, } from "./api"; import { RemarkCell } from "./RemarkCell"; @@ -505,15 +505,27 @@ export function App() { {v.annotation_count > 0 ? {v.annotation_count} ⬇ : 0} - {v.imported_count > 0 && ( -
- imported {v.imported_count} - {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.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} diff --git a/dashboard/src/api.ts b/dashboard/src/api.ts index 55f6b77..5a25a78 100644 --- a/dashboard/src/api.ts +++ b/dashboard/src/api.ts @@ -42,6 +42,14 @@ export interface Remark { created_at: string; } +/** One verification pass (a push). `count` is the annotation total after that pass; + * null for legacy pushes logged before we recorded it. */ +export interface Pass { + username: string; + at: string; + count: number | null; +} + export interface VideoRow { id: number; file_name: string; @@ -75,6 +83,7 @@ export interface VideoRow { ignored: boolean; ignored_by: string | null; remarks: Remark[]; + passes: Pass[]; // per-pass annotation-count history (push order) } export interface LeaderRow { @@ -281,6 +290,25 @@ export const api = { post<{ folders: number }>(`/projects/${projectId}/folders`, { folders }, token), }; +/** Per-pass annotation deltas from the imported baseline: verify +Δ, re-verify +Δ, … + * Skips legacy passes that have no recorded count. The first pass is "verify", + * subsequent ones "re-verify". `total` is the running annotation count after the pass. */ +export function passDeltas( + importedCount: number, + passes: Pass[], +): { label: string; delta: number; username: string; total: number }[] { + const out: { label: string; delta: number; username: string; total: number }[] = []; + let prev = importedCount; + let n = 0; + for (const p of passes ?? []) { + if (p.count == null) continue; + n += 1; + out.push({ label: n === 1 ? "verify" : "re-verify", delta: p.count - prev, username: p.username, total: p.count }); + prev = p.count; + } + return out; +} + /** Human-readable byte size. */ export function fmtBytes(n: number): string { if (!n || n <= 0) return "0 B"; diff --git a/server/src/main.rs b/server/src/main.rs index 8471e7f..e4f5cf4 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -549,7 +549,14 @@ async fn list_videos(State(s): State, user: AuthUser, AxPath(id): AxPa hand_raised, hand_raised_by, hand_raised_at, ignored, ignored_by, COALESCE((SELECT json_agg(json_build_object('username', r.username, 'body', r.body, 'created_at', r.created_at) ORDER BY r.created_at) - FROM video_remarks r WHERE r.video_id = videos.id), '[]'::json) AS remarks + FROM video_remarks r WHERE r.video_id = videos.id), '[]'::json) AS remarks, + -- per-pass annotation-count history (each push, in order): lets the UI + -- show imported baseline → verify Δ → re-verify Δ. count is null for + -- legacy pushes logged before we recorded it. + COALESCE((SELECT json_agg(json_build_object( + 'username', e.username, 'at', e.at, + 'count', (e.meta->>'annotation_count')::int) ORDER BY e.at) + FROM video_events e WHERE e.video_id = videos.id AND e.event='push'), '[]'::json) AS passes FROM videos WHERE project_id=$1 {FOLDER_FILTER} ORDER BY file_name"#); let rows = sqlx::query_as::<_, VideoRow>(&q) .bind(id) diff --git a/server/src/models.rs b/server/src/models.rs index 3070b90..1a65ccf 100644 --- a/server/src/models.rs +++ b/server/src/models.rs @@ -157,6 +157,10 @@ pub struct VideoRow { pub ignored_by: Option, // Remark thread (json array of {username, body, created_at}); '[]' when none. pub remarks: serde_json::Value, + // Per-pass annotation-count history (json array of {username, at, count}, one per + // push in order); '[]' when never pushed. Drives the imported→verify→re-verify + // count breakdown in the UI. + pub passes: serde_json::Value, } /// `POST /api/videos/:id/hand {raised}` — raise/lower the hand on a video. diff --git a/server/src/worker.rs b/server/src/worker.rs index 6736b0b..c918fc5 100644 --- a/server/src/worker.rs +++ b/server/src/worker.rs @@ -282,7 +282,11 @@ pub async fn push( verify_time_ms = COALESCE(videos.verify_time_ms, 0) + $3, annotation_count=$4, raw_json=$5, primary_annotator=$6, annotation_time_ms=$7, annotated_at=now(), - imported_count = COALESCE(NULLIF($8, 0), videos.imported_count), + -- imported_count is the ORIGINAL auto-imported baseline (set at ingest). + -- Keep it fixed once set: verification/re-verification must NOT fold added + -- annotations into it (otherwise "imported 200" would drift to 210). Only + -- establish it from the claim-time count if it was never set. + imported_count = COALESCE(NULLIF(videos.imported_count, 0), $8), claimed_by=NULL, claimed_at=NULL, lease_expires_at=NULL WHERE id=$1"#, ) @@ -299,7 +303,16 @@ pub async fn push( .map_err(err)?; tx.commit().await.map_err(err)?; - log_event(&s.db, id, &user.username, "push", json!({ "verify_time_ms": verify_time_ms })).await; + // Record this pass's resulting annotation count so the dashboard can show a + // per-pass breakdown (imported baseline → verify Δ → re-verify Δ …), not just a net. + log_event( + &s.db, + id, + &user.username, + "push", + json!({ "verify_time_ms": verify_time_ms, "annotation_count": ann_count }), + ) + .await; // Mark project activity + auto-resume its timer (a verify/re-verify restarts it). let _ = sqlx::query(