Files
OpsLog/CLAUDE.md
T
rouggy 6fb7f06375 docs: read a changelog block top to bottom — order 0.22.0 chronologically
New entries were being prepended, so a version block read backwards: an operator
met "the Yaesu meters are corrected" and "the console follows the mode" several
entries before learning that a Yaesu console existed. The fixes made sense only
to someone who had followed the development.

0.22.0 is reordered so each feature appears before what was fixed on it, and
CLAUDE.md now says to append at the bottom.

Restored the entry that INTRODUCES the Yaesu console: I had overwritten it when
editing the meters entry in place, so the release described corrections to a
panel it never announced.
2026-07-29 15:01:35 +02:00

9.1 KiB

CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

What this is

OpsLog is a Windows ham-radio logger built with Wails v2 — Go backend, React/TypeScript frontend, compiled into a single .exe. Author F4BPO. See README.md for the user-facing feature list and wiki/ for end-user documentation.

Pure Go, no CGO — SQLite is modernc.org/sqlite, serial is go.bug.st/serial. Any dependency requiring cgo breaks the build; check before adding one.

Commands

wails dev                  # hot-reload dev (Go methods also reachable at http://localhost:34115)
wails build                # full build → build/bin/OpsLog.exe (~25 s)
wails generate module      # REQUIRED after changing exported App methods (regenerates TS bindings)

go build ./...             # fast Go-only check
go test ./...              # all Go tests
go test ./internal/steppir/ -run TestApplyPendingDirHold -v    # one test
gofmt -w <file>

cd frontend && npx tsc --noEmit    # frontend typecheck alone (wails build does this too)

Release: .vscode/release.ps1 (Ctrl+Shift+P → Tasks: Run TaskRelease OpsLog) — bumps the version, pushes to Gitea, builds and publishes to Gitea + GitHub.

Prefer wails build for final validation: it is the only command that exercises Go, the TS typecheck, the bindings and the asset embed together.

Navigating app.go

app.go is ~14 000 lines and holds nearly every Wails binding. Do not read it linearly — it is organised into banner-delimited sections:

grep -n "^// ──\|^// ---" app.go     # table of contents

Sections map to features (── Motorized antenna (Ultrabeam / SteppIR) ──, ── NET Control ──, ── DX-cluster spot alerts ──, …). Find the banner, then read that range. The App struct (~line 448) is the other useful landmark: its fields document every subsystem and carry substantial explanatory comments.

Smaller root files split off self-contained areas: app_cw.go, app_qsl_designer.go, app_secret.go, chat.go, offline.go, relayauto.go, adifwatch.go, livestatus.go, update.go, telemetry.go.

Architecture

Wails binding boundary

Exported methods on *App are the entire frontend API. Adding or changing a signature requires wails generate module, which writes frontend/wailsjs/go/main/App.d.ts and models.ts. Parameters and return types must be Wails-serializable — the generator prints Not found: time.Time noise for unsupported types (that particular message is long-standing and harmless).

Backend→frontend push uses Wails events (runtime.EventsEmit / EventsOn), e.g. qso:logged, update:progress. Most hardware status is polled by the frontend on an interval rather than pushed.

Two separate databases

This split is easy to get wrong and matters:

  • a.db — settings database (settings.db / opslog.db). Always SQLite. Holds settings and profiles. Its location is chosen by the user and recorded in data/config.json.
  • a.logDb — the logbook (QSOs). Either a per-profile SQLite file, the default logbook.db, or a shared MySQL so several operators log into one database. a.dbBackend says which.

QSOs never go to a.db. internal/db holds the connection helpers and the MySQL specifics; internal/qso is the repository.

Remote MySQL is the slow path: startup deliberately brings CAT/rig links up before connecting the logbook, and hot paths (cluster spot enrichment, alert matching) avoid per-row queries — hence the in-memory wcbm worked-index and the clusterEvents queue that keeps the telnet socket draining.

Per-profile settings scoping

internal/settings.Store is a key/value store over the settings DB. Every key is transparently prefixed with the active profile (p3.), so each station profile has a complete independent set. Two consequences:

  • App.settingsScoped (atomic) gates reads until the active profile is known. Anything reading settings early must respect it or it reads the wrong scope.
  • Settings keys are declared as key<Thing> string constants near the top of app.go (~180 of them) — grep key[A-Z].*= " to find one.

Password-type keys are encrypted at rest when the secret vault is unlocked (internal/secret); a locked vault returns "" rather than ciphertext, and callers treat that as "not configured".

Hardware device pattern

The codebase talks to ~20 devices (rigs, amplifiers, antenna controllers, switches, keyers, rotators). They all follow the same shape — match it when adding one:

  1. internal/<device>/ — a self-contained package exposing a Client with New(...), Start(), Stop(), GetStatus(). The client owns its own goroutine: a reconnecting poll loop, a mutex serialising the shared connection, and a cached last-known Status. Transports are usually TCP or serial behind one io.ReadWriteCloser.
  2. Settingskey<Thing>* constants plus Get<Thing>Settings() / Save<Thing>Settings() bindings in app.go.
  3. Lifecycle — a start<Thing>() method that tears down any existing client and rebuilds it from settings; called at startup and again whenever settings are saved.
  4. Status bindingGet<Thing>Status(), polled by the frontend.
  5. UI — a settings panel in frontend/src/components/SettingsModal.tsx and a live widget in frontend/src/components/.

internal/steppir and internal/antgenius are compact, well-commented references. Wire-protocol packages carry the byte layout in the package doc comment and pin it with table tests against real captured frames — keep that up, since a wrong byte silently mistunes an antenna.

Where two devices are interchangeable (Ultrabeam / SteppIR), app.go defines a small interface (motorAntenna) plus thin per-device adapters rather than branching everywhere.

CAT

internal/cat (~7 000 lines, the largest package) is the rig abstraction: OmniRig, native FlexRadio/SmartSDR, native Icom CI-V (USB and remote-over-internet), and TCI (SunSDR / Expert Electronics). cat.Manager exposes a backend-agnostic State(); backend-specific features are reached through typed escapes such as FlexDo(func(cat.FlexController) error). Check State().Backend before using one.

Logging

internal/applog — Wails builds with the Windows GUI subsystem, so fmt.Println is discarded. Use applog.Printf (or plain log.Printf inside internal/ packages) so output reaches the rotating log file in the user data dir. That file is usually the only evidence available when diagnosing a user's hardware problem.

Conventions

Changelog is mandatory. Every user-visible change gets an entry in changelog.json, in both en and fr, in the same session as the change. Keep entries to one or two sentences — rationale belongs in the commit message. New work goes under the next version number; the release script bumps the version constants.

Append new entries at the BOTTOM of a version block, never at the top. A block is read top to bottom, so a feature must appear before the fixes made to it — prepending meant an operator read "the Yaesu meters are corrected" several entries before learning a Yaesu console existed at all.

Version lives in two places and must stay in lockstep: appVersion in telemetry.go and APP_VERSION in frontend/src/version.ts.

Bilingual UI. Every user-visible string goes through t() from frontend/src/lib/i18n.tsx (~700 keys), with both English and French provided. No hardcoded display strings.

Commit messages must not include a Co-Authored-By line or any mention of the model.

Comments explain why, not what. The existing code documents the reasoning behind non-obvious decisions — which protocol source was trusted, what field failure a workaround addresses, why an ordering matters. Match that: a comment that restates the code is noise, a comment recording the hard-won reason is why this codebase is navigable.

Gotchas

  • Adding a promoted ADIF field touches five places in lockstep. internal/adif promotes ~30 ADIF fields to real QSO columns; miss one and imports silently drop data. All of: the struct field, column list, insert args and scan in internal/qso/qso.go; the dictionary entry (Promoted: true) in internal/adif/fields.go; the adifPromoted list and the assignment in internal/adif/import.go; the writer in internal/adif/export.go; then the frontend column in RecentQSOsGrid.tsx with its EN+FR i18n keys. Trace an existing field (e.g. ant_path) across the repo as a template.
  • Generated files — don't hand-edit. internal/dxcc/dxcc_names_gen.go (cty.dat joined to the ARRL/ADIF entity list) and internal/awardref/uscounties_gen.go (emitted by cmd/cntygen from the FIPS county CSV; a one-shot generator, not part of the build).
  • Single-instance guard (main.go): a second process would open its own CAT and antenna-follow loops and the two would fight over the rig frequency.
  • Frontend controlled inputs: several editors normalise a value on every keystroke (trim, split, filter). Binding an input directly to the normalised form makes Enter/Space appear dead — keep raw text in local state and derive the stored value from it.