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.
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.md1 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:
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/)Bezeichnungen werden abgeleitet, nie gespeichert:
FloorLabel(0) == "EG", sonst"${ordinal}. OG"; Wohnungsspalten heißen „Whg. N". HilfsklasseCoMeasurementLabelsstellt die deutschen Statustexte bereit:NotSearched= „noch nicht abgesucht",Searched= „abgesucht – keine Personen betroffen",Affected= „Person(en) betroffen".2.1
Incident-ErweiterungenNeue private Listen
_buildings,_dwellings; ExposuresBuildings,Dwellings. Alle Methoden mitEnsureOpen()-Wache, Uhrzeit + Operator nach bestehendem Konventionsmuster (vgl.AddForceUnit):AddCoBuilding(clock, op, name, floorCount, apartmentsPerFloor)UpdateCoBuildingStructure(clock, op, buildingId, floorCount, apartmentsPerFloor)RemoveCoBuilding(clock, op, buildingId)RecordCoValue(clock, op, buildingId, ordinal, apartment, int? coValue)SetDwellingStatus(clock, op, buildingId, ordinal, apartment, status)SetDwellingDetails(buildingId, ordinal, apartment, residentName, keyAvailable)SetFloorDescription(buildingId, ordinal, description)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)
.fwincidentist SQLite;IncidentRepository.Save()löscht alle Tabellen und schreibt vollständig neu. Neuer MigrationsschrittApplyV14(CurrentVersion = 14):Beide Tabellen in die DELETE-Liste von
Save()aufnehmen; Schreib-/Leseschleifen analogforces.Incident.Rehydratebekommt zwei zusätzliche Parameter.4 Sync
Voller Snapshot-Broadcast nach jedem Befehl (bestehendes Muster) — neue Kollektionen wandern in
IncidentSnapshot: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?);IncidentSnapshotumIReadOnlyList<BuildingDto>+IReadOnlyList<DwellingDto>ergänzt;SnapshotMapperbeide Richtungen.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)casepro Befehl gegen die Domainmethode (Host-Uhr, Operator vom Befehl).IIncidentSession: lokalMutate(() => Incident.X(_clock, RequireOperator(), …)), remoteSend(new XCommand(Op(), …)).5 UI (Variante A: Matrix + Flyout)
5.1 ViewModels (AppLogic)
CoMessprotokollViewModel(IIncidentSession, IClock, Action onChanged)— gebaut inIncidentWorkspaceViewModel.BuildChildren()(mitOnPropertyChanged(nameof(CoMessprotokoll))), Tab zwischen ATEMSCHUTZ und DATEIEN (<TabItem Header="CO-MESSUNG">).session.Changed→ vollständiger Rebuild aussession.Incident(lokal/remote nicht unterscheidbar, wie überall).SelectedHouse), „HAUS HINZUFÜGEN", „STRUKTUR ÄNDERN", „HAUS ENTFERNEN" (Confirm-Dialog), alles mit!IsReadOnlygewacht.MatrixRows: je Geschoss ein Row-VM (Label, Cells, Description-Text); Zellen-VMDwellingCellViewModelkapseltDwelling+ injizierte Aktions-Callbacks (MusterForceRow).StatusBrush(statische Ressourcen-Pinsel aus den drei Hexfarben),CoDisplay(Wert oder „Kein Messwert" abgesetzt),OpenEditorCommand.OperatorPromptViewModel(Name, Obergeschosse, Wohnungen je Etage; numerisch validiert 1..50 / 1..30); gerendert überPendingX-Property +DataTemplatein der Workspace-View (bestehendes Overlay-Muster).5.2 View (
CoMessprotokollView.axaml)ScrollViewer(horizontal+vertikal): äußeresItemsControlüber Geschosse (absteigend sortiert), inneres horizontalesItemsControlüber Wohnungszellen; Kopfreihenfolge: Zeile 1 = „Whg. 1..M"-Köpfe, darunter Geschosszeilen mit Labelzelle links und Beschreibungsfeld rechts (~200 px breit, TextBox anSetFloorDescription).Bordermit 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.IsEnabledanIsInputEnabled/IsReadOnly), Darstellung bleibt erhalten.6 PDF (
LageBuch.Documents)Sections/CoMessprotokollSection.cs, registriert inIncidentReportDocument.Compose():7 Tests
tests/LageBuch.Domain.Tests/CoMeasurementTests.cstests/LageBuch.Persistence.Tests(Roundtrip + Migrations)tests/LageBuch.Sync.Tests(CommandApplierTests, SnapshotRoundTripTests)tests/LageBuch.AppLogic.Tests/CoMessprotokollViewModelTests.cstests/LageBuch.Documents.Tests8 Außerhalb des Scopes
Kellergeschosse, individuelle Geschossbezeichnungen, Nachmess-Timer/Alarme, Fotos je Wohnung, Excel-Import/-Export, Stammdaten-Anbindung.