From 4654ce338631b6b66f587270f9b3790c77e2f4fe Mon Sep 17 00:00:00 2001 From: "Michael J. Arcan" Date: Tue, 30 Jun 2026 09:51:26 +0200 Subject: [PATCH] feat(analytics): "My Repeaters" favorites monitoring dashboard (#1761) My Repeaters monitoring dashboard. Closes #1765. --------- Co-authored-by: Waydroid Builder --- public/analytics.js | 220 ++++++++++++++++++++++++++++++++- public/app.js | 10 +- test-a11y-axe-1668.js | 3 +- test-all.sh | 1 + test-my-repeaters-dashboard.js | 188 ++++++++++++++++++++++++++++ 5 files changed, 419 insertions(+), 3 deletions(-) create mode 100644 test-my-repeaters-dashboard.js diff --git a/public/analytics.js b/public/analytics.js index 1130fb5c..7650d380 100644 --- a/public/analytics.js +++ b/public/analytics.js @@ -120,6 +120,7 @@ + @@ -138,7 +139,7 @@ `; // 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 `
+
${esc(String(value))}
+
${esc(label)}
+
`; + } + + // 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 `${m.label}`; + } + + function _mrEmptyState() { + return ` +
+ +

No favorite repeaters yet

+

Star a repeater (the on the Nodes page or a node's detail) to add it to your watch-list. Starred repeaters show up here for at-a-glance monitoring.

+
`; + } + + async function renderMyRepeatersTab(el) { + const haveFavs = () => (typeof getFavorites === 'function' ? getFavorites() : []); + if (!haveFavs().length) { el.innerHTML = _mrEmptyState(); return; } + el.innerHTML = '
Loading your repeaters…
'; + 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 `${esc(role)}`; + } + + // 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) + ? `

${hiddenByFilter} favorite${hiddenByFilter === 1 ? '' : 's'} not shown with the active region/area filter.

` + : ''; + + 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) : ''; + const battery = n.battery_mv != null ? (n.battery_mv / 1000).toFixed(2) + ' V' : '—'; + return ` + ${favStar(n.public_key)} + ${esc(n.name || n.public_key.slice(0, 12))} + ${roleBadge(n.role)} + ${_mrStatusCell(statusOf(n))} + ${clockCell} + ${pct(trafficOf(n))} + ${pct(n.bridge_score)} + ${n.relay_count_1h != null ? n.relay_count_1h : '—'} + ${n.relay_count_24h != null ? n.relay_count_24h : '—'} + ${n.last_relayed ? timeAgo(n.last_relayed) : '—'} + ${(n.last_heard || n.last_seen) ? timeAgo(n.last_heard || n.last_seen) : '—'} + ${battery} + `; + }).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 = `

No affinity links detected between your repeaters yet — they may be too far apart, or not enough shared traffic has been observed.

`; + } else { + affinityHtml = ` + + ${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 = '↔'; + const snr = (e.avg_snr != null) ? e.avg_snr.toFixed(1) + ' dB' : '—'; + return ` + + + + + `; + }).join('')} +
LinkAffinityPacketsAvg SNR
${esc(nameByPk[e.source] || e.source.slice(0, 12))} ${arrow} ${esc(nameByPk[e.target] || e.target.slice(0, 12))}${e.ambiguous ? ' Path attribution ambiguous' : ''}${pct(e.score)}${e.weight != null ? e.weight : '—'}${snr}
`; + } + + el.innerHTML = ` +
+

My Repeaters

+

At-a-glance monitoring over the repeaters you have starred. Un-star a row to drop it from this list.

+ ${filterNotice} +
+ ${_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)')} +
+ + + + + + + + + + + + + + + + + ${rowsHtml || ''} +
RepeaterRoleStatusClockTrafficBridgeRelays 1hRelays 24hLast RelayedLast HeardBattery
None of your favorites are repeaters/rooms in this view.
+ +

Links Between My Repeaters

+

Affinity sub-graph filtered to your favorites — how much traffic flows directly between them.

+ ${affinityHtml} +
`; + + // 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 = `
Failed to load your repeaters: ${esc(e.message)}
`; + } + } + // === MY REPEATERS BLOCK END (test harness slices the renderer block to here) === + async function renderDistanceTab(el) { try { const rqs = RegionFilter.regionQueryString(); diff --git a/public/app.js b/public/app.js index ae7c4657..dcfa6e3b 100644 --- a/public/app.js +++ b/public/app.js @@ -1625,7 +1625,15 @@ window.addEventListener('DOMContentLoaded', () => { + ''; } })); - 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('') + + '' + + '' + + 'Monitor my repeaters' + + '' + + ''; bindFavStars(favDropdown, () => renderFavDropdown()); // Close dropdown on link click favDropdown.querySelectorAll('.fav-dd-item').forEach(a => { diff --git a/test-a11y-axe-1668.js b/test-a11y-axe-1668.js index 7eb01ce9..ecb5e083 100644 --- a/test-a11y-axe-1668.js +++ b/test-a11y-axe-1668.js @@ -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']; diff --git a/test-all.sh b/test-all.sh index d51a150c..a80e4c13 100755 --- a/test-all.sh +++ b/test-all.sh @@ -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 diff --git a/test-my-repeaters-dashboard.js b/test-my-repeaters-dashboard.js new file mode 100644 index 00000000..19b5c563 --- /dev/null +++ b/test-my-repeaters-dashboard.js @@ -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*\s*'\s*\+\s*'/.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, '&').replace(//g, '>') : ''; +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) => ''; +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(/↔/.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) && /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(/↔/.test(h2) && !/→/.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 = '®ion=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); +})();