mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-08-06 08:43:36 +02:00
Add customizable date binning on map. Closes #330.
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user