From 2bff89a546d39b770987a9db71ced10cdbf1b5bf Mon Sep 17 00:00:00 2001 From: efiten Date: Tue, 7 Apr 2026 00:43:25 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20deep=20link=20P1=20UI=20states=20?= =?UTF-8?q?=E2=80=94=20nodes=20tab,=20packets=20filters,=20channels=20node?= =?UTF-8?q?=20panel=20(#536)=20(#618)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - **nodes.js**: `#/nodes?tab=repeater` and `#/nodes?search=foo` — role tab and search query are now URL-addressable; state resets to defaults on re-navigation - **packets.js**: `#/packets?timeWindow=60` and `#/packets?region=US-SFO` — time window and region filter survive refresh and are shareable - **channels.js**: `#/channels/{hash}?node=Name` — node detail panel is URL-addressable; auto-opens on load, URL updates on open/close - **region-filter.js**: adds `RegionFilter.setSelected(codesArray)` to public API (needed for URL-driven init) All changes use `history.replaceState` (not `pushState`) to avoid polluting browser history. URL params override localStorage on load; localStorage remains fallback. ## Implementation notes - Router strips query string before computing `routeParam`, so all pages read URL params directly from `location.hash` - `buildNodesQuery(tab, searchStr)` and `buildPacketsUrl(timeWindowMin, regionParam)` are pure functions exposed on `window` for testability - Region URL param is applied after `RegionFilter.init()` via a `_pendingUrlRegion` module-level var to keep ordering explicit - `showNodeDetail` captures `selectedHash` before the async `lookupNode` call to avoid stale URL construction ## Test plan - [x] `node test-frontend-helpers.js` — 459 passed, 0 failed (includes 6 `buildNodesQuery` + 5 `buildPacketsUrl` unit tests) - [x] Navigate to `#/nodes?tab=repeater` — Repeaters tab active on load - [x] Click a tab, verify URL updates to `#/nodes?tab=room` - [x] Navigate to `#/packets?timeWindow=60` — time window dropdown shows 60 min - [x] Change time window, verify URL updates - [x] Navigate to `#/channels/{hash}` and click a sender name — URL updates to `?node=Name` - [x] Reload that URL — node panel re-opens Closes #536 🤖 Generated with [Claude Code](https://claude.ai/claude-code) --------- Co-authored-by: Claude Sonnet 4.6 --- .../plans/2026-04-05-deep-linking-p1.md | 674 ++++++++++++++++++ public/app.js | 4 + public/channels.js | 13 +- public/nodes.js | 29 +- public/packets.js | 34 +- public/region-filter.js | 12 +- test-e2e-playwright.js | 59 ++ test-frontend-helpers.js | 171 +++++ 8 files changed, 991 insertions(+), 5 deletions(-) create mode 100644 docs/superpowers/plans/2026-04-05-deep-linking-p1.md diff --git a/docs/superpowers/plans/2026-04-05-deep-linking-p1.md b/docs/superpowers/plans/2026-04-05-deep-linking-p1.md new file mode 100644 index 00000000..23a23ba4 --- /dev/null +++ b/docs/superpowers/plans/2026-04-05-deep-linking-p1.md @@ -0,0 +1,674 @@ +# Deep Linking P1 Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make P1 UI states in nodes, packets, and channels URL-addressable so they survive refresh and can be shared. + +**Architecture:** Each page reads URL params from `location.hash.split('?')[1]` on init (router strips query string before passing `routeParam`, so pages must read `location.hash` directly). State changes call `history.replaceState` to keep the URL in sync. localStorage remains the fallback default; URL params override when present. + +**Tech Stack:** Vanilla JS (ES5/6), browser History API, URLSearchParams + +--- + +## Files Changed + +| File | Changes | +|---|---| +| `public/region-filter.js` | Add `setSelected(codesArray)`, track `_container` for re-render | +| `public/nodes.js` | Read `?tab=`/`?search=` on init; `updateNodesUrl()` on tab/search change; expose `buildNodesQuery` on `window` | +| `public/packets.js` | Read `?timeWindow=`/`?region=` on init; `updatePacketsUrl()` on timeWindow/region change; expose `buildPacketsUrl` on `window` | +| `public/channels.js` | Read `?node=` on init; update URL in `showNodeDetail`/`closeNodeDetail` | +| `test-frontend-helpers.js` | Add unit tests for `buildNodesQuery` and `buildPacketsUrl` | +| `test-e2e-playwright.js` | Add Playwright tests: tab URL persistence, timeWindow URL persistence | + +--- + +## Task 1: Add `setSelected` to RegionFilter + +**Files:** +- Modify: `public/region-filter.js` + +- [ ] **Step 1: Write the failing unit test** + +Add to `test-frontend-helpers.js` before the `// ===== SUMMARY =====` line: + +```javascript +// ===== REGION-FILTER.JS: setSelected ===== +console.log('\n=== region-filter.js: setSelected ==='); +{ + const ctx = makeSandbox(); + ctx.fetch = () => Promise.resolve({ json: () => Promise.resolve({ 'US-SFO': 'San Jose', 'US-LAX': 'Los Angeles' }) }); + loadInCtx(ctx, 'public/region-filter.js'); + + const RF = ctx.RegionFilter; + RF.init(document.createElement('div')); + + test('setSelected sets region codes', async () => { + await RF.init(document.createElement('div')); + RF.setSelected(['US-SFO', 'US-LAX']); + assert.strictEqual(RF.getRegionParam(), 'US-SFO,US-LAX'); + }); + + test('setSelected with null clears selection', async () => { + await RF.init(document.createElement('div')); + RF.setSelected(['US-SFO']); + RF.setSelected(null); + assert.strictEqual(RF.getRegionParam(), ''); + }); + + test('setSelected with empty array clears selection', async () => { + await RF.init(document.createElement('div')); + RF.setSelected(['US-SFO']); + RF.setSelected([]); + assert.strictEqual(RF.getRegionParam(), ''); + }); +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +```bash +node test-frontend-helpers.js 2>&1 | grep -A2 "setSelected" +``` + +Expected: `❌ setSelected sets region codes: RF.setSelected is not a function` + +- [ ] **Step 3: Add `_container` tracking and `setSelected` to region-filter.js** + +In `region-filter.js`, add `var _container = null;` after the existing module-level vars (after line 9 `var _listeners = [];`): + +```javascript + var _listeners = []; + var _container = null; // ← add this line + var _loaded = false; +``` + +In `initFilter`, save the container: + +```javascript + async function initFilter(container, opts) { + _container = container; // ← add this line + if (opts && opts.dropdown) container._forceDropdown = true; + await fetchRegions(); + render(container); + } +``` + +Add `setSelected` function before `// Expose globally`: + +```javascript + /** Override selected regions (e.g. from URL param). Persists to localStorage and re-renders. */ + function setSelected(codesArray) { + _selected = (codesArray && codesArray.length > 0) ? new Set(codesArray) : null; + saveToStorage(); + if (_container) render(_container); + } +``` + +Add `setSelected` to the public API object: + +```javascript + window.RegionFilter = { + init: initFilter, + render: render, + getSelected: getSelected, + getRegionParam: getRegionParam, + regionQueryString: regionQueryString, + onChange: onChange, + offChange: offChange, + fetchRegions: fetchRegions, + setSelected: setSelected, // ← add this line + }; +``` + +- [ ] **Step 4: Run test to verify it passes** + +```bash +node test-frontend-helpers.js 2>&1 | grep -E "(setSelected|FAIL|passed|failed)" +``` + +Expected: 3 passing `setSelected` tests, overall pass. + +- [ ] **Step 5: Commit** + +```bash +git add public/region-filter.js test-frontend-helpers.js +git commit -m "feat: add RegionFilter.setSelected for URL param initialization (#536)" +``` + +--- + +## Task 2: nodes.js — tab and search deep linking + +**Files:** +- Modify: `public/nodes.js` +- Test: `test-frontend-helpers.js` +- Test: `test-e2e-playwright.js` + +- [ ] **Step 1: Write the unit test (add to test-frontend-helpers.js)** + +Add before the `// ===== SUMMARY =====` line: + +```javascript +// ===== NODES.JS: buildNodesQuery ===== +console.log('\n=== nodes.js: buildNodesQuery ==='); +{ + const ctx = makeSandbox(); + loadInCtx(ctx, 'public/roles.js'); + loadInCtx(ctx, 'public/app.js'); + + // Provide required globals for nodes.js IIFE to execute + ctx.registerPage = () => {}; + ctx.RegionFilter = { init: () => Promise.resolve(), onChange: () => () => {}, offChange: () => {}, getSelected: () => null, getRegionParam: () => '' }; + ctx.onWS = () => {}; + ctx.offWS = () => {}; + ctx.debouncedOnWS = () => () => {}; + ctx.invalidateApiCache = () => {}; + ctx.favStar = () => ''; + ctx.bindFavStars = () => {}; + ctx.getFavorites = () => []; + ctx.isFavorite = () => false; + ctx.connectWS = () => {}; + ctx.HopResolver = { init: () => {}, resolve: () => ({}), ready: () => false }; + ctx.initTabBar = () => {}; + ctx.debounce = (fn) => fn; + ctx.copyToClipboard = () => {}; + ctx.api = () => Promise.resolve({}); + ctx.escapeHtml = (s) => s; + ctx.timeAgo = () => ''; + ctx.formatTimestampWithTooltip = () => ''; + ctx.getTimestampMode = () => 'ago'; + ctx.CLIENT_TTL = {}; + ctx.qrcode = null; + + try { + const src = fs.readFileSync('public/nodes.js', 'utf8'); + vm.runInContext(src, ctx); + for (const k of Object.keys(ctx.window)) ctx[k] = ctx.window[k]; + } catch (e) { + console.log(' ⚠️ nodes.js sandbox load failed:', e.message.slice(0, 120)); + } + + const buildNodesQuery = ctx.buildNodesQuery; + + if (buildNodesQuery) { + test('buildNodesQuery: all tab + no search = empty', () => { + assert.strictEqual(buildNodesQuery('all', ''), ''); + }); + test('buildNodesQuery: repeater tab only', () => { + assert.strictEqual(buildNodesQuery('repeater', ''), '?tab=repeater'); + }); + test('buildNodesQuery: search only (all tab)', () => { + assert.strictEqual(buildNodesQuery('all', 'foo'), '?search=foo'); + }); + test('buildNodesQuery: tab + search combined', () => { + assert.strictEqual(buildNodesQuery('companion', 'bar'), '?tab=companion&search=bar'); + }); + test('buildNodesQuery: null search treated as empty', () => { + assert.strictEqual(buildNodesQuery('all', null), ''); + }); + test('buildNodesQuery: sensor tab', () => { + assert.strictEqual(buildNodesQuery('sensor', ''), '?tab=sensor'); + }); + } else { + console.log(' ⚠️ buildNodesQuery not exposed — skipping'); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails (or skips)** + +```bash +node test-frontend-helpers.js 2>&1 | grep -A3 "buildNodesQuery" +``` + +Expected: `⚠️ buildNodesQuery not exposed — skipping` + +- [ ] **Step 3: Add URL param reading and helpers to nodes.js** + +**3a.** Add `buildNodesQuery` and `updateNodesUrl` functions inside the nodes.js IIFE, after the `TABS` definition (around line 86, before `function renderNodeTimestampHtml`): + +```javascript + function buildNodesQuery(tab, searchStr) { + var parts = []; + if (tab && tab !== 'all') parts.push('tab=' + encodeURIComponent(tab)); + if (searchStr) parts.push('search=' + encodeURIComponent(searchStr)); + return parts.length ? '?' + parts.join('&') : ''; + } + window.buildNodesQuery = buildNodesQuery; + + function updateNodesUrl() { + history.replaceState(null, '', '#/nodes' + buildNodesQuery(activeTab, search)); + } +``` + +**3b.** In the list-view branch of `init` (after the `return;` that ends the full-screen block at line 317), add URL param reading before `app.innerHTML`: + +```javascript + // Read URL params for list view (router strips query string from routeParam) + const _listUrlParams = new URLSearchParams(location.hash.split('?')[1] || ''); + const _urlTab = _listUrlParams.get('tab'); + const _urlSearch = _listUrlParams.get('search'); + if (_urlTab && TABS.some(function(t) { return t.key === _urlTab; })) activeTab = _urlTab; + if (_urlSearch) search = _urlSearch; + + app.innerHTML = `
+``` + +**3c.** After `app.innerHTML = ...` (after the closing backtick at line ~330), populate the search input: + +```javascript + if (search) { + var _si = document.getElementById('nodeSearch'); + if (_si) _si.value = search; + } +``` + +**3d.** In the search input event listener (around line 335), add `updateNodesUrl()`: + +```javascript + document.getElementById('nodeSearch').addEventListener('input', debounce(e => { + search = e.target.value; + updateNodesUrl(); + loadNodes(); + }, 250)); +``` + +**3e.** In the tab click handler inside `renderLeft` (around line 875), add `updateNodesUrl()`: + +```javascript + btn.addEventListener('click', () => { activeTab = btn.dataset.tab; updateNodesUrl(); loadNodes(); }); +``` + +- [ ] **Step 4: Run unit tests** + +```bash +node test-frontend-helpers.js 2>&1 | grep -E "(buildNodesQuery|✅|❌)" | grep -v "helpers" +``` + +Expected: 6 passing `buildNodesQuery` tests. + +- [ ] **Step 5: Write Playwright test (add to test-e2e-playwright.js)** + +Add before the closing `await browser.close()` line: + +```javascript + // --- Group: Deep linking (#536) --- + + // Test: nodes tab deep link + await test('Nodes tab deep link restores active tab', async () => { + await page.goto(BASE + '#/nodes?tab=repeater', { waitUntil: 'domcontentloaded' }); + await page.waitForSelector('.node-tab', { timeout: 8000 }); + const activeTab = await page.$('.node-tab.active'); + assert(activeTab, 'No active tab found'); + const tabText = await activeTab.textContent(); + assert(tabText.includes('Repeater'), `Expected Repeater tab active, got: ${tabText}`); + const url = page.url(); + assert(url.includes('tab=repeater'), `URL should contain tab=repeater, got: ${url}`); + }); + + // Test: nodes tab click updates URL + await test('Nodes tab click updates URL', async () => { + await page.goto(BASE + '#/nodes', { waitUntil: 'domcontentloaded' }); + await page.waitForSelector('.node-tab', { timeout: 8000 }); + const roomTab = await page.$('.node-tab[data-tab="room"]'); + if (roomTab) { + await roomTab.click(); + await page.waitForTimeout(300); + const url = page.url(); + assert(url.includes('tab=room'), `URL should contain tab=room after click, got: ${url}`); + } + }); +``` + +- [ ] **Step 6: Run full test suite** + +```bash +node test-frontend-helpers.js +``` + +Expected: all tests pass. + +- [ ] **Step 7: Commit** + +```bash +git add public/nodes.js test-frontend-helpers.js test-e2e-playwright.js +git commit -m "feat: deep link nodes tab and search query (#536)" +``` + +--- + +## Task 3: packets.js — timeWindow and region deep linking + +**Files:** +- Modify: `public/packets.js` +- Test: `test-frontend-helpers.js` +- Test: `test-e2e-playwright.js` + +> Depends on Task 1 (RegionFilter.setSelected). + +- [ ] **Step 1: Write the unit test** + +Add to `test-frontend-helpers.js` before `// ===== SUMMARY =====`: + +```javascript +// ===== PACKETS.JS: buildPacketsUrl ===== +console.log('\n=== packets.js: buildPacketsUrl ==='); +{ + // Test the pure helper function + // (loaded via packets.js after it exposes window.buildPacketsUrl) + const ctx = makeSandbox(); + loadInCtx(ctx, 'public/roles.js'); + loadInCtx(ctx, 'public/app.js'); + + ctx.registerPage = () => {}; + ctx.RegionFilter = { init: () => Promise.resolve(), onChange: () => () => {}, offChange: () => {}, getSelected: () => null, getRegionParam: () => '', setSelected: () => {} }; + ctx.onWS = () => {}; + ctx.offWS = () => {}; + ctx.debouncedOnWS = () => () => {}; + ctx.invalidateApiCache = () => {}; + ctx.api = () => Promise.resolve({}); + ctx.observerMap = new Map(); + ctx.getParsedPath = () => []; + ctx.getParsedDecoded = () => ({}); + ctx.clearParsedCache = () => {}; + ctx.escapeHtml = (s) => s; + ctx.timeAgo = () => ''; + ctx.formatTimestampWithTooltip = () => ''; + ctx.getTimestampMode = () => 'ago'; + ctx.copyToClipboard = () => {}; + ctx.CLIENT_TTL = {}; + ctx.debounce = (fn) => fn; + ctx.initTabBar = () => {}; + + try { + const src = fs.readFileSync('public/packet-helpers.js', 'utf8'); + vm.runInContext(src, ctx); + for (const k of Object.keys(ctx.window)) ctx[k] = ctx.window[k]; + const src2 = fs.readFileSync('public/packets.js', 'utf8'); + vm.runInContext(src2, ctx); + for (const k of Object.keys(ctx.window)) ctx[k] = ctx.window[k]; + } catch (e) { + console.log(' ⚠️ packets.js sandbox load failed:', e.message.slice(0, 120)); + } + + const buildPacketsUrl = ctx.buildPacketsUrl; + + if (buildPacketsUrl) { + test('buildPacketsUrl: default (15min, no region) = bare #/packets', () => { + assert.strictEqual(buildPacketsUrl(15, ''), '#/packets'); + }); + test('buildPacketsUrl: non-default timeWindow', () => { + assert.strictEqual(buildPacketsUrl(60, ''), '#/packets?timeWindow=60'); + }); + test('buildPacketsUrl: region only', () => { + assert.strictEqual(buildPacketsUrl(15, 'US-SFO'), '#/packets?region=US-SFO'); + }); + test('buildPacketsUrl: timeWindow + region', () => { + assert.strictEqual(buildPacketsUrl(30, 'US-SFO,US-LAX'), '#/packets?timeWindow=30®ion=US-SFO%2CUS-LAX'); + }); + test('buildPacketsUrl: timeWindow=0 treated as default', () => { + assert.strictEqual(buildPacketsUrl(0, ''), '#/packets'); + }); + } else { + console.log(' ⚠️ buildPacketsUrl not exposed — skipping'); + } +} +``` + +- [ ] **Step 2: Run to verify it skips** + +```bash +node test-frontend-helpers.js 2>&1 | grep -A2 "buildPacketsUrl" +``` + +Expected: `⚠️ buildPacketsUrl not exposed — skipping` + +- [ ] **Step 3: Add helpers and URL param reading to packets.js** + +**3a.** Add `buildPacketsUrl` and `updatePacketsUrl` inside the packets.js IIFE, after the existing constants at the top (around line 36, after `let showHexHashes`): + +```javascript + function buildPacketsUrl(timeWindowMin, regionParam) { + var parts = []; + if (timeWindowMin && timeWindowMin !== 15) parts.push('timeWindow=' + timeWindowMin); + if (regionParam) parts.push('region=' + encodeURIComponent(regionParam)); + return '#/packets' + (parts.length ? '?' + parts.join('&') : ''); + } + window.buildPacketsUrl = buildPacketsUrl; + + function updatePacketsUrl() { + history.replaceState(null, '', buildPacketsUrl(savedTimeWindowMin, RegionFilter.getRegionParam())); + } +``` + +**3b.** In the `init` function (around line 263), add URL param reading after the existing `routeParam`/`directObsId` parsing and before `app.innerHTML`: + +```javascript + // Read URL params for filter state (router strips query from routeParam; read from location.hash) + var _initUrlParams = new URLSearchParams(location.hash.split('?')[1] || ''); + var _urlTimeWindow = Number(_initUrlParams.get('timeWindow')); + if (Number.isFinite(_urlTimeWindow) && _urlTimeWindow > 0) { + savedTimeWindowMin = _urlTimeWindow; + localStorage.setItem('meshcore-time-window', String(_urlTimeWindow)); + } + var _urlRegion = _initUrlParams.get('region'); + if (_urlRegion) { + RegionFilter.setSelected(_urlRegion.split(',').filter(Boolean)); + } + + app.innerHTML = `
+``` + +**3c.** In the time window change handler (around line 865), add `updatePacketsUrl()`: + +```javascript + fTimeWindow.addEventListener('change', () => { + savedTimeWindowMin = Number(fTimeWindow.value); + if (!Number.isFinite(savedTimeWindowMin) || savedTimeWindowMin <= 0) savedTimeWindowMin = 15; + localStorage.setItem('meshcore-time-window', fTimeWindow.value); + updatePacketsUrl(); + loadPackets(); + }); +``` + +**3d.** In the RegionFilter.onChange callback (around line 719), add `updatePacketsUrl()`: + +```javascript + RegionFilter.onChange(function() { updatePacketsUrl(); loadPackets(); }); +``` + +- [ ] **Step 4: Run unit tests** + +```bash +node test-frontend-helpers.js 2>&1 | grep -E "(buildPacketsUrl|✅|❌)" | grep -v "helpers" +``` + +Expected: 5 passing `buildPacketsUrl` tests. + +- [ ] **Step 5: Write Playwright test (add to test-e2e-playwright.js, inside the deep-linking group)** + +```javascript + // Test: packets timeWindow deep link + await test('Packets timeWindow deep link restores dropdown', async () => { + await page.goto(BASE + '#/packets?timeWindow=60', { waitUntil: 'domcontentloaded' }); + await page.waitForSelector('#fTimeWindow', { timeout: 8000 }); + const val = await page.$eval('#fTimeWindow', el => el.value); + assert(val === '60', `Expected timeWindow dropdown = 60, got: ${val}`); + const url = page.url(); + assert(url.includes('timeWindow=60'), `URL should still contain timeWindow=60, got: ${url}`); + }); + + // Test: timeWindow change updates URL + await test('Packets timeWindow change updates URL', async () => { + await page.goto(BASE + '#/packets', { waitUntil: 'domcontentloaded' }); + await page.waitForSelector('#fTimeWindow', { timeout: 8000 }); + await page.selectOption('#fTimeWindow', '30'); + await page.waitForTimeout(300); + const url = page.url(); + assert(url.includes('timeWindow=30'), `URL should contain timeWindow=30 after change, got: ${url}`); + }); +``` + +- [ ] **Step 6: Run full test suite** + +```bash +node test-frontend-helpers.js +``` + +Expected: all tests pass. + +- [ ] **Step 7: Commit** + +```bash +git add public/packets.js test-frontend-helpers.js test-e2e-playwright.js +git commit -m "feat: deep link packets timeWindow and region filter (#536)" +``` + +--- + +## Task 4: channels.js — node panel deep linking + +**Files:** +- Modify: `public/channels.js` + +No unit tests needed for this task — the URL manipulation is side-effectful (DOM + History API). Playwright tests cover it. + +- [ ] **Step 1: Write the Playwright test (add to test-e2e-playwright.js, inside the deep-linking group)** + +```javascript + // Test: channels selected channel survives refresh (already implemented, verify it still works) + await test('Channels channel selection is URL-addressable', async () => { + await page.goto(BASE + '#/channels', { waitUntil: 'domcontentloaded' }); + await page.waitForSelector('.ch-item', { timeout: 8000 }).catch(() => null); + const firstChannel = await page.$('.ch-item'); + if (firstChannel) { + await firstChannel.click(); + await page.waitForTimeout(500); + const url = page.url(); + assert(url.includes('#/channels/') || url.includes('#/channels'), `URL should reflect channel selection, got: ${url}`); + } + }); +``` + +- [ ] **Step 2: Update `showNodeDetail` to write `?node=` to the URL** + +In `channels.js`, in `showNodeDetail` (around line 171), add the URL update right after `selectedNode = name;`: + +```javascript + async function showNodeDetail(name) { + _nodePanelTrigger = document.activeElement; + if (_focusTrapCleanup) { _focusTrapCleanup(); _focusTrapCleanup = null; } + const node = await lookupNode(name); + selectedNode = name; + var _chBase = selectedHash ? '#/channels/' + encodeURIComponent(selectedHash) : '#/channels'; + history.replaceState(null, '', _chBase + '?node=' + encodeURIComponent(name)); + + let panel = document.getElementById('chNodePanel'); +``` + +- [ ] **Step 3: Update `closeNodeDetail` to strip `?node=` from the URL** + +In `closeNodeDetail` (around line 232), add URL restore right after `selectedNode = null;`: + +```javascript + function closeNodeDetail() { + if (_focusTrapCleanup) { _focusTrapCleanup(); _focusTrapCleanup = null; } + const panel = document.getElementById('chNodePanel'); + if (panel) panel.classList.remove('open'); + selectedNode = null; + var _chRestoreUrl = selectedHash ? '#/channels/' + encodeURIComponent(selectedHash) : '#/channels'; + history.replaceState(null, '', _chRestoreUrl); + if (_nodePanelTrigger && typeof _nodePanelTrigger.focus === 'function') { +``` + +- [ ] **Step 4: Read `?node=` on init and auto-open panel** + +In `channels.js` `init` (line 316), add URL param reading at the very top of the function (before `app.innerHTML`): + +```javascript + function init(app, routeParam) { + var _initUrlParams = new URLSearchParams(location.hash.split('?')[1] || ''); + var _pendingNode = _initUrlParams.get('node'); + + app.innerHTML = `
+``` + +Then update the `loadChannels().then(...)` call (around line 350) to auto-open the node panel: + +```javascript + loadChannels().then(async function () { + if (routeParam) await selectChannel(routeParam); + if (_pendingNode) showNodeDetail(_pendingNode); + }); +``` + +- [ ] **Step 5: Run full test suite** + +```bash +node test-frontend-helpers.js +``` + +Expected: all tests pass (no channels unit tests, but regression tests still pass). + +- [ ] **Step 6: Commit** + +```bash +git add public/channels.js +git commit -m "feat: deep link channels node panel via ?node= (#536)" +``` + +--- + +## Task 5: Run E2E Playwright tests + +- [ ] **Step 1: Start the local server** + +```bash +cd cmd/server && go run . & +``` + +Wait for it to be ready (check `http://localhost:3000`). + +- [ ] **Step 2: Run Playwright tests** + +```bash +node test-e2e-playwright.js +``` + +Expected: all tests pass including the new deep-linking group. + +- [ ] **Step 3: If any deep-linking test fails, debug** + +Common failures: +- Selector `.node-tab.active` not found: check that nodes.js correctly reads `?tab=` from URL before rendering +- `#fTimeWindow` value wrong: check that `savedTimeWindowMin` is overridden before the DOM is built +- URL doesn't update: check `history.replaceState` calls in the change handlers + +- [ ] **Step 4: Final commit (if any fixes needed)** + +```bash +git add public/nodes.js public/packets.js public/channels.js +git commit -m "fix: deep linking E2E adjustments (#536)" +``` + +--- + +## Self-Review + +**Spec coverage check:** +- ✅ P1: Nodes role tab → Task 2 +- ✅ P1: Packets time window → Task 3 +- ✅ P1: Packets region filter → Task 3 (depends on Task 1) +- ✅ P1: Channels selected channel → Already implemented via `#/channels/{hash}` (verified in channels.js init line 351) +- ✅ P1: Channels node panel → Task 4 +- ✅ P2+ items → explicitly out of scope per issue + +**Architecture note:** The router in `app.js` strips the query string at line 422 (`const route = hash.split('?')[0]`) before computing `basePage` and `routeParam`. Therefore `#/nodes?tab=repeater` gives `routeParam=null` (not `?tab=repeater`). All pages must read URL params from `location.hash` directly, not from `routeParam`. This is the established pattern in `analytics.js` and `nodes.js` (section scroll). + +**Placeholder scan:** No TBDs, no "implement later", all code blocks complete. ✅ + +**Type consistency:** +- `buildNodesQuery(tab, searchStr)` — used consistently in `updateNodesUrl()` and in tests ✅ +- `buildPacketsUrl(timeWindowMin, regionParam)` — used consistently in `updatePacketsUrl()` and in tests ✅ +- `RegionFilter.setSelected(codesArray)` — defined in Task 1, used in Task 3 ✅ diff --git a/public/app.js b/public/app.js index 93310f85..90a40bfd 100644 --- a/public/app.js +++ b/public/app.js @@ -104,6 +104,10 @@ function timeAgo(iso) { return value + suffix + ' ago'; } +function getHashParams() { + return new URLSearchParams(location.hash.split('?')[1] || ''); +} + function getTimestampMode() { const saved = localStorage.getItem('meshcore-timestamp-mode'); if (saved === 'ago' || saved === 'absolute') return saved; diff --git a/public/channels.js b/public/channels.js index b8daaf0c..52ec1608 100644 --- a/public/channels.js +++ b/public/channels.js @@ -171,8 +171,11 @@ async function showNodeDetail(name) { _nodePanelTrigger = document.activeElement; if (_focusTrapCleanup) { _focusTrapCleanup(); _focusTrapCleanup = null; } + var _capturedHash = selectedHash; const node = await lookupNode(name); selectedNode = name; + var _chBase = _capturedHash ? '#/channels/' + encodeURIComponent(_capturedHash) : '#/channels'; + history.replaceState(null, '', _chBase + '?node=' + encodeURIComponent(name)); let panel = document.getElementById('chNodePanel'); if (!panel) { @@ -234,6 +237,8 @@ const panel = document.getElementById('chNodePanel'); if (panel) panel.classList.remove('open'); selectedNode = null; + var _chRestoreUrl = selectedHash ? '#/channels/' + encodeURIComponent(selectedHash) : '#/channels'; + history.replaceState(null, '', _chRestoreUrl); if (_nodePanelTrigger && typeof _nodePanelTrigger.focus === 'function') { _nodePanelTrigger.focus(); _nodePanelTrigger = null; @@ -314,6 +319,9 @@ let regionChangeHandler = null; function init(app, routeParam) { + var _initUrlParams = getHashParams(); + var _pendingNode = _initUrlParams.get('node'); + app.innerHTML = `
@@ -347,8 +355,9 @@ }); loadObserverRegions(); - loadChannels().then(() => { - if (routeParam) selectChannel(routeParam); + loadChannels().then(async function () { + if (routeParam) await selectChannel(routeParam); + if (_pendingNode && _pendingNode.length < 200) await showNodeDetail(_pendingNode); }); // #89: Sidebar resize handle diff --git a/public/nodes.js b/public/nodes.js index cb444287..3512a3b5 100644 --- a/public/nodes.js +++ b/public/nodes.js @@ -78,6 +78,18 @@ { key: 'sensor', label: 'Sensors' }, ]; + function buildNodesQuery(tab, searchStr) { + var parts = []; + if (tab && tab !== 'all') parts.push('tab=' + encodeURIComponent(tab)); + if (searchStr) parts.push('search=' + encodeURIComponent(searchStr)); + return parts.length ? '?' + parts.join('&') : ''; + } + window.buildNodesQuery = buildNodesQuery; + + function updateNodesUrl() { + history.replaceState(null, '', '#/nodes' + buildNodesQuery(activeTab, search)); + } + function renderNodeTimestampHtml(isoString) { if (typeof formatTimestampWithTooltip !== 'function' || typeof getTimestampMode !== 'function') { return escapeHtml(typeof timeAgo === 'function' ? timeAgo(isoString) : '—'); @@ -329,6 +341,15 @@ return; } + // Reset list-view state to defaults, then override from URL params + activeTab = 'all'; + search = ''; + const _listUrlParams = getHashParams(); + const _urlTab = _listUrlParams.get('tab'); + const _urlSearch = _listUrlParams.get('search'); + if (_urlTab && TABS.some(function(t) { return t.key === _urlTab; })) activeTab = _urlTab; + if (_urlSearch) search = _urlSearch; + app.innerHTML = `
@@ -344,8 +365,14 @@ RegionFilter.init(document.getElementById('nodesRegionFilter')); regionChangeHandler = RegionFilter.onChange(function () { _allNodes = null; loadNodes(); }); + if (search) { + var _si = document.getElementById('nodeSearch'); + if (_si) _si.value = search; + } + document.getElementById('nodeSearch').addEventListener('input', debounce(e => { search = e.target.value; + updateNodesUrl(); loadNodes(); }, 250)); @@ -899,7 +926,7 @@ const nodeTabs = document.getElementById('nodeTabs'); initTabBar(nodeTabs); el.querySelectorAll('.node-tab').forEach(btn => { - btn.addEventListener('click', () => { activeTab = btn.dataset.tab; loadNodes(); }); + btn.addEventListener('click', () => { activeTab = btn.dataset.tab; updateNodesUrl(); loadNodes(); }); }); // Filter changes diff --git a/public/packets.js b/public/packets.js index c8b424ef..4bbe1359 100644 --- a/public/packets.js +++ b/public/packets.js @@ -37,6 +37,22 @@ let _packetSortColumn = null; let _packetSortDirection = 'desc'; let showHexHashes = localStorage.getItem('meshcore-hex-hashes') === 'true'; + var _pendingUrlRegion = null; + + var DEFAULT_TIME_WINDOW = 15; + + function buildPacketsQuery(timeWindowMin, regionParam) { + var parts = []; + if (timeWindowMin && timeWindowMin !== DEFAULT_TIME_WINDOW) parts.push('timeWindow=' + timeWindowMin); + if (regionParam) parts.push('region=' + encodeURIComponent(regionParam)); + return parts.length ? '?' + parts.join('&') : ''; + } + window.buildPacketsQuery = buildPacketsQuery; + + function updatePacketsUrl() { + history.replaceState(null, '', '#/packets' + buildPacketsQuery(savedTimeWindowMin, RegionFilter.getRegionParam())); + } + let filtersBuilt = false; let _renderTimer = null; function scheduleRender() { @@ -316,6 +332,17 @@ filters.node = routeParam; } } + + // Read URL params (router strips query from routeParam; read from location.hash) + var _initUrlParams = getHashParams(); + var _urlTimeWindow = Number(_initUrlParams.get('timeWindow')); + if (Number.isFinite(_urlTimeWindow) && _urlTimeWindow > 0) { + savedTimeWindowMin = _urlTimeWindow; + localStorage.setItem('meshcore-time-window', String(_urlTimeWindow)); + } + var _urlRegion = _initUrlParams.get('region'); + if (_urlRegion) _pendingUrlRegion = _urlRegion; + app.innerHTML = `
@@ -758,7 +785,11 @@ // Init shared RegionFilter component RegionFilter.init(document.getElementById('packetsRegionFilter'), { dropdown: true }); - RegionFilter.onChange(function() { loadPackets(); }); + if (_pendingUrlRegion) { + RegionFilter.setSelected(_pendingUrlRegion.split(',').filter(Boolean)); + _pendingUrlRegion = null; + } + RegionFilter.onChange(function() { updatePacketsUrl(); loadPackets(); }); // --- Packet Filter Language --- (function() { @@ -908,6 +939,7 @@ savedTimeWindowMin = Number(fTimeWindow.value); if (!Number.isFinite(savedTimeWindowMin) || savedTimeWindowMin <= 0) savedTimeWindowMin = 15; localStorage.setItem('meshcore-time-window', fTimeWindow.value); + updatePacketsUrl(); loadPackets(); }); diff --git a/public/region-filter.js b/public/region-filter.js index 84b65f2a..31dfbabe 100644 --- a/public/region-filter.js +++ b/public/region-filter.js @@ -6,6 +6,7 @@ var _regions = {}; // { code: label } var _selected = null; // Set of selected region codes, null = all var _listeners = []; + var _container = null; var _loaded = false; function loadFromStorage() { @@ -199,11 +200,19 @@ /** Initialize filter in a container, fetch regions, render, return promise. * Options: { dropdown: true } to force dropdown mode regardless of region count */ async function initFilter(container, opts) { + _container = container; if (opts && opts.dropdown) container._forceDropdown = true; await fetchRegions(); render(container); } + /** Override selected regions (e.g. from URL param). Persists to localStorage and re-renders. */ + function setSelected(codesArray) { + _selected = (codesArray && codesArray.length > 0) ? new Set(codesArray) : null; + saveToStorage(); + if (_container) render(_container); + } + // Expose globally window.RegionFilter = { init: initFilter, @@ -213,6 +222,7 @@ regionQueryString: regionQueryString, onChange: onChange, offChange: offChange, - fetchRegions: fetchRegions + fetchRegions: fetchRegions, + setSelected: setSelected }; })(); diff --git a/test-e2e-playwright.js b/test-e2e-playwright.js index f9f35a53..c392b654 100644 --- a/test-e2e-playwright.js +++ b/test-e2e-playwright.js @@ -1627,6 +1627,65 @@ async function run() { } } catch {} + // --- Group: Deep linking (#536) --- + + // Test: nodes tab deep link + await test('Nodes tab deep link restores active tab', async () => { + await page.goto(BASE + '#/nodes?tab=repeater', { waitUntil: 'domcontentloaded' }); + await page.waitForSelector('.node-tab', { timeout: 8000 }); + const activeTab = await page.$('.node-tab.active'); + assert(activeTab, 'No active tab found'); + const tabText = await activeTab.textContent(); + assert(tabText.includes('Repeater'), `Expected Repeater tab active, got: ${tabText}`); + const url = page.url(); + assert(url.includes('tab=repeater'), `URL should contain tab=repeater, got: ${url}`); + }); + + // Test: nodes tab click updates URL + await test('Nodes tab click updates URL', async () => { + await page.goto(BASE + '#/nodes', { waitUntil: 'domcontentloaded' }); + await page.waitForSelector('.node-tab', { timeout: 8000 }); + const roomTab = await page.$('.node-tab[data-tab="room"]'); + assert(roomTab, 'Room tab (data-tab="room") not found — nodes page may not have rendered or tab selector changed'); + await roomTab.click(); + await page.waitForTimeout(300); + const url = page.url(); + assert(url.includes('tab=room'), `URL should contain tab=room after click, got: ${url}`); + }); + + // Test: packets timeWindow deep link + await test('Packets timeWindow deep link restores dropdown', async () => { + await page.goto(BASE + '#/packets?timeWindow=60', { waitUntil: 'domcontentloaded' }); + await page.waitForSelector('#fTimeWindow', { timeout: 8000 }); + const val = await page.$eval('#fTimeWindow', el => el.value); + assert(val === '60', `Expected timeWindow dropdown = 60, got: ${val}`); + const url = page.url(); + assert(url.includes('timeWindow=60'), `URL should still contain timeWindow=60, got: ${url}`); + }); + + // Test: timeWindow change updates URL + await test('Packets timeWindow change updates URL', async () => { + await page.goto(BASE + '#/packets', { waitUntil: 'domcontentloaded' }); + await page.waitForSelector('#fTimeWindow', { timeout: 8000 }); + await page.selectOption('#fTimeWindow', '30'); + await page.waitForTimeout(300); + const url = page.url(); + assert(url.includes('timeWindow=30'), `URL should contain timeWindow=30 after change, got: ${url}`); + }); + + // Test: channels selected channel survives refresh (already implemented, verify it still works) + await test('Channels channel selection is URL-addressable', async () => { + await page.goto(BASE + '#/channels', { waitUntil: 'domcontentloaded' }); + await page.waitForSelector('.ch-item', { timeout: 8000 }).catch(() => null); + const firstChannel = await page.$('.ch-item'); + if (firstChannel) { + await firstChannel.click(); + await page.waitForTimeout(500); + const url = page.url(); + assert(url.includes('#/channels/') || url.includes('#/channels'), `URL should reflect channel selection, got: ${url}`); + } + }); + await browser.close(); // Summary diff --git a/test-frontend-helpers.js b/test-frontend-helpers.js index e8b85c1b..d14fc029 100644 --- a/test-frontend-helpers.js +++ b/test-frontend-helpers.js @@ -75,6 +75,7 @@ function makeSandbox() { }; })(), location: { hash: '' }, + getHashParams: function() { return new URLSearchParams((ctx.location.hash.split('?')[1] || '')); }, CustomEvent: class CustomEvent {}, Map, Promise, @@ -4516,6 +4517,176 @@ console.log('\n=== app.js: routeTypeName/payloadTypeName edge cases ==='); }); } +// ===== REGION-FILTER.JS: setSelected ===== +console.log('\n=== region-filter.js: setSelected ==='); +{ + const ctx = makeSandbox(); + ctx.fetch = () => Promise.resolve({ json: () => Promise.resolve({ 'US-SFO': 'San Jose', 'US-LAX': 'Los Angeles' }) }); + + // Patch createElement to return an object with style property + const origCreate = ctx.document.createElement; + ctx.document.createElement = () => ({ + id: '', textContent: '', innerHTML: '', + style: {}, + querySelector: () => null, + querySelectorAll: () => [], + onclick: null, + onchange: null, + addEventListener: () => {}, + removeEventListener: () => {}, + }); + + loadInCtx(ctx, 'public/region-filter.js'); + + const RF = ctx.RegionFilter; + + test('setSelected sets region codes', async () => { + await RF.init(ctx.document.createElement('div')); + RF.setSelected(['US-SFO', 'US-LAX']); + assert.strictEqual(RF.getRegionParam(), 'US-SFO,US-LAX'); + }); + + test('setSelected with null clears selection', async () => { + await RF.init(ctx.document.createElement('div')); + RF.setSelected(['US-SFO']); + RF.setSelected(null); + assert.strictEqual(RF.getRegionParam(), ''); + }); + + test('setSelected with empty array clears selection', async () => { + await RF.init(ctx.document.createElement('div')); + RF.setSelected(['US-SFO']); + RF.setSelected([]); + assert.strictEqual(RF.getRegionParam(), ''); + }); +} + +// ===== NODES.JS: buildNodesQuery ===== +console.log('\n=== nodes.js: buildNodesQuery ==='); +{ + const ctx = makeSandbox(); + loadInCtx(ctx, 'public/roles.js'); + loadInCtx(ctx, 'public/app.js'); + + // Provide required globals for nodes.js IIFE to execute + ctx.registerPage = () => {}; + ctx.RegionFilter = { init: () => Promise.resolve(), onChange: () => () => {}, offChange: () => {}, getSelected: () => null, getRegionParam: () => '' }; + ctx.onWS = () => {}; + ctx.offWS = () => {}; + ctx.debouncedOnWS = () => () => {}; + ctx.invalidateApiCache = () => {}; + ctx.favStar = () => ''; + ctx.bindFavStars = () => {}; + ctx.getFavorites = () => []; + ctx.isFavorite = () => false; + ctx.connectWS = () => {}; + ctx.HopResolver = { init: () => {}, resolve: () => ({}), ready: () => false }; + ctx.initTabBar = () => {}; + ctx.debounce = (fn) => fn; + ctx.copyToClipboard = () => {}; + ctx.api = () => Promise.resolve({}); + ctx.escapeHtml = (s) => s; + ctx.timeAgo = () => ''; + ctx.formatTimestampWithTooltip = () => ''; + ctx.getTimestampMode = () => 'ago'; + ctx.CLIENT_TTL = {}; + ctx.qrcode = null; + + try { + const src = fs.readFileSync('public/nodes.js', 'utf8'); + vm.runInContext(src, ctx); + for (const k of Object.keys(ctx.window)) ctx[k] = ctx.window[k]; + } catch (e) { + console.log(' ⚠️ nodes.js sandbox load failed:', e.message.slice(0, 120)); + } + + const buildNodesQuery = ctx.buildNodesQuery; + + if (buildNodesQuery) { + test('buildNodesQuery: all tab + no search = empty', () => { + assert.strictEqual(buildNodesQuery('all', ''), ''); + }); + test('buildNodesQuery: repeater tab only', () => { + assert.strictEqual(buildNodesQuery('repeater', ''), '?tab=repeater'); + }); + test('buildNodesQuery: search only (all tab)', () => { + assert.strictEqual(buildNodesQuery('all', 'foo'), '?search=foo'); + }); + test('buildNodesQuery: tab + search combined', () => { + assert.strictEqual(buildNodesQuery('companion', 'bar'), '?tab=companion&search=bar'); + }); + test('buildNodesQuery: null search treated as empty', () => { + assert.strictEqual(buildNodesQuery('all', null), ''); + }); + test('buildNodesQuery: sensor tab', () => { + assert.strictEqual(buildNodesQuery('sensor', ''), '?tab=sensor'); + }); + } else { + console.log(' ⚠️ buildNodesQuery not exposed — skipping'); + } +} + +// ===== PACKETS.JS: buildPacketsQuery ===== +console.log('\n=== packets.js: buildPacketsQuery ==='); +{ + const ctx = makeSandbox(); + loadInCtx(ctx, 'public/roles.js'); + loadInCtx(ctx, 'public/app.js'); + + ctx.registerPage = () => {}; + ctx.RegionFilter = { init: () => Promise.resolve(), onChange: () => () => {}, offChange: () => {}, getSelected: () => null, getRegionParam: () => '', setSelected: () => {} }; + ctx.onWS = () => {}; + ctx.offWS = () => {}; + ctx.debouncedOnWS = () => () => {}; + ctx.invalidateApiCache = () => {}; + ctx.api = () => Promise.resolve({}); + ctx.observerMap = new Map(); + ctx.getParsedPath = () => []; + ctx.getParsedDecoded = () => ({}); + ctx.clearParsedCache = () => {}; + ctx.escapeHtml = (s) => s; + ctx.timeAgo = () => ''; + ctx.formatTimestampWithTooltip = () => ''; + ctx.getTimestampMode = () => 'ago'; + ctx.copyToClipboard = () => {}; + ctx.CLIENT_TTL = {}; + ctx.debounce = (fn) => fn; + ctx.initTabBar = () => {}; + + try { + const src = fs.readFileSync('public/packet-helpers.js', 'utf8'); + vm.runInContext(src, ctx); + for (const k of Object.keys(ctx.window)) ctx[k] = ctx.window[k]; + const src2 = fs.readFileSync('public/packets.js', 'utf8'); + vm.runInContext(src2, ctx); + for (const k of Object.keys(ctx.window)) ctx[k] = ctx.window[k]; + } catch (e) { + console.log(' ⚠️ packets.js sandbox load failed:', e.message.slice(0, 120)); + } + + const buildPacketsQuery = ctx.buildPacketsQuery; + + if (buildPacketsQuery) { + test('buildPacketsQuery: default (15min, no region) = empty string', () => { + assert.strictEqual(buildPacketsQuery(15, ''), ''); + }); + test('buildPacketsQuery: non-default timeWindow', () => { + assert.strictEqual(buildPacketsQuery(60, ''), '?timeWindow=60'); + }); + test('buildPacketsQuery: region only', () => { + assert.strictEqual(buildPacketsQuery(15, 'US-SFO'), '?region=US-SFO'); + }); + test('buildPacketsQuery: timeWindow + region', () => { + assert.strictEqual(buildPacketsQuery(30, 'US-SFO,US-LAX'), '?timeWindow=30®ion=US-SFO%2CUS-LAX'); + }); + test('buildPacketsQuery: timeWindow=0 treated as default', () => { + assert.strictEqual(buildPacketsQuery(0, ''), ''); + }); + } else { + console.log(' ⚠️ buildPacketsQuery not exposed — skipping'); + } +} + // ===== SUMMARY ===== Promise.allSettled(pendingTests).then(() => { console.log(`\n${'═'.repeat(40)}`);