feat(analytics): "My Repeaters" favorites monitoring dashboard (#1761)

My Repeaters monitoring dashboard. Closes #1765.

---------

Co-authored-by: Waydroid Builder <build@waydroid.local>
This commit is contained in:
Michael J. Arcan
2026-06-30 00:51:26 -07:00
committed by GitHub
co-authored by Waydroid Builder
parent 30e4151f7a
commit 4654ce3386
5 changed files with 419 additions and 3 deletions
+219 -1
View File
@@ -120,6 +120,7 @@
<button class="tab-btn" data-tab="collisions">Hash Issues</button>
<button class="tab-btn" data-tab="subpaths">Route Patterns</button>
<button class="tab-btn" data-tab="nodes">Nodes</button>
<button class="tab-btn" data-tab="my-repeaters">My Repeaters</button>
<button class="tab-btn" data-tab="distance">Distance</button>
<button class="tab-btn" data-tab="neighbor-graph">Neighbor Graph</button>
<button class="tab-btn" data-tab="rf-health">RF Health</button>
@@ -138,7 +139,7 @@
</div>`;
// Tabs where the area filter is meaningful (transmitter GPS attribution)
const AREA_FILTER_TABS = new Set(['overview', 'rf', 'topology', 'hashsizes', 'collisions', 'nodes', 'clock-health']);
const AREA_FILTER_TABS = new Set(['overview', 'rf', 'topology', 'hashsizes', 'collisions', 'nodes', 'my-repeaters', 'clock-health']);
function setAreaFilterVisibility(tab) {
const el = document.getElementById('analyticsAreaFilter');
@@ -284,6 +285,7 @@
case 'collisions': await renderCollisionTab(el, d.hashData, d.collisionData); break;
case 'subpaths': await renderSubpaths(el); break;
case 'nodes': await renderNodesTab(el); break;
case 'my-repeaters': await renderMyRepeatersTab(el); break;
case 'distance': await renderDistanceTab(el); break;
case 'neighbor-graph': await renderNeighborGraphTab(el); break;
case 'rf-health': await renderRFHealthTab(el); break;
@@ -2376,6 +2378,222 @@
}
}
// ===================== MY REPEATERS (favorites monitoring dashboard) =====================
// An at-a-glance monitor over the repeaters the operator has starred
// (meshcore-favorites). Pure frontend aggregation over existing endpoints:
// /api/nodes relay activity + traffic/bridge scores
// /api/nodes/clock-skew per-node "is the clock OK" severity
// /api/analytics/neighbor-graph affinity edges, filtered to favorites
// ("throughput between my repeaters")
function _mrSummaryCard(value, label, color) {
return `<div class="analytics-stat-card" style="flex:1;min-width:110px;text-align:center;padding:16px;background:var(--card-bg);border:1px solid var(--border);border-radius:8px">
<div style="font-size:26px;font-weight:700${color ? ';color:' + color : ''}">${esc(String(value))}</div>
<div style="font-size:11px;text-transform:uppercase;color:var(--text-muted)">${esc(label)}</div>
</div>`;
}
// Status is carried by colour AND text — never colour alone — so it is
// legible to color-blind users and screen readers.
const MR_STATUS_META = {
active: { color: 'var(--status-green)', label: 'Active' },
degraded: { color: 'var(--status-yellow)', label: 'Degraded' },
silent: { color: 'var(--status-red)', label: 'Silent' },
unknown: { color: 'var(--text-muted)', label: 'Unknown' },
};
function _mrStatusCell(status) {
// Fall back to an explicit "Unknown" rather than silently mislabelling an
// unexpected status as "Silent".
const m = MR_STATUS_META[status] || MR_STATUS_META.unknown;
return `<span style="display:inline-flex;gap:5px;align-items:center;white-space:nowrap"><span style="color:${m.color}" aria-hidden="true"><svg class="ph-icon"><use href="/icons/phosphor-sprite.svg#ph-circle-fill"/></svg></span><span style="font-size:11px">${m.label}</span></span>`;
}
function _mrEmptyState() {
return `
<div class="analytics-section" style="text-align:center;padding:48px 24px;color:var(--text-muted)">
<svg class="ph-icon" aria-hidden="true" style="width:40px;height:40px;opacity:0.6"><use href="/icons/phosphor-sprite.svg#ph-star"/></svg>
<h3 style="margin:12px 0 6px">No favorite repeaters yet</h3>
<p>Star a repeater (the <svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-star"/></svg> on the <a href="#/nodes" class="analytics-link">Nodes</a> page or a node's detail) to add it to your watch-list. Starred repeaters show up here for at-a-glance monitoring.</p>
</div>`;
}
async function renderMyRepeatersTab(el) {
const haveFavs = () => (typeof getFavorites === 'function' ? getFavorites() : []);
if (!haveFavs().length) { el.innerHTML = _mrEmptyState(); return; }
el.innerHTML = '<div style="padding:40px;text-align:center;color:var(--text-muted)">Loading your repeaters…</div>';
try {
const rq = RegionFilter.regionQueryString() + AreaFilter.areaQueryString();
// When a region/area filter is active the node fetch is scoped, so a
// starred repeater outside that scope won't come back. Surface that count
// instead of silently dropping it from the watch-list (#1761 MAJOR).
const filterActive = rq.length > 0;
// Fetch the three sources ONCE; star toggles re-paint from this cache
// (paint() below) instead of re-hitting the network. Trade-off: this
// pages the full node list (up to ~10k) to keep only the starred few —
// fine for a handful of favorites and amortized by CLIENT_TTL.nodeList
// across tabs. If favorite counts ever grow large, switch to per-node
// Promise.all(favs.map(pk => api('/nodes/' + pk))).
const [nodesResp, clockData, graphData] = await Promise.all([
fetchAllNodes('&sortBy=lastSeen' + rq, { ttl: CLIENT_TTL.nodeList }),
api('/nodes/clock-skew', { ttl: CLIENT_TTL.analyticsRF }).catch(() => []),
api('/analytics/neighbor-graph?min_count=1&min_score=0', { ttl: CLIENT_TTL.analyticsRF }).catch(() => ({ nodes: [], edges: [] })),
]);
const allNodes = nodesResp.nodes || nodesResp;
const clockByPk = {};
(Array.isArray(clockData) ? clockData : []).forEach(c => { if (c && c.pubkey) clockByPk[c.pubkey] = c; });
const now = Date.now();
function ageOf(n) {
const ms = n.last_heard ? new Date(n.last_heard).getTime() : n.last_seen ? new Date(n.last_seen).getTime() : 0;
return ms ? now - ms : Infinity;
}
function statusOf(n) {
const th = (typeof getHealthThresholds === 'function') ? getHealthThresholds(n.role) : { degradedMs: 3600000, silentMs: 86400000 };
const age = ageOf(n);
return age < th.degradedMs ? 'active' : age < th.silentMs ? 'degraded' : 'silent';
}
const pct = v => (v != null ? (v * 100).toFixed(1) + '%' : '—');
// #1456: prefer traffic_share_score, fall back to usefulness_score.
const trafficOf = n => (n.traffic_share_score != null ? n.traffic_share_score : (n.usefulness_score != null ? n.usefulness_score : null));
function roleBadge(role) {
// Route the unknown-role fallback through ROLE_COLORS.unknown (backed by
// --mc-role-unknown / the shared Wong palette) instead of a hard-coded
// literal, so every swatch color flows through the CSS-variable system
// (#1761 nit). ROLE_COLORS always resolves .unknown.
const rc = window.ROLE_COLORS || {};
const c = rc[role] || rc.unknown || 'var(--role-unknown, #6b7280)';
const style = (window.aaBadgeStyle && window.aaBadgeStyle(c)) || ('background:' + c + ';color:#fff');
return `<span class="badge" style="${style}">${esc(role)}</span>`;
}
// Re-renders from the cached fetch using the LIVE favorites set, so an
// un-star drops the row with no refetch. Re-binds its stars after each
// paint (the innerHTML reset clears listeners).
function paint() {
const favs = new Set(haveFavs());
if (!favs.size) { el.innerHTML = _mrEmptyState(); return; }
const myNodes = allNodes.filter(n => favs.has(n.public_key) && (n.role === 'repeater' || n.role === 'room'));
const myKeys = new Set(myNodes.map(n => n.public_key));
// Favorites the scoped fetch didn't return — with a region/area filter
// active these are (most likely) outside the current scope. Surface the
// count so a hidden favorite isn't silently missing (#1761 MAJOR).
const allByPk = new Set(allNodes.map(n => n.public_key));
// Favorites absent from the scoped fetch: usually outside the active
// region/area filter, but the set also covers deleted/expired stars
// and role-filtered clients — so the notice stays causally neutral
// ("not shown with the active filter") rather than claiming a cause.
const hiddenByFilter = [...favs].filter(pk => !allByPk.has(pk)).length;
const filterNotice = (filterActive && hiddenByFilter > 0)
? `<p class="text-muted" style="display:flex;gap:6px;align-items:center"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-funnel"/></svg>${hiddenByFilter} favorite${hiddenByFilter === 1 ? '' : 's'} not shown with the active region/area filter.</p>`
: '';
let active = 0, degraded = 0, silent = 0, clockOk = 0, totalRelay24 = 0;
myNodes.forEach(n => {
const s = statusOf(n); if (s === 'active') active++; else if (s === 'degraded') degraded++; else silent++;
const cs = clockByPk[n.public_key];
if (cs && cs.severity === 'ok') clockOk++;
totalRelay24 += (n.relay_count_24h || 0);
});
const rank = { silent: 0, degraded: 1, active: 2 };
const sorted = myNodes.slice().sort((a, b) => {
const d = rank[statusOf(a)] - rank[statusOf(b)];
return d !== 0 ? d : (a.name || '').localeCompare(b.name || '');
});
const rowsHtml = sorted.map(n => {
const cs = clockByPk[n.public_key];
const clockCell = cs ? renderSkewBadge(cs.severity, currentSkewValue(cs), cs) : '<span class="text-muted">—</span>';
const battery = n.battery_mv != null ? (n.battery_mv / 1000).toFixed(2) + ' V' : '—';
return `<tr>
<td>${favStar(n.public_key)}</td>
<td><a href="#/nodes/${encodeURIComponent(n.public_key)}/analytics" class="analytics-link">${esc(n.name || n.public_key.slice(0, 12))}</a></td>
<td>${roleBadge(n.role)}</td>
<td>${_mrStatusCell(statusOf(n))}</td>
<td>${clockCell}</td>
<td style="text-align:right">${pct(trafficOf(n))}</td>
<td style="text-align:right">${pct(n.bridge_score)}</td>
<td style="text-align:right">${n.relay_count_1h != null ? n.relay_count_1h : '—'}</td>
<td style="text-align:right">${n.relay_count_24h != null ? n.relay_count_24h : '—'}</td>
<td>${n.last_relayed ? timeAgo(n.last_relayed) : '—'}</td>
<td>${(n.last_heard || n.last_seen) ? timeAgo(n.last_heard || n.last_seen) : '—'}</td>
<td style="text-align:right">${battery}</td>
</tr>`;
}).join('');
const nameByPk = {};
myNodes.forEach(n => { nameByPk[n.public_key] = n.name || n.public_key.slice(0, 12); });
const myEdges = ((graphData && graphData.edges) || [])
.filter(e => myKeys.has(e.source) && myKeys.has(e.target))
.sort((a, b) => (b.score || 0) - (a.score || 0));
let affinityHtml;
if (!myEdges.length) {
affinityHtml = `<p class="text-muted" style="padding:8px 0">No affinity links detected between your repeaters yet — they may be too far apart, or not enough shared traffic has been observed.</p>`;
} else {
affinityHtml = `<table class="analytics-table">
<thead><tr><th scope="col">Link</th><th scope="col" style="text-align:right">Affinity</th><th scope="col" style="text-align:right">Packets</th><th scope="col" style="text-align:right">Avg SNR</th></tr></thead>
<tbody>${myEdges.map(e => {
// The affinity graph is undirected — the server marks every edge
// bidirectional — so the link glyph is always a double arrow.
const arrow = '&harr;';
const snr = (e.avg_snr != null) ? e.avg_snr.toFixed(1) + ' dB' : '—';
return `<tr>
<td>${esc(nameByPk[e.source] || e.source.slice(0, 12))} ${arrow} ${esc(nameByPk[e.target] || e.target.slice(0, 12))}${e.ambiguous ? ' <svg class="ph-icon" role="img" aria-label="Path attribution ambiguous"><title>Path attribution ambiguous</title><use href="/icons/phosphor-sprite.svg#ph-question"/></svg>' : ''}</td>
<td style="text-align:right">${pct(e.score)}</td>
<td style="text-align:right">${e.weight != null ? e.weight : '—'}</td>
<td style="text-align:right">${snr}</td>
</tr>`;
}).join('')}</tbody>
</table>`;
}
el.innerHTML = `
<div class="analytics-section">
<h3><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-star-fill"/></svg> My Repeaters</h3>
<p class="text-muted">At-a-glance monitoring over the repeaters you have starred. Un-star a row to drop it from this list.</p>
${filterNotice}
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(120px,1fr));gap:16px;margin-bottom:20px">
${_mrSummaryCard(myNodes.length, 'My Repeaters')}
${_mrSummaryCard(active, 'Active')}
${_mrSummaryCard(degraded, 'Degraded')}
${_mrSummaryCard(silent, 'Silent')}
${_mrSummaryCard(clockOk + ' / ' + myNodes.length, 'Clock OK')}
${_mrSummaryCard(totalRelay24.toLocaleString(), 'Relays (24h)')}
</div>
<table class="analytics-table" id="my-repeaters-table">
<thead><tr>
<th scope="col" aria-label="Favorite"></th>
<th scope="col">Repeater</th>
<th scope="col">Role</th>
<th scope="col">Status</th>
<th scope="col">Clock</th>
<th scope="col" style="text-align:right">Traffic</th>
<th scope="col" style="text-align:right">Bridge</th>
<th scope="col" style="text-align:right" title="Relays in the last hour">Relays 1h</th>
<th scope="col" style="text-align:right" title="Relays in the last 24 hours">Relays 24h</th>
<th scope="col">Last Relayed</th>
<th scope="col">Last Heard</th>
<th scope="col" style="text-align:right">Battery</th>
</tr></thead>
<tbody>${rowsHtml || '<tr><td colspan="12" class="text-muted">None of your favorites are repeaters/rooms in this view.</td></tr>'}</tbody>
</table>
<h3 style="margin-top:28px"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-graph"/></svg> Links Between My Repeaters</h3>
<p class="text-muted">Affinity sub-graph filtered to your favorites how much traffic flows directly between them.</p>
${affinityHtml}
</div>`;
// Live un-star: re-paint from cache (no refetch) when a star toggles.
if (typeof bindFavStars === 'function') bindFavStars(el, paint);
}
paint();
} catch (e) {
el.innerHTML = `<div style="padding:40px;text-align:center;color:var(--status-red)">Failed to load your repeaters: ${esc(e.message)}</div>`;
}
}
// === MY REPEATERS BLOCK END (test harness slices the renderer block to here) ===
async function renderDistanceTab(el) {
try {
const rqs = RegionFilter.regionQueryString();
+9 -1
View File
@@ -1625,7 +1625,15 @@ window.addEventListener('DOMContentLoaded', () => {
+ '</a>';
}
}));
favDropdown.innerHTML = items.join('');
// Footer action, separated from the favorites list by a top border. It is
// deliberately not a favorite row (no star), so it carries no data-key the
// star handler would act on.
favDropdown.innerHTML = items.join('')
+ '<a href="#/analytics?tab=my-repeaters" class="fav-dd-item" style="border-top:1px solid var(--border)">'
+ '<span class="fav-dd-status"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-chart-line"/></svg></span>'
+ '<span class="fav-dd-name">Monitor my repeaters</span>'
+ '<span class="fav-dd-meta"></span>'
+ '</a>';
bindFavStars(favDropdown, () => renderFavDropdown());
// Close dropdown on link click
favDropdown.querySelectorAll('.fav-dd-item').forEach(a => {
+2 -1
View File
@@ -78,6 +78,7 @@ const ROUTES = [
'/analytics?tab=clock-health',
'/analytics?tab=scopes',
'/analytics?tab=prefix-tool',
'/analytics?tab=my-repeaters',
'/audio-lab',
];
@@ -92,7 +93,7 @@ const REGISTERED_PAGES = [
const REGISTERED_ANALYTICS_TABS = [
'overview', 'rf', 'topology', 'channels', 'hashsizes', 'collisions',
'subpaths', 'nodes', 'distance', 'neighbor-graph', 'rf-health',
'clock-health', 'roles', 'prefix-tool', 'scopes',
'clock-health', 'roles', 'prefix-tool', 'scopes', 'my-repeaters',
];
const THEMES = ['dark', 'light'];
+1
View File
@@ -15,6 +15,7 @@ node test-aging.js
node test-issue-1065-gesture-hints-gates.js
node test-frontend-helpers.js
node test-fetch-all-nodes-pagination.js
node test-my-repeaters-dashboard.js
node test-url-state.js
node test-perf-go-runtime.js
node test-channel-psk-ux.js
+188
View File
@@ -0,0 +1,188 @@
/**
* "My Repeaters" a favorites monitoring dashboard analytics tab.
*
* An at-a-glance monitor over the repeaters the operator has starred
* (meshcore-favorites). Frontend-only aggregation over /api/nodes,
* /api/nodes/clock-skew and /api/analytics/neighbor-graph; no server change.
*
* Two layers:
* - structural pins (file-grep) for tab WIRING that needs a DOM/app to run;
* - BEHAVIORAL tests that execute renderMyRepeatersTab against stub globals
* and assert on the rendered HTML including regression guards for the
* review fixes (escaping, status a11y text, fetch-once caching).
*/
'use strict';
const fs = require('fs');
const path = require('path');
let passed = 0, failed = 0;
function assert(cond, msg) {
if (cond) { passed++; console.log(' ✓ ' + msg); }
else { failed++; console.error(' ✗ ' + msg); }
}
const a = fs.readFileSync(path.join(__dirname, 'public', 'analytics.js'), 'utf8');
const app = fs.readFileSync(path.join(__dirname, 'public', 'app.js'), 'utf8');
console.log('\n=== tab wiring + dropdown shortcut (structural) ===');
assert(/data-tab="my-repeaters"[^>]*>\s*My Repeaters\s*</.test(a), 'tab bar has a "My Repeaters" button');
assert(/case 'my-repeaters':\s*await renderMyRepeatersTab\(el\)/.test(a), 'renderTab dispatches my-repeaters');
assert(/AREA_FILTER_TABS[\s\S]{0,200}'my-repeaters'/.test(a), 'my-repeaters participates in region/area filtering');
assert(/href="#\/analytics\?tab=my-repeaters"/.test(app) && /Monitor my repeaters/.test(app),
'favorites dropdown links to the My Repeaters dashboard');
// Round-3 nits: no dead fav-dd-monitor class; monitor row has a fav-dd-meta
// cell so its columns line up with the favorite rows.
assert(!/fav-dd-monitor/.test(app), 'dead fav-dd-monitor class removed');
assert(/Monitor my repeaters<\/span>\s*'\s*\+\s*'<span class="fav-dd-meta">/.test(app),
'monitor link carries a fav-dd-meta cell for column alignment');
// --- Execute the real render against stub globals. ---
const start = a.indexOf('function _mrSummaryCard');
const end = a.indexOf('// === MY REPEATERS BLOCK END');
if (start < 0 || end < 0) { console.error(' ✗ could not locate the My Repeaters block'); process.exit(1); }
const block = a.slice(start, end);
const esc = s => s ? String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;') : '';
let favorites = [];
const getFavorites = () => favorites.slice();
let _regionQS = '';
const RegionFilter = { regionQueryString: () => _regionQS };
const AreaFilter = { areaQueryString: () => '' };
let nodesData = [];
let fetchAllNodesCalls = 0;
const fetchAllNodes = async () => { fetchAllNodesCalls++; return { nodes: nodesData }; };
let clockData = [], graphData = { nodes: [], edges: [] };
const api = async (p) => (p.indexOf('/nodes/clock-skew') === 0 ? clockData : graphData);
const CLIENT_TTL = { nodeList: 0, analyticsRF: 0 };
const getHealthThresholds = () => ({ degradedMs: 3600000, silentMs: 86400000 });
const renderSkewBadge = (sev) => 'CLOCKBADGE:' + sev;
const currentSkewValue = (cs) => (cs.recentMedianSkewSec != null ? cs.recentMedianSkewSec : cs.medianSkewSec);
const favStar = (pk) => '<button class="fav-star" data-fav="' + pk + '"></button>';
const timeAgo = () => '5m ago';
let lastBindCb = null;
const bindFavStars = (el, cb) => { lastBindCb = cb; };
const window = { ROLE_COLORS: { repeater: '#3b82f6', room: '#a855f7' }, aaBadgeStyle: null };
const M = new Function(
'esc', 'getFavorites', 'RegionFilter', 'AreaFilter', 'fetchAllNodes', 'api', 'CLIENT_TTL',
'getHealthThresholds', 'renderSkewBadge', 'currentSkewValue', 'favStar', 'timeAgo', 'bindFavStars', 'window',
block + '\nreturn { renderMyRepeatersTab, _mrStatusCell, _mrSummaryCard };')(
esc, getFavorites, RegionFilter, AreaFilter, fetchAllNodes, api, CLIENT_TTL,
getHealthThresholds, renderSkewBadge, currentSkewValue, favStar, timeAgo, bindFavStars, window);
function makeEl() { return { _h: '', set innerHTML(v) { this._h = v; }, get innerHTML() { return this._h; } }; }
const tick = () => new Promise(r => setTimeout(r, 0));
(async () => {
const nowIso = new Date().toISOString();
const oldIso = new Date(Date.now() - 5 * 86400000).toISOString();
console.log('\n=== empty state ===');
favorites = [];
let el = makeEl();
await M.renderMyRepeatersTab(el);
assert(/No favorite repeaters yet/.test(el.innerHTML), 'empty state when there are no favorites');
console.log('\n=== populated render (behavioral) ===');
favorites = ['AA', 'BB', 'CC'];
nodesData = [
{ public_key: 'AA', name: 'Active Rptr', role: 'repeater', last_seen: nowIso, last_heard: nowIso, traffic_share_score: 0.42, bridge_score: 0.1, relay_count_1h: 7, relay_count_24h: 120, last_relayed: nowIso, battery_mv: 4010 },
{ public_key: 'BB', name: 'Silent Rptr', role: 'repeater', last_seen: oldIso, last_heard: oldIso, traffic_share_score: 0.05, bridge_score: 0.9, relay_count_1h: 0, relay_count_24h: 3 },
{ public_key: 'CC', name: 'My Phone', role: 'client', last_seen: nowIso },
];
clockData = [{ pubkey: 'AA', severity: 'ok', recentMedianSkewSec: 2 }];
graphData = { edges: [
{ source: 'AA', target: 'BB', score: 0.66, weight: 88, avg_snr: 7.5, bidirectional: true, ambiguous: true },
{ source: 'AA', target: 'CC', score: 0.9, weight: 5 },
] };
el = makeEl();
fetchAllNodesCalls = 0;
await M.renderMyRepeatersTab(el);
let html = el.innerHTML;
assert(/Active Rptr/.test(html) && /Silent Rptr/.test(html), 'both favorite repeaters listed');
assert(!/My Phone/.test(html), 'non-repeater favorite (client) excluded');
assert(html.indexOf('Silent Rptr') < html.indexOf('Active Rptr'), 'silent repeater sorted before active');
assert(/CLOCKBADGE:ok/.test(html), 'clock badge rendered from clock-skew data');
assert(/42\.0%/.test(html) && /90\.0%/.test(html), 'traffic/bridge rendered as percentages');
assert(/4\.01 V/.test(html), 'battery rendered in volts');
assert(/&harr;/.test(html) && /88/.test(html), 'favorite↔favorite affinity edge shown with weight');
assert(!/href="#\/nodes\/CC/.test(html), 'affinity edge to non-favorite-repeater CC excluded');
console.log('\n=== bot fixes (behavioral regression guards) ===');
// Status a11y: cell carries TEXT, not colour alone.
assert(/>Active<\/span>/.test(html) && /<span style="font-size:11px">Silent<\/span>/.test(html),
'status column shows a text label, not colour alone');
// esc(String(value)) regression: a 0-valued summary card renders "0", not "".
assert(/font-weight:700[^>]*>0<\/div>/.test(html),
'summary cards render the number 0 (esc(String(0)) not swallowed to empty)');
// Caching: un-star must re-paint WITHOUT another fetch.
const callsAfterRender = fetchAllNodesCalls;
assert(callsAfterRender === 1, 'initial render fetched the node list exactly once');
favorites = ['AA']; // simulate un-starring BB
assert(typeof lastBindCb === 'function', 'a re-paint callback was bound for live un-star');
lastBindCb();
await tick();
html = el.innerHTML;
assert(fetchAllNodesCalls === callsAfterRender, 'un-star re-paints from cache — NO extra fetch');
assert(!/Silent Rptr/.test(html) && /Active Rptr/.test(html), 'un-starred repeater dropped on re-paint');
console.log('\n=== round-2 fixes (regression guards) ===');
// Re-render the full set for the affinity/layout assertions.
favorites = ['AA', 'BB', 'CC'];
el = makeEl();
await M.renderMyRepeatersTab(el);
const h2 = el.innerHTML;
// Affinity arrow is always ↔ (undirected); never →.
assert(/&harr;/.test(h2) && !/&rarr;/.test(h2), 'affinity link always renders ↔, never →');
// Ambiguous marker is a ph-question icon with aria-label, not a "(?)" string.
assert(/ph-question/.test(h2) && /aria-label="Path attribution ambiguous"/.test(h2) && !/\(\?\)/.test(h2),
'ambiguous affinity edge uses a ph-question icon, not "(?)"');
// Summary cards use a responsive grid (no flex-wrap-and-expand).
assert(/grid-template-columns:repeat\(auto-fit/.test(h2), 'summary cards use an auto-fit grid');
// Status numbers carry no status colour (Tufte: rely on the label).
assert(!/var\(--status-green-text\)/.test(h2), 'summary numbers drop the status colour');
// _mrSummaryCard helper: value-colour is opt-in, label always escaped.
assert(!/font-weight:700;color:/.test(M._mrSummaryCard(5, 'Active')) && /font-weight:700;color:red/.test(M._mrSummaryCard(5, 'X', 'red')),
'_mrSummaryCard colours the number only when a colour is explicitly passed');
// _mrStatusCell falls back to an explicit "Unknown", not "Silent".
assert(/Unknown/.test(M._mrStatusCell('weird')) && /Active/.test(M._mrStatusCell('active')),
'_mrStatusCell maps unknown status to "Unknown"');
// Dead CSS class removed from the favourite star call.
assert(!/fav-mr-star/.test(h2), 'dead fav-mr-star class removed');
console.log('\n=== un-star last favorite → empty state (from cache) ===');
favorites = ['AA'];
el = makeEl();
await M.renderMyRepeatersTab(el);
const callsBeforeUnstar = fetchAllNodesCalls;
favorites = [];
lastBindCb();
await tick();
assert(/No favorite repeaters yet/.test(el.innerHTML) && fetchAllNodesCalls === callsBeforeUnstar,
'removing the last favorite shows the empty state without refetching');
console.log('\n=== region/area filter hides a favorite → notice (MAJOR-1) ===');
_regionQS = '&region=somewhere'; // filter active → scoped fetch
favorites = ['AA', 'ZZ']; // ZZ is outside the scope (not fetched)
nodesData = [
{ public_key: 'AA', name: 'In-Scope Rptr', role: 'repeater', last_seen: nowIso, last_heard: nowIso, traffic_share_score: 0.1, bridge_score: 0.2, relay_count_24h: 5 },
];
graphData = { edges: [] };
el = makeEl();
await M.renderMyRepeatersTab(el);
assert(/1 favorite not shown with the active region\/area filter/.test(el.innerHTML),
'hidden favorite is surfaced with a causally-neutral "not shown" notice');
assert(!/outside the active/.test(el.innerHTML),
'notice no longer claims the favorite is "outside" the filter (could be deleted/client)');
assert(/In-Scope Rptr/.test(el.innerHTML), 'in-scope favorite still rendered alongside the notice');
_regionQS = ''; // no filter → no notice
el = makeEl();
await M.renderMyRepeatersTab(el);
assert(!/not shown with the active region/.test(el.innerHTML),
'no filter active → no hidden-favorite notice');
console.log('\n────────────────────────────────────────');
console.log(` ${passed} passed, ${failed} failed`);
process.exit(failed ? 1 : 0);
})();