Preserve repeater values when browsing away

This commit is contained in:
Jack Kingsman
2026-03-10 15:40:26 -07:00
parent bb4a601788
commit 1bf760121d
5 changed files with 204 additions and 12 deletions

View File

@@ -80,6 +80,28 @@ export function formatAdvertInterval(val: string | null): string {
return `${trimmed}h`;
}
function formatFetchedRelative(fetchedAt: number): string {
const elapsedSeconds = Math.max(0, Math.floor((Date.now() - fetchedAt) / 1000));
if (elapsedSeconds < 60) return 'Just now';
const elapsedMinutes = Math.floor(elapsedSeconds / 60);
if (elapsedMinutes < 60) {
return `${elapsedMinutes} minute${elapsedMinutes === 1 ? '' : 's'} ago`;
}
const elapsedHours = Math.floor(elapsedMinutes / 60);
return `${elapsedHours} hour${elapsedHours === 1 ? '' : 's'} ago`;
}
function formatFetchedTime(fetchedAt: number): string {
return new Date(fetchedAt).toLocaleTimeString([], {
hour: 'numeric',
minute: '2-digit',
second: '2-digit',
});
}
// --- Generic Pane Wrapper ---
export function RepeaterPane({
@@ -99,10 +121,22 @@ export function RepeaterPane({
className?: string;
contentClassName?: string;
}) {
const fetchedAt = state.fetched_at ?? null;
return (
<div className={cn('border border-border rounded-lg overflow-hidden', className)}>
<div className="flex items-center justify-between px-3 py-2 bg-muted/50 border-b border-border">
<h3 className="text-sm font-medium">{title}</h3>
<div className="min-w-0">
<h3 className="text-sm font-medium">{title}</h3>
{fetchedAt && (
<p
className="text-[11px] text-muted-foreground"
title={new Date(fetchedAt).toLocaleString()}
>
Fetched {formatFetchedTime(fetchedAt)} ({formatFetchedRelative(fetchedAt)})
</p>
)}
</div>
{onRefresh && (
<button
type="button"

View File

@@ -17,6 +17,7 @@ import type {
const MAX_RETRIES = 3;
const RETRY_DELAY_MS = 2000;
const MAX_CACHED_REPEATERS = 20;
interface ConsoleEntry {
command: string;
@@ -35,7 +36,15 @@ interface PaneData {
lppTelemetry: RepeaterLppTelemetryResponse | null;
}
const INITIAL_PANE_STATE: PaneState = { loading: false, attempt: 0, error: null };
interface RepeaterDashboardCacheEntry {
loggedIn: boolean;
loginError: string | null;
paneData: PaneData;
paneStates: Record<PaneName, PaneState>;
consoleHistory: ConsoleEntry[];
}
const INITIAL_PANE_STATE: PaneState = { loading: false, attempt: 0, error: null, fetched_at: null };
function createInitialPaneStates(): Record<PaneName, PaneState> {
return {
@@ -61,6 +70,67 @@ function createInitialPaneData(): PaneData {
};
}
const repeaterDashboardCache = new Map<string, RepeaterDashboardCacheEntry>();
function clonePaneData(data: PaneData): PaneData {
return { ...data };
}
function normalizePaneStates(paneStates: Record<PaneName, PaneState>): Record<PaneName, PaneState> {
return {
status: { ...paneStates.status, loading: false },
neighbors: { ...paneStates.neighbors, loading: false },
acl: { ...paneStates.acl, loading: false },
radioSettings: { ...paneStates.radioSettings, loading: false },
advertIntervals: { ...paneStates.advertIntervals, loading: false },
ownerInfo: { ...paneStates.ownerInfo, loading: false },
lppTelemetry: { ...paneStates.lppTelemetry, loading: false },
};
}
function cloneConsoleHistory(consoleHistory: ConsoleEntry[]): ConsoleEntry[] {
return consoleHistory.map((entry) => ({ ...entry }));
}
function getCachedState(publicKey: string | null): RepeaterDashboardCacheEntry | null {
if (!publicKey) return null;
const cached = repeaterDashboardCache.get(publicKey);
if (!cached) return null;
repeaterDashboardCache.delete(publicKey);
repeaterDashboardCache.set(publicKey, cached);
return {
loggedIn: cached.loggedIn,
loginError: cached.loginError,
paneData: clonePaneData(cached.paneData),
paneStates: normalizePaneStates(cached.paneStates),
consoleHistory: cloneConsoleHistory(cached.consoleHistory),
};
}
function cacheState(publicKey: string, entry: RepeaterDashboardCacheEntry) {
repeaterDashboardCache.delete(publicKey);
repeaterDashboardCache.set(publicKey, {
loggedIn: entry.loggedIn,
loginError: entry.loginError,
paneData: clonePaneData(entry.paneData),
paneStates: normalizePaneStates(entry.paneStates),
consoleHistory: cloneConsoleHistory(entry.consoleHistory),
});
if (repeaterDashboardCache.size > MAX_CACHED_REPEATERS) {
const lruKey = repeaterDashboardCache.keys().next().value as string | undefined;
if (lruKey) {
repeaterDashboardCache.delete(lruKey);
}
}
}
export function resetRepeaterDashboardCacheForTests() {
repeaterDashboardCache.clear();
}
// Maps pane name to the API call
function fetchPaneData(publicKey: string, pane: PaneName) {
switch (pane) {
@@ -102,15 +172,24 @@ export interface UseRepeaterDashboardResult {
export function useRepeaterDashboard(
activeConversation: Conversation | null
): UseRepeaterDashboardResult {
const [loggedIn, setLoggedIn] = useState(false);
const conversationId =
activeConversation && activeConversation.type === 'contact' ? activeConversation.id : null;
const cachedState = getCachedState(conversationId);
const [loggedIn, setLoggedIn] = useState(cachedState?.loggedIn ?? false);
const [loginLoading, setLoginLoading] = useState(false);
const [loginError, setLoginError] = useState<string | null>(null);
const [loginError, setLoginError] = useState<string | null>(cachedState?.loginError ?? null);
const [paneData, setPaneData] = useState<PaneData>(createInitialPaneData);
const [paneStates, setPaneStates] =
useState<Record<PaneName, PaneState>>(createInitialPaneStates);
const [paneData, setPaneData] = useState<PaneData>(
cachedState?.paneData ?? createInitialPaneData
);
const [paneStates, setPaneStates] = useState<Record<PaneName, PaneState>>(
cachedState?.paneStates ?? createInitialPaneStates
);
const [consoleHistory, setConsoleHistory] = useState<ConsoleEntry[]>([]);
const [consoleHistory, setConsoleHistory] = useState<ConsoleEntry[]>(
cachedState?.consoleHistory ?? []
);
const [consoleLoading, setConsoleLoading] = useState(false);
// Track which conversation we're operating on to avoid stale updates after
@@ -120,6 +199,10 @@ export function useRepeaterDashboard(
// Guard against setting state after unmount (retry timers firing late)
const mountedRef = useRef(true);
useEffect(() => {
activeIdRef.current = conversationId;
}, [conversationId]);
useEffect(() => {
mountedRef.current = true;
return () => {
@@ -127,6 +210,17 @@ export function useRepeaterDashboard(
};
}, []);
useEffect(() => {
if (!conversationId) return;
cacheState(conversationId, {
loggedIn,
loginError,
paneData,
paneStates,
consoleHistory,
});
}, [consoleHistory, conversationId, loggedIn, loginError, paneData, paneStates]);
const getPublicKey = useCallback((): string | null => {
if (!activeConversation || activeConversation.type !== 'contact') return null;
return activeConversation.id;
@@ -172,7 +266,12 @@ export function useRepeaterDashboard(
setPaneStates((prev) => ({
...prev,
[pane]: { loading: true, attempt, error: null },
[pane]: {
loading: true,
attempt,
error: null,
fetched_at: prev[pane].fetched_at ?? null,
},
}));
try {
@@ -182,7 +281,7 @@ export function useRepeaterDashboard(
setPaneData((prev) => ({ ...prev, [pane]: data }));
setPaneStates((prev) => ({
...prev,
[pane]: { loading: false, attempt, error: null },
[pane]: { loading: false, attempt, error: null, fetched_at: Date.now() },
}));
return; // Success
} catch (err) {
@@ -193,7 +292,12 @@ export function useRepeaterDashboard(
if (attempt === MAX_RETRIES) {
setPaneStates((prev) => ({
...prev,
[pane]: { loading: false, attempt, error: msg },
[pane]: {
loading: false,
attempt,
error: msg,
fetched_at: prev[pane].fetched_at ?? null,
},
}));
toast.error(`Failed to fetch ${pane}`, { description: msg });
} else {

View File

@@ -244,6 +244,20 @@ describe('RepeaterDashboard', () => {
expect(screen.getByText('7.5 dB')).toBeInTheDocument();
});
it('shows fetched time and relative age when pane data has been loaded', () => {
mockHook.loggedIn = true;
mockHook.paneStates.status = {
loading: false,
attempt: 1,
error: null,
fetched_at: Date.now(),
};
render(<RepeaterDashboard {...defaultProps} />);
expect(screen.getByText(/Fetched .*Just now/)).toBeInTheDocument();
});
it('renders action buttons', () => {
mockHook.loggedIn = true;

View File

@@ -1,7 +1,10 @@
import { StrictMode, createElement, type ReactNode } from 'react';
import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
import { renderHook, act } from '@testing-library/react';
import { useRepeaterDashboard } from '../hooks/useRepeaterDashboard';
import {
resetRepeaterDashboardCacheForTests,
useRepeaterDashboard,
} from '../hooks/useRepeaterDashboard';
import type { Conversation } from '../types';
// Mock the api module
@@ -43,6 +46,7 @@ const repeaterConversation: Conversation = {
describe('useRepeaterDashboard', () => {
beforeEach(() => {
vi.clearAllMocks();
resetRepeaterDashboardCacheForTests();
});
it('starts with logged out state', () => {
@@ -123,6 +127,7 @@ describe('useRepeaterDashboard', () => {
expect(result.current.paneData.status).toEqual(statusData);
expect(result.current.paneStates.status.loading).toBe(false);
expect(result.current.paneStates.status.error).toBe(null);
expect(result.current.paneStates.status.fetched_at).toEqual(expect.any(Number));
});
it('refreshPane still issues requests under StrictMode remount probing', async () => {
@@ -304,4 +309,38 @@ describe('useRepeaterDashboard', () => {
expect(mockApi.repeaterOwnerInfo).toHaveBeenCalledTimes(1);
expect(mockApi.repeaterLppTelemetry).toHaveBeenCalledTimes(1);
});
it('restores dashboard state when navigating away and back to the same repeater', async () => {
const statusData = { battery_volts: 4.2 };
mockApi.repeaterLogin.mockResolvedValueOnce({ status: 'ok' });
mockApi.repeaterStatus.mockResolvedValueOnce(statusData);
mockApi.sendRepeaterCommand.mockResolvedValueOnce({
command: 'ver',
response: 'v2.1.0',
sender_timestamp: 1000,
});
const firstMount = renderHook(() => useRepeaterDashboard(repeaterConversation));
await act(async () => {
await firstMount.result.current.login('secret');
await firstMount.result.current.refreshPane('status');
await firstMount.result.current.sendConsoleCommand('ver');
});
expect(firstMount.result.current.loggedIn).toBe(true);
expect(firstMount.result.current.paneData.status).toEqual(statusData);
expect(firstMount.result.current.consoleHistory).toHaveLength(2);
firstMount.unmount();
const secondMount = renderHook(() => useRepeaterDashboard(repeaterConversation));
expect(secondMount.result.current.loggedIn).toBe(true);
expect(secondMount.result.current.loginError).toBe(null);
expect(secondMount.result.current.paneData.status).toEqual(statusData);
expect(secondMount.result.current.paneStates.status.loading).toBe(false);
expect(secondMount.result.current.consoleHistory).toHaveLength(2);
expect(secondMount.result.current.consoleHistory[1].response).toBe('v2.1.0');
});
});

View File

@@ -364,6 +364,7 @@ export interface PaneState {
loading: boolean;
attempt: number;
error: string | null;
fetched_at?: number | null;
}
export interface TraceResponse {