Building a Station-Comparison Tool for PSKReporter with Claude Code
I run a small internal tool called psk-analysis — a spot tracker built on top of PSKReporter’s live MQTT feed. It watches a handful of tracked callsigns (mine, DL7EDU, and a few friends’), stores every reception report in Postgres, and gives us dashboards: spot maps, DXCC summaries, SNR-over-time, band activity, that kind of thing. Express + Prisma backend, React + Vite frontend, deployed on my home Talos Kubernetes cluster via ArgoCD.
This post is about the most recent chunk of work on it: a station comparison page, a reworked SNR-by-bearing chart, and a new system status page — and, since I did all of it with Claude Code, a bit about what that process actually looked like.
The starting idea: comparing two stations
The original ask was simple: “I want to compare two tracked stations side by side.” Straightforward on paper, but it grew a few genuinely interesting wrinkles once we started brainstorming it properly instead of just building the first thing that came to mind:
- Same callsign, two time windows. Turns out the most useful comparison isn’t always “me vs. my friend” — it’s “me with antenna A vs. me with antenna B,” i.e. the same callsign at two different times. That meant the compare page needed an independent date/time range per side, not just a single shared filter, plus a way to visually disambiguate “DL7EDU (A)” from “DL7EDU (B)” everywhere a label shows up.
- A shared map, not just two maps. Rather than two independent spot maps, I wanted one map showing the spotters that heard both stations, with a color-coded winner per spotter (best SNR, in green, no red — losing isn’t an error), and the two individual maps narrowed down to each station’s unique contacts only.
- An SNR-by-bearing chart that shows, for every compass direction, the single strongest contact — with the marker’s distance from center encoding real distance and its size encoding signal strength.
- A collapsible SNR-by-spotter table: one row per spotter (how many times it heard each side), expandable into the band/mode breakdown.
None of this was fully specified upfront. It came out of a proper back-and-forth: propose an approach, get corrected, propose again. That’s the part I want to spend a bit of time on.
The process: brainstorm → spec → plan → build → review
For anything beyond a one-line fix, the workflow that worked best was a staged one, driven by Claude Code’s “superpowers” skill set:
- Brainstorm. Ask clarifying questions one at a time, propose 2-3 approaches with tradeoffs, converge on a design.
- Write a spec. A short design document, committed to the repo, that I read and signed off on before any code got written.
- Write a plan. A task-by-task implementation plan with the actual code for each step already written out — not vague instructions like “add sorting,” but the literal diff.
- Execute with fresh subagents. Each task in the plan gets dispatched to a brand-new subagent with no memory of the rest of the conversation — just its task, the relevant interfaces, and the global constraints. That isolation turned out to matter a lot: no context pollution, no subtle confusion from earlier decisions bleeding into unrelated work.
- Review, twice. Every task gets reviewed by a separate subagent against the spec (did it build the right thing?) and for code quality (is it well-built?) before moving on. At the end, one more whole-branch review across everything, on the most capable model available, looking specifically for integration issues no single task-scoped review would catch.
- Merge and deploy. Fast-forward merge, then the usual CI → image tag bump → ArgoCD sync → verify-against-the-live-site loop.
The genuinely useful part of this isn’t the ceremony — it’s that disagreements got caught before code existed, not after. When the bearing chart redesign turned out to have a real usability problem (“everything’s clustered near the center”), that surfaced as feedback on a shipped feature, and got root-caused and fixed the same way: understand the actual complaint, don’t just patch the symptom.
The bug that taught me the most: MQTT topic encoding
Before any of the UI work, there was a genuinely subtle bug. A user (me) asked: “when I operate as DL7EDU/P, why don’t those spots show up?”
The ingester subscribes to PSKReporter’s MQTT broker using narrow, per-callsign topic filters, something like pskr/filter/v2/+/+/DL7EDU/#. That worked fine for plain callsigns. But when I actually transmitted as DL7EDU/P, nothing showed up — and there was no error anywhere, which is the worst kind of bug.
Turned out PSKReporter’s broker encodes a / inside a reported callsign — portable suffixes like /P, or DXCC prefix overrides like W4/DL7EDU — as a literal . in the topic string, and keeps it as one topic segment (DL7EDU.P), not two. MQTT’s + and # wildcards only ever match whole segments, never substrings, so a filter built from the bare callsign could never match. The fix I’d already shipped for the application-level matching logic (splitting the callsign on / and comparing segments) was completely correct — it just never got the chance to run, because the message never arrived in the first place.
I confirmed this the fun way: sniffing the broker directly with mosquitto_sub while transmitting live, and watching DL7EDU.P show up in the raw topic string in real time.
pskr/filter/v2/17m/FT8/DL7EDU.P/OH3OJ/JO62/KP20/230/224 {"sq":...,"sc":"DL7EDU/P",...}The real fix was to stop trying to pre-filter at the topic level entirely and subscribe to the whole firehose (pskr/filter/v2/#, the same thing PSKReporter’s own reference collector does), doing all the matching client-side against the untouched JSON payload instead. More bandwidth, but correct — and it’s the kind of bug that’s genuinely hard to find without live-capturing the actual wire traffic, since it produces silence, not an error.
Small things that mattered
A few smaller lessons, in no particular order:
- Ties are UI decisions, not defaults. When two stations have the exact same best SNR to a spotter, defaulting to “station A wins” is a subtle lie — fixed it so neither side gets the “winner” styling on an exact tie.
- Auto-scale, don’t hardcode a worldwide max. The bearing chart originally scaled every marker against the theoretical antipodal maximum distance (~20,015 km). If your farthest contact that day was 4,000 km, every point crammed into the inner fifth of the chart. Scaling to 4 “nice” round divisions of the actual farthest spot in the current data — the same “nice ticks” approach most charting libraries use — fixed it completely.
- Keyboard accessibility isn’t optional, even for a home tool. A collapsible table row driven by
onClickon a<tr>with notabindexis invisible to keyboard navigation. Small fix, easy to miss, caught in review. - Native
<title>tooltips are slow. Swapping them for an instant custom-styled tooltip made the whole chart feel dramatically more responsive, even though nothing about the underlying data changed.
Where it ended up
The compare page now has: a shared “common spots” map with color-coded winners, two “unique contacts” maps, a redesigned bearing chart (auto-scaled rings, image-like scroll-to-zoom and drag-to-pan, a proper trend line across bearings), a collapsible sortable spotter table, and independent per-station time windows for comparing the same callsign against itself. There’s also a new /status page — total spots, distinct spotters, tracked callsigns, and a live/stale indicator based on how recently a spot was actually ingested, which turned out to be a much simpler and more honest signal than trying to expose the ingester’s internal MQTT connection state.
None of this was a huge amount of code in any single step. What made it work was refusing to skip the “why” before jumping to the “how” — for a hobby project, that’s a discipline I don’t always have on my own, and it’s the part I got the most value out of working this way.