Skip to content

Design: CO-Messprotokoll für Hochhaus-Einsätze (Issue #126) #134

Description

@DeepDiver1975

Design: CO-Messprotokoll für Hochhaus-Einsätze (Issue #126)

Datum: 2026-08-24 · Status: genehmigt im Brainstorming · Umsetzungsplan: docs/superpowers/plans/2026-08-24-co-messprotokoll.md

1 Zweck

Strukturierte Erfassung von CO-Messwerten und Wohnungsstatusen in einem oder mehreren Hochhäusern während eines Einsatzes, als neues Modul (Tab „CO-MESSUNG") der Einsatz-Arbeitsfläche. Ersatz für das Excel-Sheet aus dem Issue:

  • Mehrere Häuser je Einsatz; je Haus konfigurierbare Stockwerkzahl (EG + N OG) und Wohnungen je Etage — erfassbar beim Anlegen, später änderbar.
  • Matrix: Geschosse vertikal (oben „N. OG" → unten „EG"), Wohnungen horizontal („Whg. 1" … „Whg. M"), rechts je Geschoss eine Lagebeschreibung.
  • Je Wohnung je Stockwerk (jede Tür einzeln): Bewohnername, Vorgefundene Lage als Ampel (Gelb = noch nicht abgesucht, Grün = abgesucht/keine Personen, Rot = Person(en) betroffen), Schlüssel vorhanden j/n, CO-Messwert.
  • Ein CO-Messwert pro Wohnung/Etage — dieselbe Größe erscheint in der Matrixzelle und im Detaileditor (keine Redundanz).
  • Einsatztauglichkeit: Statuswechsel und Messwerteintrag mit wenigen Tipps; Ampelfarben #FFC000 / #92D050 / #FF0000; Platzhaltertext „Kein Messwert".

Entscheidungen aus dem Brainstorming: Datenstruktur je Wohnung je Stockwerk (nicht je Spalte); EG + N OG ohne Keller; ETB-Systemmeldungen bei Statuswechsel und Messwertänderung; eigener PDF-Abschnitt; UI-Variante A (Matrix mit Zellen-Flyout-Editor).

2 Domain (src/LageBuch.Domain/CoMeasurement/)

public enum DwellingStatus { NotSearched, Searched, Affected }   // Gelb / Grün / Rot

public sealed class Building          // Identität + Struktur, keine Messdaten
{
    Guid Id;
    string Name;                      // frei benennbar, z. B. "Haus A"
    int FloorCount;                   // Anzahl Obergeschosse, gültig 1..50
    int ApartmentsPerFloor;           // gültig 1..30
    IReadOnlyDictionary<int, string?> FloorDescriptions;  // Ordinal -> Lagetext
}

public sealed record Dwelling         // eine Tür: (Geschoss × Wohnung)
{
    Guid Id;
    Guid BuildingId;
    int FloorOrdinal;                 // 0 = EG, 1 = 1. OG …
    int ApartmentNumber;              // 1-basiert
    string? ResidentName;
    DwellingStatus Status;            // Neuanlage: NotSearched
    bool? KeyAvailable;
    int? CoValue;                     // Messwert in ppm; null = kein Messwert
}

Bezeichnungen werden abgeleitet, nie gespeichert: FloorLabel(0) == "EG", sonst "${ordinal}. OG"; Wohnungsspalten heißen „Whg. N". Hilfsklasse CoMeasurementLabels stellt die deutschen Statustexte bereit: NotSearched = „noch nicht abgesucht", Searched = „abgesucht – keine Personen betroffen", Affected = „Person(en) betroffen".

2.1 Incident-Erweiterungen

Neue private Listen _buildings, _dwellings; Exposures Buildings, Dwellings. Alle Methoden mit EnsureOpen()-Wache, Uhrzeit + Operator nach bestehendem Konventionsmuster (vgl. AddForceUnit):

Methode Verhalten ETB (Systemmeldung)
AddCoBuilding(clock, op, name, floorCount, apartmentsPerFloor) Legt Haus an, erzeugt Wohnungen 0..FloorCount × 1..ApartmentsPerFloor im Zustand NotSearched „CO-Messprotokoll eröffnet: Haus A (EG–8. OG, 10 Wohnungen je Geschoss)"
UpdateCoBuildingStructure(clock, op, buildingId, floorCount, apartmentsPerFloor) Ändert das Raster; beim Verkleinern fallen außerhalb liegende Wohnungen weg immer: „CO-Struktur geändert: Haus A jetzt EG–6. OG, 8 Wohnungen je Geschoss" + falls weggefallen: „, 5 Wohnungen entfernt"
RemoveCoBuilding(clock, op, buildingId) Entfernt Haus samt Wohnungen „CO-Messprotokoll entfernt: Haus A"
RecordCoValue(clock, op, buildingId, ordinal, apartment, int? coValue) Negativer Wert wird abgewiesen; wirft bei unbekannter Wohnung nur bei echter Änderung: „CO-Messung Haus A, 3. OG, Whg. 2: 45 ppm" bzw. „… : Messwert gelöscht"
SetDwellingStatus(clock, op, buildingId, ordinal, apartment, status) Wirft bei unbekannter Wohnung nur bei echtem Wechsel: „Whg.-Status Haus A, 3. OG, Whg. 2: abgesucht – keine Personen betroffen"
SetDwellingDetails(buildingId, ordinal, apartment, residentName, keyAvailable) Null-Parameter = unverändert; Arbeitsdaten wie Bemerkung still
SetFloorDescription(buildingId, ordinal, description) Trimmt; leer → entfernt still

Ortsangabe in den Texten: {Hausname}, {Geschosslabel}, Whg. {Nr}. Unbekannte IDs/Ordinals werfen (KeyNotFoundException), damit replizierte Befehle laut scheitern statt stumm zu noop-en. Rehydrate(...) wird um beide Listen erweitert.

3 Persistenz (Migration V14)

.fwincident ist SQLite; IncidentRepository.Save() löscht alle Tabellen und schreibt vollständig neu. Neuer Migrationsschritt ApplyV14 (CurrentVersion = 14):

CREATE TABLE co_buildings (
    id TEXT PRIMARY KEY,
    name TEXT NOT NULL,
    floor_count INTEGER NOT NULL,
    apartments_per_floor INTEGER NOT NULL,
    floor_descriptions TEXT NOT NULL DEFAULT '{}',   -- JSON: {"0":"links","3":"rechts"}
    ordinal INTEGER NOT NULL                          -- Reihenfolge
);
CREATE TABLE co_dwellings (
    id TEXT PRIMARY KEY,
    building_id TEXT NOT NULL,
    floor_ordinal INTEGER NOT NULL,
    apartment_number INTEGER NOT NULL,
    resident_name TEXT,
    status INTEGER NOT NULL,                          -- DwellingStatus-Ordinal
    key_available INTEGER,                            -- nullable bool
    co_value INTEGER                                  -- nullable int (ppm)
);

Beide Tabellen in die DELETE-Liste von Save() aufnehmen; Schreib-/Leseschleifen analog forces. Incident.Rehydrate bekommt zwei zusätzliche Parameter.

4 Sync

Voller Snapshot-Broadcast nach jedem Befehl (bestehendes Muster) — neue Kollektionen wandern in IncidentSnapshot:

  • DTOs: BuildingDto(Id, Name, FloorCount, ApartmentsPerFloor, FloorDescriptions als Dictionary<string,string?>, Ordinal), DwellingDto(Id, BuildingId, FloorOrdinal, ApartmentNumber, ResidentName, Status (string enum via SyncJson), KeyAvailable, CoValue als int?); IncidentSnapshot um IReadOnlyList<BuildingDto> + IReadOnlyList<DwellingDto> ergänzt; SnapshotMapper beide Richtungen.
  • Commands (SyncCommand.cs, jeweils [JsonDerivedType]):
    • AddCoBuildingCommand(OperatorDto Operator, string Name, int FloorCount, int ApartmentsPerFloor)
    • UpdateCoBuildingStructureCommand(OperatorDto Operator, Guid BuildingId, int FloorCount, int ApartmentsPerFloor)
    • RemoveCoBuildingCommand(OperatorDto Operator, Guid BuildingId)
    • RecordCoValueCommand(OperatorDto Operator, Guid BuildingId, int FloorOrdinal, int ApartmentNumber, int? CoValue)
    • SetDwellingStatusCommand(OperatorDto Operator, Guid BuildingId, int FloorOrdinal, int ApartmentNumber, DwellingStatus Status)
    • UpdateDwellingDetailsCommand(Guid BuildingId, int FloorOrdinal, int ApartmentNumber, string? ResidentName, bool? KeyAvailable) — ohne Operator (still wie RenameFile)
    • SetFloorDescriptionCommand(Guid BuildingId, int FloorOrdinal, string? Description) — ohne Operator (still)
  • CommandApplier: ein case pro Befehl gegen die Domainmethode (Host-Uhr, Operator vom Befehl).
  • Sessions: sieben neue Methodenpaare auf IIncidentSession: lokal Mutate(() => Incident.X(_clock, RequireOperator(), …)), remote Send(new XCommand(Op(), …)).

5 UI (Variante A: Matrix + Flyout)

5.1 ViewModels (AppLogic)

  • CoMessprotokollViewModel(IIncidentSession, IClock, Action onChanged) — gebaut in IncidentWorkspaceViewModel.BuildChildren() (mit OnPropertyChanged(nameof(CoMessprotokoll))), Tab zwischen ATEMSCHUTZ und DATEIEN (<TabItem Header="CO-MESSUNG">).
    • Abonniert session.Changed → vollständiger Rebuild aus session.Incident (lokal/remote nicht unterscheidbar, wie überall).
    • Kopfzeile: Hausauswahl (SelectedHouse), „HAUS HINZUFÜGEN", „STRUKTUR ÄNDERN", „HAUS ENTFERNEN" (Confirm-Dialog), alles mit !IsReadOnly gewacht.
    • MatrixRows: je Geschoss ein Row-VM (Label, Cells, Description-Text); Zellen-VM DwellingCellViewModel kapselt Dwelling + injizierte Aktions-Callbacks (Muster ForceRow).
  • Zellen-VM liefert: StatusBrush (statische Ressourcen-Pinsel aus den drei Hexfarben), CoDisplay (Wert oder „Kein Messwert" abgesetzt), OpenEditorCommand.
  • Hausdialoge als Overlay-VMs im Stil OperatorPromptViewModel (Name, Obergeschosse, Wohnungen je Etage; numerisch validiert 1..50 / 1..30); gerendert über PendingX-Property + DataTemplate in der Workspace-View (bestehendes Overlay-Muster).

5.2 View (CoMessprotokollView.axaml)

  • ScrollViewer (horizontal+vertikal): äußeres ItemsControl über Geschosse (absteigend sortiert), inneres horizontales ItemsControl über Wohnungszellen; Kopfreihenfolge: Zeile 1 = „Whg. 1..M"-Köpfe, darunter Geschosszeilen mit Labelzelle links und Beschreibungsfeld rechts (~200 px breit, TextBox an SetFloorDescription).
  • Zelle: Border mit Ampelhintergrund (#FFC000 Gelb default, #92D050 Grün, #FF0000 Rot mit heller Schrift), Inhalt CO-Wert mit Einheit „ppm" bzw. grau abgesetzter Platzhalter „Kein Messwert"; Tap/Klick öffnet Flyout.
  • Flyout-Editor je Wohnung: drei große Status-Buttons (GRÜN/GELB/ROT — einmal tippen genügt; damit ist die Schnelleingabe „ok" des Issues abgedeckt), CO-Eingabe numerisch (Ganzzahl ppm; Watermark „Kein Messwert"; ungültige Eingabe wird nicht übernommen), ToggleSwitch „Schlüssel vorhanden", TextBox „Name Bewohner". Änderungen schreiben direkt durch (Grid-Konvention), Flyout schließt per Button/X.
  • Farblegende fix unten eingeblendet (drei Farbchips + Bedeutung).
  • Lesegeschützt/geschlossen: kompletter Tab inert (IsEnabled an IsInputEnabled/IsReadOnly), Darstellung bleibt erhalten.

6 PDF (LageBuch.Documents)

Sections/CoMessprotokollSection.cs, registriert in IncidentReportDocument.Compose():

  • Leerzustand „— kein CO-Messprotokoll erfasst —".
  • Je Haus: Titelzeile (Name, Struktur „EG–8. OG, 10 Whg./Geschoss"); QuestPDF-Tabelle: Spalten „Geschoss | Whg. 1 … Whg. M | Lage"; Zellen zeigen CO-Wert in ppm mit ampelfarbenem Zellhintergrund, leere Zellen „—".
  • Darunter kompaktes Blocklisting „Betroffene Wohnungen" (Status Affected): je Zeile Haus, Geschoss, Whg., Bewohner, Schlüssel ja/nein, letzter CO-Wert (ppm) — nur wenn mindestens ein Eintrag existiert.
  • Farblegende als Fußnote der Sektion.

7 Tests

Ebene Dateien Abgedeckt
Domain tests/LageBuch.Domain.Tests/CoMeasurementTests.cs Anlegen/Ändern/Entfernen inkl. ETB-Texte, Strukturverkleinerung wirft Wohnungen weg, Statuswechsel/Messwerte nur bei echter Änderung, EnsureOpen, unbekannte IDs werfen, Labels
Persistence tests/LageBuch.Persistence.Tests (Roundtrip + Migrations) V14 aufsteigende Migration, Save/Load-Roundtrip beider Tabellen inkl. leerer Beschreibungen/nullbarer Felder
Sync tests/LageBuch.Sync.Tests (CommandApplierTests, SnapshotRoundTripTests) je neuer Befehl ein Applier-Case; Snapshot-Hin- und Rückweg verlustfrei
AppLogic tests/LageBuch.AppLogic.Tests/CoMessprotokollViewModelTests.cs Rebuild bei Changed, Read-only inert, Strukturdialog-Validierung, Zellenprojektion (Farben/Platzhalter)
Documents tests/LageBuch.Documents.Tests Sektion rendert mit/ohne Häuser, Betroffenenliste
Acceptance Render-Test + Screenshots Tab sichtbar, Matrix zeichnet; PNG-Pfade für den PR-Body (AGENTS.md: Before/After n/a — neues Feature, After genügt)

8 Außerhalb des Scopes

Kellergeschosse, individuelle Geschossbezeichnungen, Nachmess-Timer/Alarme, Fotos je Wohnung, Excel-Import/-Export, Stammdaten-Anbindung.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions