From 10a900386bf28b1f57e5bcacadaedd5fa2f001cb Mon Sep 17 00:00:00 2001
From: Laura Batalha <5883822+lbatalha@users.noreply.github.com>
Date: Thu, 23 Jul 2026 21:38:04 +0100
Subject: [PATCH 1/4] feat: add PSK Reporter Band Activity overlay
Compact horizontal bar chart showing PSKReporter spot counts per HF band.
Features:
- Client-side counting from usePSKReporter data (filtered by time window)
- Real-time updates via custom events (no polling)
- Fixed panel size, all 15 HF bands always visible
- Inactive bands dimmed with reduced opacity
- Minimizeable with footer that shows total spots and time window
- Draggable panel with saved position
- Styled with band colors and consistent with other overlays
---
psk-reporter-band-activity-plan.md | 160 +++++++++++++
server/routes/pskreporter.js | 21 ++
src/hooks/usePSKReporter.js | 14 ++
src/plugins/layerRegistry.js | 3 +
.../layers/usePSKReporterBandActivity.js | 224 ++++++++++++++++++
5 files changed, 422 insertions(+)
create mode 100644 psk-reporter-band-activity-plan.md
create mode 100644 src/plugins/layers/usePSKReporterBandActivity.js
diff --git a/psk-reporter-band-activity-plan.md b/psk-reporter-band-activity-plan.md
new file mode 100644
index 00000000..b07d8339
--- /dev/null
+++ b/psk-reporter-band-activity-plan.md
@@ -0,0 +1,160 @@
+# Implementation Plan: PSKReporter Band Activity Map Overlay
+
+## Goal
+
+A compact map overlay panel showing spot counts per HF band from PSKReporter, using the same time window as the PSKReporter panel.
+
+## Files to create / modify
+
+| # | Action | Path |
+| --- | ------ | -------------------------------------------------- |
+| 1 | Create | `src/plugins/layers/usePSKReporterBandActivity.js` |
+| 2 | Edit | `src/plugins/layerRegistry.js` โ import + register |
+| 3 | Edit | `server/routes/pskreporter.js` โ add API endpoint |
+
+---
+
+## 1. Backend: `/api/pskreporter/band-activity` endpoint
+
+**File**: `server/routes/pskreporter.js` (append inside the `module.exports` function)
+
+```js
+app.get('/api/pskreporter/band-activity', (req, res) => {
+ const minutes = parseInt(req.query.minutes) || 15;
+ const cutoff = Date.now() - minutes * 60 * 1000;
+ const counts = {};
+
+ for (const [, spots] of pskMqtt.recentSpots) {
+ for (const spot of spots) {
+ if (spot.timestamp < cutoff) continue;
+ const band = spot.band;
+ if (!band || band === 'Unknown') continue;
+ counts[band] = (counts[band] || 0) + 1;
+ }
+ }
+
+ const bands = Object.entries(counts).sort((a, b) => b[1] - a[1]);
+
+ res.json({ bands, minutes, timestamp: new Date().toISOString() });
+});
+```
+
+- No cache needed โ `pskMqtt.recentSpots` is in-memory, iteration is fast for <500 entries
+- Respects user's `minutes` parameter (comes from `ohc_psk_age`)
+
+---
+
+## 2. Frontend: Layer plugin
+
+**File**: `src/plugins/layers/usePSKReporterBandActivity.js`
+
+**Metadata:**
+
+```js
+export const metadata = {
+ id: 'psk-band-activity',
+ name: 'PSK Reporter Band Activity',
+ description: 'Spot counts per HF band',
+ icon: '๐ก',
+ category: 'propagation',
+ defaultEnabled: false,
+ defaultOpacity: 0.85,
+ version: '1.0.0',
+};
+```
+
+**Data flow:**
+
+- On mount + when enabled: read `localStorage.getItem('ohc_psk_age')` (default 15)
+- Listen for `ohc-psk-age-changed` on `window` โ recalculate when time window changes
+- Fetch `GET /api/pskreporter/band-activity?minutes=${pskAge}` every 60s (120s in low-memory mode)
+
+**State:**
+
+```js
+const [bandCounts, setBandCounts] = useState([]); // [[band, count], ...]
+const [pskAge, setPskAge] = useState(15);
+```
+
+**Panel rendering** (single L.Control, topright):
+
+Compact horizontal bar chart:
+
+```
+โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+โ ๐ก PSK Reporter Band Activity โถ โ
+โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโค
+โ 20m [โโโโโโโโโโโโ] 142 โ
+โ 40m [โโโโโโโโโโโโ] 98 โ
+โ 15m [โโโโโโโโโโโโ] 73 โ
+โ 10m [โโโโโโโโโโโโ] 51 โ
+โ 80m [โโโโโโโโโโโโ] 34 โ
+โ 30m [โโโโโโโโโโโโ] 12 โ
+โ 17m [โโโโโโโโโโโโ] 8 โ
+โ 60m [โโโโโโโโโโโโ] 2 โ
+โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโค
+โ Total: 420 ยท Last 15 min โ
+โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+```
+
+**Design details:**
+
+- Band bar width = `(count / maxCount) * 100%` โ scales to the most active band
+- Band colors from `DEFAULT_BAND_COLORS` (consistent with other overlays)
+- Only show bands with `count > 0`
+- Sort descending by count
+- Mono font, `min-width: 180px`, same panel styling as all existing overlays
+- Draggable + minimizable via `makeDraggable()` + `addMinimizeToggle()`
+- No map markers โ stats-only panel
+
+**Events:**
+
+```js
+window.addEventListener('ohc-psk-age-changed', () => {
+ try {
+ setPskAge(parseInt(localStorage.getItem('ohc_psk_age')) || 15);
+ } catch {}
+});
+```
+
+**Fetch cycle:**
+
+```js
+// On mount + when enabled
+fetch(`/api/pskreporter/band-activity?minutes=${pskAge}`)
+ .then((r) => r.json())
+ .then((data) => setBandCounts(data.bands || []));
+
+// Poll every 60s (120s low-memory)
+setInterval(fetch, 60000);
+```
+
+---
+
+## 3. Registration
+
+**File**: `src/plugins/layerRegistry.js`
+
+```js
+// Add import near the top
+import * as PSKReporterBandActivityPlugin from './layers/usePSKReporterBandActivity.js';
+
+// Add to layerPlugins array (alphabetical-ish placement under 'propagation' category)
+PSKReporterBandActivityPlugin,
+
+// Optional: add to PINNED_SHORTCUTS (pick an unused letter)
+'psk-band-activity': 'b', // or whatever letter is available
+```
+
+---
+
+## Summary of all changes
+
+| Change | Lines of code |
+| ------------------------------------------- | -------------- |
+| New file `usePSKReporterBandActivity.js` | ~120 lines |
+| Edit `layerRegistry.js` (import + register) | 2 lines |
+| Edit `pskreporter.js` (API endpoint) | ~15 lines |
+| **Total** | **~140 lines** |
+
+No CSS changes needed โ reuses existing `.panel-wrapper`, `floating-panel-header`, etc. from `main.css`.
diff --git a/server/routes/pskreporter.js b/server/routes/pskreporter.js
index 0378102b..c4c819f8 100644
--- a/server/routes/pskreporter.js
+++ b/server/routes/pskreporter.js
@@ -206,6 +206,27 @@ module.exports = function (app, ctx) {
});
});
+ // Band activity โ counts of recent spots per band.
+ // Reads directly from pskMqtt.recentSpots (no cache needed โ in-memory iteration).
+ app.get('/api/pskreporter/band-activity', (req, res) => {
+ const minutes = parseInt(req.query.minutes) || 15;
+ const cutoff = Date.now() - minutes * 60 * 1000;
+ const counts = {};
+
+ for (const [, spots] of pskMqtt.recentSpots) {
+ for (const spot of spots) {
+ if (spot.timestamp < cutoff) continue;
+ const band = spot.band;
+ if (!band || band === 'Unknown') continue;
+ counts[band] = (counts[band] || 0) + 1;
+ }
+ }
+
+ const bands = Object.entries(counts).sort((a, b) => b[1] - a[1]);
+
+ res.json({ bands, minutes, timestamp: new Date().toISOString() });
+ });
+
// Combined endpoint - returns stream info (live spots via SSE, no HTTP backfill)
app.get('/api/pskreporter/:callsign', async (req, res) => {
const callsign = req.params.callsign.toUpperCase();
diff --git a/src/hooks/usePSKReporter.js b/src/hooks/usePSKReporter.js
index 09a8a1c2..44282728 100644
--- a/src/hooks/usePSKReporter.js
+++ b/src/hooks/usePSKReporter.js
@@ -96,6 +96,20 @@ export const usePSKReporter = (callsign, options = {}) => {
if (txChanged || rxChanged) {
setLastUpdate(new Date());
}
+
+ // Compute band counts filtered by the user's time window and broadcast
+ const now = Date.now();
+ const cutoff = now - minutes * 60 * 1000;
+ const counts = {};
+ const allReports = [...txReportsRef.current, ...rxReportsRef.current].filter((r) => r.timestamp > cutoff);
+ for (const report of allReports) {
+ const band = report.band;
+ if (!band || band === 'Unknown') continue;
+ counts[band] = (counts[band] || 0) + 1;
+ }
+ const bands = Object.entries(counts).sort((a, b) => b[1] - a[1]);
+ const total = bands.reduce((sum, [, c]) => sum + c, 0);
+ window.dispatchEvent(new CustomEvent('psk-band-activity-changed', { detail: { bands, total } }));
},
[identifier, minutes, maxSpots, cleanOldSpots],
);
diff --git a/src/plugins/layerRegistry.js b/src/plugins/layerRegistry.js
index 43db15e4..ea928b9c 100644
--- a/src/plugins/layerRegistry.js
+++ b/src/plugins/layerRegistry.js
@@ -30,6 +30,7 @@ import * as IBPLayerPlugin from './layers/useIBPLayer.js';
import * as WinlinkGatewaysPlugin from './layers/useWinlinkGateways.js';
import * as AircraftPlugin from './layers/useAircraft.js';
import * as ATCSectorsPlugin from './layers/useATCSectors.js';
+import * as PSKReporterBandActivityPlugin from './layers/usePSKReporterBandActivity.js';
// Auto-discover local/custom plugins (gitignored โ survive updates)
const localPluginModules = import.meta.glob('./local/*.js', { eager: true });
@@ -73,6 +74,7 @@ const layerPlugins = [
WinlinkGatewaysPlugin,
AircraftPlugin,
ATCSectorsPlugin,
+ PSKReporterBandActivityPlugin,
...localPlugins,
];
@@ -105,6 +107,7 @@ const PINNED_SHORTCUTS = {
'winlink-gateways': 'k',
aircraft: 'x',
'atc-sectors': 'z',
+ 'psk-band-activity': 'b',
};
export function getAllLayers() {
diff --git a/src/plugins/layers/usePSKReporterBandActivity.js b/src/plugins/layers/usePSKReporterBandActivity.js
new file mode 100644
index 00000000..afbe2dd4
--- /dev/null
+++ b/src/plugins/layers/usePSKReporterBandActivity.js
@@ -0,0 +1,224 @@
+import { useEffect, useRef, useState } from 'react';
+import { esc } from '../../utils/escapeHtml.js';
+import { DEFAULT_BAND_COLORS } from '../../utils/bandColors.js';
+import { addMinimizeToggle } from './addMinimizeToggle.js';
+import { makeDraggable } from './makeDraggable.js';
+
+/**
+ * PSK Reporter Band Activity Overlay
+ *
+ * Shows a compact horizontal bar chart of spot counts per HF band.
+ * Receives live data from usePSKReporter via 'psk-band-activity-changed' event,
+ * which already filters by the user's time window (ohc_psk_age).
+ *
+ * Data source: usePSKReporter hook (txReports + rxReports)
+ * Update: real-time via event (no polling needed)
+ */
+
+export const metadata = {
+ id: 'psk-band-activity',
+ name: 'PSKR Band Activity',
+ description: 'Spot counts per HF band from PSKReporter',
+ icon: '๐ก',
+ category: 'propagation',
+ defaultEnabled: false,
+ defaultOpacity: 0.85,
+ version: '1.0.0',
+};
+
+const BAND_ORDER = [
+ '160m',
+ '80m',
+ '60m',
+ '40m',
+ '30m',
+ '20m',
+ '17m',
+ '15m',
+ '12m',
+ '10m',
+ '8m',
+ '6m',
+ '4m',
+ '2m',
+ '70cm',
+];
+
+export function useLayer({ enabled = false, map = null }) {
+ const [bandCounts, setBandCounts] = useState([]); // [[band, count], ...]
+ const [pskAge, setPskAge] = useState(() => {
+ try {
+ return parseInt(localStorage.getItem('ohc_psk_age')) || 15;
+ } catch {
+ return 15;
+ }
+ });
+ const [totalSpots, setTotalSpots] = useState(0);
+
+ const controlRef = useRef(null);
+ const updateTimeoutRef = useRef(null);
+ const latestRef = useRef({ total: 0, age: 15 });
+
+ // Read time window from localStorage and listen for changes
+ useEffect(() => {
+ const sync = () => {
+ try {
+ const v = parseInt(localStorage.getItem('ohc_psk_age'));
+ if (Number.isFinite(v) && v > 0) setPskAge(v);
+ } catch {}
+ };
+ window.addEventListener('ohc-psk-age-changed', sync);
+ return () => {
+ window.removeEventListener('ohc-psk-age-changed', sync);
+ };
+ }, []);
+
+ // Listen for band activity data from usePSKReporter
+ useEffect(() => {
+ if (!enabled) return;
+
+ const handler = (e) => {
+ const { bands, total } = e.detail || {};
+ if (bands) setBandCounts(bands);
+ if (total !== undefined) setTotalSpots(total);
+ };
+
+ window.addEventListener('psk-band-activity-changed', handler);
+ return () => {
+ window.removeEventListener('psk-band-activity-changed', handler);
+ };
+ }, [enabled]);
+
+ // Update the panel content when data changes (no recreation)
+ useEffect(() => {
+ if (!enabled) return;
+ updateTimeoutRef.current = setTimeout(() => {
+ const container = controlRef.current?.getContainer() || document.querySelector('.psk-band-activity');
+ if (!container) return;
+
+ const maxCount = bandCounts.length > 0 ? bandCounts[0][1] : 0;
+ const countsMap = new Map(bandCounts);
+
+ const rows = BAND_ORDER.map((band) => {
+ const count = countsMap.get(band) || 0;
+ const active = count > 0;
+ const color = DEFAULT_BAND_COLORS[band] || '#888888';
+ const barWidth = maxCount > 0 ? Math.max((count / maxCount) * 100, 1.5) : 0;
+ return `
+
+
${esc(band)}
+
+
${count}
+
+ `;
+ }).join('');
+
+ // Inject into psk-band-content (our unique content div)
+ let contentTarget = container.querySelector('.psk-band-content');
+ if (!contentTarget) {
+ contentTarget = document.createElement('div');
+ contentTarget.className = 'psk-band-content';
+ container.insertBefore(contentTarget, container.querySelector('.psk-band-footer'));
+ }
+ contentTarget.innerHTML = `${rows}
`;
+ }, 30);
+
+ return () => {
+ if (updateTimeoutRef.current) {
+ clearTimeout(updateTimeoutRef.current);
+ updateTimeoutRef.current = null;
+ }
+ };
+ }, [enabled, bandCounts, totalSpots]);
+
+ // Keep ref in sync with state (so footer creation reads latest values)
+ useEffect(() => {
+ latestRef.current = { total: totalSpots, age: pskAge };
+ }, [totalSpots, pskAge]);
+
+ // Update footer independently (runs on totalSpots or pskAge change)
+ useEffect(() => {
+ if (!enabled) return;
+ const footer = document.querySelector('.psk-band-activity .psk-band-footer');
+ if (footer) {
+ const span = footer.querySelector('#psk-band-footer-text');
+ if (span) span.textContent = `Total: ${totalSpots} ยท Last ${pskAge} min`;
+ }
+ }, [enabled, totalSpots, pskAge]);
+
+ // Create the control panel
+ useEffect(() => {
+ if (!enabled || !map || controlRef.current) return;
+
+ const Control = L.Control.extend({
+ options: { position: 'topright' },
+ onAdd: function () {
+ const panelWrapper = L.DomUtil.create('div', 'panel-wrapper');
+ const div = L.DomUtil.create('div', 'psk-band-activity', panelWrapper);
+ div.style.cssText = 'min-width: 180px; max-width: 240px;';
+ div.innerHTML = `
+
+
+
+
+ Total: 0 ยท Last ${latestRef.current.age} min
+
+ `;
+
+ L.DomEvent.disableClickPropagation(div);
+ L.DomEvent.disableScrollPropagation(div);
+
+ return panelWrapper;
+ },
+ });
+
+ const control = new Control();
+ map.addControl(control);
+ controlRef.current = control;
+
+ // Make draggable and minimizable
+ setTimeout(() => {
+ const container = document.querySelector('.psk-band-activity');
+ if (container) {
+ const saved = localStorage.getItem('psk-band-activity-position');
+ if (saved) {
+ try {
+ const { top, left } = JSON.parse(saved);
+ container.style.position = 'fixed';
+ container.style.top = top + 'px';
+ container.style.left = left + 'px';
+ container.style.right = 'auto';
+ container.style.bottom = 'auto';
+ } catch {}
+ }
+
+ makeDraggable(container, 'psk-band-activity-position', { snap: 5 });
+ addMinimizeToggle(container, 'psk-band-activity-position', {
+ contentClassName: 'psk-panel-content',
+ buttonClassName: 'psk-minimize-btn',
+ });
+ }
+ }, 150);
+
+ return () => {
+ if (controlRef.current) {
+ map.removeControl(controlRef.current);
+ controlRef.current = null;
+ }
+ };
+ }, [enabled, map]);
+
+ // Cleanup on disable
+ useEffect(() => {
+ if (!enabled) {
+ if (controlRef.current) {
+ map.removeControl(controlRef.current);
+ controlRef.current = null;
+ }
+ }
+ }, [enabled, map]);
+
+ return null;
+}
From 4b940b5e6037898af222c71f45fda03bae6b7336 Mon Sep 17 00:00:00 2001
From: Laura Batalha <5883822+lbatalha@users.noreply.github.com>
Date: Fri, 24 Jul 2026 15:12:57 +0100
Subject: [PATCH 2/4] remove implementation plan
---
psk-reporter-band-activity-plan.md | 160 -----------------------------
1 file changed, 160 deletions(-)
delete mode 100644 psk-reporter-band-activity-plan.md
diff --git a/psk-reporter-band-activity-plan.md b/psk-reporter-band-activity-plan.md
deleted file mode 100644
index b07d8339..00000000
--- a/psk-reporter-band-activity-plan.md
+++ /dev/null
@@ -1,160 +0,0 @@
-# Implementation Plan: PSKReporter Band Activity Map Overlay
-
-## Goal
-
-A compact map overlay panel showing spot counts per HF band from PSKReporter, using the same time window as the PSKReporter panel.
-
-## Files to create / modify
-
-| # | Action | Path |
-| --- | ------ | -------------------------------------------------- |
-| 1 | Create | `src/plugins/layers/usePSKReporterBandActivity.js` |
-| 2 | Edit | `src/plugins/layerRegistry.js` โ import + register |
-| 3 | Edit | `server/routes/pskreporter.js` โ add API endpoint |
-
----
-
-## 1. Backend: `/api/pskreporter/band-activity` endpoint
-
-**File**: `server/routes/pskreporter.js` (append inside the `module.exports` function)
-
-```js
-app.get('/api/pskreporter/band-activity', (req, res) => {
- const minutes = parseInt(req.query.minutes) || 15;
- const cutoff = Date.now() - minutes * 60 * 1000;
- const counts = {};
-
- for (const [, spots] of pskMqtt.recentSpots) {
- for (const spot of spots) {
- if (spot.timestamp < cutoff) continue;
- const band = spot.band;
- if (!band || band === 'Unknown') continue;
- counts[band] = (counts[band] || 0) + 1;
- }
- }
-
- const bands = Object.entries(counts).sort((a, b) => b[1] - a[1]);
-
- res.json({ bands, minutes, timestamp: new Date().toISOString() });
-});
-```
-
-- No cache needed โ `pskMqtt.recentSpots` is in-memory, iteration is fast for <500 entries
-- Respects user's `minutes` parameter (comes from `ohc_psk_age`)
-
----
-
-## 2. Frontend: Layer plugin
-
-**File**: `src/plugins/layers/usePSKReporterBandActivity.js`
-
-**Metadata:**
-
-```js
-export const metadata = {
- id: 'psk-band-activity',
- name: 'PSK Reporter Band Activity',
- description: 'Spot counts per HF band',
- icon: '๐ก',
- category: 'propagation',
- defaultEnabled: false,
- defaultOpacity: 0.85,
- version: '1.0.0',
-};
-```
-
-**Data flow:**
-
-- On mount + when enabled: read `localStorage.getItem('ohc_psk_age')` (default 15)
-- Listen for `ohc-psk-age-changed` on `window` โ recalculate when time window changes
-- Fetch `GET /api/pskreporter/band-activity?minutes=${pskAge}` every 60s (120s in low-memory mode)
-
-**State:**
-
-```js
-const [bandCounts, setBandCounts] = useState([]); // [[band, count], ...]
-const [pskAge, setPskAge] = useState(15);
-```
-
-**Panel rendering** (single L.Control, topright):
-
-Compact horizontal bar chart:
-
-```
-โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
-โ ๐ก PSK Reporter Band Activity โถ โ
-โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโค
-โ 20m [โโโโโโโโโโโโ] 142 โ
-โ 40m [โโโโโโโโโโโโ] 98 โ
-โ 15m [โโโโโโโโโโโโ] 73 โ
-โ 10m [โโโโโโโโโโโโ] 51 โ
-โ 80m [โโโโโโโโโโโโ] 34 โ
-โ 30m [โโโโโโโโโโโโ] 12 โ
-โ 17m [โโโโโโโโโโโโ] 8 โ
-โ 60m [โโโโโโโโโโโโ] 2 โ
-โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโค
-โ Total: 420 ยท Last 15 min โ
-โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
-```
-
-**Design details:**
-
-- Band bar width = `(count / maxCount) * 100%` โ scales to the most active band
-- Band colors from `DEFAULT_BAND_COLORS` (consistent with other overlays)
-- Only show bands with `count > 0`
-- Sort descending by count
-- Mono font, `min-width: 180px`, same panel styling as all existing overlays
-- Draggable + minimizable via `makeDraggable()` + `addMinimizeToggle()`
-- No map markers โ stats-only panel
-
-**Events:**
-
-```js
-window.addEventListener('ohc-psk-age-changed', () => {
- try {
- setPskAge(parseInt(localStorage.getItem('ohc_psk_age')) || 15);
- } catch {}
-});
-```
-
-**Fetch cycle:**
-
-```js
-// On mount + when enabled
-fetch(`/api/pskreporter/band-activity?minutes=${pskAge}`)
- .then((r) => r.json())
- .then((data) => setBandCounts(data.bands || []));
-
-// Poll every 60s (120s low-memory)
-setInterval(fetch, 60000);
-```
-
----
-
-## 3. Registration
-
-**File**: `src/plugins/layerRegistry.js`
-
-```js
-// Add import near the top
-import * as PSKReporterBandActivityPlugin from './layers/usePSKReporterBandActivity.js';
-
-// Add to layerPlugins array (alphabetical-ish placement under 'propagation' category)
-PSKReporterBandActivityPlugin,
-
-// Optional: add to PINNED_SHORTCUTS (pick an unused letter)
-'psk-band-activity': 'b', // or whatever letter is available
-```
-
----
-
-## Summary of all changes
-
-| Change | Lines of code |
-| ------------------------------------------- | -------------- |
-| New file `usePSKReporterBandActivity.js` | ~120 lines |
-| Edit `layerRegistry.js` (import + register) | 2 lines |
-| Edit `pskreporter.js` (API endpoint) | ~15 lines |
-| **Total** | **~140 lines** |
-
-No CSS changes needed โ reuses existing `.panel-wrapper`, `floating-panel-header`, etc. from `main.css`.
From 3320c422ce2c57db9cf59b23502d45bcbb54d8d1 Mon Sep 17 00:00:00 2001
From: Laura Batalha <5883822+lbatalha@users.noreply.github.com>
Date: Tue, 28 Jul 2026 19:54:23 +0100
Subject: [PATCH 3/4] refactor: remove dead /api/pskreporter/band-activity
endpoint
Overlay now uses client-side events exclusively, no server fetch needed.
---
server/routes/pskreporter.js | 21 ---------------------
1 file changed, 21 deletions(-)
diff --git a/server/routes/pskreporter.js b/server/routes/pskreporter.js
index c4c819f8..0378102b 100644
--- a/server/routes/pskreporter.js
+++ b/server/routes/pskreporter.js
@@ -206,27 +206,6 @@ module.exports = function (app, ctx) {
});
});
- // Band activity โ counts of recent spots per band.
- // Reads directly from pskMqtt.recentSpots (no cache needed โ in-memory iteration).
- app.get('/api/pskreporter/band-activity', (req, res) => {
- const minutes = parseInt(req.query.minutes) || 15;
- const cutoff = Date.now() - minutes * 60 * 1000;
- const counts = {};
-
- for (const [, spots] of pskMqtt.recentSpots) {
- for (const spot of spots) {
- if (spot.timestamp < cutoff) continue;
- const band = spot.band;
- if (!band || band === 'Unknown') continue;
- counts[band] = (counts[band] || 0) + 1;
- }
- }
-
- const bands = Object.entries(counts).sort((a, b) => b[1] - a[1]);
-
- res.json({ bands, minutes, timestamp: new Date().toISOString() });
- });
-
// Combined endpoint - returns stream info (live spots via SSE, no HTTP backfill)
app.get('/api/pskreporter/:callsign', async (req, res) => {
const callsign = req.params.callsign.toUpperCase();
From de37203041f7e0956cfedafabb0fca8b12a8f29f Mon Sep 17 00:00:00 2001
From: Laura Batalha <5883822+lbatalha@users.noreply.github.com>
Date: Mon, 3 Aug 2026 16:37:46 +0100
Subject: [PATCH 4/4] feat: add PSK Reporter Band Activity as a dockable panel
---
src/DockableApp.jsx | 8 +-
.../PSKReporterBandActivityPanel.jsx | 166 ++++++++++++++++++
src/components/index.js | 1 +
3 files changed, 174 insertions(+), 1 deletion(-)
create mode 100644 src/components/PSKReporterBandActivityPanel.jsx
diff --git a/src/DockableApp.jsx b/src/DockableApp.jsx
index 0e12bb0f..d9fdaae9 100644
--- a/src/DockableApp.jsx
+++ b/src/DockableApp.jsx
@@ -22,6 +22,7 @@ import {
RotatorPanel,
DXpeditionPanel,
PSKReporterPanel,
+ PSKReporterBandActivityPanel,
APRSPanel,
MapDataListView,
MeshComPanel,
@@ -442,7 +443,8 @@ export const DockableApp = ({
'propagation-bars': { name: 'VOACAP Bars', icon: '๐', group: 'Propagation' },
'band-conditions': { name: 'Band Conditions', icon: '๐ถ', group: 'Propagation' },
'band-health': { name: 'Band Health', icon: '๐ถ' },
- 'band-activity': { name: 'Band Activity', icon: '๐ฅ' },
+ 'band-activity': { name: 'Band Activity (Continent)', icon: '๐ฅ' },
+ 'psk-bands': { name: 'Band Activity (PSKR)', icon: '๐ก' },
ibp: { name: 'IBP Beacons', icon: '๐ก', group: 'Propagation' },
'dx-cluster': { name: 'DX Cluster', icon: '๐ป' },
'psk-reporter': { name: 'PSK Reporter', icon: '๐ก' },
@@ -904,6 +906,10 @@ export const DockableApp = ({
content = ;
break;
+ case 'psk-bands':
+ content = ;
+ break;
+
case 'dx-cluster':
content = (
{
+ const [pskAge, setPskAge] = useState(() => {
+ try {
+ return parseInt(localStorage.getItem('ohc_psk_age')) || 15;
+ } catch {
+ return 15;
+ }
+ });
+
+ // Listen for localStorage changes to pskAge
+ useEffect(() => {
+ const sync = () => {
+ try {
+ const v = parseInt(localStorage.getItem('ohc_psk_age'));
+ if (Number.isFinite(v) && v > 0) setPskAge(v);
+ } catch {}
+ };
+ window.addEventListener('ohc-psk-age-changed', sync);
+ return () => window.removeEventListener('ohc-psk-age-changed', sync);
+ }, []);
+
+ const { txReports = [], rxReports = [] } = pskReporter;
+
+ // Compute band counts from reports, filtered by pskAge
+ const { bandCounts, totalSpots, maxCount } = useMemo(() => {
+ const cutoff = Date.now() - pskAge * 60 * 1000;
+ const counts = {};
+ let total = 0;
+
+ const allReports = [...txReports, ...rxReports];
+ for (const report of allReports) {
+ if (report.timestamp <= cutoff) continue;
+ const band = report.band;
+ if (!band || band === 'Unknown') continue;
+ counts[band] = (counts[band] || 0) + 1;
+ }
+
+ const entries = Object.entries(counts);
+ entries.forEach(([, c]) => {
+ total += c;
+ });
+ const sorted = entries.sort((a, b) => b[1] - a[1]);
+ const max = sorted.length > 0 ? sorted[0][1] : 0;
+
+ return { bandCounts: sorted, totalSpots: total, maxCount: max };
+ }, [txReports, rxReports, pskAge]);
+
+ const countsMap = new Map(bandCounts);
+
+ return (
+
+
+ {BAND_ORDER.map((band) => {
+ const count = countsMap.get(band) || 0;
+ const active = count > 0;
+ const color = DEFAULT_BAND_COLORS[band] || '#888888';
+ const barWidth = maxCount > 0 ? Math.max((count / maxCount) * 100, 1.5) : 0;
+ return (
+
+
+ {band}
+
+
+
+ {count}
+
+
+ );
+ })}
+
+
+
+ Total: {totalSpots} ยท Last {pskAge} min
+
+
+ );
+};
+
+export default PSKReporterBandActivityPanel;
diff --git a/src/components/index.js b/src/components/index.js
index e214c953..430bbc16 100644
--- a/src/components/index.js
+++ b/src/components/index.js
@@ -27,6 +27,7 @@ export { SolarPanel } from './SolarPanel.jsx';
export { PropagationPanel } from './PropagationPanel.jsx';
export { DXpeditionPanel } from './DXpeditionPanel.jsx';
export { PSKReporterPanel } from './PSKReporterPanel.jsx';
+export { default as PSKReporterBandActivityPanel } from './PSKReporterBandActivityPanel.jsx';
export { DXNewsTicker } from './DXNewsTicker.jsx';
export { WeatherPanel } from './WeatherPanel.jsx';
export { AnalogClockPanel } from './AnalogClockPanel.jsx';