The old file was a confusing mix of eras: an accurate current
Windows/Rocky/VPS environment section sitting alongside stale leftovers
from before this project's current setup existed -- a wrong port (9090,
staging is actually 9190), old docker-compose v1 syntax, a podman-compose
three-terminal dev loop nobody uses, an nginx config for laikazinas.lsm.lv
(the real current LSM production site, unrelated to and confusable with
this project's actual temporary test domain), Fly.io commands from a
hosting platform no longer in use, and an example command that happens to
run one of the files this session's security review flagged as a
mislabeled test script capable of touching a live database.
Replaced with a short landing page: what the tool does, how it's built,
current status (Rocky-only development now that Windows is retired from
the workflow, laikapstak.li as the temporary test environment ahead of
eventually replacing the live laikazinas.lsm.lv service), and a security
summary -- all pointing to docs/ for actual technical depth rather than
duplicating half-true instructions. Updated docs/README.md's own
description of the root README to match.
Documents release a6b2b84 (the queryLatestTemperatures fix and the user's
CSS polish pass) in the changelog and README status line. Also fixes a real
bug in VPS_RELEASE_RUNBOOK.md's own checksum step, found by actually running
it for this release: it generated the .sha256 file against a full local
path rather than a bare filename, which fails verification on the VPS since
that path doesn't exist there.
queryLatestTemperatures picked the single most-recent row per city with no
regard for whether that row's tempAvg was actually populated yet. The
open-data source publishes fields on different schedules within the same
hour (snow lands well before the hourly temperature aggregate), so the
freshest row for a city routinely has a real dateTime but a still-null
tempAvg while the previous hour's row has good data. Confirmed live on both
Rocky and the VPS: every city's newest row had tempAvg null (but a real
snowAvg=0), one hour after the actual last real reading.
Fixed by requiring tempAvg IS NOT NULL in the WHERE clause, so DISTINCT ON's
"most recent" pick skips a fresh partial row and finds the newest row that
actually has a value. Checked before changing: this is the only caller of
queryLatestTemperatures (Faktiskā's fixed-station lookup); a city with zero
real temperature history ever now returns no row instead of a null-valued
one, which the frontend already treats identically to a present-but-null
observation (falls through to the same "Nav datu" missing state either
way) -- confirmed via MapGraphics.tsx before shipping, not assumed.
- Add a .secondary button variant and hover/focus states (border-color,
focus ring) to text/number/date/select inputs, matching the existing
checkbox and button interaction language.
- Restyle Brīdinājumi's warning checkbox to match the same custom-checkbox
look already used elsewhere (border-based, hover, focus-visible outline)
instead of its own older shadow-based style.
- Remove a hardcoded Inter font-family on Faktiskā's reset-temperature
button so it inherits the app's default Monda font like everything else.
VPS_STAGING_PLAN.md describes the release process at a policy level; the
changelog table narrates what happened after each release. Neither is a
step-by-step "how" a person (or Guntis) could actually follow without
already knowing the process from having run it. This is that document --
copy-pasteable commands, in order, parameterized by commit SHA.
Writing it surfaced a real regression: earlier releases (through ef64895)
retained the release tarball under /srv/weathertool/releases/<sha>/ and
backed up .env.staging before every edit; tonight's releases (0be325f,
9bab93d, 001b014) did neither -- I'd been deleting the transfer copy from
/tmp after each deploy and sed-ing .env.staging directly with no backup.
Practical cost: rolling back to any of tonight's releases would have meant
rebuilding from git history and re-transferring, instead of just re-pointing
.env.staging at an already-present image. Closed the gap for the current
live release (001b014) retroactively -- exported it directly on the VPS
(checksum matches the exact bytes shipped tonight, confirming no drift) and
backed up the current .env.staging -- and the runbook restores both habits
going forward.
Six real commits (padding, the Result.css collision fix + dead code
cleanup, Ūdens's card layout, its button styling, the Valmiera substitution,
and click-to-edit) had detailed commit messages but no corresponding
roadmap changelog rows or README status updates -- caught while checking
doc status before a planned VPS push.
The "Rādīt stacijas" panel is the only way to fix a value today, and it
opens all 13 fields at once even when only one or two need a tweak. Since
most values are already correct, that's the wrong default interaction for
the common case.
Click a temperature badge directly on the map to edit it inline instead.
Scoped strictly to mode==="faktiska" (Kartes/Ūdens/Brīdinājumi share the
same MapView component and are unaffected). Mirrors Brīdinājumi's existing
canvas-coordinate pattern exactly (getBoundingClientRect scaling via
canvasPoint) rather than inventing a new one, hit-testing against
Faktiskā's already-fixed per-resolution marker positions (faktiskaTemplates)
-- no dragging or calibration needed, just a box test against 13 known
points. The floating input is a plain DOM element positioned over the
canvas, not drawn onto it, so it's preview-only like Brīdinājumi's drag
handles and never reaches the exported PNG.
Reuses the existing updateValue function via a new onEditValue callback
prop, so overrides/reset/the "Manuāli" badge in the stations panel all stay
in sync automatically -- no parallel state to maintain. Added a hover
cursor and a one-line hint ("Klikšķini uz temperatūras kartē, lai to
mainītu") for discoverability, since the whole point was fixing an
undiscoverable interaction, not trading it for another one.
Verified end-to-end: hover shows a pointer cursor over a badge; clicking
shows a correctly positioned input prefilled with the real current value;
Enter commits and the canvas redraws immediately; the change is reflected
in the "Rādīt stacijas" panel as a manual override; Escape cancels without
committing; works correctly at both 1920x1080 and 3840x1440 (different
badge size and marker positions per template); no console errors; Kartes
(a different MapView mode) is unaffected.
LVĢMC's open-data station list has no station in or near Valmiera at all
(confirmed live 2026-08-23 in OpenDataStationService.scala), so that fixed
position has been silently blank. Priekuļi is the closest currently-
reporting station -- measured against the app's own calibrated map
coordinates (~56px away vs. the next-closest candidate, Rūjiena, at ~81px),
not a guess. Same pattern already used for Stende/Talsi and Zīlāni/Jēkabpils.
Implemented client-side in Faktiskā's fetch only: query Priekuļi's reading,
remap it back onto the "Valmiera" key before it reaches the rest of the
component. Valmiera's marker position and on-map label are untouched (both
are tied to Valmiera's real map location, not Priekuļi's) -- only the data
source for that one slot changes. This also fixes a second, previously
unexplained symptom for free: the weather-symbol picker only lists cities
with a value, so Valmiera never appeared there either; it now does.
Provisional pending direct confirmation from LVĢMC -- documented as such in
PRODUCT_WORKFLOWS.md, matching how Stende/Zīlāni are already flagged.
Verified: typecheck clean, production build succeeds, headless-browser
checks confirm Valmiera's field shows Priekuļi's real live value (14.8°C,
fresh timestamp, not flagged manual/stale) and now appears in the
weather-symbol exceptions list (13 cities, was 12).
They used the app's generic flat button style (transparent border, solid
gray fill) while the temperature cards next to them have a considered
look (border, soft glass background, label hierarchy) -- so the buttons
read as a lightweight, generic control next to more polished content.
Matched border, border-radius, and background to the cards, and gave them
a bit more height/padding so the block reads with similar visual weight.
(Measured actual box positions before touching anything: button and card
tops are pixel-identical at the user's real viewport width, confirmed via
Playwright bounding boxes -- the "buttons look higher" read was about
visual weight/style mismatch, not a coordinate offset.)
The No/Līdz number-input boxes were sized for a hardcoded 3-column grid
(minmax(250px,1fr)) despite only ever holding 2-3 digit temperatures.
Switched to grid-template-columns: repeat(auto-fit, minmax(165px,1fr)) --
the browser now fits however many 165px+ cards actually fit at the current
width, growing them to fill leftover space, with no manual breakpoints
needed (removed three now-redundant @media overrides for .rangeGrid).
Caught a real bug while tuning the floor value: a naive hardcoded
repeat(6, minmax(130px,1fr)) first pass produced 6 columns at every width
from 1050px to 1900px, but at some widths in that range (e.g. 1300px) the
resulting per-input width came out to ~38px -- native number-input spinner
arrows ate nearly all of it, and the digits, while genuinely present in the
DOM (verified via inputValue()), were visually clipped to nothing. The map
preview below (fed by the same signal) still rendered correctly, which is
what made clear this was a paint/width issue, not a data bug. Fixed two
ways: hid the native spinner arrows (reclaims space, matches the project's
existing pattern of fully custom-styling other native inputs like
checkboxes) and switched to auto-fit so the actual computed column width
can never fall below the safe floor regardless of viewport width -- the
exact class of bug a hand-rolled breakpoint ladder is prone to.
Verified via a programmatic sweep (14 widths from 375px to 1900px, reading
each input's real computed width and value, not just eyeballing
screenshots) that every width keeps at least ~53px per input -- confirmed
by direct visual check to be comfortably legible, well clear of the ~38px
threshold that broke -- and headless-browser screenshots at 1600/1300/700px
confirm all six cards render correctly with real live data.
Result.css (Kartes' results grid/list): a newer consolidated .item rule
used background:var(--surface)!important to force out an older duplicate
rule instead of removing it -- the old rule's background-color was fully
dead but its padding/text-align/max-width were still silently active
underneath. Consolidated into one rule per view, dropped the !important.
Dead code, verified via a systematic check of every class in each
stylesheet against actual .tsx usage (not just the classes a prior review
happened to flag -- that list undercounted by several in mapGraphics.css
alone):
- mapGraphics.css: an entire leftover "Production layout" block (9 classes:
graphicsSetup, graphicsMain, dataOptions, dataOptionsGrid, productionOptions,
productionToolbar, loadMapButton, choiceGroup, windChoice), including a
scoped selector (.mapGraphicsPage .workspaceRail) that never matched
because no such element exists in the current Faktiskā markup -- leftover
from an earlier panel restructure.
- index.css / aggregator.css: unused .grid-1-1/.grid-1-2 utility classes
and a standalone .panel rule superseded by the more specific
cityPanel/queryPanel/resultsPanel variants.
- calendar.module.css: .calendarNav (never rendered) and .nextMonth (no
next-month dimming logic exists, only prevMonth).
- DateList.tsx: an empty <ul> wrapping fully commented-out code, superseded
by the Calendar component already rendered above it.
Verified: typecheck clean, production build succeeds, and headless-browser
screenshots of every affected page (Kartes including an actual query
result, Faktiskā, Arhīvs) show no visual regression.
Kartes, Stacijas, and Apskats had no CSS of their own and rendered fully
edge-to-edge (no other layout control was actually shared across all pages
before this -- appContent only set min-height). clamp(16px,3vw,48px) gives
consistent side padding globally while staying full-width, not adding a
centered max-width column -- the ask was breathing room, not a narrower
layout. Verified on Rocky: Kartes now has visible margins, Home (which
already set its own max-width/padding) shows no visible double-padding
issue at 1600px viewport width.
The changelog claimed ci.yml #1 verified green (it didn't -- that was a
wrong WebFetch summary of the Gitea Actions page, reported as fact without
checking it visually) and CONTINUOUS_INTEGRATION.md described the
HOME/cache permission warning as harmless (it wasn't -- it blocked
resolving actions/checkout entirely). Both corrected with what actually
happened: three real, different bugs, found only by running the workflow
and reading user-provided screenshots of the actual failures.
Pushing to rocky alone doesn't trigger CI (Gitea can't see that private bare
repo), and two separate git push commands are easy to half-remember. 'all'
has two push URLs (rocky, gitea) so one push updates the staging checkout's
source and triggers CI together. Deliberately excludes origin (GitHub)
pending the still-open question about that repo's ownership/involvement.
Node got installed (previous fix worked) but actions/checkout@v4's bundled
JS uses modern syntax (class static blocks) requiring Node >=16.11.
hseeberger/scala-sbt is Debian bullseye-based, whose default apt "nodejs"
package is a stale Node 12 -- SyntaxError: Unexpected token '{' at "static
{". HOP's own image is bookworm-based (newer default), which is why they
never hit this. Use NodeSource's setup script to get an actual current Node
20 regardless of the distro's packaged version.
The frontend job succeeded after the runner HOME fix, but backend failed at
a different point: actions/checkout is a JavaScript action and needs a Node
runtime inside the job container to run it at all (exec: "node": executable
file not found in $PATH), and hseeberger/scala-sbt has no Node installed.
Same lesson HOP's own CI setup already documented ("JavaScript actions need
Node in the job image") -- mirroring their fix: install git+nodejs via apt
before the checkout step.
Both jobs in the first two CI runs failed identically at the checkout step:
"Unable to clone https://github.com/actions/checkout ... mkdir /.cache:
permission denied". The runner container runs as a fixed uid with no
writable $HOME, so act cloning action sources into its cache directory
failed outright -- this affects every remote action reference, not just the
actions/cache action type as I'd assumed earlier. Fixed by setting
HOME=/data (the already-writable bind-mounted data directory) on the
runner container; not a source change, just re-triggering CI to confirm.
Adds CONTINUOUS_INTEGRATION.md (mirrors HOP's CI doc structure), links it
from README, and records the CI changelog entry plus the follow-up review's
open findings (scheduler crash-loop, System.gc(), connection pooling, the
PostgresService field-routing invariant, and the mislabeled main-method
"tests") in the roadmap's Phase 2/Phase 5 checklists.
Runs on push/PR to codex/staging-baseline plus manual dispatch: sbt test for
the backend, and npm ci/typecheck/build/audit (full + production-only) for
the frontend, matching the checks already documented as the manual Rocky
verification routine. Verified sbt test compiles and passes with no .env
file present (matching what a checkout-only CI job will actually have) via
a git-archive dry run before writing this.
Two separate jobs, each with its own container image, rather than one
shared runner-label image with ad-hoc installs — reuses the exact
hseeberger/scala-sbt image already used all session for local backend
builds, and a plain node:20-bookworm for the frontend, so nothing needs
apt-get bootstrapping of a second language runtime into the same container.
Caught by an independent follow-up review: the FTP auth-bypass fix in
6b9c7cf only gated /api/fetch/lvgmc/stations, the one route the original
security review named. This sibling route calls the same fetch.fetchFile,
which opens a real, unauthenticated FTP login to LVGMC regardless of any
caller-supplied filename, and was only getting the traversal fix
(ValidateFileName) applied to it, not the auth-bypass fix. Traced every
caller of fetch.fetchFile/fetchWeatherStations in Server.scala/Main.scala
this time to confirm these are the only two HTTP-reachable call sites and
both are now gated.
Release 0be325f (image weathertool:0be325fbbbf92b03bc8d574dc6f7b9449ef310ea)
ships the path-traversal/auth-bypass/DoS and SQL-injection fixes to
production; b0b58d2 remains the rollback target.
Records the path-traversal/auth-bypass/DoS fixes (6b9c7cf), the SQL
injection fix (8c45d8d), and the subsequent Postgres password rotation on
Rocky in the roadmap changelog and Phase 4 checklist, and notes current
status in README/DEVELOPMENT_AND_STAGING. VPS deployment of these fixes is
still pending.
field on that route reached PostgresService.query unvalidated, which splices
it into SQL via Fragment.const (unescaped) whenever the aggregate key is
min/max/avg/sum/distinct, or whenever granularity is "hour" in the list
branch. Add ValidateField (allowlists WeatherData's known field names, same
pattern AggFieldList already uses for /query/country) and apply it to the
field path segment. AggregateKey values reaching Fragment.const elsewhere are
already safe since they come from a closed ADT, not raw user input.
- Add ValidateFileName (allowlist regex, rejects .. and separators) and apply
it to every route that concatenates a raw path segment into a filesystem or
remote FTP path: /show/lvgmc-forecast, /show/grib, /grib/binary-chunk, and
/debug/file. Previously an unauthenticated caller could read arbitrary
files, including /proc/self/environ (leaks LVGMC_PASSWORD/POSTGRES_PASSWORD).
- Harden ValidateInt to reject negative integers.
- Gate /api/fetch/lvgmc/stations behind ENABLE_LVGMC_FTP_JOBS so it can no
longer trigger a real, unauthenticated FTP login regardless of the flag;
stop leaking error.getMessage in its response.
- Add an explicit /api/* catch-all (NotFound) so an unmatched API route can
never fall through to the SPA fallback and be served index.html as a 200.
- Cap binary-chunk read length at 64MB to prevent an unbounded allocation.
The SPA fallback (2c44888/b0b58d2) is deployed to VPS as b0b58d2,
verified via curl and headless-browser on real routes, a missing
asset, and /api, both locally and through the public domain.
Also updated the FTP saga: re-testing this morning found a Rocky-only
failure with the VPS confirmed off, which the two-machine-collision
theory from last night can't explain. Decided to stop self-testing
and wait for a real answer from LVGMC about the account's connection
policy rather than keep guessing through trial and error.
Direct hits on client-side routes not in the hardcoded list (e.g. a
browser refresh on /faktiska or /udens-temperatura) 404ed instead of
loading the app — a known limitation that was actually hit in
production. Real files now serve as-is; anything else falls back to
index.html so the SolidJS router handles it, matching the existing
"TODO rewrite in more generic way" comment. Future routes need no
backend changes.
The ltv account fails to authenticate from the VPS specifically
(works fine from Rocky with the same credentials, network path to
ftp.meteo.lv:21 confirmed fine from the VPS too) — narrowed to either
a password transcription error in .env.staging or LVGMC IP-allowlisting
the account, neither confirmable without inspecting the password.
Caught via the overnight watch this was set up for rather than
assumed safe; reverted ENABLE_LVGMC_FTP_JOBS to false on the VPS
immediately once the failure was confirmed real, restoring stable
operation. FTP stays enabled on Rocky, where it works.
Changelog rows for the COALESCE upsert fix, both timezone fixes, the
ENABLE_LVGMC_FTP_JOBS/ENABLE_HARMONIE_JOBS split, and the VPS compose
LVGMC templating fix — none had one yet. Updated the stale "currently
deployed" references (were still e446f1ff) to the actual final
release (3eddf95). Recorded the DMI Harmonie research (no API key
needed, new domain, verified live) and the 5-step verification plan
for actually wiring it up, deferred to a dedicated session. One line
about the VPS's first FTP fetch is marked pending — a background
watch is still confirming it as of this commit; will follow up once
it resolves.
deploy/vps/compose.yml hardcoded LVGMC_USER/PASSWORD/URL to inert
placeholder strings directly in the file, unlike POSTGRES_* which
already read from .env.staging — so simply adding real values to
.env.staging would have had no effect. Switched to the same
${VAR} substitution pattern, and enabled ENABLE_LVGMC_FTP_JOBS now
that real credentials exist. HARMONIE stays on placeholders pending
real DMI credentials.
Real LVGMC FTP credentials arrived today; real DMI HARMONIE
credentials haven't. The combined flag would have enabled both the
moment FTP's were ready, crash-looping the app on HARMONIE's still-
placeholder values via parMapN — caught this before it happened
(the Grib job was ~15 min from its first scheduled run). Split into
ENABLE_LVGMC_FTP_JOBS and ENABLE_HARMONIE_JOBS so each provider can
be enabled independently as its own credentials become real.
Same root cause as the station-observation fix (this uses the same
open-data portal, same UTC DATETIME field): the raw UTC string was
passed straight through to the frontend as a display label, which
JS's Date parser then reads as local time for a string with no
timezone suffix — silently showing observation times 2-3h behind
the newsroom's actual clock. Internal recency filtering (isRecent)
was already self-consistent either way; this only affects display.
Found while wiring up real FTP credentials: the open-data portal's
DATETIME field is UTC, but was being stored into weather.dateTime
as-is with no conversion — while the private FTP feed's "Laiks"
column is already Latvia local time and also stored as-is. Both
paths write the same column, so the table has been silently holding
two timelines 2-3h apart since open-data went live, invisible only
because FTP was never actually running with real data until today.
Verified live: at real local time 20:31 EEST, the fix now correctly
produces dateTime=20:00 instead of 17:00, matching what FTP writes
for the same real hour.
ON CONFLICT DO UPDATE was a blind full-row overwrite. Open-data
station rows always carry null visibilityMin/dewPoint/sunDuration
and an empty phenomena array (fields it doesn't publish), so once a
second source (FTP) writes real values for those fields, a later
open-data write for the same (city, dateTime) would silently null
them back out. Switched to COALESCE(excluded.field, weather.field)
so a missing value from one source never erases a real value the
other already wrote; phenomena needs NULLIF against an empty array
specifically, since Scala's List[String] never maps to SQL NULL.
.symbolPalette/.selectedPreview/.currentSymbol are used by both
Faktiskā's IconInputs.tsx and Brīdinājumi's Warnings.tsx, but were
only defined in mapGraphics.css (Faktiskā's own stylesheet, twice,
with a fully dead first version) and then size-patched for both
consumers via !important overrides in weatherIcons.css. Moved the one
real definition into weatherIcons.css, the file both pages actually
import, and removed the dead/duplicate/!important versions.
No visual change intended — verified via headless-browser screenshot
on Faktiskā and computed-style checks on both pages. One incidental
fix: .currentSymbol.empty's Inter font was silently losing to the
!important rule; normal cascade now applies it correctly again.
Found by a fresh-eyes review after today's design pass: WindInputs
had no productionTemplate branching, so it kept showing "Wind
direction"/"Wind speed"/"Gusts" in English right under the freshly-
translated "Temperatūra un vējš" heading. Threaded the same
productionTemplate flag MapView.tsx already uses for its other
labels. Also removed .assignedSymbol, an unreferenced CSS rule the
same review flagged as dead.
Brīdinājumi was tucked in the "Vairāk" overflow menu despite being a
primary newsroom workflow. Moved it into the always-visible nav
alongside Stacijas/Kartes/Faktiskā/Ūdens, and gave every visible nav
item a small icon (reusing the same icons as their Home cards) so the
header matches the icon treatment the overflow menu already had.
canvas textBaseline "middle" centers on font em-box metrics, not
visible ink — digits have no descenders, so they read as sitting too
high in their boxes. Center on the actual glyph bounds instead,
matching the technique already used correctly in Ūdens's drawRanges.
Shared by Faktiskā and the older Kartes comparison map.
Both "Jaunākās temperatūras" and "Kartes noformējums" dumped their
full content on load, forcing a scroll past both just to reach the
map/export on every visit. Both now collapse by default (Faktiskā
only; the older non-production Kartes overlay panel keeps its current
always-open behavior) behind a toggle that still surfaces a manual-
override count so nothing is silently hidden.
Also fixed a real bug found in the process: Faktiskā's resolution
buttons carried both "resolutionChoices" and "faktiskaResolutionChoices"
classes, and the former (defined in waterTemperature.css for a narrow
sidebar layout) was winning the cascade in this wide-content context,
forcing full-width, left-aligned buttons instead of the intended
compact pair.
Restructured "Kartes noformējums" into distinct bordered cards instead
of one continuous flow, translated the still-English "Weather symbols"
/"City assignments" block to Latvian ("Laikapstākļu simboli"/"Mākoņi
pilsētām"), and collapsed the city list to show only active exceptions
by default with a "Rādīt pārējās pilsētas" button to reveal the rest.
Replaced the flat near-white page background with a layered blue-gray
gradient (from a sampled palette) for actual visual depth, while
keeping buttons/icons/links on the original saturated accent blue so
interactive elements still stand out against the calmer backdrop.
Also removed the "Testa dati" badge on Home, a synthetic-data leftover
now that both Rocky and the VPS run on real data only.
Deployed everything since 6185dbf (Brīdinājumi draggable symbols, real
open-data station/water-temperature ingestion, synthetic-data removal,
scheduler split, METEO_* deletion, real per-zone water-temperature
ranges), flipped ENABLE_SCHEDULED_JOBS=true on the VPS, and wiped the
VPS weather table's synthetic rows after a PostgreSQL backup.