Add customizable date binning on map. Closes #330.

This commit is contained in:
Jack Kingsman
2026-07-21 11:54:09 -07:00
parent bc22f537d9
commit 6f743446b0
2 changed files with 271 additions and 21 deletions
+162 -19
View File
@@ -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<MapSinceId>(getSavedSinceId);
const [customSince, setCustomSince] = useState('');
const [nowSec, setNowSec] = useState(() => Date.now() / 1000);
const [selectedLayerId, setSelectedLayerId] = useState<string>(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<Record<string, LeafletCircleMarker | null>>({});
@@ -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 (
<div className="flex flex-col h-full">
@@ -907,6 +990,66 @@ export function MapView({
/>{' '}
repeater
</span>
{/* "Heard since" filter. Hidden in discovery mode, which selects
nodes by live traffic rather than by recency. */}
{!(showPackets && discoveryMode) && (
<div
className="flex flex-wrap items-center gap-1"
role="group"
aria-label="Show nodes heard since"
>
<span className="text-[0.6875rem] text-muted-foreground">Since</span>
{MAP_SINCE_PRESETS.map((preset) => (
<button
key={preset.id}
type="button"
onClick={() => setSinceId(preset.id)}
aria-pressed={sinceId === preset.id}
className={cn(
'rounded px-1.5 py-0.5 text-[0.625rem] uppercase tracking-wider transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
sinceId === preset.id
? 'bg-primary/10 text-primary font-medium'
: 'bg-muted hover:bg-accent'
)}
>
{preset.label}
</button>
))}
<button
type="button"
onClick={() => setSinceId('custom')}
aria-pressed={sinceId === 'custom'}
className={cn(
'rounded px-1.5 py-0.5 text-[0.625rem] uppercase tracking-wider transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
sinceId === 'custom'
? 'bg-primary/10 text-primary font-medium'
: 'bg-muted hover:bg-accent'
)}
>
Custom
</button>
{sinceId === 'custom' && (
<>
<input
type="datetime-local"
value={customSince}
onChange={(e) => 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 && (
<button
type="button"
onClick={() => setCustomSince('')}
className="rounded px-1.5 py-0.5 text-[0.625rem] uppercase tracking-wider bg-muted hover:bg-accent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
Clear
</button>
)}
</>
)}
</div>
)}
<label className="flex items-center gap-1.5 cursor-pointer">
<input
type="checkbox"
+109 -2
View File
@@ -133,7 +133,7 @@ describe('MapView', () => {
expect(screen.getByText('Static')).toBeInTheDocument();
});
it('keeps the 7-day cutoff stable for the lifetime of the mounted map', () => {
it('keeps the relative cutoff stable across re-renders that do not advance the clock', () => {
vi.useFakeTimers();
try {
vi.setSystemTime(new Date('2026-03-15T12:00:00Z'));
@@ -164,15 +164,122 @@ describe('MapView', () => {
expect(screen.getByText(/showing 1 contact heard in the last 7 days/i)).toBeInTheDocument();
vi.advanceTimersByTime(2 * 60 * 1000);
// Re-rendering alone must not recompute the cutoff — that was the memo
// thrash this guards against (see "Reduce memo thrash on map update").
rerender(<MapView contacts={[contact]} focusedKey={null} />);
expect(screen.getByText(/showing 1 contact heard in the last 7 days/i)).toBeInTheDocument();
expect(screen.getByText('Almost Stale')).toBeInTheDocument();
} finally {
vi.useRealTimers();
}
});
describe('"heard since" filter', () => {
function contactLastSeen(name: string, key: string, lastSeen: number | null): Contact {
return {
public_key: key.repeat(32),
name,
type: 1,
flags: 0,
direct_path: null,
direct_path_len: -1,
direct_path_hash_mode: -1,
route_override_path: null,
route_override_len: null,
route_override_hash_mode: null,
last_advert: null,
lat: 40,
lon: -74,
last_seen: lastSeen,
on_radio: false,
favorite: false,
last_contacted: null,
last_read_at: null,
first_seen: null,
};
}
const nowSec = () => Math.floor(Date.now() / 1000);
it('narrows to a relative preset and restores on a wider one', () => {
const fresh = contactLastSeen('Fresh Node', 'aa', nowSec() - 60);
const older = contactLastSeen('Older Node', 'bb', nowSec() - 5 * 60 * 60);
render(<MapView contacts={[fresh, older]} />);
// Default window is 7 days, so both are visible.
expect(screen.getByText('Fresh Node')).toBeInTheDocument();
expect(screen.getByText('Older Node')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '<1h' }));
expect(screen.getByText('Fresh Node')).toBeInTheDocument();
expect(screen.queryByText('Older Node')).toBeNull();
expect(screen.getByText(/heard in the last 1 hour/i)).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '<1d' }));
expect(screen.getByText('Older Node')).toBeInTheDocument();
});
it('reveals contacts older than the previous 7-day ceiling under "All"', () => {
const ancient = contactLastSeen('Ancient Node', 'cc', nowSec() - 30 * 24 * 60 * 60);
render(<MapView contacts={[ancient]} />);
// Previously the map capped at 7 days and this node was unreachable.
expect(screen.queryByText('Ancient Node')).toBeNull();
fireEvent.click(screen.getByRole('button', { name: 'All' }));
expect(screen.getByText('Ancient Node')).toBeInTheDocument();
expect(screen.getByText(/heard at any time/i)).toBeInTheDocument();
});
it('treats a custom datetime as local wall-clock time', () => {
vi.useFakeTimers();
try {
vi.setSystemTime(new Date('2026-03-15T12:00:00'));
// 11:00 and 13:00 local, either side of a 12:30 local cutoff.
const before = contactLastSeen(
'Before Cutoff',
'dd',
Math.floor(new Date('2026-03-15T11:00:00').getTime() / 1000)
);
const after = contactLastSeen(
'After Cutoff',
'ee',
Math.floor(new Date('2026-03-15T13:00:00').getTime() / 1000)
);
render(<MapView contacts={[before, after]} />);
fireEvent.click(screen.getByRole('button', { name: 'Custom' }));
fireEvent.change(screen.getByLabelText(/heard since \(local time\)/i), {
target: { value: '2026-03-15T12:30' },
});
expect(screen.getByText('After Cutoff')).toBeInTheDocument();
expect(screen.queryByText('Before Cutoff')).toBeNull();
} finally {
vi.useRealTimers();
}
});
it('always shows the focused contact even when it falls outside the window', () => {
const stale = contactLastSeen('Stale Focus', 'ff', nowSec() - 30 * 24 * 60 * 60);
render(<MapView contacts={[stale]} focusedKey={stale.public_key} />);
fireEvent.click(screen.getByRole('button', { name: '<1h' }));
expect(screen.getByText('Stale Focus')).toBeInTheDocument();
expect(screen.getByText(/plus the focused contact/i)).toBeInTheDocument();
});
});
it('excludes contacts whose public key is in blockedKeys', () => {
const visible: Contact = {
public_key: 'aa'.repeat(32),