Skip to content

Releases

v0.12.0

Choose a tag to compare

@github-actions github-actions released this 13 Oct 15:08
48ae9a4

Image: ghcr.io/feuerwehr-oberwil/kp-front:0.12.0
· Update: docker compose pull && docker compose up -d
· New deployment: docs/DEPLOYMENT.md

Migrations run automatically on boot, after an automatic pre-migration dump.


Upgrade notes

Nothing in this release needs a manual step on a station that does not use the removed print
relay: docker compose pull && docker compose up -d is enough. Read on if one of these applies.

  • Migrations run on boot (eleven, all automatic): floor packs (plan_page_floors,
    plan_page_floor_parts, plan_page_floor_marker, plan_alignment_marker_notes),
    Objektbesuche (object_visits, visit_programmes, users.notify_object_visits),
    incidents.last_closed_at, divera_emergencies.responses_json / responses_at, the
    Objekt's measures / remarks / measures_source, and dropping print_jobs together
    with a stored print-agent secret. The downgrade recreates an empty print_jobs; the secret is
    not restored.
  • The station print relay is gone (see Removed). A PRINT_AGENT_SECRET left in .env is
    ignored. A print agent still polling KP Front gets 404s – remove KP Front from its
    configuration or switch it off; KP Rück's print agent is unaffected.
  • New optional settings – nothing changes while they are unset: OBJECT_VISITS_INTEGRATION_KEY
    and SHAREPOINT_EXPORT_TENANT_ID / _CLIENT_ID / _CLIENT_SECRET (Objektbesuche organizer
    and SharePoint filing, a second app registration), ROSTER_SNAPSHOT_SOURCE /
    ROSTER_SNAPSHOT_TOKEN (roster snapshot), ENTRA_LOGIN_TENANT_ID / _CLIENT_ID /
    _CLIENT_SECRET / _ACCOUNTS («Mit Microsoft anmelden», a third app registration, see
    docs/microsoft-login.md; PUBLIC_URL must match its redirect URI), STATION_INDEX_SOURCE /
    STATION_INDEX_TOKEN («Stationsdaten-Index», see below), and
    REFRESH_REUSE_GRACE_SECONDS (default 60; 0 keeps refresh-token rotation strictly
    one-time). docker-compose.yml passes every one of these credentials from .env into the app
    (.env.example lists them; see Fixed). The integration credentials can also be set in
    /admin › Anbindungen; new config
    keys (doctrine.entryPressureMin, doctrine.equipment, roster.snapshotIntervalMin,
    roster.snapshotMaxDeactivatePct, roster.diveraResponses, map.buildingRegister) all have
    defaults (docs/CONFIGURATION.md).
  • New outbound requests. The Gebäude card asks the federal GWR and BFE registers about the
    building at the Einsatzort (inside Switzerland only); set map.buildingRegister: false to turn
    that off. Every serving process fetches the MeteoSwiss precipitation radar every 5 min
    (nationwide files, no coordinate sent) on by default; a station outside Switzerland or
    without outbound access sets WEATHER_LAYER_ENABLED=false, and nothing is fetched or offered.
    The server now polls Divera itself (the devices no longer do).
  • Disk. Plan tile pyramids are written to the data volume in the background after the update
    (about 10 MB for a dense A1 sheet; derived data, not in the backup). Replay snapshots are
    stored gzipped from now on (about 5× smaller); older ones stay as they are. The image is about
    37 MB larger: hidden sourcemaps for reading field crash reports (about 20 MB) and h5py for
    the radar (about 17 MB). The radar's frames are kept in memory only, never on disk.
  • docker-compose.yml pulls postgres and caddy from AWS ECR Public
    (public.ecr.aws/docker/library/postgres:16-alpine, …/caddy:2-alpine, same digests) instead
    of Docker Hub – the same official images, without Docker Hub's anonymous pull limit. Nothing to
    migrate: docker compose up -d recreates the two containers once and the database stays on its
    volume. A host whose outbound traffic goes through an allowlist needs public.ecr.aws on it.
  • /admin needs a connection. It is no longer in the offline precache.
  • API. Live writes into a closed Einsatz are refused with 409 incident_closed;
    /api/print/*, /api/print-jobs/* and /api/print-agent/* are gone. A script of your own
    that writes into closed Einsätze or uses the print queue needs to change.
  • Objektbesuche organizers (fwo-admin and the like): deploy this release before an organizer
    that schedules rounds; the organizer's domain must be allowed for the station's CARTO key.
  • Roster snapshot / station index (new, optional): nothing changes unless a source is set. A
    station that set «Personenstamm-Quelle» keeps it – it is the fallback; to move to the one
    address, publish index.json beside roster.json (scripts/station_index_build.py) and set
    «Stationsdaten-Index».

Added

  • The Tafel gets pages: the FKS «Erste Führung» and the Handbuch sheets. A page strip on the
    Tafel – «Skizze» (the free board, unchanged) plus «+ Seite» – adds the FKS «Erste Führung»
    poster 1:1 (Problemerfassung · Lagekarte · Massnahmen · Mittel · Verbindungen ·
    Abspracherapport with its six Signaturen) and the FKS Handbuch sheets 8.1 Problemerfassung,
    8.6 Mittel, 8.8 Verbindungen, 8.9 Konzept and 8.11 Rapport, in German, French and Italian. A new
    Tafel starts empty. The keyboard works like a spreadsheet (Enter down, Tab across, Shift+Enter a
    line break, Esc reverts; «1124» becomes 11:24), the Lagekarte box is a live, read-only mini
    Karte, Mittel starts with the Einsatz's vehicles, and every edit is one ↶ step that syncs and
    works offline. The Rapport prints each page in its own layout (the Erste Führung as the poster,
    its Lagekarte as the Karte at print time) and finally the Tafel's own drawing too
    («Tafel – Seiten» in the print menu). A station replaces the bundled set with its own copy
    (board-template/1, docs/board-templates.md): admin_board_templates validate|load|push [--prune] or /admin › Tafel-Vorlagen, distributed like the checklists.
    Two devices on one page merge cell by cell (a true clash keeps the later edit and writes a
    Verlauf row), and an older build can no longer delete the pages it does not know. «Drucken» on
    a page makes a PDF of it (or of all pages) in the same layout. Entries stack from the top and can
    be reordered by hand — drag the grip, or Alt+↑ / Alt+↓ — on screen and on paper, the same on
    every device. Konzept and Rapport can be added more than once («Konzept», «Konzept 2» …; a
    station marks its own pages with repeatable: true), «+ Seite» lists the page titles, PageUp /
    PageDown switch pages when no field has focus, and on a phone a wide table scrolls sideways
    instead of squeezing its columns.
  • Atemschutznotfall for one Trupp. Hold «Notfall» on a crew inside: every device turns that
    Trupp red at the top of the alarm strip with a running Notfall clock, its last place, last Druck
    with its age and Funkkanal, and «Sicherungstrupp einsetzen» as the first offer; tone, OS
    notification and Web Push follow, the Verlauf and the Rapport record raising, the Sicherungstrupp
    going in and «Notfall beendet» (also held, undoable). The held tile fills from left to right
    while it is held (a tap says «Gedrückt halten» in its place); on the Tafel the Trupp stands at
    the top as its own card in red – ⚠ and the name its card shows, the Notfall clock and the
    Sicherungstrupp action (on a tablet one slim row with the facts) – the crew's own card wears the
    same ⚠, and an opened card lands below the banner, never under it. «Raus» on a crew in a
    Notfall ends the Notfall too (one undo step restores both). A calm hint says when an
    Atemschutz-Trupp goes in with no Sicherungstrupp ready. The Web Push leaves the moment the save
    reaches the server, not on the next 30 s sweep (never for an Übung or the demo); a crew in a
    Notfall cannot be taken off the board, and «Einsatz abschliessen» asks about a running Notfall
    first – closing anyway writes «Notfall beim Abschluss nicht beendet». No action needed.
  • Wetter on the Karte: MeteoSwiss precipitation radar. Ebenen get a «Wetter» group with
    «Niederschlag (Radar)» (a device setting, off by default): the last hour of MeteoSwiss radar
    under every line and symbol, with a floating pill to step or play through it and a colour key in
    Ebenen. «Standard» and «Alle aus» switch it off with the other layers, and while it is on the
    Ebenen read «Eigene Auswahl». Old data is never shown as current: a stale radar is greyed with
    «Stand hh:mm», and offline it simply ages. The backend fetches the nationwide files only (no
    coordinate leaves the station), renders each radar frame once and serves it from memory; the
    algorithms are KP Rück's (R5), ported with its tests. No action needed; WEATHER_LAYER_ENABLED=false
    switches it off (stations outside Switzerland or without outbound access). The image grows by
    ~17 MB (h5py; numpy was already in it).
  • Station index: one address for all station data. A station publishes an index.json
    next to its data files that lists them by kind with checksums (station-index/1,
    docs/CONFIGURATION.md §4d); KP Front gets ONE setting for it – «Stationsdaten-Index» on
    /admin › Anbindungen (STATION_INDEX_SOURCE + optional token) – and reads the roster through
    it. A file that does not match the index's sha256 is refused and the roster stays as it was.
    scripts/station_index_build.py writes the index for a folder. vehicles, groups and
    keywords are reserved kinds: listed, reported as not read yet, format documented. KP Rück
    reads the same index with byte-identical code. No action needed – see Upgrade notes.
  • Objektbesuche: visit an object without an Einsatz, offline (module objectVisits, off by
    default). A member opens «Objektbesuche» from the launcher, picks an object from a work list,
    the search or «In der Nähe», answers a station checklist of the new kind visit (OK / Mangel /
    n. a., Ja/Nein, Text, Zahl, Auswahl, ...
Read more

v0.11.0

Choose a tag to compare

@github-actions github-actions released this 13 Sep 17:19

Image: ghcr.io/feuerwehr-oberwil/kp-front:0.11.0
· Update: docker compose pull && docker compose up -d
· New deployment: docs/DEPLOYMENT.md

Migrations run automatically on boot, after an automatic pre-migration dump.


Added

  • The station aligns its Objektpläne ahead of time – nobody waits for a computation during
    an emergency.
    Every distinct byte version of a Modul-PDF is pinned as an immutable plan
    revision and queues one durable alignment job; a background worker renders the exact
    original, segments it, and matches it against the buildings around the object (one
    building snapshot per station, refreshed weekly – not one Overpass request per sheet). The
    matcher searches every pose by FFT cross-correlation and is gated on coverage, not a
    score: measured on all 243 real Modul-1/-2 sheets of one station, 219 align, every proposal
    above 0.7 was right and every one below 0.5 wrong. Nothing reaches an incident before a
    person has looked: /admin → Objektpläne shows the proposals as a wall – every sheet a
    thumbnail with the building outlines drawn through its fit, ready ones pre-marked, one
    «Übernehmen (N)» applies the marked cards, ✕ rejects. Aligning by hand in the review modal
    IS the field's «Karte verknüpfen» – same crosses, loupe and instrument – and a sheet nobody
    aligned simply stays unlinked. An incident freezes the revision and fit it opened, so a
    later replacement or approval never moves a running Einsatz's backdrop, and the printed
    Rapport projects onto the pinned revision. In the field a station-approved sheet reads «Von
    der Station freigegeben»; the field itself never asks the server for a proposal any more.
    The module catalogue says which sheets may be proposed for (modules[].alignment: auto | manual | none, default auto for Modul 1/2/2-3), and the Modul-Katalog shows the resolved
    value. The CV stack is the optional georef dependency group – the published image ships
    it (~60 MB); a build without it answers 503 and keeps the manual flow.
  • One tactical object, two surfaces – the twin concept is gone. A symbol drawn on a
    Modul sheet used to reach the Karte as a projection with its own layer, selection, drag,
    panels and Ebenen rows, and the other way round; every edit through a twin was routed back
    by hand. It is one record now: the sheet body's presence is the anchor, the map body is
    baked through the georeference, and each surface draws the other's objects as ordinary
    markers with the chrome it already has. The last hand-placement decides where an object
    stands; machine writes (the live-GPS re-route of a hose) never flip an anchor; a corrected
    reference journals «Referenz angepasst – n Objekte neu verortet» as one undoable step, an
    automatic aspect measurement journals «Blattform gemessen», and «Referenz zurücksetzen»
    loses nothing («Referenz entfernt – n Objekte behalten ihre letzte Position»). Print reads
    the same board view the screen does, so the exported Objektplan shows what the sheet shows
    by construction. «Hierher übertragen» is gone in both directions – there is nothing to
    transfer. Workspace schema 2: the blob carries objects, and the three legacy collections
    stay written as derived views so an un-updated device keeps rendering.
  • The el role – the Einsatzleiter keeps the record, never the picture. A third incident
    role between editor and viewer: an el session reads everything and edits the operational
    record – Anwesenheit incl. Zeitplan, Mittel, Checklisten, the Rapport with its Beilagen, the
    Einsatzdaten – while Karte, Pläne, Trupps and Einstellungen stay view-only. Server-enforced
    on its own slice (PUT …/workspace/record, the full workspace PUT keeps refusing), append-
    only journal rows attributed to it, and a third card in the Mitglieder form.
  • Standing links – the Stations-Terminal and the laminated Atemschutz QR. Two station-
    level credentials that bind to «whichever Einsatz is open», resolved at exchange time: a depot
    PC enrolled once (/l/t<secret> → /terminal, a year-long device cookie, «Kein laufender
    Einsatz» until the alarm opens the Einsatz by itself, read-only), and a QR laminated onto the
    Überwachungstafel that opens the Atemschutz board of the running Einsatz with the AS link's
    write slice. Both keys rotate independently under Einsatz-Links; rotation ends every printed
    QR, enrolled device and open session at once. Plus a printable A5 QR card.
  • A Trupp is «Trupp N». One counter per Einsatz (removed Trupps and unlinked chips
    included, never reused), a crew history in its log – registration, every crew edit,
    transfer and re-entry write who the Trupp is from that moment on – and every Verlauf row
    reads «Trupp 1 (Meier Anna / Dürring Jan): Eintritt». The Rapport's Atemschutz page prints
    one «Einsatz n» row per cycle naming the crew that went in. The leader stays the face of a
    Trupp; the number is a «#1» badge beside it. Design record in docs/trupp-naming.md.
  • Gefahrgut speaks UN. The Gas/Chemie symbols carry Stoff and UN-Nr. with a
    common-substances list one pick away and the full ADR name corpus behind the search
    (exact-only name→UN – a wrong UN would put wrong distances on the map). ERG Schutzabstand
    rings – isolation solid red, protective dashed amber, day/night by the clock – and
    «Übernehmen» turns a distance into a real Absperrkreis. The ADR readout explains its codes,
    the UN deep link opens the localized ERI-Card, and a Gefahrentafel dropped beside an object
    docks to it and travels with it («Lösen» frees it).
  • The station's SharePoint becomes a source. Objektpläne, Geodaten, Checklisten and the
    Arbeitsmappe pull themselves on a schedule from the station's own folders – read-only, each
    area from its own folder, matched by the module rules the station already configured. The
    System card counts down the client-secret expiry Azure will not warn about, «Verbindung
    testen» hands back Azure's own error text, and the Objektpläne page says per slot where a
    plan comes from – Hand-Upload, Bucket or SharePoint. A failed run can no longer disguise
    itself as a green one, and two files claiming one slot import neither and say so. Setup
    guide: docs/sharepoint-connector.md.
  • One global undo timeline behind the header pair. The Karte, each Plan and the
    Anwesenheit kept their own history and the Trupp board had nothing but expiring toasts, so
    the operator had to remember which surface the last tap landed on. One chronological
    timeline now records every step across surfaces and names what ↶ will take back – in the
    hold-tooltip and as a flash at the button – so the Trupp board could drop every
    confirm-with-undo toast. Redo says «Wiederherstellen».
  • The Verlauf completes the words the Funk actually speaks. Read off ten weeks of one
    station's real Verlauf: the shipped Textbausteine were used once in 131 rows, the vocabulary
    every time. The 14 radio abbreviations (AS, Hösi, TS, DL, RWA, …) and the Geschosse now
    complete from the first letter, one typo still finds a term, and spelling help never marks
    a row the way a person or a Trupp does.
  • The search field IS the name entry. The «Name eingeben» detour is retired everywhere:
    type in the search, commit with «‹Name› verwenden» – Anwesenheit guests, Mittel, Partner-
    organisationen, the capture poster (which finally records guests) alike.
  • Rapport and Trupps, from the September field tests. The Kroki predicts the
    Partnerorganisationen (an offer strip, never auto-ticked); «Gerettete» dedupes what stands on
    the Lage; the Beilagen-ZIP downloads in place instead of walking the PWA onto iOS quick-look.
    A Trupp assignment warning offers the move it warns about; «Bearbeiten» is offered in every
    status because the record is what prints; the Trupp form is one flat phone column with the
    Standard line folded («300 bar · Kanal 11 – Ändern»); Trupps ohne Atemschutz get their own
    default Funkkanal (doctrine.defaultFunkkanalEinfach); removing an unused AS-Trupp offers
    «nicht eingesetzt»; the break clock rolls into hours. A device offline past 60 s shows a
    standing «Jetzt synchronisieren», Ebenen gain Alle ein / Alle aus / Standard, and the Objekt-
    Picker gets the width its two columns need.
  • «D pur» – the dock draws, the editor styles. The drawing dock keeps only what the stroke
    needs (✕, Freihand/Punkte, ✓); every styling decision moves to the editor that opens the
    moment a line lands, and the last line is the template for the next.
  • The Verwaltung becomes one idiom. Every setting a table row with the prose behind a
    hover-and-pin ⓘ and «Standard» spoken only on deviation; every record list a hierarchy;
    «Links & Zugänge» replaces the scatter across Erfassung, Einsatz-Link and Statistik-Export;
    the Einrichtung checklist is tickable by hand; the connector table shows «vor 12 min», the
    server's error sentence verbatim, and amber once a poll is older than its own window.
  • Every polling connector now says when it last actually worked. Divera, Traccar and the
    Mannschaft sync reported one thing about themselves – whether a key was entered – which is the
    same sentence on a station whose key was rotated two years ago. They now keep the record
    SharePoint has kept since it shipped: last attempt, last success, last error, and the counts
    from the last run, served on GET /api/system. The two timestamps stay separate on
    purpose, because a green tick standing through a fortnight of refused keys is precisely the
    silent death this surface exists to prevent. The alarm connector counts an authenticated
    webhook delivery as a success, not only a poll – the webhook is the primary intake – and the
    Traccar writes are throttled so a 30-second sweep does not rewrite its status row twice a
    mi...
Read more

v0.10.0

Choose a tag to compare

@github-actions github-actions released this 06 Sep 19:15

Image: ghcr.io/feuerwehr-oberwil/kp-front:0.10.0
· Update: docker compose pull && docker compose up -d
· New deployment: docs/DEPLOYMENT.md

Migrations run automatically on boot, after an automatic pre-migration dump.


Added

  • A Trupp no longer has to be an Atemschutz-Trupp – the board becomes «Trupps». The former
    Atemschutz view is now the home of every Trupp: Sektionen and other Trupps ohne Atemschutz
    stand alongside AS-Trupps, each with its own Auftragsliste, and the Art of a Trupp can be
    corrected after the fact – «Wieder einrücken» asks it again. Anmelden requires an Auftrag, the
    Gruppenführer is a Funktion rather than a name, and Trupp events joined the journal
    vocabulary, so the Verlauf and the printed report say who led and what changed. The safety
    side is untouched: only AS-Trupps that are out tick, the handed-over Tafel keeps its
    «Atemschutzüberwachung» title, and the printed section stays the ASÜ. On a phone the Trupp
    form is a stack of three sections; in the rail the view is «Trupps» with a stopwatch icon.
    Closing an Einsatz now stops the clocks – and asks before it does.
  • The Atemschutz-Link – a QR that hands the Überwachung to a phone. «Überwachung abgeben»
    mints a link from a running Einsatz; the scanned phone gets a focused board – one Trupp per
    screen, a two-step form – and sheds the phone's own login first, so a private session never
    bleeds into the handed-over Tafel. Revocable like the other incident links.
  • Abschnitte on Flächen, and a Führungsrhythmus re-raise. A drawn Fläche can carry a Leiter
    and an Auftrag – the shape is the Abschnitt (FKS Einsatzführung 3.5.2) – rendered on the map
    label and identically on the paper Kroki, with a quiet Richtwert hint above four assigned
    Abschnitte. And every done Pendenz in the Verlauf offers «wieder in 30/60 min»: one tap
    appends a fresh timed Wiedervorlage while the closed item stays closed and the record stays
    append-only.
  • Rapport and Verlauf carry more of the record. The Verlauf takes generic Beilagen (photos,
    audio, any file) with named downloads and their own Rapport rows; URLs in entries become links
    – in the app and on paper; «Gerettete» offers what already stands on the Lage;
    Anwesenheits-Abweichungen can be settled with the log saying what that means; «Foto
    hinzufügen» takes the whole pick instead of one photo at a time; and «Weitergeben» on the
    Rapport page mints a revocable view-only link for one Einsatz.
  • Drawing and symbols keep growing into the FKS. Chain lines, freehand Flächen with
    Schraffur, ten more signatures from the Vegetationsbrand and Zivile sets, a von–bis Geschoss
    for symbols that span storeys, a rotation control with an explicit pivot, and a fixed compass
    chip that owns plan rotation.
  • An attached alarm can deliver its Zeiten at once instead of field by field.

Fixed

  • Online backups retain replaced files through SQL capture and pin complete blobs before
    compression. Interrupted captures preserve previous backup pairs; scheduled backups cannot
    overlap a restore. Branding imports retain immutable asset versions for backups and history.
  • Schema rollback has an explicit restore-and-leave-stopped path before selecting the older
    image. Restore failures no longer report success or proceed without a safety copy unless
    that omission was explicitly requested.
  • Concurrent workspace conflict recovery now applies the merged document to the editor before
    its next save. Cold starts renew a valid session instead of unnecessarily showing login.
  • Authenticated reloads no longer get stuck in WebKit when tab-lock acquisition is rejected
    during navigation. Core reload and rejected-entry recovery checks now run in WebKit as well
    as Chromium; offline service-worker automation runs in Chromium.
  • Journal and audit delivery survive offline reloads and expose rejected or unstored entries
    through one recovery notice. Audit retries retain stable IDs and cannot duplicate the record;
    localStorage migration keeps its source when IndexedDB refuses a write.
  • Concurrent PDF rendering and print reversal share a process-wide PDFium lock. Malformed
    legacy alarm values cannot stop healthy teams' notifications; new writes validate those values.
  • Request limits count actual streamed bytes, and the external heartbeat checks database and
    storage readiness before reporting health. Interrupted blob replacement preserves the old file.
  • Startup rejects an unknown database revision before creating or rotating migration backups,
    preserving the pre-upgrade dump when an incompatible older image is repeatedly restarted.
  • Three field-feedback rounds (01.–06.09.) closed an iPhone crash, lost Ausrückzeiten, a
    real 500 on an Einsatzort move, Atemschutz timers that ticked for Trupps that were not out,
    and the on-screen keyboard covering the field being typed into. Journal suggestions became
    incident-aware, keep the text around the cursor, and can be dragged horizontally on touch
    again.
  • The printed Rapport tells the truth in more corners. Keyword highlighting is
    word-bounded, photos print upright, half-typed notes no longer leak onto paper, «Nicht
    eingesetzt» gets its span, a long Kontaktperson wraps instead of losing its second half, the
    Zeitplan says «1 Schicht» and «eingeteilt» consistently, and the station logo sits on both
    sheets. Trupp rows in the Verlauf dedupe per cycle, report honest crew diffs and «Rückzug
    abgebrochen», and the closed row and the open card tell one time.
  • Resilience and memory sweeps. One fault line per view instead of a white screen, a
    bounded boot that skips a crashing Einsatz, a symbol pack that degrades instead of
    disappearing, an exit for every stuck state, and image memory budgeted in bytes – session
    thumbnails, one decode at a time, plan bitmaps capped – so an iPhone survives a photo-heavy
    Einsatz.
  • The public demo no longer requests notification or geolocation permission uninvited on first
    taps, and no longer greets every visitor with a false «Anmeldung abgelaufen» banner: the cold
    boot's refused token refresh set the expiry flag before the automatic sign-in existed, and
    nothing lifted it afterwards.

Changed

  • Updated React, the map wrapper, Vite, Vitest, jsdom, and pinned CI actions together.
    Source builds require a supported Node version from package.json (Node 24 LTS ≥24.15.0
    recommended); the container already meets that requirement. The browser build target is unchanged.
  • The public demo is now a stable front door rather than a station-shaped deployment. It
    refreshes session-only configuration after automatic login, introduces the shared/resetting
    environment in one compact first-visit card, blocks incident lifecycle actions, suppresses
    operational pressure/push alarms, and uses a demo-only 0 Alarmdruck while ordinary stations
    keep the 100/50 defaults and cannot disable them. The public site and privacy text now match
    the behavior visitors actually receive.
    Upgrade note: doctrine.alarmBar: 0 used to mean «threshold off» and was documented as
    such. A station that stored a 0 now falls back to the shipped 100/50 on read — the
    pressure alarm it had switched off is switched back on — and the next change in Verwaltung
    persists that. Set the two lines to the values the station wants before the first edit.
  • A fresh self-hosted station has a finishable setup path. /admin separates deployment
    administration from incident roles, teaches the remaining station data and integration steps,
    treats manual incident creation as a complete operating mode, and makes automatic alarm intake
    an optional Divera/webhook upgrade. Setup, deployment, backup and restore documentation were
    reconciled with the Docker path and verified against an isolated clean install.
  • «Teilen» has one door. The top bar's own Teilen button is gone on every width – the
    Einsatz card's Teilen row opens the same sheet and is now the only entry.
  • A terminology consistency round (31.08.): Anwesenheit, Karte/Lagekarte, Einsatzende,
    Kurzbericht, «Wer & was», and the settings labels now use one word per thing, in all four
    languages.

Security

  • The 2026-09-05 security audit (SEC-01…SEC-12) and its residual findings are closed.
    Journal media URLs are validated at ingest and neutralized at render, so a poisoned row
    degrades to a linkless chip instead of stored XSS. The app ships an enforced script CSP
    (script-src 'self' plus a runtime hash of its own theme-boot line). Online PIN guessing
    meets a per-account bounded delay – it slows, never locks – and failed logins are logged.
    Uploads are checked by magic bytes rather than declared content type, event and alarm intake
    get batch/size caps, and the per-source throttle ignores the forgeable X-Forwarded-For
    header unless TRUSTED_FORWARDED_HOPS declares how many proxy hops to trust.
    Upgrade note: behind exactly one reverse proxy (Railway, the compose Caddy profile) set
    TRUSTED_FORWARDED_HOPS=1, or every request keys on the proxy's address and the per-source
    throttle collapses into one shared bucket – the backend now warns at startup about that shape.

  • PINs are 6–12 digits behind a submit-based pad. The pad shows one dot per typed digit, no
    empty slots, and submits only on ✓ (or Enter) – a bystander watching a login learns nothing
    about the PIN's length. Existing 6-digit PINs keep verifying untouched; SEED_PIN accepts the
    new range.

  • Storage operations reject symlink aliases and paths outside the configured storage root.
    Invalid public branding paths return 404. Refreshed vulnerable transitive tooling dependencies.

  • Rotating ADMIN_SECRET now invalidates existing admin sessions, demo lifecycle guards fail
    closed even when stored configuration ...

Read more

v0.9.0

Choose a tag to compare

@github-actions github-actions released this 30 Aug 11:25

Image: ghcr.io/feuerwehr-oberwil/kp-front:0.9.0
· Update: docker compose pull && docker compose up -d
· New deployment: docs/DEPLOYMENT.md

Migrations run automatically on boot, after an automatic pre-migration dump.


Added

  • Georeferenz – a plan and the map show the same place. A Modul plan is tied to the map
    through shared landmarks: tap on the sheet, tap on the map, done. Points may be set in any
    order
    on either surface – both halves hold numbered open slots, either side may start a pair,
    and halves merge closest-first once the fit is credible enough to judge distance (20 m
    tolerance), conservatively in capture order before that. Queueing the two sides in different
    orders used to pair twelve points with twelve wrong ones and the only way out was doing all of
    them again; the two point clouds still have the same shape, so the mode solves the
    correspondence itself now, renumbers its crosses and turns the Passung green – and only when
    the new assignment is genuinely good and clearly better than what stands, so honest-but-imprecise
    taps are left alone. Every half is draggable, and a tap on a cross offers Verschieben · Punkt
    löschen · Behalten instead of an invisible edit mode. From three points the fit states a mean
    deviation in metres – at two it deliberately says «aus 2 Punkten» and no number, because two
    points always solve exactly and a 0.0 m there would be the app's most confident lie. Afterwards
    symbols and vehicles mirror as twins onto both surfaces, every projection is an ordinary row
    in «Ebenen», and an object can be moved across for good with «Hierher übertragen» – attached
    Leitungen are released cleanly rather than left orphaned, and the move is written to the record
    as one move, so a replay reconstructs the object on exactly one surface. A finished fit
    carries over to further Module of the same object. A magnifier on both sides, so you hit the
    corner of the house and not the roof beside it.

  • A twin is the thing itself, not a picture of it. Settled over three field rounds
    (29./30.08.) and now doctrine: a mirrored object is fully interaction- and
    presentation-equivalent
    to its original, and the only difference left is which surface stores
    it. Interaction: a tap opens the source's own editor in place («Zum Original» is a door, not a
    detour), a drag moves the one source object through the fit, a selected mirrored drawing wears
    the native vertex vocabulary of the surface it is standing on – grips, «+» midpoints,
    hold-to-delete, Verlängern – and a mirrored Truppmarker carries the same pill and action bar the
    map's own marker wears (rename, the Atemschutz-Trupp join menu, Farbe, Position markieren, Spur
    löschen, «Im Atemschutz zeigen», plus «Auf Karte zeigen» as its one extra door). Presentation: no
    twin-only tone, no dimmed grips, no separate size band – map twins use the native symbol band,
    sheet twins the sheet's own, and a Feuer's Ausbreitung arrows point where the source draws them
    instead of being re-aimed through the fit. A mirrored Leitung keeps its whole FKS voice:
    arrowhead, Teilstück-Gabel, S/W/H/P letter, marker rhythm, Leitungs-Nr tag, Länge and
    Absperrkreis radius, measured on the source geodesics so it reads its length even on an
    uncalibrated sheet. A mirrored symbol carries its Geschoss badge and its Entwicklung arrows, and a
    composite – a Grosslüfter, a Drehleiter – stacks its part again instead of arriving as a bare
    vehicle body. Generic Trupp chips are numbered once across both pictures while the
    surfaces are linked, so a Karte and a Modul no longer both start at «Trupp 1». ⚠️ Three
    mechanical residues are deliberate: an endpoint anchored to a Leitung blocks the whole-object
    drag (its grip detaches and reshapes instead), a shared GPS position stays inert (a self-report
    is nobody's to move), and the Hubretter boom stays source-only – the projection has no honest
    length for metre-scaled geometry.

  • The printed Objektplan shows what the screen's sheet shows. Draw the whole Lage on the
    Karte, watch it standing on the linked Modul – and the PDF attached nothing, because the sheet
    itself carried no stroke and the plan pages printed only sheet-native marks. The mirrored content
    (symbols, drawings, Notizen, shapes, Trupp chips with their recorded trails) is projected into
    printable pages under the same visibility gates the screen's mirror uses, and the preflight
    checkbox seeds on for a plan whose only marks are mirrored. Live vehicles and shared responder
    positions stay off the paper: they are moments, not records.

  • FireHub (Tercero) alarms, without a second integration to learn. A station on FireHub now
    points its «Einsatzstart» and «Einsatzende» webhooks at POST /api/firehub/webhook and gets the
    same result every other intake path gives: a start auto-opens the incident, an end stamps the
    Einsatzende
    on its Rapport (reportMeta.endedAt ?? closed_at, so an operator-entered time still
    wins) without closing the card – retiring the Einsatz stays the operator's call. It is a payload
    adapter over the existing provider-neutral intake, not a new pipeline: no server-side key, no DB
    migration, deduped on the stable opsID (never the volatile opsNumber), and authenticated
    with the same alarm_webhook_secret as the generic webhook – put in the URL as ?secret=…, because
    FireHub's schema and headers are fixed and cannot carry a custom auth header. The address is composed
    from street + city (a payload field Tercero is adding, degrading to street-only until it ships);
    FireHub sends no coordinates yet, so the pin is geocoded. Listed in the capability registry
    (GET /api/system › integrations) as a discoverable dispatch-system choice. See
    docs/ALARM-INTEGRATIONS.md.

  • The Meldeleiste – one strip, one ranking, everything visible. Nine banners used to compete
    for the same screen edges with nothing but z-index deciding who could be read: the worst realistic
    stack on an iPad in landscape covered the due Wiedervorlage – the one message whose whole doctrine
    is «stays up until handled» – with the one that could have waited. They are one strip under the top
    bar now. Every pending message is a row, ranked by class before time (Atemschutz-Alarm → Alarm →
    Wiedervorlage → GPS → Prüfen → Tab-Sperre → Update → Installieren), and with nothing pending the
    strip is not in the DOM at all. A first cut kept the rest behind a +n disclosure; measured against
    real use the strip holds zero or one message almost always, so the queue went the same week –
    alles offen, nothing privileged, no «which one did I just act on». Row bodies are inert – only
    labelled buttons act, plus the permanently underlined title of a message that has somewhere to go
    (permanent, not hover-only: this runs on an iPad). «Nur ansehen» left the layer to become a mode
    chip beside the Einsatzname, and the intake review shrank to a «Passt / Bearbeiten» row – one that
    yields while an alarm is pending, because confirming one Einsatz's data while looking at another's
    is a nonsense question.

  • An Atemschutz-Alarm that sounds now says what it is for. The escalating tone, the wake lock and the
    OS notification always fired app-wide, but away from the board their cause was a small chip and a
    dot – a noise the operator had to investigate rather than act on, and since low pressure joined
    the tier, genuinely ambiguous. Each Trupp in alarm now has its own Meldeleiste row at rank 1 –
    above the dispatch, because a fresh alarm can wait twenty seconds and a Trupp out of air cannot –
    naming which Trupp and why: «Atemschutz überfällig» or «Alarmdruck erreicht», with bar and limit.
    The tier is read from the same fold that plays the tone, so «tone implies row» holds by
    construction. No ✕ (an overdue Trupp is ended by a Funkkontakt or a Druckmeldung, not a tap), and
    deliberately not muted by the bell – this row is exactly what must stay readable after the bell is
    pressed. The row names the whole crew («Atemschutz überfällig – Meier A. · Keller B. · Weber
    C.»), because at 3am the question is who exactly is overdue and the board is one tap away, not
    zero; the OS notification keeps the short name, a tray line has no room to spend. «Zum Trupp»
    takes the row down along with the tone and lands on the board with the card itself ringing – the
    strip was covering the mute bell and the controls needed next – and the row comes back the moment
    a new emergency starts, the tier clearing and re-crossing or the reason changing. The TopBar
    chip no longer waits for red either: it speaks from «Kontakt fällig» on, amber and quiet, with the
    same ticking clock. ⚠️ Found on the way: a pressure alarm posted «Kontakt herstellen» as its OS
    notification. Air does not come back on the radio; title and body follow the reason now.

  • The Trupp card answers taps, not only buttons. Druck is a tap zone straight into the pending
    Druckmeldung – an always-inline ±, because a Druckmeldung must never cost an opening tap – and
    only «Bestätigen» commits, appearing once the value is dirty (an unchanged reading is what
    Funkkontakt is for). The Kontakt clock folds its secondary times away by default while countdown
    and alarm colours stay visible, and a Verlauf footer previews the latest event («zuletzt: 16:21
    Draussen») and expands the per-Trupp log in place. Tap zones only ever open things – no tap can
    log – and the action bar stays as the redundant explicit path. The board grid stops stretching
    rows, so an opened Zeiten- or Verlauf-fold grows only its own card.

  • Offline-Vorbereitung – the device packs its own bags. «Alles für offline laden» relied on
    somebody remembering it before losing coverage. T...

Read more

v0.8.0

Choose a tag to compare

@github-actions github-actions released this 22 Aug 19:17

Image: ghcr.io/feuerwehr-oberwil/kp-front:0.8.0
· Update: docker compose pull && docker compose up -d
· New deployment: docs/DEPLOYMENT.md

Migrations run automatically on boot, after an automatic pre-migration dump.


Added

  • Eine Ausbreitung zeigt in vier Richtungen, und jede trägt ihre eigene Grenze. A fire running
    along a façade to both sides and stopped at only one Brandmauer could not be drawn: horizontally
    the symbol knew 'E' | 'W' – either/or – and a boundary belonged to the AXIS rather than to the
    arrow, so one vBounded put a bar on both vertical arrows whether it was true or not. All four
    directions are independent now and each carries its own bar, drawn across it the way the symbol
    prints it. The boundary is never locked: tapping it on an arrow that is off means «dorthin, und
    dort gestoppt» and switches the direction on, because the ordinary case must not cost two taps.
    ⚠️ The old shape sits in every Einsatz that ever had an Entwicklung, running and archived, and
    the workspace blob is not migrated – a rapport from 2025 still prints from it years later.
    Nothing reads the fields raw; everything goes through normalizeSpread, and kroki.py's
    _spread_dirs mirrors it line for line so paper and screen cannot drift apart.

  • «Entfällt» ist eine Antwort – auf dem Schirm und auf dem Papier. A Fehlalarm has no
    Kontaktperson and an Ölspur is never reported back to the ELZ, and neither step had a way of
    saying so: the rapport of a routine Einsatz could not reach complete, so «Angaben fehlen noch»
    stood in front of every print until it was being tapped away unread. Both fields now take the
    same escape the Mittel step has had all along – a recorded «Entfällt» that satisfies the step and
    is logged in the Verlauf as the deliberate answer it is. On paper it prints as – in the value
    column, where a field nobody answered keeps its empty write-in rule: «gibt es nicht» and
    «vergessen» stay two different statements on a sheet that gets signed.

Changed

  • Jedes «Einsatz abschliessen» geht durch dieselbe Tür. The Einsatz-Menü row and «Alle
    Einsätze» archived plainly – no report_done_at, none of the seven steps checked – while the
    identically labelled Rapport path stamped and counted. Both doors run the one counting confirm
    now and end in completeRapport; the menu row shows the open-point badge before the press, the
    confirm button says «Trotzdem abschliessen» when points are open, and a failed close or reopen is
    reported instead of swallowed. One word pair for the lifecycle throughout: abschliessen / wieder
    öffnen.

  • Der Ziehgriff der Leisten geht mit den Wörtern, nicht mit dem Eingabegerät. The decision was
    already made and only half carried out: with «Beschriftung · Wörter» on, the expand chevron is not
    rendered at all, because there is nothing left to expand once the word stands under the sign. The
    drag handle was the remainder the same thought had missed. It is gone whenever the words are on,
    on every device – and it stays everywhere when they are off, because then it is the only way to
    see a label at all. ⚠️ The earlier condition was pointer: coarse, which was both too wide and
    too narrow: it left the handle on the desktop and would have missed an iPad on a trackpad, which
    reports pointer: fine and is exactly the device the rule was written for.

Fixed

  • «Beschriftung · Wörter» erreichte die Leiste auf dem Plan nicht. The setting promises the word
    under every sign in both rails and kept that promise on the Lage only: the Whiteboard renders the
    same <ToolRail> – its own comment says so – but never passed it labels. One setting, two rails,
    and one of them was not listening. ⚠️ Threaded through as a prop rather than a second
    useDevicePrefs() in the Whiteboard: that hook keeps its own state per call site, so a second call
    would have made a copy that goes stale the moment the setting is switched.

  • Die Fussknöpfe der Leisten schnitten ihre Wörter ab, die Werkzeuge darüber nicht – same frame,
    same font. The browser's own padding: 1px 6px on a <button>: .vrail-tool zeroes it,
    .vrail-nbtn never did, so twelve pixels that stand in no rule of ours left a foot button 64 px
    for a label where a tool had 76. That is why «Absperrkreis» (75 px) and «Vergrössern» (70 px) did
    not fit. Measured in the built bundle, in Sora, against the real rail: all 23 labels of both rails
    fit now, and it needed no shorter words – the established terms stay.

  • Ein Druckauftrag in der Warteschlange ist kein gedruckter Rapport. «Ausdrucken» stamped
    reportMadeAt the instant the job left the device – including right after the dialog that had just
    said the printer was offline – so the app offered to close an Einsatz whose rapport existed on no
    sheet of paper anywhere. The open job lives on the workspace now and shows as an amber band under
    the Rapport head, polled every 15 s, and the stamp waits for the relay to say done. A relay 404
    is its own answer («swept after 7 days, outcome unknown» – cleared without stamping), and cancel
    distinguishes cancelled / late / gone / unreachable instead of dressing a network failure up as
    «zu spät».

  • Die Glocke im Atemschutz verspricht nur, was sie halten kann. Three honest states instead of an
    action label, including a silent «Ton nicht freigegeben» whose tap retries the unlock: the tone
    needs an AudioContext the browser only releases inside a gesture, and nothing guaranteed one. The
    unlock now rides the first touch of every Einsatz rather than the Trupp form alone. The mute
    finally covers both channels – the OS kept posting «Atemschutz überfällig» every 30 s past a
    bell that said off – and it is scoped to one Einsatz per device, so a tablet muted at a drill in
    February is armed again for the next real alarm.

  • Der Kachel-Download zählte Versuche, nicht Treffer. The progress bar reaches 100 % whatever
    happens – it counts attempts finished, and must, or a dead host would hang it – so «fertig» said
    nothing about «geklappt»: on dead WLAN in the Magazin it toasted a green «Karte offline verfügbar
    (0 Kacheln)». The result buckets stored / notFound / failed now, a 404 counting as final (no
    coverage at a layer's edge is not a miss that «Weiterladen» could ever fill), and four outcomes get
    four messages – green only when everything fetchable arrived.

  • Die Beschriftung einer Leitung stand auf der Leitung. The boxed end tag («1 · S · +2 · Müller
    H.») was drawn 72 % along the last segment, centred on the line, and its box is opaque – so on
    paper it covered the last quarter of the very Leitung it names, the line appeared to stop dead at
    the tag, and the Teilstück fork past it read as a second, unattached mark. It is pushed clear along
    the segment's normal now, by the box's own reach in that direction plus half the stroke, on the
    side away from the line's centre of mass – so a hose that loops back is labelled outside its bend –
    and clamped inside the sheet, because a tag that is cut off says nothing.

  • Der Kroki-Ausschnitt zeigt jetzt, was das Blatt zeigt. Four ways the preview disagreed with the
    paper it previews. The words rendered at plain screen size while the symbols beside them were
    scaled, so in Hoch they came out about 1.7× too big; on top of that a second print reference
    for Hoch inflated everything scaled by a further 1.6×, on the reasoning that the server sizes
    symbols in absolute pixels on an orientation-dependent canvas – it does not, it scales every rule
    by width / 1050 for both shapes. The tag stacked onto two rows where kroki.py joins every part
    onto one. And it was anchored at a hand-dragged endLabelAt, a field no payload the server sees
    ever carries, so the crop was showing a position that cannot come out. ⚠️ Consequence worth
    knowing: a tag you drag on the Lage does not move on the paper – making the sheet follow the
    hand is a payload and server change, not a preview one. The Teilstück fork is scaled as a whole
    instead of being handed a smaller width (forkDims floors the spine at 14 px, so a scaled-down
    width simply hit the floor and left a full-size comb on a hairline), and its bearing stops reading
    the raw last segment – a hose drawn with a finger routinely ends in a 3 px vertex that carries no
    direction.

  • Der Lade-Ping lag über der Arbeit. The in-workspace splash stands in for the map, and it was a
    transparent position: fixed sheet above everything: while the tiles and the symbol pack loaded,
    the brand pulse and the station wordmark were painted straight across the Rapport, the rails and
    the panels – all perfectly usable – with nothing behind them to say what they belonged to. A
    working screen looked broken. It sits at the map's own layer now, and only the stalled state comes
    forward, because at that point it carries a «Neu laden» button and an action behind an open dialog
    is not an action.

v0.7.0

Choose a tag to compare

@github-actions github-actions released this 21 Aug 21:10

Image: ghcr.io/feuerwehr-oberwil/kp-front:0.7.0
· Update: docker compose pull && docker compose up -d
· New deployment: docs/DEPLOYMENT.md

Migrations run automatically on boot, after an automatic pre-migration dump.


Added

  • A Schema in a Checkliste opens as a picture, and it is the viewer every other picture uses.
    A Kommandoakten page scaled into a 14px reading column is a picture of a diagram, not one that
    can be read, and looking harder was the only option. The preview is a button with a magnifier
    now; it opens full-screen, starts fitted, and takes the same gestures as a Verlauf photo –
    pinch, wheel, drag, double-tap onto the point under the finger, panning bounded by the image's
    own overhang. On a wide screen the reader runs in two columns, so a figure or a wide table now
    spans both, the way a plate sits in a printed page instead of in half a column. ⚠️ The viewer
    offers no «herunterladen» for a Schema: the document belongs to the BGV and is one tap from
    its source, and a copy pulled out of the app goes stale silently while the page in the app is
    kept current. The button is switched off for this one caller (download: false), not removed –
    for a Verlauf photo, a Rapport-Beilage and an Objektfoto, getting the file out is the point.

  • The landing page answers «and what if we run both?» before the mail arrives. The KP-Rück
    section named the sister app and stopped there, so the obvious next question – how much is set
    up twice – was answered only by a document in the other repository. Five rows now say what is
    shared (one alarm payload, one Divera key, one print agent in the Magazin, one roster file, one
    Stichwort list) and one line says what deliberately stays apart: database, updates and login.
    Neither app needs the other to run, and the page says that in the same breath, so nobody reads
    «works together» as «depends on». All four languages, from the same content/*.json.

  • The Telefonliste's numbers stand under each other, and a step reads as a step. The list is
    twelve separate tables, one per heading, and each one sized its columns to its own longest line
    – so the number sat somewhere else in every block and the eye had to find it again on every
    scroll. A fixed column width puts all of them on one line. In the same reading surface, a
    bullet at level 0 is a STEP and one at level 1 is a detail of it; they differed by 16px of
    indent and nothing else, so a screenful read as a flat wall. The step keeps full ink and a
    filled dot, the detail is damped and gets a hollow one.

  • The Wehr's own paperwork sits on the Rapport, pre-filled. Every station has forms that live
    outside this app and still have to be filled in afterwards – a Getränkeabrechnung for the
    Gemeinde, a Schadenmeldung, an internal form. report.links (Verwaltung › Rapport) puts them
    under the Fotos as a list to tick off, and the URL can carry placeholders – {stichwort},
    {ort}, {datum}, {einsatzleiter} and five more – that are filled in from the running
    Einsatz when the link is opened, so a Google Form comes up with Anlass and Datum already in it
    instead of blank. The tick is per-incident and shared across devices, merged per link so two
    people ticking two different forms keep both. Configure none and the section does not exist
    – no empty card explaining a feature a station does not use. It never reaches the paper: the
    rapport is the record, a to-do list of links is not part of it, and neither is it an
    Abschluss-Assistent step that could hold up an archive. Nothing is ever ticked automatically –
    opening a form says nothing about whether it was submitted, so the app asks once, when the
    operator comes back from it. ⚠️ A placeholder sends incident text to whoever hosts the form,
    and the configured links are served by the public config endpoint – both are spelled out in
    docs/CONFIGURATION.md §1d and PRIVACY.md.

  • Filters are one button per surface, and the search line is the same line everywhere.
    Grad, Status and Kategorie were an inline segmented track and a legend strip – two rows of
    chrome above the list you came for, over controls that are barely reached in the field. They
    are dropdown buttons now, at every size, and multi-select: picks inside a facet OR together
    («Of + Wm» is the Kader), facets AND with each other. Ort is deliberately not its own facet –
    only somebody who is here can be «Vor Ort», and as a separate group the two could be combined
    into a contradiction whose only honest answer is an empty list.

  • Einsätze nobody ever came back to are swept up. Closing an Einsatz is a deliberate act, and
    somebody who does not know the act exists never performs it, so incidents that were worked on
    and then left open accumulated forever. alarms.staleIncidentDays is a second, much longer
    clock for exactly those, kept apart from autoArchiveDays, which only ever swept alarm noise
    nobody had recorded anything on. Both archive reversibly, neither stamps report_done_at –
    the Rapport was not finished and the record must never claim it was – and each writes its own
    Verlauf row naming the clock that ran out. An Einsatz that disappears off the list without a
    word is the failure mode this sweep must not have.

  • The landing page speaks English and Italian, laid over the German base the same way French
    is. Both carry the same visible line French does: no firefighter who speaks the language has
    read them yet. Terminology follows the national bodies rather than a dictionary, and the app's
    own glossary rather than a translator's – SCBA, not «breathing apparatus».

  • The Wiedergabe shows what was written. The Verlauf now runs under the scrubber as a lane of
    ticks, and the entry the playhead stands in reads along the bottom of the bar like a subtitle,
    narrating the Einsatz while it plays. «im Verlauf» opens the Verlauf landed on that row, and –
    the other direction – every row in the Verlauf sets the moment during a Wiedergabe, so the
    whole picture reads as it did when the line was written. Rows the playhead has not reached are
    dimmed: they had not been written yet.

  • The station's lists are one Excel file you edit and give back. Verwaltung › Daten ›
    Arbeitsmappe downloads the Wehr's Mannschaft, Dienstgrade, Fahrzeuge, Mittel,
    Mittel-Bestände, Quellen, Partnerorganisationen and Symbolfeld-Optionen as one .xlsx,
    editable in Excel, Numbers or LibreOffice and uploaded back. The Mittel-Katalog and the
    Dienstgrade in particular had no browser route at all. Before anything is written the
    screen says what would happen
    – per sheet: how many rows are new, how many change, and what
    would be deactivated (people, never deleted – Einsätze resolve their names through those
    rows) or removed (list entries), named rather than counted, plus every refused row with its
    sheet and row number so it can be found in the operator's own file. Cancelling writes nothing.
    There is no «Ersetzen» mode: a row's absence from a sheet that is present is the only way
    anything goes away, and a sheet the file does not carry is not touched at all – a workbook
    with no Fahrzeuge tab leaves the fleet alone, while a Fahrzeuge tab holding only its
    header clears it, on purpose, after saying so. The file is parsed on the server and only the
    key paths it has sheets for are rewritten, so nothing else in the configuration can ride along
    and the write stays undoable through «Letzte Änderungen». Ids are treated as the join keys
    they are: a Kennung Excel turned into a date or a number is refused with the cell quoted back
    rather than silently rewritten, formulas never import as their own text, and
    mittel.catalogue[].when – a rule no spreadsheet column can express – is carried over
    untouched. ⚠️ It is not a backup: it covers the list-shaped data only, and the page says
    so. Name, Sprache, Karte, Doktrin, Alarmierung, Logos, Objektpläne, Kartenebenen and die
    eigenen Formulare stay with the Sicherung's JSON export.

  • An entry can stay open until somebody ticks it off. The BGV form «AUFTRÄGE / PENDENZEN»
    wants Was · Wer · Prio · Erteilt · Erledigt, and three of those columns already fell out of the
    append-only Verlauf – what was missing was a way to say a line is not finished. The ring
    beside Info · Auftrag · Sofortmassnahme says it, and its menu is the whole model in one list: a
    new open item, an urgent one, or a Meldung on one already open. Open items sit at the top of
    the Verlauf, dringend first then oldest, each carrying its own thread; a Meldung is written in
    the ordinary composer, pre-linked, which is why it can be a Sprachnotiz or a photo without a
    line of new code. «Wer» is read off the sentence rather than asked for – the composer already
    marks known names, and a Trupp is titled by its Gruppenführer. On the Rapport the section prints
    after the Verlauf it comes from, with «offen» where nobody ticked the item off: the one thing
    somebody has to take away from the Einsatz. ⚠️ Tracking hangs off the lifecycle event, never off
    entryType: 'auftrag' – keying it to the tag would have turned every Auftrag row already
    written, live incidents and archive alike, into an eternally open Pendenz nobody can tick off.
    Old rows stay plain text; there is no migration.

  • A journal entry can say when it has to come back. The composer used to ask first which KIND
    of row you were writing – «Eintrag» or «Erinnerung» – and that answer took everything the
    ordinary sheet has away from the reminder: Art, Foto, Sprachnotiz, the ring, the link to an open
    Pendenz. «Auftrag erteilt» and «um 22:10 nachfassen» had to be two rows about one thing. A due
    time is a property of an entry now, like the ring beside it: a clock in the meta...

Read more

v0.6.0

Choose a tag to compare

@github-actions github-actions released this 12 Aug 10:14

Image: ghcr.io/feuerwehr-oberwil/kp-front:0.6.0
· Update: docker compose pull && docker compose up -d
· New deployment: docs/DEPLOYMENT.md

Migrations run automatically on boot, after an automatic pre-migration dump.


Added

  • The landing page speaks French, and it is generated rather than written twice.
    site/index.html used to be the page; it is now the output of site/index.template.html
    plus one text file per language (site/content/de.json, fr.json). German is the base and
    every other language is laid over it – the same overlay mechanism as src/config/copy/ – so a
    translation writes only what it translates, a gap falls back to German visibly, and
    build.mjs prints the coverage after every run. A third language is one entry in
    content/config.json and one file in content/; the template does not change. The switcher is
    two plain text links (no flags, no dropdown, no cookie, and deliberately no
    Accept-Language redirect
    – a German-speaking firefighter sent to /fr/ by a browser
    setting is worse than a switcher he can see), with hreflang alternates both ways and a
    per-language canonical. The duplicate-per-language alternative was cheaper today and drifts
    on every design change – and landing.css is byte-identical across kp-front and kp-rueck, so
    drift would cost twice.
    ⚠️ The built pages are committed, because GitHub Pages serves site/ verbatim: the page in
    the repo is the page on the web. node site/build.mjs --check runs in CI so a stale build
    fails loudly instead of silently serving yesterday's text.
    ⚠️ The French page carries a visible line saying no French-speaking firefighter has read it
    yet.
    It is not decoration – it comes off when somebody has. Screenshots stay German on every
    language: they come from a real instance, and restaged ones would be a claim rather than a
    proof. The page says that too.

  • The landing page names what shipped since it was last written – Zeitreise (replaying an
    incident minute by minute, waits skipped), Standort teilen, a Schlauchleitung belonging to a
    Trupp, Truppfarben, Partnerorganisationen, photos as Rapport-Beilagen, and the Kroki as of a
    chosen moment. Plus an eighth step in the night's timeline: the debrief, which is the only
    place Zeitreise is actually used.

  • Every name in a Verlaufszeile links itself. The «Von» field is gone – it asked for
    something the sentence already says, and it only ever knew people. In its place one
    vocabulary: crew, Mittel, Partnerorganisationen, Fahrzeuge and Alarmgruppen, matched against
    the line as it is typed, longest match first so «Meier Anna» wins over «Meier». Matches are
    tinted per kind in the composer, in the Verlauf, and – via a markup field on the row – in the
    printed Rapport. Suggestions start at two letters instead of three, but at exactly two only
    a word start counts: two letters of fuzzy subsequence would put half the Mannschaft under every
    «im» somebody types.

  • A locked roster row points at the Trupp that locks it. Tapping somebody who is under PA used
    to open the Atemschutzüberwachung and leave the finding to whoever tapped – on a page of cards
    the Trupp they were sent to was often off-screen, so «why can I not tick this person» was still
    a search. The row carries the Trupp id now: the card scrolls itself into view and flashes a
    ring. The ring is ink, not a status colour – every hue on that card already means something
    (blau angemeldet, grün aktiv, amber Rückzug, rot überfällig), so a blue ring around a blue card
    said «angemeldet» twice and «look here» not at all.

  • A plan entry can pin its own bytes, and the deployment can insist on it.
    objects.manifest.json accepts a sha256 per plan; where it is set,
    admin_objects validate|load|push refuse to publish anything else under that module. That
    half runs in the publishing tree, so it only ever catches a current manifest beside a stale
    PDF – see Fixed below for the half that does not. The half that holds is server-side:
    admin_objects push now declares every plan's digest to the API, PUT /api/objects/{id}/plans/{module}
    verifies any digest it is given on every deployment, and REQUIRE_PLAN_DIGEST makes one
    mandatory for an automated publish – on automatically for the public demo, off for a station
    so an older CLI keeps working. A person picking a PDF in the admin UI is never asked for one:
    they have no tree to be stale. ⚠️ These are wrong-tree checks, not corruption checks.

  • Every search box forgives an umlaut and one typo. lib/search is now the single decision
    about what a typed query finds, and every list that had its own substring test uses it:
    Anwesenheit, both person fields, the Trupp form, the shared Combo, «Standort teilen», the symbol
    palette, the Plan-Picker, the Datenquellen list. Umlauts match in either direction («Mueller»
    finds Müller and «Müller» finds Mueller, because both sides are folded the same way), accents
    fold away («Celine» finds Céline), and one wrong, missing, extra or swapped letter still finds
    the name — from four characters up, and only at a word start. Below that, one edit matches half
    the Mannschaft, which is worse than no match at all.

  • A name typed anywhere is somebody who was there. Typing a name is how a Gast, a Nachbarwehr
    or an AdF whose roster row never synced gets onto an Einsatz — and only two surfaces recorded
    one. Everywhere else the name stopped on the object it was typed onto: a Fahrer on a vehicle, a
    «Stv.» on the Einsatzleiter glyph, the Einsatzleiter on the Rapport. So an Einsatz could be led
    by somebody the Anwesenheit had never heard of, and the Personalblatt printed from it is also
    the Soldblatt. Every person field now files the name under an id — the roster's if it names one
    of ours, a fresh Gast's otherwise — and a Gast gets the job («Fahrer TLF») in the same act, on
    one row, in one Verlaufszeile.
    ⚠️ The free-type escape in a dropdown commits when the field is LEFT, not per keystroke.
    «Muster Felix» typed letter by letter would otherwise have put thirteen people on the list.

  • «Trupp finden» — one list of every Trupp standing anywhere. Lage markers and plan chips,
    Atemschutz or not; tapping a row goes there. The Atemschutz card could already jump to its own
    Trupp, but only to that one: a team marker dropped straight onto the Lage, or a chip on a
    Gebäude storey, was reachable only by remembering which surface it went onto and then finding it
    among the symbols. It searches the PEOPLE in a Trupp too — you know who went in, not which
    number they were given — and lives on the Trupp tool's own dock, because reaching for that tool
    is already the gesture for «etwas mit einem Trupp».

  • The Einsatzleiter symbol is a pair, with a handover. Its two rows read «EL» and «Stv. EL»
    instead of «Name» and «Stv.» — the value on one row and the job on the other never read as the
    two halves of one job — and a ⇄ between them swaps the two in one commit, which re-files both
    people through the same path a normal edit takes. The stored keys are unchanged, so the Kroki,
    the map caption and every Bemerkung keep reading exactly as they did.

  • Every replaced station config is kept, and can be put back. Each write to
    deployment_config now stores the document it replaces, with when and by what (api / cli /
    branding); admin_config history lists them and restore <id> puts one back — and the restore
    is kept too, so stepping back is reversible. This is the part that matters for a real station:
    the demo can be repaired by re-running its reset, and Oberwil cannot — there is no seed file to
    rebuild a station from, and until now a bad write was simply permanent. admin_config load
    additionally exits 2 rather than emptying a section that currently has content.

  • An archived Einsatz can be deleted — by an admin, from the Verwaltung. A real Einsatz was
    undeletable by anybody, so a duplicate created when two people took the same alarm sat in the
    Verlauf and in the statistics forever. Two doors, because they answer different questions:
    an Übung is deletable by any editor at any time (it exists to be thrown away), a real
    Einsatz
    takes an admin session and has to be archived first.
    ⚠️ Archived is the gate, not a nicety: it is the operator saying the Einsatz is over, and the
    only moment at which «löschen» is a decision rather than an accident. Deleting one destroys an
    Einsatzakte — Verlauf, hash-chained Prüfkette, Anwesenheit, photos, voice memos.

  • POST /api/alarms accepts KP Rück's payload. The endpoint exists in both apps with the same
    path and the same purpose and took incompatible bodies: source_id was required here and
    optional there, so a relay written against KP Rück got a 422. It is optional here too now —
    without it there is nothing to dedupe on, so a redelivery creates a second incident, which is
    the sender's trade to make. number is declared and ignored (an Einsatz here has no field for
    it), so it appears in the OpenAPI contract instead of being dropped silently.

  • A symbol knows which material it is, where it came from, and says so where you look. The
    symbol→Mittel capture was a guess delivered in a toast: missed constantly, and gone for good
    once it timed out. It is a row in the symbol's own detail panel now — it cannot time out, and it
    is still there ten minutes later when you remember. A catalogue entry may name the variant it
    is (when: {Typ: "Exhauster"}, or a LIST of clauses, any one of which matching is enough),
    because one symbol is routinely several materials: a station has Lüfter, Hochleistungslüfter and
    Exhauster and exactly one «VKF Luefter mobil» to place. A Lüfter set to saugen is an
    Exhau...

Read more

v0.5.0

Choose a tag to compare

@github-actions github-actions released this 08 Aug 14:40

Image: ghcr.io/feuerwehr-oberwil/kp-front:0.5.0
· Update: docker compose pull && docker compose up -d
· New deployment: docs/DEPLOYMENT.md

Migrations run automatically on boot, after an automatic pre-migration dump.


Added

  • The rapport prints the number WinFAP actually joins on. «Einsatz-Nr» sits in the details
    box beside the Alarmierung: the alerting system's own reference for the alarm, short form (its
    first four hex) first because that is what gets typed, the full reference behind it so the slip
    in the hand and the sheet on the desk can be checked against each other. It is the same value
    /api/stats/incidents exports as alarm_ref, resolved server-side in
    compose_report_from_payload rather than sent by the client, so a rapport printed from a stale
    tab cannot put a different number on paper from the one the exporter sends. A missing reference
    prints no row rather than an empty label, because an empty label invites a guess. The
    «Einsatz-ID» left the footer with it – this app's own incident UUID joins nothing, and two
    number-ish things on one sheet is how the wrong one ends up in the record system. ⚠️ It is an
    address key, not an incident key: 52.9 % of Einsätze share it with another, so it removes the
    address disagreement from the ±3 h join – it does not replace the join. See
    docs/STATS-EXPORT.md.

  • A half-typed entry survives leaving the surface. Every «… erfassen» form lives on a
    surface, and surfaces unmount when another is chosen in the rail – start recording a Mittel,
    the radio goes, jump to the Verlauf, come back, and the half-filled form was gone with nothing
    said. Drafts are kept outside the component that owns them, dropped on submit but not on
    cancel («weg» and «ich mache gleich weiter» look identical from inside a form, and losing the
    entry is the more expensive mistake), and dropped wholesale when the incident changes so the
    next Einsatz can never be handed the previous one's entry.

  • A measured segment carries a «+» in its middle. The Plan has had one per segment for as
    long as it has been able to measure; the Lage map only let a point be inserted by tapping the
    line itself, which is a 2.5px dashed stroke to aim a gloved finger at. Both surfaces now offer
    the same handle in the same place, on the same segments – an area's closing edge included – so
    a node lands between two others without the aim. Tapping the line still works and inserts at
    exactly the same index; the two routes read one rule rather than each keeping its own copy.

    The dock's «Punkt in der Mitte setzen» went with it. It solved the same aiming problem by
    moving the map under a fixed centre, but it could only ever APPEND – the correction actually
    needed mid-measurement is a point between two others, and a dock button that looks like it
    places a point but never puts one where you are looking is the worse kind of almost-right.

Changed

  • app.css is a manifest, not a stylesheet. It had reached 5'480 lines — 56 % larger than
    when it was first written down as the biggest obstacle for anyone working in this repo — and
    the global styles now live in src/styles/NN-*.css: tokens, base, map, one file per surface.
    Nothing about the rendered page changed, and that is checked rather than asserted: every line
    lands in exactly one part, in order, and the emitted CSS bundle came out identical down to its
    content hash (the same hash the running demo was already serving). The numbering is the
    cascade — 20-touch-floors.css is last because its (pointer: coarse) targets have to beat
    every surface above it, so a new block goes where it belongs and gets renumbered, rather than
    being appended for tidiness. For self-hosters this changes nothing; for anyone editing the app
    it is the difference between searching one 5'000-line file and opening the right one.

    The same treatment started on Whiteboard.tsx, which had grown the other way (1'650 → 2'516):
    Plan-Maßstab and Messen moved out whole into usePlanMeasure, together with the two prompt
    surfaces that were sitting 1'800 lines away from the state they read. The rest of that file is
    still one component and still too big.

  • The Einsatzrapport is a surface, not a sheet. It sits in the left rail under Material (key
    R) and behaves like Anwesenheit and Mittel: the rail stays put and the page changes under it.
    It is filled in ACROSS an Einsatz – a sentence here, a time there, jump to Anwesenheit because
    somebody arrived, come back – which a modal was the wrong shape for, and choosing the Kroki
    crop used to put the operator two dialogs deep on the one screen that has to stay legible at
    3am. From 1080px up it splits into the form you TYPE on the left and the round-up you TICK OFF
    on the right; the DOM order is untouched, because it is the printed order and tests pin it.
    There is no ✕ and no «Abbrechen» – a page is left by choosing another surface.

  • «Abschnitte» moved onto the print button. What goes on the paper is several decisions in a
    row, and a dialog to open, tick and close for each one was the long way round something done
    while looking at the button that prints. They are checkbox rows in the menu on the ▾ half of
    «Einsatzrapport (PDF)» now, under a heading that names them, and the menu stays open as they
    are flipped. Printing prints; the picker is there for the rare sheet that leaves something out.

  • The readiness chip appears only when something is wrong. A green «Alles bereit» spent a
    control on the most contested row of the surface to announce that nothing had happened, and
    taught the eye to skip exactly the spot a warning appears in. What is still missing is named as
    chips under the title instead – and «noch offen» now includes the Rückmeldung ELZ, which needs
    both halves: a name with no time does not say when it was given, a time with no name does not
    say who gave it.

  • The surface shortcuts are the first letter of the German word. K Karte · C Checkliste ·
    A Atemschutz · P Personal · M Material · R Rapport. The old set had grown one letter at
    a time – H for Checkliste, W for Anwesenheit, I for Mittel – and not one of the three
    could be derived from anything. Taking C/P/M displaced three tools, which follow the same
    rule rather than landing on whatever was free: Lasso → W (Wählen), Absperrkreis → U
    (Umkreis, which is what it draws), Koordinaten → X. Surfaces resolve before tools, so a
    letter claimed for a surface silently swallows a tool – the three moved in the same change.

  • The Einsatzobjekt sits with its plans. It decides WHICH plans load, so it belongs on the
    Plan surface rather than in the incident menu. The chip joined the Maßstab in the stage's
    bottom-left corner and reads the address: «Mühlemattstrasse 8» is shorter than «Schloss
    Bottmingen» and is what an Einsatz is actually called by. The name stays the label in the
    picker, which is where an object is searched for.

  • Overlays are one design system. The confirm card and the Atemschutz Trupp modal were flat
    --surface with their own radius while seventeen sheets are --glass at --r-hero, so a
    confirm opening over a sheet was visibly a different colour of card. .btn – the dense
    map-dock button – was being used inside dialogs, where .ip-btn is the house family. And
    --mono, which is for figures, had spread to micro-labels, so one sheet set every label in
    Spline Sans Mono while the dialog beside it set the same label in Sora. Destructive confirms
    take the outline danger rather than a solid red fill, which is what every other delete in the
    app wears. Every «… erfassen» is a modal, including Mittel: one that behaved differently was
    worse than either pattern applied everywhere.

  • The demo seed is the demo as it was arranged, not as it was first written. The command
    picture had been rearranged on the live demo and would have been thrown away at the next
    nightly reset: five symbols moved (the Rettung onto floor 1, the Lüfter turned 111° → 23°), the
    Angriffsleitung reshaped, and the two Atemschutz-Trupps' Aufträge swapped — Trupp 1 löscht, Trupp
    2 rettet, with the Ziele shortened to what actually fits a card («2. OG», «Rettung 2OG») and
    Trupp 2 left without a Leitungs-Nummer, so the demo shows both sides of the hose↔Trupp link at
    once. The scene keys were captured into incident.workspace.json; the Trupps had to go into
    build_demo_workspace instead, because their clocks are rebuilt relative to each reset and a
    frozen timestamp in the data file is exactly what that code exists to avoid.

  • Messen works on the demo's plans without calibrating first. The reset now seeds a STATION
    plan calibration (deployment_config.plan_scales_json) — Modul 1 against a 100 m reference,
    plus the generated Gebäude floor-stack. Station-level rather than a planScale in the scene
    file on purpose: a per-incident calibration overrides the station one, so seeding it in the
    scene would have left every incident except the seeded one uncalibrated. admin_config load
    only writes config_json, so the reset script's step 2 cannot wipe what step 1 puts there.

Removed

  • The demo's Modul 6 is gone, and the plan generator with it. The Schloss ships a hand-drawn
    Modul 1 and Modul 2-3; Modul 6 was the last generated placeholder and never got a drawn
    replacement, so the demo now shows two real sheets instead of two real ones and a filler. The
    modul6 slot itself is untouched – it is a module preset every station can fill, and the app
    still places a Trupp there when an object has no building footprint; this object simply has no
    sheet for it. gen_plans.py went with it: once Modul 6 was out, the only thing the script could
    ...
Read more

v0.4.0

Choose a tag to compare

@github-actions github-actions released this 01 Aug 10:43

Image: ghcr.io/feuerwehr-oberwil/kp-front:0.4.0
· Update: docker compose pull && docker compose up -d
· New deployment: docs/DEPLOYMENT.md

Migrations run automatically on boot, after an automatic pre-migration dump.


Two threads. A review pass before publishing the repository more widely – every claim in the
documentation checked against the code, three of them promises the code did not keep. And a run of
operational fixes that only a real incident could have produced: the call of 31 July is the reason
several of the entries below exist.

Added

  • incident.created now names the alarm it came from. The webhook said "a Divera incident was
    opened" without saying which one, so a receiver holding something back for one particular alarm
    could only guess. The milestone chain is exactly that case: it holds group and vehicle times that
    KP Front rejects with a 404 until the incident is open there. With source_ref – the Divera alarm
    id it files them under anyway – it can listen for this event and deliver in the same moment
    instead of waiting out its own interval. For a manually opened incident there is no source alarm;
    the field is then null, but present.

Fixed

  • The Rapport for the 31 July incident printed without its alarm times. Groups and vehicles had
    been alerted and the times were complete everywhere they should be – journal, workspace,
    database – and the time grid on the paper was still empty. Two independent causes. The first was
    the rule itself: the field-classification decision made the whole grid disappear as soon as
    anything had been captured digitally, which meant the better the automatic capture worked, the
    less stood on the signed report. The fully automated incident the milestone integration was built
    for produced a sheet with no alarm or dispatch times at all. That decision is reversed – the grid
    always prints, captured values as times, missing ones as __:__ to be completed by hand. The
    second was a lost write: a vehicle that leaves the geofence and reaches the scene moments later
    fires two milestones in one breath, and on 31 July the Pikett officer's "ausgerückt" and "vor Ort"
    were five milliseconds apart. Both journal lines were written, but the second write had read the
    workspace before the first and overwrote it afterwards. That write is now a compare-and-set on
    workspace_rev with a re-read. The Pikett dispatch time from that incident stays missing in
    production
    – editing a real operational record by hand was deliberately not part of the fix.
  • A hanging push notification could block alarm intake. pywebpush passes timeout through to
    requests; unset it is None, meaning unbounded. And notify_new_alarm is awaited inline in
    both the Divera webhook and the generic alarm intake, so a push service that accepted the
    connection and then went quiet hung the alarm – and an alarm that does not arrive is the worst
    failure this system has. Delivery was also serial, so twenty registered devices against a dead
    service cost minutes on the alarm path. Now 10 s per endpoint, fanned out concurrently: one
    timeout in total however many endpoints are dead. Unexpected errors are logged and the
    subscription is kept – unsubscribing on an unknown error would silently retire a working device.
  • A captive portal logged the operator out instead of reporting "unreachable". A 200 with a
    non-JSON body is a hotel or guest wifi answering on the backend's behalf. The unguarded
    JSON.parse raised a SyntaxError rather than an ApiError, so the callers' status === 0
    offline branches never ran: the incident list discarded its cache, and AuthProvider put the user
    back at the login screen with an intact offline cache sitting right behind it.
  • A frozen GPS feed looked exactly like a stationary fleet. useVehicleLayer discarded
    gps.error. When the Traccar feed fails the vehicles correctly stay at their last known position
    – a vehicle that vanishes reads as "abgerückt", not as "feed gone" – but the symbols then looked
    precisely as authoritative as they had a minute earlier, and the FU makes positioning decisions on
    them. After three missed polls (60 s at pollMs=15s) the vehicles carry "GPS · veraltet (n min)"
    and an amber "GPS eingefroren" chip appears in the top bar. Amber rather than red: a frozen
    position is a caveat, red belongs to the overdue Trupp. Vehicles stay non-draggable throughout –
    making them movable would have been a worse bug than the one being fixed.
  • The pre-migration backup had never run – on any instance. The image pulled
    postgresql-client from bookworm, so version 15, while the documented stack runs
    postgres:16-alpine, and pg_dump refuses to dump a server newer than itself. start.sh caught
    the failure, printed one warning line nobody read, and migrated anyway. The first fix pinned the
    client to 16 and repaired the self-hosted path while leaving the more important one open: Railway
    production runs Postgres 18.4. The client is now pinned to the highest server the image will ever
    face (ARG PG_CLIENT_MAJOR), start.sh compares client against server at runtime, and – the
    change that matters – a pending migration with no usable dump now aborts the start instead of
    warning past it. A safety net that fails silently is worse than none, because you plan around it.
    A plain restart with no pending migration never enters the block; deliberate override is
    ALLOW_MIGRATION_WITHOUT_BACKUP=1. Dumps are written to .part and renamed only after gzip -t,
    so a directory of fragments can no longer rotate away the last good backup.
  • The demo-reset guard sat in the CLI; the scheduler did the deleting. reset() drops every
    incident – journal and hash chain with it, through ON DELETE CASCADE – and all personnel. The
    KP_DEMO_RESET=1 check lived in the if __name__ == "__main__" block, so it covered the command
    line only, while scheduler.py imports reset and awaits it directly. The unattended path, the
    one running on a timer, went straight past the check onto whatever database DATABASE_URL named
    at that moment. The module docstring meanwhile stated it could "never be pointed at a real
    station's database by accident". The check now sits inside reset() itself, so every caller is
    covered by construction rather than by remembering.
  • A build from source baked the root .env into the image. .dockerignore listed
    backend/.env, but the Dockerfile does COPY . . and both docker-compose.yml and
    DEPLOYMENT.md tell an operator to put .env in the repository root. SECRET_KEY,
    POSTGRES_PASSWORD, ADMIN_SECRET, DIVERA_ACCESS_KEY, TRACCAR_PASSWORD, VAPID_PRIVATE_KEY
    and STT_API_KEY therefore landed in an image layer, and in any exported build cache.
    Demonstrated rather than assumed: with the old file the .env is present in the built image, with
    the new one it is gone. Only building from source was affected – pulling the published images
    never was.
  • "Wird gedruckt" now shows where the job actually stands. The station-printer toast said "Wird
    gedruckt …" and nothing else, blended visually into the button it sat on, and disappeared
    mid-job. The missing icon was a typo – printJobToast sent icon: 'print' while the sprite only
    knows printer, so the running stage of all things rendered as bare text. The three stages are
    now a chain: completed keeps its tick and steps back, the running one carries the icon, the
    pending one stays visible as a dot. Below 600 px every stage except the running one drops its
    label. The failure case deliberately keeps a sentence rather than the chain, because "Druck
    fehlgeschlagen – Drucker prüfen" is the instruction. The toast also survives navigation now:
    <Overlays/> hung in only one of the capture poster's three return branches, so tapping "back"
    during printing lost the display while the job carried on unseen.
  • The telemetry veto in PRIVACY.md did nothing — twice over. The page tells an operator to
    put KP_TELEMETRY_ENABLED=0 in their compose file and promises it "outranks the admin switch".
    Settings has no env_prefix, so the field bound to TELEMETRY_ENABLED and the documented
    KP_ spelling matched nothing; and docker-compose.yml passed no telemetry variable into the
    container at all, so even the correct name in .env would have done nothing — compose's .env
    is interpolation-only. Consent still defaults to off in the database, so nothing was ever
    transmitted, but a station that had enforced the ban per the documentation had enforced
    nothing. Both halves are fixed and pinned by tests, including one that fails if the compose
    fallbacks ever become blank — blank means "off" to this app, so an innocent-looking
    ${KP_TELEMETRY_ENABLED:-} would silently disable telemetry for every deployment.
  • The browser no longer calls Overpass directly. README.md promised "every external service
    is proxied by the backend (the browser never calls a third party)". The «Umrisse» surface
    POSTed the incident's bounding box straight from the browser to three public Overpass mirrors,
    one of them hosted in Russia — and it is prefetched on every incident open, so this was the
    normal path, not an edge case. It now goes through /api/overpass/buildings; the mirror race
    and its timeouts moved server-side unchanged. OVERPASS_MIRRORS makes the list configurable,
    so a station can point it at its own Overpass or switch the surface off. A test scans the
    frontend for direct third-party fetch() calls so the README claim cannot quietly lapse again.
  • The capture poster could read and rewrite the tactical map. ALARM-INTEGRATIONS.md
    promised the poster token reaches "attendance/material/journal/Einsatzende – no ...
Read more

v0.3.0

Choose a tag to compare

@github-actions github-actions released this 28 Jul 20:00

Image: ghcr.io/feuerwehr-oberwil/kp-front:0.3.0
· Update: docker compose pull && docker compose up -d
· New deployment: docs/DEPLOYMENT.md

Migrations run automatically on boot, after an automatic pre-migration dump.


The Zeitplan release: a long incident is a staffing question, and it finally has a surface.
Around it, a release about getting back out – the app was already hard to crash, but a handful of
states could only be cleared by restarting it, or in one case by resetting the browser – and about
the app looking like one app rather than a dozen surfaces that each decided for themselves.
Everything below has been running in production at Feuerwehr Oberwil.

Added

  • Rückmeldung – the app asks after a mishap, and can now send it. After a crash the launcher
    offers to file a report. It could previously only be copied or mailed; there is now a Senden
    button, and afterwards the sheet shows what the server actually stored – a preview written by
    the sender is a promise, one returned by the receiver is a check.

    Alongside it, and deliberately separate, is a second channel for background crashes, which
    a deployment has to switch on first. The distinction is the whole design: pressing Senden by
    hand is the consent, the same as sending an e-mail. Nobody is watching when a background
    crash fires, so that channel defaults to off – and off means a NULL column, which is what every
    existing installation updates into. It is enabled in the admin area, never on the device:
    the fire service is the controller, not whoever happens to be holding the tablet.

    What leaves the building is built field by field in app/telemetry/scrub.py – nothing is passed
    through or spread, so a field nobody wrote a line for cannot leak. Free text is scrubbed too,
    because the value is usually in the message: paths, e-mails, phone numbers, IPs, coordinates
    (WGS84 and LV95), UUIDs, tokens, street names with house numbers, and the full user agent
    reduced to «iPad Safari» so it can't fingerprint. Every payload is written to the station's own
    log before it is sent and kept verbatim in telemetry_outbox – two copies on your own
    infrastructure, and the admin sheet shows the same table. KP_TELEMETRY_ENABLED=0 overrides
    every switch in the UI. See PRIVACY.md, which also answers the IP question
    honestly, including the part that can't be solved in code.

  • arm64 images. The published image builds for linux/arm64 as well as linux/amd64, so an
    ARM host (Hetzner CAX, Oracle Ampere, a Raspberry Pi) can run it. The Vite stage builds on the
    native build platform, so the multi-arch build doesn't emulate the slow part.
    DEPLOYMENT.md said linux/amd64 for one release longer than that was
    true; it now names both, and says what an older release looks like when you try it on an ARM
    machine (no matching manifest for linux/arm64/v8) and how to run one anyway.

  • Zeitplan – the Schichtenplanung, as a second view of the Anwesenheit. A long incident is not
    a staffing problem you can hold in your head at 04:00, and the question it asks – who is still
    going to be here at six, and how many is that?
    – had no surface. The Anwesenheit answers who is
    here now
    ; this answers who will be, on the same list of people, one tap away on the same
    screen.

    It is a grid of who × when: one row per person, one lane of time each, worked directly the way
    the paper Führungsformular is filled in. A shift carries one of three states, and the distinction
    is the whole point of the surface: verfügbar (they said they could), eingeteilt (you are
    counting on them), and anwesend – which is not a plan at all but the recorded attendance,
    drawn in from the Anwesenheit and read-only here. The Zeitplan never writes attendance. You
    can plan a shift for somebody who never turns up, and the record will keep saying so; ticking
    people in and out stays exactly where it was.

    Underneath runs the Deckung: three step lines counting available, planned and actually
    present across the whole span, so a gap is something you see the shape of rather than something
    you work out. It folds open to the numbers, because the curve says where and only a digit says
    how many. Multi-day incidents get day boundaries and dates on the axis – past midnight, «07:29»
    alone never said which morning.

    It prints as the Führungsformular «Zeitplan» (A4 landscape, monochrome – it is rules and bars,
    and a colour cartridge is a consumable), rendered server-side like the rapport, and a viewer may
    print it: somebody arriving to take over the shift should be able to print the sheet they are
    walking into without an editor PIN.

  • Anwesenheit: somebody who leaves and comes back is two times, not one contradiction. The
    record carried a single checkedInAt/leftAt pair, so a second «anwesend» kept the first
    arrival and the old departure – the person stood there as present and signed off at once, and
    the second stretch existed nowhere at all. Times are a list now; first arrival and last
    departure stay derived from it (the latter simply missing while somebody is back, because they
    are not gone), so Rapport, statistics export and the QR sheet read on unchanged and an entry
    written before this projects its old pair. Nothing to migrate, both shapes stay readable
    forever.

    The words followed the data: «Block» was workshop language that had slipped into the surface –
    on the Platz nobody thinks in blocks – so it is «Erfasste Zeiten», and «Neue Zeit ab jetzt»
    says what the button actually does, which is end the running one and open the next. And the
    time left the row: a tile that read «ab Beginn» took most of the width and squeezed the name to
    «B…», the one thing that list may never do. The row answers who is here, the sheet answers
    since when – and the sheet can show all of a person's times, which the tile never could.

  • One time picker on every device, and it knows which days the incident touches. TimeField
    had two personalities: a wheel popover under a finger, a bare text input at the desk. Everything
    added in recent rounds lived only in the popover branch, so for three rounds one of the two
    implementations was being fixed while the other was the one being tested. The trigger now opens
    the same popover everywhere, and typing is not gone but pulled into it: a text field sits
    above the wheels and carries them as you type, so a keyboard stays a keyboard and gets the same
    days and the same shortcuts as the finger.

    Those days are a third wheel listing only the days the incident actually touches – deliberately
    not a date picker: no month, no year, no 31 rows, and no wheel at all for an incident that ends
    the same evening, so the normal case pays nothing. Before this, which day a HH:MM meant was
    inferred from the old timestamp, and «put this row on Wednesday» could only be said by deleting
    the row and writing a new one. «ab Einsatzbeginn» now carries the time it would set – a
    shortcut without its number is a promise you cannot check before tapping it – and «noch da»
    replaces the wastebasket on «bis», which said the opposite of what it did: emptying a «bis»
    means the person never left, and it is also the way back out of a mistyped «gegangen».

  • Atemschutz: «Wieder einrücken» has a second exit – «Bereitstellen». It knew only one way
    back, which was straight in. The reported case is the other one: fresh cylinder, new order, and
    the Trupp waits as Sicherungstrupp. It used to stand in the incident immediately with the
    contact clock running on a crew that is standing next to the vehicle – a clock that inevitably
    goes overdue without anything having happened. «Bereitstellen» puts the Trupp exactly where a
    freshly registered one lies: angemeldet, no clock. AdF rows can be removed again as well.

  • A line that is already drawn can be measured. Length and Schläuche were reachable only by
    tracing the line a second time with the measuring tool, and the elevation profile not at all –
    it lived solely in the transient measuring path. The line editor carries a «Messung» group:
    length and Schläuche stand there at once, the profile folds open and only then fetches
    swisstopo, so a tap on a line stays silent offline. Plan does the same in its calibrated metres,
    minus the profile – a building plan carries no elevation data.

Fixed

  • A Modul-5 sub-sheet's label fits the plan rail again. The rail read
    «RWA · Migros – modul5-rwa» and ran straight off its 216px edge. Modul 4 and the Modul-5
    sub-sheets have no fixed tile in the catalog, so their label comes from the data — the PDF's
    filename. A station that names its file Wasser.pdf hands over exactly the right word; ours
    carry the object name plus the raw module key, which is neither short nor a name. The filename
    is now taken only when it looks like a sub-sheet name, and otherwise the sub-slot key out of
    the id is used, which is the structural part and always clean: modul5-rwa → «RWA»,
    modul5-wasser → «Wasser». The monogram in the chip is unchanged, so the collapsed rail still
    reads as before. A long label additionally truncates with an ellipsis instead of being sliced
    off mid-word — what stands in that tile is station data, and "it fits today" is not a property
    we control.
  • No more states that only an app restart could clear. A sweep across every state and
    transition that could strand the app turned up three classes, none of which offered a way out
    on screen:
    • Boot could hang forever with nothing to tap. No fetch had a timeout, and the field
      failure isn't a refused connection but a half-open one – a dying access point, one bar of
      LTE, a captive portal...
Read more