Files
kst4contest/docs/PROJECT_CONTEXT.md
T
Rsclub2_2andClaude Opus 5.5 2a5c89b943 Next major release/version1.50 (#89)
* Make DBController work on one database file per instance

The controller was a static singleton: an eagerly created static instance
opened the root database during class initialization, and both the connection
and the path were static fields. A second operator profile in the same process
was therefore impossible, and ChatController's own "new DBController()" never
opened anything - it silently adopted the eagerly opened root connection.

- drop the eager static instance in favour of a lazily created default instance
- turn connection and path into instance state
- add a constructor taking a database file name relative to the application
  directory plus a flag whether a missing file is seeded from the bundled
  template
- create additional profile databases empty instead of seeding them: the
  bundled template carries 3452 foreign callsigns and user_version 0, which
  would show a new operator foreign data and trigger the full callsign
  normalization rebuild. The schema is created by the existing table setup.
- remember the shutdown hook so closeDBConnection can deregister it; otherwise
  every profile switch would leave another hook holding a dead connection

All SQL statements keep referencing the plain field and are unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hpa6bjie5qkeNG62y6FmXm

* Allow preferences and chat controller to be bound to a file set

ChatPreferences gains a constructor taking a preferences file name relative to
the application directory, so "profiles/OP2/preferences.xml" is as valid as the
historic flat "preferences.xml". A missing file is still seeded from the
bundled template, which gives an additional operator the same clean defaults a
first installation gets.

ChatController gains a constructor that passes both relative file names and the
seed flag through to ChatPreferences and DBController. The existing
constructors delegate to the historic file names, so nothing changes yet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hpa6bjie5qkeNG62y6FmXm

* Introduce the operator profile model and its registry

Adds the descriptor, the path derivation and the registry persistence for
operator profiles. Nothing calls them yet, so behaviour is unchanged.

The descriptor stores only a shared/own flag, never a path. All file names are
derived in OperatorProfilePaths, so a stored path can never drift apart from
the flag that produced it. A profile identifier is a stable, file system safe
slug assigned once, so renaming a profile never moves a directory.

The registry is created lazily. An installation that only has the historic flat
layout gets no registry file and no profiles directory; the root profile is
synthesised in memory instead. That keeps a single operator installation
byte for byte the one it was before, and it keeps a downgrade to an older
release a no-op. A missing, unreadable or malformed registry is logged and
treated like an installation without additional profiles, never as an error.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hpa6bjie5qkeNG62y6FmXm

* Select the operator profile at startup

Resolves the active operator profile before the chat controller is built and
passes its two file names on, so preferences, layout and worked data follow the
profile.

The resolution is deliberately quiet for existing installations. With no
registry or exactly one profile nothing is asked and nothing is written, so a
single operator start is unchanged. Only from two profiles on does a small
picker appear with the last used profile preselected, where Enter or a double
click starts immediately. A "--profile" argument, or the equivalent system
property, skips the picker; an unknown name warns and falls back to the normal
selection instead of refusing to start.

The startup decision itself lives in OperatorProfileBootstrap and contains no
user interface code, so it is covered by headless tests. The window title gains
the profile name only when a second profile exists.

Command line parsing happens in init() and is kept in a process wide holder,
because JavaFX only knows the parameters of the instance it launched itself.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hpa6bjie5qkeNG62y6FmXm

* Release the background resources a discarded runtime owns

Several resources outlived a disconnect on purpose, which was harmless while a
process ran exactly one session for its whole life. They are now released when
the controller itself is closed:

- the ON4KST connection supervisor thread, which stopByUser did not touch
- the sked reminder scheduler, which had no shutdown at all
- the reachability executor, whose shutdown method existed but was never called
- the PSTRotator retry scheduler and its pending retry
- the map tile proxy, whose stop method existed but was never called, leaving a
  server socket and a twelve thread pool behind
- the station map bridge listeners and its coalescing animation, which would
  otherwise keep firing into a dead user interface

These are real leaks today; they only become visible when a second runtime is
built in the same process.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hpa6bjie5qkeNG62y6FmXm

* Switch the operator profile while the application is running

Splits the teardown out of stop() into a reusable, idempotent shutdownRuntime()
and adds File - Switch operator profile..., which tears the current runtime
down and builds a fresh one for the selected profile.

The new runtime is a new application instance rather than a second start() on
the existing one. Many controls are instance fields created once, so reusing
the instance would re-parent mounted nodes and register every listener twice.
A fresh instance is safe because the class keeps no mutable static state.

Closing every window during a switch would end the process under the JavaFX
default, so the application takes over the exit decision: implicit exit is
turned off, the main window gets an explicit close handler, and every exit path
runs through the launcher. That also fixes losing the window layout on exit
after a switch, because JavaFX only calls stop() on the instance it launched.

The two view timers are now cancelled null-safe; stop() used to dereference
them unguarded, which would fail if shutdown happened before they were created.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hpa6bjie5qkeNG62y6FmXm

* Manage operator profiles from the settings window

Adds a "Profiles" tab, appended last so no established tab position shifts,
offering create, duplicate, rename, delete, a switch between shared and own
worked stations, and activation of another profile.

Duplicating copies the whole configuration except callsign and password. The
antenna, locator, layout and integration settings are exactly the work nobody
wants to enter twice, while the credentials belong to one operator only. The
root profile can neither be deleted nor moved off the common station database,
because its files are the installation itself.

The login callsign and its raw form now default to empty instead of a real
callsign. The XML reader treats an empty element as "not set" and falls back to
the field default, so without this change a profile created without credentials
would come up carrying the callsign compiled into the defaults - and an
operator could transmit under someone else's call.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hpa6bjie5qkeNG62y6FmXm

* Document operator profiles

Adds a manual section in both languages covering where profile files live, the
choice between shared and own worked stations, managing profiles, the startup
selection and the --profile argument, switching while running, and the fact
that passwords stay in clear text so profiles are not an access boundary.

Records the architecture in PROJECT_CONTEXT: lazy registry, derived paths, why
an additional profile database is created empty, why the login callsign default
is empty, and the constraints of rebuilding the runtime for a switch.

Adds a v1.50 changelog entry in both languages including the upgrade notes: no
file is moved, and going back to an older release stays possible.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hpa6bjie5qkeNG62y6FmXm

* Make the terrain profile cache multi operator safe

The cache stored a single owner identity in a meta table and dropped the whole
TerrainProfileCache table whenever the configured callsign or locator differed
from it. With several operator profiles that turns the cache into a permanent
miss: every switch between two operators with different locators would discard
every computed profile.

Entries are separated by owner identity through the primary key already, so the
wipe is replaced by an owner table that simply records which identities are in
use. A different owner now misses the cache instead of clearing everybody's.

The cache also moves out of the worked station database into its own global
terrainprofilecache.db. Terrain profiles are pure geometry derived from two
locators and a sample count; at a multi operator station both operators share
one location, so a per profile copy would only double the traffic against the
terrain service. No migration is needed, the new file refills itself, and the
old tables stay readable for older releases.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hpa6bjie5qkeNG62y6FmXm

* Fail cleanly when the registry path has no directory

SpotBugs flagged a null passed to Files.createTempFile: the null check guarded
only createDirectories, while the temporary file creation would still have
dereferenced it. The path is resolved absolute so this is practically
unreachable, but bailing out with a log line is cheaper than the latent NPE.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hpa6bjie5qkeNG62y6FmXm

* Code Version 1.50

* Add the in-app update concept for 1.50

Captures the agreed design so it can be handed over later: extended
update feed with per-artifact metadata, a build-time package-type
marker, a testable check service replacing the blocking call in the
ChatController constructor, a non-modal hint suppressed while connected
to ON4KST, verified downloads and platform-specific execution.

Concept only, nothing implemented yet. Line anchors were verified
against this branch.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HT8R7zTMoQnmJj6dGbaqqf

* Use one base font size and a per-profile startup design

JavaFX picks a 13px default font on macOS and Linux but 12px on
Windows, so labels in fixed-size controls were cut off outside
Windows. Both stylesheets now set 12px on the root.

Each operator profile now chooses light or dark mode for startup on
the GUI settings tab. The Windows menu entries remain a quick switch
for the running session. All windows, including the update window
and the top candidates list, follow the design, and the main window
no longer starts light while the station map starts dark.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Show a drag-to-install layout in the macOS DMG

The DMG window now has a background with an arrow from the app to
the Applications folder, in the KST4Contest colours. Finder applies
the layout through AppleScript; if that fails, e.g. on a headless
runner, the script warns and builds the plain DMG as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Use the macOS system menu bar in all windows

On macOS the main menus move into the system menu bar and are shared
with the secondary windows, so they stay available whichever window
has the focus. The connection state indicator becomes a read-only
LINK menu there instead of the status bar button. Other platforms
are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* List direct commits on main in the release notes

GitHub's generated release notes only cover merged pull requests, so
commits pushed straight to main were missing. The release job now
builds the notes itself: "What's Changed" holds "Commits on main"
(first-parent, without bot and squash-merge commits) and GitHub's PR
list as "Merged PRs".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Build with Gradle in two modules, and free core of JavaFX

Etappen 1 and 2 of the Compose migration (43 commits).

The Maven build, the single source tree and the JPMS module-info went together.
core now holds the domain and app-desktop the user interface, both built by
Gradle, packaged through jpackage from Gradle, with SpotBugs and PMD wired in.
JavaFX moves to the classpath, which is why the launcher is Main and not the
Application subclass. The PR check, the nightly artifacts, the release and the
AUR packages all build from Gradle; the macOS DMG points at its output.
Verified on Linux, macOS and Windows.

core then loses JavaFX entirely. It observes through kst4contest.observe --
ObservableValue, ObservableRoster, and UiDispatcher as the one boundary a
worker thread crosses to touch the interface. The controller collections become
rosters and the visible station list a pure derivation rather than a
FilteredList/SortedList chain, which is also what fixed two focus defects only
a load test had exposed: the keyboard focus stays where the operator put it,
and the send field keeps it after a row click.

The rule that outlives this commit: ObservableRoster is the store and the list
a view is bound to is a mirror of it, never the other way round.

* Draw the whole interface in Compose

Etappen 3 to 6a of the migration (129 commits). Every window the operator sees
is Compose; the JavaFX ones still exist beside them so the two could be
compared against a live session, which is the only way the differences came to
light.

The toolchain first, then the settings window with its eleven tabs and 72 real
preferences; then the multi-column table as a component, and with it the rules
it needs -- stable row identity, a selection that survives a refresh, column
order the operator can drag, and a repaint when the rows change rather than
when the list instance does. Then the update and monitor windows, and the main
window: 22 station columns, the filter bar, the selected-station panel, the
message tabs, the priority bar, the sked controls, the status indicators and
the timeline, assembled behind a splitter because Compose has none.

The map stops being a WebView. A Compose canvas projects WebMercator tiles
itself, with the clustering in core and an asynchronous fetcher in place of the
local tile proxy; derive() becomes a pure Kotlin reimplementation instead of a
reflection call into com.sun.javafx.

Most of the volume here is parity the operator would notice rather than new
behaviour: desktop density instead of Material's phone minimums, colours taken
from the JavaFX stylesheets instead of Material's palette, row dividers, band
columns, grid-square labels and their colours, the home marker and antenna
beam, pinch-to-zoom under Wayland, and layouts that survive a window narrower
than the chart they stack.

Two lessons are worth more than the diff. A control bound to a value Compose
cannot observe writes through and never redraws -- that one shipped repeatedly
with a green build, because no test drew anything. And a layout that clips
instead of wrapping is worse than one that is merely too narrow: half a word
is the worst available outcome.

* Remove JavaFX

The second half of Etappe 6 (61 commits). grep -rl "javafx\." over both main
source trees is empty, nothing imports javafx, neither classpath resolves it,
and org.openjfx is out of the build and the version catalog.

The finding that shaped the work: the JavaFX main window was built on every
start and never shown -- primaryStage's setScene and show were both commented
out. Most of those 11,655 lines were dead weight rather than a port. But not
all of them, and that is the trap this commit is really about: dead-looking
methods still fed live Compose sinks. They were cut at their seams instead of
deleted, and a sweep script that follows calls was written to find them,
because reading the code does not reveal them.

In order: the colour derivation frozen as checked-in test data, so the pin
survives JavaFX being removed instead of silently comparing against itself;
the live feeds lifted out of the never-shown window and given names and tests;
the dead scaffolding deleted in slices; then the lifecycle. UiDispatcher now
delivers to the AWT event dispatch thread, the ten Alerts are modal Compose
dialogs, HostServices gives way to java.awt.Desktop with the mailto/http split
JavaFX did not need, and extends Application, start(Stage), stop and launch are
gone -- main builds the runtime and a shutdown hook replaces stop().

An independent review found two deadlocks in that move, both the same failure
class, both invisible to the build: a blocking wait on the event thread for
work that can only advance on the event thread. The profile picker waited on a
latch and froze the client for good; ComposeWindowHost.close waited on
Thread.join and returned with the window still marked open, which made a
profile switch raise the dead runtime's window and leave the rebuilt profile
without its Connect button. A third came out of fixing them: a ComposeDialog
built off the event thread cannot create its GL context and never paints.

Three defects only a GUI run showed, never the build: an NPE on a deleted
field, a Compose menu still hanging on deleted MenuItems, and a category
bookkeeping field that looked dead but is read by the outgoing-message category
resolution -- deleting it would have sent messages in the wrong category.

The binding docs and the manual no longer describe a JavaFX client.

* Draw the menu bar in Compose, and give the drawing layer a net

What the removal left open (4 commits).

Compose's MenuBar is a javax.swing.JMenuBar set on the window, so Swing draws
it and no theme reaches it; with no look-and-feel configured that meant the
default Metal menu above a themed window. The bar is drawn in the window now,
except on macOS, where the system bar is where that platform expects it. Two
renderers read one description -- mainMenuModel is a plain function of the
state, which also gives the menus their first tests. Closing the main window
quits the application again, and the window writes its size back to the
profile; both had quietly stopped working when the last JavaFX stage went.

Then Etappe 7. Four faults shipped in the settings-window branch alongside a
green suite of 382 tests, because not one of those tests drew anything: a
control that never redrew, a tab strip that clipped mid-word, a form taller
than its window with no way down, and colours that came from Material rather
than the stylesheet. One regression test per class, and the spec's acceptance
criterion is met rather than assumed -- each fault was put back deliberately
and the matching test watched to fail.

The CI had never run the suite at all; it does now, headless. What that took
was not Xvfb: skiko's native library links against libGL and only has to be
loadable, since the drawing tests render in software. Without the package the
whole Compose half of the suite dies with a message that reads like a missing
display. Found by running the job under act rather than on GitHub.

The six tests that open a real window skip headless by their own guard. The
summary step counts from the result XML and fails on failures there, because
this project's rule is never to infer a green suite from an exit code.

* Make the colours adjustable per operator profile

Six colour roles per design, per profile: surface, window surface, field interior,
text, accent and separator line. A change applies at once in every open window and
survives a restart once the settings are saved, like any other preference.

Three sources, later wins role by role: the shipped stylesheet from the classpath,
then the profile's own stylesheet file if there is one, then the overrides stored in
the profile's preferences. resolvePalette is a pure function and reports where every
role's colour came from, which the tab shows per field -- with two sources able to
decide one colour, that is the only thing keeping "why is this colour what it is"
answerable, and the spec calls it mandatory rather than decorative.

Four things this had to get right, each learned the hard way and each pinned:

A role is a selector and a property, not a property alone. Three of the six are
stated outside .root -- text on .label, the separator on .separator *.line -- and
-fx-background-color alone appears a dozen times in the evening sheet. Measured: the
shipped daylight sheet states no colour at all, the evening sheet states five and
implies the sixth.

Kst4ContestTheme reads the palette from the store's state and not from remember.
That read is what makes a change repaint every open window; remember(darkMode) held
the palette until somebody toggled day/evening, so a change reached nothing.

A derived colour follows its source's value, never its provenance. The root
profile's own stylesheet is the one copyResourceIfRequired writes at every startup --
the shipped sheet itself -- so layer 2 declares the accent on every existing
installation with nobody having chosen anything. Keying textAccent on "does this come
from a file" repainted the evening accent from green to blue on upgrade and made
"reset to shipped" disagree with "discard my changes".

The contrast guard measures where colours are actually drawn, read off Theme.kt's
Material mapping rather than off the role names: background and surface are both the
window surface, and primary is textAccent, not accent. It reports a pair only when it
is both under WCAG AA and worse than the shipped sheet -- the shipped sheets have
three pairs under 4.5 themselves, so an absolute floor would warn on an untouched
client and teach the operator to ignore warnings.

The way back out of an unreadable palette is deliberately overbuilt, because it is
the one failure that locks itself in. "Reset to the shipped colours" takes the
shipped palette as a required parameter and reads no composition local, so there is
no path by which it could be drawn in the colours it exists to undo; it looks out of
place under a customised palette, and that is what makes it findable. Ctrl+Z, Ctrl+D
and Ctrl+R reach the three ways back even with the caret in a colour field. A
mistyped colour is refused and the field snaps back.

The profile's stylesheet is the hand-editing route the copy under ~/.praktiKST has
been promising since it was first written and never read. It is read now. A button
writes the shipped template as a starting point and never replaces a file that is
already there. An unreadable, truncated or non-CSS file does not prevent startup --
the role falls back to the shipped design. A sheet stating -fx-base and no
-fx-background states the derived window surface too, the way JavaFX does, or the
most natural hand edit would leave every window the shipped light grey.

101 new tests. All of it runs headless.

* Document the adjustable colours, and record the design and the plan

Both manuals get the Colours tab in full: the six roles, that a change applies at
once but survives a restart only after Save settings, the three ways back with their
keyboard shortcuts, what each provenance label means, and the readability notice that
warns without refusing.

Two things an operator would otherwise have to discover. The reset button is always
drawn in the shipped colours and works when nothing else can be read -- the risk
section of the design says plainly that a keyboard path only helps whoever knows it,
so the manual is where this has to be written down. And the root profile already has
both stylesheet files, because KST4Contest has been writing them at startup for
years, so most of its roles report "from file" rather than "shipped" with identical
values.

PROJECT_CONTEXT.md records the decisions that outlive this stage: the three sources
and their precedence, that a role is a selector and a property, that the theme must
read the store's state rather than remember, where the contrast pairs come from, why
the root profile's file layer always exists, and why the lifeline refuses the active
palette. Also corrects its claim that CSS stays global -- a profile now owns its
stylesheets.

The design document and the implementation plan are kept as they were written,
including the plan's own corrections where measuring the code contradicted it.

* Speak the operator's language

The interface is German or English, chosen per operator profile on the GUI tab, in force at
once in every open window, and remembered across restarts. 376 texts in both languages.

The texts are ordinary .properties files that nobody has to compile: a contributor copies the
English one, translates the values, and opens a pull request. A third module -- build tooling
that never ships -- turns them into Kotlin before anything compiles, which is what makes a
mistyped key and a wrong argument count compile errors rather than things an operator
discovers. It lives in the main build rather than buildSrc because buildSrc's tests do not run
on `./gradlew build`, measured, and a generator whose tests never run is the exact failure it
exists to prevent.

Incompleteness is allowed and incorrectness is not. A missing key warns and falls back to
English, so a partial contributed language can be submitted at all; an orphan key, a
placeholder the translation drops, a duplicate key or an unreadable file fail the build with
the generator's own message naming the file. The argument count always comes from the English
base. German is held complete by a test because it ships; a contributed language is not.

What is deliberately not translated is the harder half. Protocol frames and chat commands
would break the connection or go unread; the beacon texts, auto answers, snippets and
shortcuts go to other radio amateurs and sit in every operator's stored profile; the 273
println calls in core are diagnostics, and a German stack trace is worse for whoever reads the
bug report; and QRA, QRG, QTF, LOS, Fresnel and wkd read the same in either language. Three
tests guard the boundary by measuring behaviour rather than text: the protocol frames built
under three locales, every preference default compared by reflection, and no translation value
being a protocol command. The reflective one is the only one that covers what nobody thought
of.

One insight turned up three times. Anything built outside a composition freezes the language it
was built in: the window title, the settings tab strip and every column header are all
constructed once at startup -- the columns from Java -- so each now holds a function read when
it is drawn. That also fixed something older and unrelated to language: the main window's title
was a fixed "KST4Contest (Compose)" for the whole session, where the JavaFX window had carried
the live connection line. It carries it again.

A column's id is never translated. It is what the remembered widths and order are keyed on.

* Document the two languages, and how to contribute a third

Both manuals get the language picker in full: where it sits, that it applies per profile and
takes effect at once in every open window, and -- at more length -- what is deliberately left
alone and why. An operator who sees an English beacon text in a German client should be able
to read that it is meant that way.

The contribution route is written down in both, because plain text files are only useful if
somebody knows they are there: copy, translate, submit; a partial file is welcome; the build
reports mistakes by file name and writes a worksheet of what is still missing.

PROJECT_CONTEXT records the decisions that outlive this stage -- the four kinds of text and
the rule that the addressee decides, that core produces no display text, that incompleteness
is allowed and incorrectness is not, that anything built outside a composition needs its text
read inside one, and that a column id is never translated.

CLAUDE.md said two modules; there are three, and the third never ships. It also states the
German orthography now, which this session got wrong across a spec and two plans: ss after a
short vowel, so muss and dass.

* Remove the implementation plans now that every stage is executed

Thirteen plans, all of them step-by-step instructions for work that is committed. A plan left
behind reads to the next person like an open intention, and these describe a build that no
longer exists -- the first still explains how to introduce Gradle.

The specifications stay. They say why the architecture is what it is, which the code cannot,
and three of them are still argued from. The live-findings note stays too: CLAUDE.md sends
people to it, and its hardest lesson -- that a blocking wait on the AWT event thread deadlocks
the interface while the build stays green -- is written down nowhere else.

Its sweep script stays as well. The note argues explicitly that the script belongs beside the
finding so the next reader can measure rather than believe, and removing it would contradict
the reasoning it illustrates.

Two references in the note and one in the JavaFX-removal spec pointed at plans and now say
where the reasoning went instead.

* Point the JavaFX-removal spec at where its plan's reasoning went

* Remove the stray release-metadata patch from the working tree

A 416-line patch file that was applied long ago and has sat in the repository root since. It
predates this branch and stays in the shared history, where rewriting it would mean rewriting
commits that are already on main and on origin.

* Check the translation files in CI and say what they hold

classes already runs the generator, so an orphan key, a dropped placeholder
or a duplicate fails the compile job with the generator's own message naming
the file -- verified by breaking strings_de.properties on purpose.

A step summary counts each language against the base, so the author of a
translation PR sees what they covered without reading the build log.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Refuse a translation key that would not form a Kotlin name

The generator escaped every translation value but emitted the key verbatim as
the member identifier, and identifierFor strips only . _ - -- so a key carrying
a space, a brace, a quote or a newline from a \uXXXX escape broke out of
val <name>: String get() = lookup(...) into an init block or property
initializer that runs when Strings is instantiated. Translation files are the
contribution surface that arrives by pull request, so this was a path from a
merged .properties change to code in every client.

The generated identifier is now held to letters-then-letters-and-digits before
any source is written, alongside the existing orphan and collision refusals.
Proven by replaying a file-writing payload against the built generator: it now
throws and writes nothing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Port the rebased diagnostics and session logging onto the modules

origin/main landed replayable session logging (MessageHistoryRecorder), file
logging (ApplicationFileLogging) and a user-list refresh split on the old flat
layout. The rebase carried the new files, but three things needed the new
architecture:

- ChatController.fireUserListUpdate() used javafx Platform; core is toolkit-free
  now, so it dispatches through uiDispatcher like every other fire method.
- Two new tests were left in the old src/test/java; moved into core.
- ScoreServiceRefreshTest mocked javafx Platform statically; it now injects the
  DirectUiDispatcher the no-arg constructor installs.

The Leaflet map performance work was dropped with the HTML map it optimised.

classes testClasses and test are green: 1069 tests, 0 failures, 3 skipped.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Give the debug-to-file recording a home in the Compose settings

origin/main added a checkbox to the old JavaFX settings that recorded the whole
ON4KST session to file; the Compose redraw dropped the old settings UI, so the
toggle had no control left even though the preference and its wiring survived
the rebase.

It returns to the message-handling tab: the state writes the preference and asks
the application, through a new SettingsHost callback, to open or close the
error-log handler and the history recorder in the order the old checkbox kept.
The explanatory line and its German translation say that passwords are redacted.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Fix Codex review findings on PR 89: station row key, map selection, sort after filter

- Station row key now includes the chat category, so the same callsign in two
  categories no longer collides.
- Map marker click and Reset view go through the central station selection.
- Sorting now operates on the filtered rows, so filters stay effective.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Keep the map drawing test off the network and the user's tile cache

The test assumed no tile ever arrives, but it used the default TileFetcher,
which reads the real disk cache and the OSM tile server. On CI a tile now and
then landed before the frame was rendered and covered the background the
'unreachable tile source' test asserts on. An empty temporary cache and a
source that always fails make the assumption hold by construction.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-10-08 23:22:11 +02:00

43 KiB
Raw Blame History

KST4Contest Project Context

Last reviewed: 2026-10-03

This file is the durable technical project context for KST4Contest. It is not a user manual and not a replacement for the changelog. Current code, tests and authoritative external specifications remain the source of truth when this document is stale or ambiguous.

Purpose

KST4Contest is a Java/Kotlin desktop client for ON4KST chat, with a Compose Multiplatform user interface, focused on VHF/UHF/microwave contest workflows. It combines chat handling with contest-oriented station prioritisation, sked/timeline workflows and integrations with logging, aircraft-scatter, rotor and DX-cluster tooling.

Current Architecture

  • Java 21 desktop application built with Gradle in the modules core (domain, free of user-interface technology) and app-desktop (the Compose Multiplatform user interface).
  • Domain, controller and network code is under core/src/main/java/kst4contest/, the user interface under app-desktop/src/main/java/kst4contest/. Package names are shared across both modules.
  • Responsibilities are separated across controller, service, logic, model, utility and view areas.
  • Network/parser/service/controller/UI boundaries should remain explicit.
  • Long-running network/message processing must tolerate malformed or incomplete external input without terminating processing threads.
  • ObservableRoster state in core is the canonical store; the ObservableList a TableView is bound to is a mirror of it, not the store.

Important Invariants

Chat identity

  • For remote chat participants, the complete visible callsign plus category forms the chat-member identity.
  • Full callsign variants can therefore be distinct chat-member identities.
  • Base-call normalization is permitted only for explicitly base-call-wide functions.
  • Worked status is shared across suffix variants of the same base call.
  • Monitoring a variant such as DN9APW-2 or DN9APW-70 intentionally monitors the base call DN9APW.
  • Suffixes must not be globally interpreted as a band/category/frequency.

Band and availability semantics

  • ON4KST categories 2 and 3 are the main operational categories, but unexpected category values must fail safely.
  • NOT-QRV overrides positive inferred band-availability hints.
  • Unknown/missing frequency, QRB, QTF or similar external data must remain unavailable rather than becoming a fabricated zero/default.
  • Features that depend on frequency should use the current/actual QRG according to current implemented rules; do not silently revert to a fixed 144 MHz default.
  • Complete digit-only frequencies use their final three digits as the kHz part and are accepted only when the resulting MHz value lies within a supported Band range. The same full-frequency parser is used for station names and public or directed chat messages. Relative QRG rules and bare three-digit context handling remain separate.

Threading

Conceptually:

thread-safe canonical domain state in core
        |   kst4contest.observe: ObservableRoster / ObservableValue
        |
        | hand-off via UiDispatcher
        v
mirror on the user-interface thread (FxRosterBinding -> ObservableList)

core contains no user-interface technology. It observes through kst4contest.observe and hands UI-visible work over through UiDispatcher, whose implementation is AwtUiDispatcher in app-desktop. It delivers to the AWT event dispatch thread, which is the thread Compose composes on. The former JavaFxUiDispatcher was deleted with the JavaFX lifecycle.

MessageBusManagementThread must not directly iterate or mutate a UI-bound collection; it works on snapshots. UI-visible changes cross the controller/UI boundary through the dispatcher.

A roster hands out immutable snapshots and has no incremental change protocol: listeners receive the whole new content. FxRosterBinding mirrors a roster into the ObservableList a TableView holds, and must be disposed when its owner goes away — shutdownRuntime() releases all of them, which is what keeps a discarded runtime from surviving an operator profile switch.

A batch of changes belongs in one SimpleRoster.mutate(...), not one call per element: with several thousand ON4KST users the difference is not cosmetic.

Configuration and Layout Persistence

  • Configuration and layout live in the preferences.xml of the active operator profile; see "Operator Profiles and Per-Profile Persistence" below. The current preferences.xml configuration version is 8. Version 8 adds the default-enabled messageHandling_debugModeToFileEnabled setting. Missing entries in older files retain the enabled default. Version 6 introduced optional managed leaf-column widths below guiOptions, identified by stable table and column IDs. Parent-column widths remain derived from their leaf columns.
  • GUIstationMapClusteringEnabled is a layout preference below guiOptions. It defaults to true, is selectively autosaved and controls only screen-based clustering of nearby map markers. Missing or malformed values retain the enabled default for backward compatibility.
  • Stored widths take precedence. Without a usable entry, a managed column is sized once when meaningful table data first becomes available. Message and similar free-text columns use a flexible initial width instead of following the longest value.
  • Main-window and separate-monitor DXCluster/QSO tables use distinct layout IDs even though they share the underlying message stores.
  • Window sizes and positions, relevant divider positions and managed column widths are selectively autosaved after a short debounce. A pending write is flushed during application shutdown.
  • Selective layout writes update the XML already on disk, preserve unknown XML nodes and must not persist unconfirmed functional settings from the current UI. Save Settings remains the full settings writer and includes the current layout.
  • Full and selective writes are synchronized and replace preferences.xml atomically. Missing, unknown or malformed width entries do not prevent loading and fall back to initial sizing.
  • Older configuration files require no migration. Older KST4Contest versions can ignore the additional elements; a complete rewrite by such a version may discard column widths without invalidating the remaining file.

Operator Profiles and Per-Profile Persistence

  • One operator profile owns one preferences.xml, one worked-station database, its own pair of stylesheets and its own interface language (all since release 1.50). Everything else under ~/.praktiKST/ stays global: audio files, DEM and terrain packages, the error log and the version-info feed.
  • The root profile is the historic flat installation: preferences.xml and praktiKST.db directly below the application directory. It is never moved, and it always uses the common station database, because that database is the installation's own.
  • Additional profiles live under profiles/<profileId>/. profileId is a stable, file-system-safe slug assigned once; renaming a profile changes only its display name and never moves a directory.
  • The registry profiles.xml is created lazily. An installation that has only the flat layout gets no registry and no profiles/ directory; the root profile is synthesised in memory. Startup with no or exactly one profile therefore asks nothing and writes nothing, and a downgrade to an older release is a no-op.
  • The registry stores profileId, displayName, rootProfile and sharedWorkedDatabase, never a path. Both file names are derived in OperatorProfilePaths alone, so a stored path cannot drift apart from the flag that produced it.
  • sharedWorkedDatabase resolves to a path, not to a schema change: a sharing profile points at the flat praktiKST.db, an owning profile at its own file. There is no owner column, and no SQL statement in DBController knows about profiles.
  • A profile database is created empty. The bundled /praktiKST.db resource carries several thousand foreign callsigns and user_version = 0; seeding an additional profile from it would show a new operator foreign data and trigger the full callsign-normalization rebuild. Only the root installation is seeded from the resource. preferences.xml of a new profile is seeded from /praktiKSTpreferences.xml, because its defaults are what a first installation gets.
  • Worked-state semantics are unchanged and now apply per database file: normalized base callsign as key, worked state shared across suffix variants, three-day expiry, manual reset.
  • stn_loginCallSign and stn_loginCallSignRaw default to empty. The preferences reader treats an empty element as "not set" and falls back to the field default, so a non-empty default would make a profile created without credentials come up carrying a compiled-in callsign.
  • Passwords remain plaintext per profile. Profiles separate configuration; they are explicitly not an access-control boundary. This is documented in both manuals.

Runtime profile switching

  • A switch tears the current runtime down through Kst4ContestApplication.shutdownRuntime() and builds a new Kst4ContestApplication instance. Reusing the instance is not possible: many controls are inline-initialised instance fields, so a second start() would re-parent mounted nodes and register every listener twice. The approach is only sound because the class holds no mutable static state.
  • All exits run through ApplicationRuntimeLauncher.exitApplication(), including the main window's close request: a profile switch closes every window of the old runtime before the new one exists, so a window close must never be what ends the process. The former Platform.setImplicitExit(false) and Application.stop() are gone with JavaFX; a shutdown hook on shutdownRuntime() covers a SIGTERM instead, and the guard is atomic because the hook runs on its own thread.
  • shutdownRuntime() is idempotent and must release everything that outlives a disconnect: the ON4KST supervisor thread, the sked reminder scheduler, the reachability executor, the PSTRotator retry scheduler, the station-list coalescing trigger, both view timers and every Compose window. Several of these were real leaks before; they only became visible once a second runtime could exist.
  • ApplicationConstants.sessionRuntimeUniqueId must not be regenerated during a switch, so UDP readers started earlier still recognise their own poison pill.
  • The layout autosave is flushed and then cancelled before a switch, so a pending debounced write cannot land after the profile changed.

External Interfaces

Treat current implementation/tests and authoritative upstream documentation as source of truth before modifying any interface.

Known integration areas include:

  • ON4KST chat;
  • AirScout;
  • UCXLog / DXLog UDP XML (contactinfo, contactreplace);
  • Win-Test UDP;
  • PSTRotator TCP;
  • DXCluster;
  • local SQLite persistence.

CR/LF framing, XML framing, ports/transports, callsign normalization and frequency formatting are protocol behaviour and must not be changed as incidental cleanup.

Local DX Cluster output

  • Local spots use a fixed 75-character, DXSpider-compatible payload line followed by two BEL characters and CRLF.
  • The DX callsign begins in column 27 and occupies up to 12 characters. The 30-character comment begins in column 40, and the five-character UTC time begins in column 71.
  • Spotter and frequency padding is calculated dynamically so frequencies from 50 MHz through 24 GHz do not shift the following fields.
  • Comments are padded or truncated to exactly 30 characters. Automatic AirScout comments retain the locator first and use the compact form JO51HK AP 1m/100%;4m/75%.
  • A DX callsign longer than 12 characters is rejected and logged rather than truncated.
  • Trigger conditions, QRG recognition and normalisation, login, keepalive, multi-client delivery and the local-only trust boundary remain separate from line formatting.

Logging and Worked-state persistence

  • The Simplelogfile interpreter reads the selected text file after connection startup and then once per minute using a fixed built-in callsign pattern.
  • Simplelogfile callsigns are normalized to base callsigns and set only the global Worked state for every active variant. They do not create per-band Worked or grid-square state.
  • Simplelogfile-derived Worked state is not persisted in SQLite. The selected file is the durable source and is read again in each application session.
  • The interpreter only adds positive runtime marks. It does not remove existing marks during the current session and does not reset automatically when a new contest starts. A database reset does not modify the file; callsigns contained in it are marked as worked again during the next periodic evaluation.
  • A missing selected file is created. Read, path and creation failures are contained so the periodic timer remains alive; successful creation triggers a one-time, non-blocking UI notice with the exact path and setup/contest checks.
  • Network-derived and manually assigned Worked, NOT-QRV and worked-grid state continues to use SQLite with its established lifetime and reset behaviour.
  • Each completed initial ON4KST user list loads one SQLite Worked/NOT-QRV snapshot. ChatController applies that snapshot by normalized base callsign to every new category and suffix variant before the completed category is published. The same event-driven path runs again after a reconnect; startup synchronization does not depend on a fixed-delay timer.
  • Automatic QRG updates require both an enabled source and valid incoming RadioInfo or Win-Test STATUS data. Merely enabling a source does not provide or validate a current QRG.
  • UCXLog-compatible QSO packets and Win-Test ADDQSO packets are converted into one validated external-QSO state. Logger-specific numeric, metre and centimetre values and Win-Test band IDs are normalised once; the resolved band is then the sole source for per-band Worked and worked-grid state.
  • A missing or unknown logger band sets only the global Worked state. Worked-grid state requires both a recognised project band and a valid locator; no band or locator is inferred. Packets without a usable callsign are discarded without terminating the listener.
  • External logger threads do not read or mutate the user-list mirror. ChatController applies global and per-band Worked state to every active variant of the base callsign on the user-interface thread, through UiDispatcher, before evaluating a band-upgrade notice.
  • The established Win-Test handling for 24, 47 and 76 GHz remains unchanged. Their Worked flags are retained, while only frequencies represented by the project Band model can create worked-grid state.

Win-Test log recovery

  • Win-Test only broadcasts new QSOs. A listener started later never sees the earlier ones, so KST4Contest pulls them with the Win-Test IHAVE / NEEDQSO protocol, ported from the wtKST WtLogSync implementation. The answers are ordinary ADDQSO packets and reuse the established Worked path; the recovery itself never touches database or UI.
  • The recovery is not configurable. It is bound to the existing Win-Test network listener, runs automatically once a station is detected through HELLO or STATUS, and stays active so gaps caused by lost broadcasts are refetched.
  • The station-name filter remains a QRG-sync setting. Log recovery covers every station in the network, because each band station of a multi-station setup keeps its own log and contributes per-band Worked state.
  • A QSO is identified by StationName@LogUniqueID plus the Win-Test QSO number. That identity deduplicates the answers of overlapping requests, so a recovered log is written once instead of once per resend.
  • Win-Test framing must be resolved on the raw datagram bytes: the checksum byte is not valid ASCII and would otherwise corrupt the trailing fields, which carry the log ID of ADDQSO and the run-length inventory of IHAVE. A broken checksum discards IHAVE only; the established handling of the other message types is unchanged and still does not verify checksums.
  • Win-Test answers broadcasts only; an identical unicast request to the same station stays unanswered (verified against Win-Test). Outgoing Win-Test packets therefore derive their broadcast address from the source address of received Win-Test packets, with the configured address as fallback for a station behind a router. A configured address pointing at a non-existent network raises no send error, so it silently disabled both log recovery and SKED handover before. Only genuine Win-Test message types update that address; internal control packets such as the poison pill must not redirect outgoing traffic.
  • IHAVE inventories are run-length encoded and may be split, so the announced first row is honoured instead of assuming that an inventory starts at QSO number one. A station that never sends a usable inventory is served by a blind block fallback starting at QSO number one.

Terrain data providers

  • The active terrain profile provider is Open-Meteo using Copernicus GLO-90 data.
  • The terrain profile cache lives in its own global database ~/.praktiKST/terrainprofilecache.db. It is deliberately not part of an operator profile: terrain profiles are pure geometry derived from two locators and a sample count, and at a multi operator station both operators share one location, so a per-profile copy would only double the traffic against the terrain service.
  • Cached entries are separated by owner identity through the primary key (owner_callsign_raw + owner_locator6). Earlier versions stored a single owner identity in a meta table and dropped the whole cache whenever the configured callsign or locator changed; with several operator profiles that would discard every computed profile on each switch. The old TerrainProfileCache* tables inside praktiKST.db are left in place and are still readable by older releases; the new file starts empty and refills itself.
  • OfflineDemImportService only prepares a local directory and copies manually selected Copernicus GLO-30 GeoTIFF files into it. Importing files does not activate an offline provider or change the active calculation chain.

ON4KST session and authentication

  • One KST4Contest connection authenticates one ON4KST TCP session with one local login callsign and one password.
  • The TCP session uses one common locator for both categories; the locator is not part of authentication.
  • The primary category is part of the initial login. A distinct second category is added to the same session through ON4KST Single Sign-on; it must not create a second TCP connection or local login.
  • Name in Chat is a visible category-specific name field, not a login callsign or message destination. Private messages to the local station are addressed to the local login callsign.
  • The visible Name in Chat field, message context, QRG and beacon configuration remain category-specific.

ON4KST session liveness

  • Only after the session is fully authenticated and synchronized, more than 90 seconds without inbound server data trigger one client-side CK|\r\n liveness probe. The trailing pipe matches the framing used by the server for its own CK|\r\n probe. The probe state belongs to the TCP session, so a two-category session still sends only one probe per idle phase.
  • The live ON4KST test returned OK|\r\n for CK|\r\n. KST4Contest treats both that confirmed frame and the originally specified OK\r\n form as internal responses: it records inbound activity, confirms the outstanding probe and does not publish the response as chat content. Any other inbound server frame also confirms reachability and starts a new idle phase.
  • A CK initiated by the server remains a separate protocol case and receives the established empty CRLF response. It must not be confused with the client-side probe.
  • If no inbound frame arrives by about 210 seconds, the existing reconnect flow remains responsible for replacing the session.
  • Liveness diagnostics contain the session id, opcode and timing only. They must not include credentials, complete server frames or normal chat messages.

ON4KST replay history and diagnostic logging

  • MessageHistoryRecorder is the single non-JavaFX owner of raw ON4KST session-history file I/O. Reader and writer threads only report complete frames to it.
  • The current program session is stored at ~/.praktiKST/Messagehistory.raw as one UTC<TAB>RX|TX<TAB>RAW_FRAME event per line. Timestamps use UTC with milliseconds; TCP CR/LF terminators are not part of the raw frame.
  • RX is recorded immediately after BufferedReader.readLine(), before liveness handling or internal filtering. TX is recorded only after the final frame has been written and flushed successfully.
  • A non-empty current file is archived once when recording is first initialised for a new program session. Reconnects do not rotate it. Disabling closes the writer; re-enabling in the same program session appends to the same current file.
  • LOGINC/LOGIN password fields are replaced by <REDACTED> through protocol-aware redaction. Other frames are not reconstructed or normalised for history output.
  • History I/O failures are warning-level diagnostics and must never terminate reader, writer or message-processing threads or deliberately disconnect ON4KST.
  • The default-enabled Messagehandling setting also controls additional application FINE/INFO records in kst4contest-errors.log. WARNING/SEVERE records and exceptions remain file-logged regardless of that setting. File-log timestamps use UTC with milliseconds.
  • The raw history is intended for future replay/simulation tooling and can contain personal chat and station data even though credentials are redacted.

User Workflow / UI Invariants

  • Contest operating speed and low-friction interaction are primary goals.
  • Incidental code changes must not unexpectedly change selection, focus, sorting, tab state, map zoom or prefilled text.
  • Map reset clears the selected target without changing zoom unless explicitly redesigned.
  • Group nearby stations re-renders only the existing station-marker layer from JavaScript stationData. It must not reload the WebView, tiles or station data, request a new controller snapshot, or change zoom, viewport or selection.
  • Base-callsign aggregation into one geographical marker happens before screen-based clustering. Disabling clustering displays each resulting positionable map station individually but never splits active variants of the same normalised base callsign into separate geographical markers.
  • A hidden Station Map is not continuously refreshed. Relevant background changes mark it dirty; the next showWindow() performs one current full refresh before normal visible-window updates resume.
  • Maidenhead grid construction depends only on viewport bounds, zoom and WebView size. Ordinary station-data refreshes must not rebuild or serialise the grid when those inputs are unchanged.
  • Panning updates viewport and grid state through notifyViewport() without rebuilding station markers or screen-based clusters. Zoom changes, station-data changes and clustering toggles still rebuild the marker layer.
  • Debug file logging reports Station Map refreshes only when total FX-thread refresh time reaches 100 ms. The diagnostic separates snapshot and render time and includes visible-member and station counts.
  • Station selection preserves the established /cq callsign prefill behaviour.
  • Sending without an explicitly selected send category preserves the established Main-category fallback unless explicitly changed.

Autoanswer / Beacon

  • Automated-message loops must be prevented.
  • Cooldown/minimum-interval rules must be preserved.
  • A reply rejected before a complete valid TX item is queued must not consume cooldown.
  • Current implementation/tests define the exact message markers and timer details.

Build / Verification

  • Use the repository Gradle wrapper (.\gradlew.bat on Windows).
  • The project uses Java 21, Kotlin 2.2 and Compose Multiplatform 1.8.x at this context snapshot. JavaFX has been removed; no org.openjfx dependency remains.
  • JUnit 5/Mockito, PMD and SpotBugs are part of the verification environment.
  • Build/test configuration has historically allowed some test/static-analysis failures not to fail the process exit code. Always read actual summaries/reports.

Documentation Surfaces

  • German and English manuals under github_docs/.
  • Repository README.
  • Eleventy-based project website under website/.
  • Changelog/release communication.
  • This technical project context under docs/PROJECT_CONTEXT.md.

After implementation use targeted documentation-impact checks. Do not run a complete manual audit unless explicitly requested, release preparation is broad, or targeted checks indicate systematic drift.

Website / Deployment Relationship

The repository contains the KST4Contest website under website/, published separately from the desktop application build.

  • Historical GHz-Tagung papers are published as German and English PDF assets below website/src/assets/papers/ and explained on /background/. Their editable ODT sources remain outside the public Eleventy input tree and must never be copied into the generated site.
  • The home-page news teaser is derived from the date-sorted Eleventy news tag collection. It deliberately renders nothing when that collection is empty; it must not be pinned to a release version.

Current website/deployment scripts and update-feed behaviour must be inspected before changes; do not rely on historical assumptions.

  • APPLICATION_CURRENT_VERSION is the user-visible semantic version and must use the dotted major.minor.patch form. APPLICATION_CURRENTVERSIONNUMBER is retained only for older feeds and encodes patch releases by appending the patch digit, for example 1.43.1 as 1.431.
  • The tagged-release workflow creates the GitHub Release before building the website update feed. This ordering is required because versionInfo.js reads the published release body through the GitHub Releases API.
  • After publication, the workflow tests and builds the website, validates the expected Stable version, attaches kst4ContestVersionInfo.xml to the release and uploads the complete website build as a workflow artifact.

Server-side website statistics

The detailed operator runbook is website/ops/analytics/README.md. This section records only the durable architecture and operational boundaries.

Architecture and data flow

  • Production runs on Ubuntu Server 24.04 with Nginx 1.24, Node.js 18.19.1 and GoAccess 1.8.1. GoAccess has GeoIP2/MMDB and OpenSSL support but no Zlib support; missing Zlib is intentional for this operating model.
  • Nginx writes eligible GET page requests to a reduced, tab-separated website analytics log in addition to the normal operational log. It records server name, client IP, time, method, normalized path without query string, protocol, status, bytes and user agent; referrer and remote user are omitted.
  • Nginx removes assets, downloads, status/monitoring paths, update feed, counter requests and known crawler clients from the website log. GoAccess supplies the second crawler-classification layer, anonymises IP addresses at the configured level and performs Country lookup locally through GeoLite2-Country.
  • A second reduced Nginx log contains only case-sensitive GET requests with final status 200 for the exact path /kst4ContestVersionInfo.xml. It deliberately retains browser and bot requests and stays excluded from website page views, visits and the public visitor counter.
  • The hourly systemd timer starts the Node.js generator. Each registered site and both reduced streams are processed from the optional uncompressed .1 rotation followed by the current log; older .gz files are not part of regular processing. Logrotate retains raw logs for 14 days and requires delaycompress for this handover.
  • GoAccess maintains one private report/database per registered site and one combined report/database. The generator then updates its separate daily visit-counter state, publishes visitor-count.json for enabled sites, and produces durable private website/update-information day and year views. Private reports are served with Basic Auth from stats.hamradioonline.de; the public home page requests only its same-origin counter file.
  • The manual production rollout was completed and verified on 2026-09-14. The hourly timer is active. Durable website metrics are covered from 2026-09-13, and separate live update-information metrics are covered from 2026-09-14. Earlier dates remain unknown until a verified historical import extends either series.

Durable invariants and persistence

  • GoAccess defines a visit by IP address, date and user agent. The public value is therefore an approximate visit total, not a count of unique people, and remains separate from page views.
  • The public contract contains only schemaVersion, visits, since and updatedAt. The browser uses no analytics cookie, local storage or third-party request; failed or invalid responses leave the counter hidden.
  • The site registry at /etc/hamradioonline-analytics/sites.json owns stable site IDs, hostnames, current uncompressed log paths, activation/coverage dates and output targets. stats.hamradioonline.de is never registered as an analytics site. Existing state cannot be continued with a changed hostname or activation/coverage identity without an explicit migration or reset decision.
  • GoAccess databases below /var/lib/hamradioonline-analytics/db retain rolling 395-day detail through --persist and --restore. /var/lib/hamradioonline-analytics/public-counter-state.json retains daily visit values from activation onward and remains the durable business source for the lifetime public total. Its schema and public meaning are unchanged.
  • /var/lib/hamradioonline-analytics/private-metrics-state.json permanently retains website page-view totals, absolute Country values and normalized path totals per day, plus update-information request totals and absolute Country values per day. It contains no per-request IP address, raw user agent or timestamp. Update hours and conservative client groups are retained only for the latest 14 Europe/Berlin calendar days. There is no permanent path-by-Country request matrix.
  • Update-information requests are not program starts or user counts. Browsers and bots can fetch the XML, and existing application versions do not send a reliable KST4Contest-specific user agent; a Java user agent identifies only an unknown Java application.
  • GoAccess 1.8.1 exposes Country data as geolocation; combined jobs explicitly enable VIRTUAL_HOSTS and require vhosts. Individual site jobs do not enable that panel. geo_location and virtual_hosts are invalid interface names.
  • Private daily values are upserted rather than added. Regular hourly reprocessing and historical imports are repeatable; overlapping aggregates must be equal or monotonically more complete. The generator fails on inconsistent overlaps or when path sums differ from the existing GoAccess page-request definition.
  • The explicit historical-import mode accepts verified Nginx combined or reduced TSV logs and inclusive coverage dates. Node.js reads .gz files without relying on Zlib support in GoAccess. Missing earlier history remains unknown rather than becoming zero, and the first covered date is stored and displayed.
  • Generator inputs and staged GoAccess outputs are validated before publication. Files are replaced atomically, temporary normalized per-day inputs use private run directories and are removed after the run, dry-runs use temporary state without the production lock, and productive runs use /run/hamradioonline-analytics/generator.lock. Exit codes are 1 for runtime/publication errors, 2 for configuration errors and 3 when the production lock cannot be acquired.

Components, roles and external services

  • hamradio-analytics is the locked, non-login service account which reads configuration, Country MMDB and reduced logs and writes service state. Nginx runs as www-data, writes both reduced logs and can read only reports and the public counter, not private databases or either state file. stats-reader is a local Nginx Basic Auth login whose password file remains outside Git.
  • Root-managed program/configuration lives below /opt/hamradioonline-analytics and /etc/hamradioonline-analytics; service state lives below /var/lib/hamradioonline-analytics. Report/public directories are shared read-only with Nginx through group ownership; no ACL dependency exists.
  • MaxMind is used only by geoipupdate to download GeoLite2-Country. Visitor lookups are local; Account ID and License Key remain in the protected server configuration. Let's Encrypt supplies TLS for the statistics vhost through the permanent ACME webroot and /snap/bin/certbot. GitHub supplies website source and deployment, not analytics processing.
  • The website deploy checks out origin/main, builds the static site and publishes it to the Nginx document root. It does not install or overwrite analytics programs/configuration, systemd, Nginx, Logrotate, GeoIP, Basic Auth or Certbot state; those remain separate manual server operations.
  • Passwords, hashes, deployment credentials, MaxMind credentials, ACME account data and private TLS keys must remain outside the repository and ordinary diagnostic output.

Open operational work

  • The server has no comprehensive automated backup plan yet. A future server-wide plan must include both non-regenerable state files, GoAccess databases and protected operational configuration without extending the published 14-day raw-log retention.
  • Historical production data has not been imported. The actual regular Nginx log format and complete available coverage must be verified before selecting an import range; missing history must not be represented as zero.
  • The first real rotation after installing the XML log still requires an explicit operational check of both .1 handovers, ownership/readability, subsequent generator success and absence of duplicate counting.
  • The combined GoAccess 1.8.1 report remains unchanged. A finer Country-level graphic under /combined/ is deliberately deferred; no decision has been made between a later GoAccess upgrade and a custom report integration.

Important Decisions and Workarounds

  • The interface language has four kinds of text, and the rule is the addressee (release 1.50): interface text is translated; protocol text (the pipe-framed opcodes in On4KstProtocol, and the /-prefixed chat commands) and amateur shorthand (the ChatPreferences defaults, snippets, shortcuts, and QRA/QRG/QTF/LOS/Fresnel in labels) are not; and diagnostic text -- the println calls in core -- is not either, because a German stack trace is worse for whoever reads the bug report. Ask who reads it, not what it looks like.

  • core produces no display text. It returns a key or an enum and app-desktop turns it into words. That is the language half of "core stays toolkit-free", and it also means no German string can reach a protocol or a log line by construction. Two violations predate the rule and are named in the stage 9 spec: PathPropagationAssessment and PriorityCalculator.

  • Incompleteness is allowed in a translation, incorrectness is not. A missing key warns and falls back to English at runtime, so a partial contributed language can be submitted; an orphan key, a placeholder mismatch, a duplicate key or an unreadable file fail the build. The argument count always comes from the English base.

  • Anything built outside a composition needs its text read inside one. Window titles, tab titles and column headers are all built once at startup -- the columns from Java -- so each holds a function rather than a string. A stored string freezes at whatever was true when the window opened, which is how the main window's title said "KST4Contest (Compose)" for a whole session and the tab strip stayed English while its contents switched.

  • A column's id is never translated. It is what the remembered column widths and order are keyed on; translating it would read an operator's stored layout under names that no longer match.

  • The palette has three sources, and the later one wins role by role (release 1.50): the shipped stylesheet from the classpath, then the profile's own stylesheet file if present, then the six role overrides stored in that profile's preferences. resolvePalette is a pure function and reports the provenance of every role, which the colours tab shows per field -- with two sources able to decide the same colour, that is the only thing that keeps "why is this colour what it is" answerable, and it is not optional.

  • A role is a selector and a property, not a property alone. Three of the six are stated outside .root (text on .label, the separator on .separator *.line), and -fx-background-color alone appears a dozen times in the evening sheet. JavaFxStylesheet.read returns a complete palette with Modena fallbacks and is right for layer 1; declaredRoles returns only what a sheet actually names and is the only thing correct for layer 2. Measured: the shipped daylight sheet states no colour at all, the evening sheet five.

  • The root profile's layer 2 always exists, and that is not a bug. copyResourceIfRequired writes KST4ContestDefaultDay.css and …Evening.css at every startup, and those are exactly the paths paletteRelativeFileName(rootProfile, …) resolves to — reading them is what fixes the long-standing defect that the copies were written and never read. Consequence to keep in mind: on the default installation most roles report from file, not shipped, with the same values. Anything keyed on provenance rather than on a colour's value will therefore misfire there; textAccent did, and repainted the evening accent on upgrade.

  • A file stating -fx-base and no -fx-background states the derived window surface too, because that is what JavaFX does with it. The settings tab keeps the two roles independent — the spec separates them so an operator can set one without the other — but a hand-edited sheet follows the stylesheet language, or -fx-base: #303030 would leave every window the shipped light grey.

  • The contrast guard's pairs come from Theme.kt's Material mapping, not from the role names. background and surface are both windowBackground with labelTextFill on them, and primary is textAccent, not accent; base reaches the screen only through the menu strip and the selection tint. Measuring text against base alone let an operator black out every window and hear nothing.

  • Kst4ContestTheme must read the palette from the PaletteStore's state, never from remember. That read is what makes a colour change recompose every open window; remember(darkMode) held the palette until someone toggled day/evening, so a change reached nothing. ThemeReadsPaletteStoreTest pins it.

  • The contrast guard is relative to the shipped sheet, not an absolute WCAG floor. Measured: the shipped sheets have three pairs under 4.5 themselves (daylight accent 2.86, evening accent 2.26, evening text-in-field about 2.9), so an absolute rule would warn on an untouched client and teach the operator to ignore warnings. It reports a pair only when it is both under the floor and worse than shipped, so the shipped state and any improvement never warn.

  • The "reset to the shipped colours" button never takes the active palette. It receives the shipped palette as a required parameter and reads no composition local, so it has no path to the resolved one. An operator who sets text and surface to the same black cannot see any other control, this one included, unless it refuses their palette.

  • Preserve full callsign/category identity while applying base-call normalisation only to specifically defined features.

  • Keep canonical worker-thread domain state separate from the row lists the user interface draws from.

  • The station map is a Compose canvas that fetches its own tiles. The JavaFX WebView, the bundled Leaflet resources, the CSS-3D-transform workaround and the TileProxyServer were removed with JavaFX; offline DEM, terrain packages and the profile cache are unaffected and must stay that way.

  • Deliberate test data, comments and Easter eggs are preserved unless explicitly changed.

Planned Technical Direction

These are planned directions, not necessarily implemented behaviour:

  • improve propagation/path modelling using higher-resolution terrain data, including Copernicus GLO-30;
  • increase terrain/path sampling through a dedicated service/API;
  • support high-precision station locations (e.g. extended Maidenhead locators or direct GPS coordinates) while preserving compatible standard display;
  • improve terrain/Fresnel/diffraction/refraction modelling for VHF/UHF/microwave use;
  • evaluate/implement richer tropospheric/scatter models;
  • continue integration of aircraft-scatter and propagation data into reachability/contest workflows.

Before implementing planned items, re-check current decisions and obtain a fresh concept approval.

Known Limitations / Maintenance Notes

  • Historical project context is useful but may be stale; current code/tests win.
  • External service/API behaviour must be verified against current upstream documentation when uncertain.
  • Screenshots in manuals/website may need targeted replacement after visible UI changes; never fabricate them.
  • station_map_path_analysis.png and station_map_reset.png predate the Group nearby stations checkbox in the map header. Replace them with current screenshots when suitable source images are available; the website reuses station_map_path_analysis.png through /manual/assets/.

Recent Significant Changes

2026-08-25 – Durable project context introduced

  • Added a persistent technical context layer so future agents/developers can understand architecture, invariants and cross-project dependencies without replaying chat history.
  • Documentation maintenance uses targeted impact assessment rather than a full audit after every implementation.

KST4Contest website

  • Source is maintained inside this repository under website/.
  • User-facing feature/configuration changes may require a targeted website check.

hamradioonline.de

  • Serves as the broader amateur-radio umbrella site/infrastructure context.
  • KST4Contest content/download/manual links and related knowledge content may intersect with the broader site strategy.

Webserver / hosting infrastructure

  • KST4Contest website and other hamradioonline services depend on the hosting environment.
  • Operational details should be maintained in a private infrastructure context rather than duplicated into this public project context when sensitive.

Planned propagation / terrain service

  • Intended to provide richer terrain/propagation data (including higher-resolution Copernicus GLO-30-based processing) to KST4Contest and potentially related hamradioonline tooling.
  • Interface contracts must be documented on both provider and consumer sides when they become concrete.