time and blink modified
This commit is contained in:
@@ -3141,6 +3141,11 @@ pub struct VideoEntry {
|
|||||||
pub completed: bool,
|
pub completed: bool,
|
||||||
pub completed_at: Option<String>,
|
pub completed_at: Option<String>,
|
||||||
pub completed_by: 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
|
/// 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));
|
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());
|
let mut out: Vec<VideoEntry> = Vec::with_capacity(all.len());
|
||||||
for (idx, n) in all.into_iter().enumerate() {
|
for (idx, n) in all.into_iter().enumerate() {
|
||||||
let (completed, completed_at, completed_by) =
|
let (completed, completed_at, completed_by) =
|
||||||
status.remove(&n).unwrap_or((0, None, None));
|
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 {
|
out.push(VideoEntry {
|
||||||
video_number: (idx + 1) as i64,
|
video_number: (idx + 1) as i64,
|
||||||
video_name: n,
|
video_name: n,
|
||||||
completed: completed != 0,
|
completed: completed != 0,
|
||||||
completed_at,
|
completed_at,
|
||||||
completed_by,
|
completed_by,
|
||||||
|
modified_count,
|
||||||
|
last_modified_at,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
Ok(out)
|
Ok(out)
|
||||||
|
|||||||
@@ -614,7 +614,7 @@ body,
|
|||||||
padding-right: 36px;
|
padding-right: 36px;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
|
||||||
width: 220px;
|
width: 360px;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
max-height: calc(100vh - 24px);
|
max-height: calc(100vh - 24px);
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
|
|||||||
133
src/App.tsx
133
src/App.tsx
@@ -64,6 +64,33 @@ const displayVideo = (v: string | null | undefined) => {
|
|||||||
return v;
|
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 = {
|
const INITIAL_VIEWPORT: Viewport = {
|
||||||
center: [51.4, 25.5],
|
center: [51.4, 25.5],
|
||||||
zoom: 9,
|
zoom: 9,
|
||||||
@@ -557,6 +584,16 @@ function AppShell({
|
|||||||
}, [autoLinkMaxM]);
|
}, [autoLinkMaxM]);
|
||||||
const [lassoListOpen, setLassoListOpen] = useState<boolean>(false);
|
const [lassoListOpen, setLassoListOpen] = useState<boolean>(false);
|
||||||
const [showDeleted, setShowDeleted] = 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
|
// "Only deleted" inverts the deleted-asset filter: live rows are hidden and
|
||||||
// the bbox fetch implicitly includes deleted rows. Useful for triaging the
|
// the bbox fetch implicitly includes deleted rows. Useful for triaging the
|
||||||
// trash without scrolling through everything else.
|
// trash without scrolling through everything else.
|
||||||
@@ -1758,6 +1795,8 @@ function AppShell({
|
|||||||
completed: boolean;
|
completed: boolean;
|
||||||
completed_at: string | null;
|
completed_at: string | null;
|
||||||
completed_by: string | null;
|
completed_by: string | null;
|
||||||
|
modified_count: number;
|
||||||
|
last_modified_at: string | null;
|
||||||
};
|
};
|
||||||
const [videoEntries, setVideoEntries] = useState<VideoEntry[]>([]);
|
const [videoEntries, setVideoEntries] = useState<VideoEntry[]>([]);
|
||||||
const [completedVideosExpanded, setCompletedVideosExpanded] =
|
const [completedVideosExpanded, setCompletedVideosExpanded] =
|
||||||
@@ -3565,6 +3604,8 @@ function AppShell({
|
|||||||
layerVisibility={layerVisibility}
|
layerVisibility={layerVisibility}
|
||||||
selectedAsset={selectedAsset}
|
selectedAsset={selectedAsset}
|
||||||
multiSelectedIds={Array.from(multiSelectedIds)}
|
multiSelectedIds={Array.from(multiSelectedIds)}
|
||||||
|
blinkModified={blinkModified}
|
||||||
|
blinkTick={blinkTick}
|
||||||
onSelect={(a, additive) => {
|
onSelect={(a, additive) => {
|
||||||
if (additive) {
|
if (additive) {
|
||||||
if (!a) return;
|
if (!a) return;
|
||||||
@@ -3750,6 +3791,8 @@ function AppShell({
|
|||||||
layerVisibility={layerVisibility}
|
layerVisibility={layerVisibility}
|
||||||
selectedAsset={selectedAsset}
|
selectedAsset={selectedAsset}
|
||||||
multiSelectedIds={Array.from(multiSelectedIds)}
|
multiSelectedIds={Array.from(multiSelectedIds)}
|
||||||
|
blinkModified={blinkModified}
|
||||||
|
blinkTick={blinkTick}
|
||||||
onSelect={(a, additive) => {
|
onSelect={(a, additive) => {
|
||||||
if (additive) {
|
if (additive) {
|
||||||
if (!a) return;
|
if (!a) return;
|
||||||
@@ -4002,6 +4045,16 @@ function AppShell({
|
|||||||
/>
|
/>
|
||||||
<span className="label">Only deleted</span>
|
<span className="label">Only deleted</span>
|
||||||
</label>
|
</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}
|
{videosExpanded ? "▼" : "▶"} Videos — all {counts.byVideo.size}
|
||||||
{filters.videos.size > 0 && ` (${filters.videos.size} picked)`}
|
{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>
|
</h4>
|
||||||
{videosExpanded && (
|
{videosExpanded && (
|
||||||
<>
|
<>
|
||||||
@@ -4127,6 +4203,8 @@ function AppShell({
|
|||||||
completed: false,
|
completed: false,
|
||||||
completed_at: null,
|
completed_at: null,
|
||||||
completed_by: null,
|
completed_by: null,
|
||||||
|
modified_count: 0,
|
||||||
|
last_modified_at: null,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
const all = videoEntries.concat(extras);
|
const all = videoEntries.concat(extras);
|
||||||
@@ -4215,21 +4293,58 @@ function AppShell({
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={(ev) => {
|
onClick={(ev) => {
|
||||||
ev.preventDefault();
|
ev.preventDefault();
|
||||||
|
ev.stopPropagation();
|
||||||
flyToVideo(label);
|
flyToVideo(label);
|
||||||
}}
|
}}
|
||||||
title={`Fly to ${label}`}
|
title={`Fly to ${label}`}
|
||||||
style={{
|
style={{
|
||||||
marginLeft: 4,
|
marginLeft: 4,
|
||||||
padding: "0 6px",
|
marginRight: 6,
|
||||||
fontSize: 11,
|
padding: "0 8px",
|
||||||
border: "1px solid #ccc",
|
fontSize: 12,
|
||||||
|
border: "1px solid #3498db",
|
||||||
borderRadius: 3,
|
borderRadius: 3,
|
||||||
background: "#fff",
|
background: "#fff",
|
||||||
|
color: "#2980b9",
|
||||||
cursor: "pointer",
|
cursor: "pointer",
|
||||||
|
minWidth: 28,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
⤢
|
⤢
|
||||||
</button>
|
</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>
|
</label>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -6180,9 +6295,9 @@ function AppShell({
|
|||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
display: "flex",
|
display: "flex",
|
||||||
gap: 6,
|
flexDirection: "column",
|
||||||
|
gap: 4,
|
||||||
marginTop: 6,
|
marginTop: 6,
|
||||||
alignItems: "stretch",
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<select
|
<select
|
||||||
@@ -6194,19 +6309,19 @@ function AppShell({
|
|||||||
}
|
}
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
title="File format for the next export"
|
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="geojson">GeoJSON</option>
|
||||||
<option value="kml">KML</option>
|
<option value="kml">KML</option>
|
||||||
<option value="csv">CSV</option>
|
<option value="csv">CSV</option>
|
||||||
</select>
|
</select>
|
||||||
<button
|
<button
|
||||||
className="primary-btn"
|
className="primary-btn"
|
||||||
style={{ background: "#2c3e50", flex: 1 }}
|
style={{ background: "#2c3e50", width: "100%" }}
|
||||||
onClick={handleExport}
|
onClick={handleExport}
|
||||||
disabled={busy}
|
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…
|
Export data…
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -70,6 +70,8 @@ type Props = {
|
|||||||
selectedAsset?: Asset | null;
|
selectedAsset?: Asset | null;
|
||||||
onSelect?: (asset: Asset | null, additive?: boolean) => void;
|
onSelect?: (asset: Asset | null, additive?: boolean) => void;
|
||||||
multiSelectedIds?: number[];
|
multiSelectedIds?: number[];
|
||||||
|
blinkModified?: boolean;
|
||||||
|
blinkTick?: number;
|
||||||
onAssetRightClick?: (asset: Asset) => void;
|
onAssetRightClick?: (asset: Asset) => void;
|
||||||
onLinkClick?: (idA: number, idB: number) => void;
|
onLinkClick?: (idA: number, idB: number) => void;
|
||||||
onMetadataLineClick?: (videoName: string) => void;
|
onMetadataLineClick?: (videoName: string) => void;
|
||||||
@@ -146,6 +148,8 @@ export function MapView({
|
|||||||
selectedAsset,
|
selectedAsset,
|
||||||
onSelect,
|
onSelect,
|
||||||
multiSelectedIds = [],
|
multiSelectedIds = [],
|
||||||
|
blinkModified = false,
|
||||||
|
blinkTick = 0,
|
||||||
onAssetRightClick,
|
onAssetRightClick,
|
||||||
onLinkClick,
|
onLinkClick,
|
||||||
onMetadataLineClick,
|
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) {
|
if (multiSelectedIds.length > 0) {
|
||||||
const idSet = new Set(multiSelectedIds);
|
const idSet = new Set(multiSelectedIds);
|
||||||
const halos: Array<[number, number]> = [];
|
const halos: Array<[number, number]> = [];
|
||||||
|
|||||||
Reference in New Issue
Block a user