time and blink modified

This commit is contained in:
2026-05-29 18:51:34 +05:30
parent e64a186e94
commit ea7730bef9
4 changed files with 185 additions and 10 deletions

View File

@@ -3141,6 +3141,11 @@ pub struct VideoEntry {
pub completed: bool,
pub completed_at: Option<String>,
pub completed_by: Option<String>,
// 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<String>,
}
/// 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<String>)> = 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<String, (i64, Option<String>)> =
std::collections::HashMap::new();
for (n, c, at) in mod_rows {
mod_map.insert(n, (c, at));
}
let mut out: Vec<VideoEntry> = 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)

View File

@@ -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;

View File

@@ -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<boolean>(false);
const [showDeleted, setShowDeleted] = useState<boolean>(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<boolean>(false);
const [blinkTick, setBlinkTick] = useState<number>(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<VideoEntry[]>([]);
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({
/>
<span className="label">Only deleted</span>
</label>
<label className="filter-row">
<input
type="checkbox"
checked={blinkModified}
onChange={(e) => setBlinkModified(e.target.checked)}
/>
<span className="label" title="Pulse a halo around every modified asset until you turn this off">
Blink modified points
</span>
</label>
</>
)}
@@ -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 (
<span
style={{
marginLeft: 8,
fontSize: 10,
color: "#f39c12",
fontWeight: 700,
}}
title="Total modified assets across all videos"
>
{totalMod} in {vidsWithMod} video(s)
</span>
);
})()}
</h4>
{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,
}}
>
</button>
{e.modified_count > 0 && (
<span
style={{
display: "inline-flex",
alignItems: "center",
gap: 4,
marginLeft: 4,
}}
title={
e.last_modified_at
? `${e.modified_count} modified · last edit ${e.last_modified_at} UTC`
: `${e.modified_count} modified asset(s)`
}
>
<span
style={{
fontSize: 10,
padding: "0 5px",
background: "#f39c12",
color: "#fff",
borderRadius: 8,
fontWeight: 700,
}}
>
{e.modified_count}
</span>
<span style={{ fontSize: 10, color: "#666" }}>
{e.last_modified_at
? formatTimeAgo(e.last_modified_at)
: "—"}
</span>
</span>
)}
</label>
);
};
@@ -6180,9 +6295,9 @@ function AppShell({
<div
style={{
display: "flex",
gap: 6,
flexDirection: "column",
gap: 4,
marginTop: 6,
alignItems: "stretch",
}}
>
<select
@@ -6194,19 +6309,19 @@ function AppShell({
}
disabled={busy}
title="File format for the next export"
style={{ fontSize: 12, padding: "2px 4px" }}
style={{ fontSize: 12, padding: "3px 4px", width: "100%" }}
>
<option value="source">JSON (round-trip + bbox)</option>
<option value="source">KML Tool JSON (re-importable)</option>
<option value="geojson">GeoJSON</option>
<option value="kml">KML</option>
<option value="csv">CSV</option>
</select>
<button
className="primary-btn"
style={{ background: "#2c3e50", flex: 1 }}
style={{ background: "#2c3e50", width: "100%" }}
onClick={handleExport}
disabled={busy}
title="Export all live assets in the selected format. JSON is the only re-importable format and includes bbox overlays."
title="Export all live assets in the selected format. 'KML Tool JSON' is the only format this tool can re-import (it preserves coords, side, asset_name and bboxes)."
>
Export data
</button>

View File

@@ -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]> = [];