From ea7730bef9e21515385717eb75e866d5da6b8c67 Mon Sep 17 00:00:00 2001 From: ravi Date: Fri, 29 May 2026 18:51:34 +0530 Subject: [PATCH] time and blink modified --- src-tauri/src/commands.rs | 27 ++++++++ src/App.css | 2 +- src/App.tsx | 133 +++++++++++++++++++++++++++++++++++--- src/MapView.tsx | 33 ++++++++++ 4 files changed, 185 insertions(+), 10 deletions(-) diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 05317c0..598d3b2 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -3141,6 +3141,11 @@ pub struct VideoEntry { pub completed: bool, pub completed_at: Option, pub completed_by: Option, + // Number of live assets in this video with modified=1, and the most + // recent modified_at across them. Lets the UI render an "edited" + // badge + relative time without an extra round-trip per video. + pub modified_count: i64, + pub last_modified_at: Option, } /// Sort key for stable numbering: parse the first `YYYY_MMDD_HHMMSS` pattern @@ -3216,16 +3221,38 @@ pub async fn list_videos( status.insert(n, (c, at, by)); } + // Aggregate modification stats per video in a single query so the cost + // is O(rows) regardless of how many videos exist. + let mod_rows: Vec<(String, i64, Option)> = sqlx::query_as( + "SELECT video_name, + SUM(CASE WHEN modified = 1 THEN 1 ELSE 0 END) AS mc, + MAX(CASE WHEN modified = 1 THEN modified_at END) AS last_at + FROM assets WHERE deleted = 0 + GROUP BY video_name", + ) + .fetch_all(&state.pool) + .await + .map_err(|e| e.to_string())?; + let mut mod_map: std::collections::HashMap)> = + std::collections::HashMap::new(); + for (n, c, at) in mod_rows { + mod_map.insert(n, (c, at)); + } + let mut out: Vec = Vec::with_capacity(all.len()); for (idx, n) in all.into_iter().enumerate() { let (completed, completed_at, completed_by) = status.remove(&n).unwrap_or((0, None, None)); + let (modified_count, last_modified_at) = + mod_map.remove(&n).unwrap_or((0, None)); out.push(VideoEntry { video_number: (idx + 1) as i64, video_name: n, completed: completed != 0, completed_at, completed_by, + modified_count, + last_modified_at, }); } Ok(out) diff --git a/src/App.css b/src/App.css index c4d0eed..b470254 100644 --- a/src/App.css +++ b/src/App.css @@ -614,7 +614,7 @@ body, padding-right: 36px; border-radius: 6px; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2); - width: 220px; + width: 360px; font-size: 12px; max-height: calc(100vh - 24px); overflow-y: auto; diff --git a/src/App.tsx b/src/App.tsx index 00ac8e1..482d2d6 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -64,6 +64,33 @@ const displayVideo = (v: string | null | undefined) => { return v; }; +// SQLite `datetime('now')` writes a UTC string `YYYY-MM-DD HH:MM:SS` with no +// timezone. Bare " UTC" suffix is not portable across browsers — normalize to +// ISO 8601 (`T` separator + trailing `Z`) so Date.parse works everywhere. +const formatTimeAgo = (iso: string | null | undefined): string => { + if (!iso) return ""; + let norm = iso.trim(); + if (!/[zZ]|[+-]\d{2}:?\d{2}$/.test(norm)) { + norm = norm.replace(" ", "T") + "Z"; + } + const t = Date.parse(norm); + if (!Number.isFinite(t)) return ""; + const diffMs = Date.now() - t; + if (diffMs < 0) return "just now"; + const s = Math.floor(diffMs / 1000); + if (s < 60) return `${s}s ago`; + const m = Math.floor(s / 60); + if (m < 60) return `${m} min ago`; + const h = Math.floor(m / 60); + if (h < 24) return `${h}h ago`; + const d = Math.floor(h / 24); + if (d < 30) return `${d} day${d === 1 ? "" : "s"} ago`; + const mo = Math.floor(d / 30); + if (mo < 12) return `${mo} mo ago`; + const y = Math.floor(d / 365); + return `${y}y ago`; +}; + const INITIAL_VIEWPORT: Viewport = { center: [51.4, 25.5], zoom: 9, @@ -557,6 +584,16 @@ function AppShell({ }, [autoLinkMaxM]); const [lassoListOpen, setLassoListOpen] = useState(false); const [showDeleted, setShowDeleted] = useState(false); + // Pulse animation for "Blink modified points". A simple bool toggle drives + // a setInterval in an effect; the tick is plumbed through to MapView so its + // dedicated overlay layer redraws every ~600 ms with alternating opacity. + const [blinkModified, setBlinkModified] = useState(false); + const [blinkTick, setBlinkTick] = useState(0); + useEffect(() => { + if (!blinkModified) return; + const h = window.setInterval(() => setBlinkTick((t) => t + 1), 600); + return () => window.clearInterval(h); + }, [blinkModified]); // "Only deleted" inverts the deleted-asset filter: live rows are hidden and // the bbox fetch implicitly includes deleted rows. Useful for triaging the // trash without scrolling through everything else. @@ -1758,6 +1795,8 @@ function AppShell({ completed: boolean; completed_at: string | null; completed_by: string | null; + modified_count: number; + last_modified_at: string | null; }; const [videoEntries, setVideoEntries] = useState([]); const [completedVideosExpanded, setCompletedVideosExpanded] = @@ -3565,6 +3604,8 @@ function AppShell({ layerVisibility={layerVisibility} selectedAsset={selectedAsset} multiSelectedIds={Array.from(multiSelectedIds)} + blinkModified={blinkModified} + blinkTick={blinkTick} onSelect={(a, additive) => { if (additive) { if (!a) return; @@ -3750,6 +3791,8 @@ function AppShell({ layerVisibility={layerVisibility} selectedAsset={selectedAsset} multiSelectedIds={Array.from(multiSelectedIds)} + blinkModified={blinkModified} + blinkTick={blinkTick} onSelect={(a, additive) => { if (additive) { if (!a) return; @@ -4002,6 +4045,16 @@ function AppShell({ /> Only deleted + )} @@ -4047,6 +4100,29 @@ function AppShell({ > {videosExpanded ? "▼" : "▶"} Videos — all {counts.byVideo.size} {filters.videos.size > 0 && ` (${filters.videos.size} picked)`} + {(() => { + const totalMod = videoEntries.reduce( + (s, v) => s + (v.modified_count || 0), + 0, + ); + const vidsWithMod = videoEntries.filter( + (v) => (v.modified_count || 0) > 0, + ).length; + if (totalMod === 0) return null; + return ( + + ✎ {totalMod} in {vidsWithMod} video(s) + + ); + })()} {videosExpanded && ( <> @@ -4127,6 +4203,8 @@ function AppShell({ completed: false, completed_at: null, completed_by: null, + modified_count: 0, + last_modified_at: null, }), ); const all = videoEntries.concat(extras); @@ -4215,21 +4293,58 @@ function AppShell({ type="button" onClick={(ev) => { ev.preventDefault(); + ev.stopPropagation(); flyToVideo(label); }} title={`Fly to ${label}`} style={{ marginLeft: 4, - padding: "0 6px", - fontSize: 11, - border: "1px solid #ccc", + marginRight: 6, + padding: "0 8px", + fontSize: 12, + border: "1px solid #3498db", borderRadius: 3, background: "#fff", + color: "#2980b9", cursor: "pointer", + minWidth: 28, }} > ⤢ + {e.modified_count > 0 && ( + + + ✎ {e.modified_count} + + + {e.last_modified_at + ? formatTimeAgo(e.last_modified_at) + : "—"} + + + )} ); }; @@ -6180,9 +6295,9 @@ function AppShell({
diff --git a/src/MapView.tsx b/src/MapView.tsx index 9f44ead..fc77a88 100644 --- a/src/MapView.tsx +++ b/src/MapView.tsx @@ -70,6 +70,8 @@ type Props = { selectedAsset?: Asset | null; onSelect?: (asset: Asset | null, additive?: boolean) => void; multiSelectedIds?: number[]; + blinkModified?: boolean; + blinkTick?: number; onAssetRightClick?: (asset: Asset) => void; onLinkClick?: (idA: number, idB: number) => void; onMetadataLineClick?: (videoName: string) => void; @@ -146,6 +148,8 @@ export function MapView({ selectedAsset, onSelect, multiSelectedIds = [], + blinkModified = false, + blinkTick = 0, onAssetRightClick, onLinkClick, onMetadataLineClick, @@ -1559,6 +1563,35 @@ export function MapView({ ); } } + if (blinkModified) { + // Single halo layer over every live modified asset. Radius + opacity + // alternate on blinkTick so the user perceives a pulse without setting + // up a frame loop per point. + const pts: Array<[number, number]> = []; + for (const a of assets) { + if (a.deleted !== 0) continue; + if (a.modified !== 1) continue; + if (a.lat !== null && a.lng !== null) pts.push([a.lng, a.lat]); + } + if (pts.length > 0) { + const phase = blinkTick % 2 === 0; + layers.push( + new ScatterplotLayer<[number, number]>({ + id: `blink-modified-${phase ? "a" : "b"}`, + data: pts, + getPosition: (d) => d, + getRadius: phase ? 18 : 13, + radiusUnits: "pixels", + getFillColor: phase ? [241, 196, 15, 90] : [241, 196, 15, 30], + getLineColor: phase ? [243, 156, 18, 255] : [243, 156, 18, 180], + stroked: true, + getLineWidth: phase ? 3 : 2, + lineWidthUnits: "pixels", + pickable: false, + }), + ); + } + } if (multiSelectedIds.length > 0) { const idSet = new Set(multiSelectedIds); const halos: Array<[number, number]> = [];