time and blink modified
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
133
src/App.tsx
133
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<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>
|
||||
|
||||
@@ -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]> = [];
|
||||
|
||||
Reference in New Issue
Block a user