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(
|