From 6f743446b075a3e93d8e9273780e23e370fe20b0 Mon Sep 17 00:00:00 2001 From: Jack Kingsman Date: Tue, 21 Jul 2026 11:54:09 -0700 Subject: [PATCH] Add customizable date binning on map. Closes #330. --- frontend/src/components/MapView.tsx | 181 +++++++++++++++++++++++++--- frontend/src/test/mapView.test.tsx | 111 ++++++++++++++++- 2 files changed, 271 insertions(+), 21 deletions(-) diff --git a/frontend/src/components/MapView.tsx b/frontend/src/components/MapView.tsx index fd08b66..bc2e462 100644 --- a/frontend/src/components/MapView.tsx +++ b/frontend/src/components/MapView.tsx @@ -23,6 +23,7 @@ import { dedupeConsecutive, } from '../utils/visualizerUtils'; import { getRawPacketObservationKey } from '../utils/rawPacketIdentity'; +import { cn } from '@/lib/utils'; interface MapViewProps { contacts: Contact[]; @@ -167,6 +168,50 @@ const MAP_RECENCY_COLORS = { const MAP_MARKER_STROKE = '#0f172a'; const MAP_REPEATER_RING = '#f8fafc'; +// --- "Heard since" filter --- +// Relative presets mirror the marker recency legend so the chips and the dot +// colors describe the same buckets. `seconds: null` means "no lower bound". +const MAP_SINCE_PRESETS = [ + { id: '1h', label: '<1h', windowLabel: '1 hour', seconds: 3600 }, + { id: '1d', label: '<1d', windowLabel: '24 hours', seconds: 24 * 60 * 60 }, + { id: '3d', label: '<3d', windowLabel: '3 days', seconds: 3 * 24 * 60 * 60 }, + { id: '7d', label: '7d', windowLabel: '7 days', seconds: 7 * 24 * 60 * 60 }, + { id: 'all', label: 'All', windowLabel: null, seconds: null }, +] as const; + +type MapSinceId = (typeof MAP_SINCE_PRESETS)[number]['id'] | 'custom'; + +const DEFAULT_MAP_SINCE_ID: MapSinceId = '7d'; +const MAP_SINCE_STORAGE_KEY = 'remoteterm-map-since'; + +/** Relative presets drift as time passes, so recompute the cutoff on this cadence. */ +const MAP_SINCE_TICK_MS = 60_000; + +function getSavedSinceId(): MapSinceId { + try { + const stored = localStorage.getItem(MAP_SINCE_STORAGE_KEY); + // 'custom' is deliberately not restored: a stale absolute timestamp from a + // previous session would silently filter the map on load. + if (stored && MAP_SINCE_PRESETS.some((p) => p.id === stored)) { + return stored as MapSinceId; + } + } catch { + // localStorage may be disabled; fall through to the default. + } + return DEFAULT_MAP_SINCE_ID; +} + +/** + * Convert a `datetime-local` value (local wall clock, no offset) to epoch + * seconds. Per spec `new Date()` interprets the date-time form in the browser's + * local zone, which is what we want to compare against UTC-anchored `last_seen`. + */ +function localDateTimeToEpochSec(value: string): number | null { + if (!value) return null; + const ms = new Date(value).getTime(); + return Number.isNaN(ms) ? null : ms / 1000; +} + // --- Packet visualization constants --- const THREE_DAYS_SEC = 3 * 24 * 60 * 60; const PARTICLE_LIFETIME_MS = 3000; @@ -502,7 +547,9 @@ export function MapView({ blockedNames, onSelectContact, }: MapViewProps) { - const [sevenDaysAgo] = useState(() => Date.now() / 1000 - 7 * 24 * 60 * 60); + const [sinceId, setSinceId] = useState(getSavedSinceId); + const [customSince, setCustomSince] = useState(''); + const [nowSec, setNowSec] = useState(() => Date.now() / 1000); const [selectedLayerId, setSelectedLayerId] = useState(getSavedLayerId); const activeLayer = TILE_LAYERS.find((l) => l.id === selectedLayerId) ?? TILE_LAYERS[0]; @@ -562,9 +609,45 @@ export function MapView({ return [config.lat, config.lon]; }, [config]); - // Determine time window for packet visualization + // Determine time window for packet visualization. This bounds packet replay + // only; node visibility is governed by the "heard since" filter below. const threeDaysAgoSec = useMemo(() => Date.now() / 1000 - THREE_DAYS_SEC, []); + const activeSincePreset = MAP_SINCE_PRESETS.find((p) => p.id === sinceId) ?? null; + const sinceIsRelative = activeSincePreset != null && activeSincePreset.seconds != null; + + // Only tick while a relative preset is active — "All" and absolute custom + // cutoffs are fixed, so re-rendering the map on a timer would be pure waste. + useEffect(() => { + if (!sinceIsRelative) return; + const timer = setInterval(() => setNowSec(Date.now() / 1000), MAP_SINCE_TICK_MS); + return () => clearInterval(timer); + }, [sinceIsRelative]); + + useEffect(() => { + try { + if (sinceId === 'custom') return; // session-only; see getSavedSinceId + localStorage.setItem(MAP_SINCE_STORAGE_KEY, sinceId); + } catch { + // localStorage may be disabled; selection stays in memory only. + } + }, [sinceId]); + + /** Epoch seconds; `null` means no lower bound (show everything ever heard). */ + const sinceCutoffSec = useMemo(() => { + if (sinceId === 'custom') return localDateTimeToEpochSec(customSince); + if (!activeSincePreset || activeSincePreset.seconds == null) return null; + return nowSec - activeSincePreset.seconds; + }, [sinceId, customSince, activeSincePreset, nowSec]); + + const isWithinSinceWindow = useCallback( + (lastSeen: number | null | undefined) => { + if (sinceCutoffSec == null) return true; + return lastSeen != null && lastSeen > sinceCutoffSec; + }, + [sinceCutoffSec] + ); + // Filter contacts for map display const mappableContacts = useMemo(() => { const isBlocked = (c: Contact) => @@ -577,26 +660,18 @@ export function MapView({ (c) => isValidLocation(c.lat, c.lon) && discoveredKeys.has(c.public_key) && !isBlocked(c) ); } - if (showPackets) { - // Packet mode: show only last 3 days - return contacts.filter( - (c) => - isValidLocation(c.lat, c.lon) && - !isBlocked(c) && - (c.public_key === focusedKey || (c.last_seen != null && c.last_seen > threeDaysAgoSec)) - ); - } + // Both packet and normal mode honour the user's "heard since" filter. The + // focused contact is always shown so deep links never land on a blank map. return contacts.filter( (c) => isValidLocation(c.lat, c.lon) && !isBlocked(c) && - (c.public_key === focusedKey || (c.last_seen != null && c.last_seen > sevenDaysAgo)) + (c.public_key === focusedKey || isWithinSinceWindow(c.last_seen)) ); }, [ contacts, focusedKey, - sevenDaysAgo, - threeDaysAgoSec, + isWithinSinceWindow, showPackets, discoveryMode, discoveredKeys, @@ -766,9 +841,7 @@ export function MapView({ }, [focusedKey, mappableContacts]); const includesFocusedOutsideWindow = - focusedContact != null && - (focusedContact.last_seen == null || - focusedContact.last_seen <= (showPackets ? threeDaysAgoSec : sevenDaysAgo)); + focusedContact != null && !isWithinSinceWindow(focusedContact.last_seen); // Track marker refs to open popup programmatically const markerRefs = useRef>({}); @@ -813,11 +886,21 @@ export function MapView({ return lines; }, [showPackets, particles]); - const timeWindowLabel = showPackets ? '3 days' : '7 days'; + const sinceLabel = useMemo(() => { + if (sinceId === 'custom') { + return sinceCutoffSec == null + ? 'at any time' + : `since ${new Date(sinceCutoffSec * 1000).toLocaleString()}`; + } + if (!activeSincePreset || activeSincePreset.windowLabel == null) return 'at any time'; + return `in the last ${activeSincePreset.windowLabel}`; + }, [sinceId, sinceCutoffSec, activeSincePreset]); + + const contactCountLabel = `${mappableContacts.length} contact${mappableContacts.length !== 1 ? 's' : ''}`; const infoLabel = showPackets && discoveryMode ? `${mappableContacts.length} node${mappableContacts.length !== 1 ? 's' : ''} discovered from live traffic` - : `Showing ${mappableContacts.length} contact${mappableContacts.length !== 1 ? 's' : ''} heard in the last ${timeWindowLabel}${includesFocusedOutsideWindow ? ' plus the focused contact' : ''}`; + : `Showing ${contactCountLabel} heard ${sinceLabel}${includesFocusedOutsideWindow ? ' plus the focused contact' : ''}`; return (
@@ -907,6 +990,66 @@ export function MapView({ />{' '} repeater + {/* "Heard since" filter. Hidden in discovery mode, which selects + nodes by live traffic rather than by recency. */} + {!(showPackets && discoveryMode) && ( +
+ Since + {MAP_SINCE_PRESETS.map((preset) => ( + + ))} + + {sinceId === 'custom' && ( + <> + setCustomSince(e.target.value)} + aria-label="Show nodes heard since (local time)" + className="rounded border border-input bg-background px-1.5 py-0.5 text-[0.6875rem] text-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring" + /> + {customSince && ( + + )} + + )} +
+ )}