Phase 5: das sicherheitsrelevanteste Modul des Projekts. Ueber jeder Ansicht steht dauerhaft, dass die analoge Atemschutzueberwachung fuehrend bleibt — abschaltbar nur zusammen mit der Fundstelle einer Freigabe des Kommandanten. Es gibt keinen Timer. In der Datenbank stehen Zeitstempel; wie lange ein Trupp unter Atemschutz ist, rechnet jede Ansicht bei jedem Bild neu aus. Damit ist die Anforderung "uebersteht Neuladen und Verbindungsabbruch" keine Funktion, die gelingen muss, sondern eine Eigenschaft, die sich nicht verlieren laesst. Server und Browser rechnen mit denselben Funktionen. Der Rueckzugsdruck folgt dem doppelten Anmarschverbrauch: was der Weg hinein gekostet hat, kostet der Weg zurueck noch einmal. Bis der Trupp das Einsatzziel meldet, greift die Zwei-Drittel-Regel — sie liegt hoeher und ist damit die vorsichtigere Annahme. Das Protokoll ist doppelt gesichert: entzogene Rechte und ein Trigger auf druckmessung und atemschutz_ereignis. Ein Fehler wird durch einen neuen Eintrag richtiggestellt, nicht durch Aendern. Verteilt wird ueber Server-Sent Events statt Socket.IO — es geht nur in eine Richtung, und so bleibt das Deployment bei "next start". Behoben: ein abgerissener Ereignisstrom meldete sich nicht. Die Anzeige haette weiter "Verbunden" behauptet, waehrend der Draht tot war. Jetzt gilt Stille von acht Sekunden als Abriss. Dazu zwei Altlasten: der Navigationseintrag "Protokoll" fuehrte ins Leere, und die Startseite stand noch auf Phase 1. 68 neue Tests (51 Unit, 17 gegen eine echte Datenbank, 8 E2E je Browser). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
264 lines
9.0 KiB
TypeScript
264 lines
9.0 KiB
TypeScript
import { DruckArt, TruppStatus } from "@prisma/client";
|
||
|
||
/**
|
||
* Die Rechenregeln der Atemschutzüberwachung.
|
||
*
|
||
* Diese Datei hat bewusst keinen Datenbankzugriff und kein `server-only`: sie
|
||
* wird auf dem Server für die verbindlichen Werte gebraucht und im Browser für
|
||
* die laufende Anzeige zwischen zwei Aktualisierungen. Beide Seiten rechnen
|
||
* mit demselben Code — sonst zeigte das Tablet etwas anderes an als das
|
||
* Protokoll später ausweist.
|
||
*
|
||
* **Die Zeit gehört dem Server.** Nirgends wird eine Restzeit heruntergezählt.
|
||
* Jede Angabe entsteht aus zwei Zeitstempeln, und den späteren liefert der
|
||
* Aufrufer. Damit übersteht jede Anzeige einen Neustart, einen zweiten
|
||
* Bildschirm und einen Verbindungsabbruch — es gibt schlicht nichts, das
|
||
* verloren gehen könnte.
|
||
*
|
||
* **Und über allem:** was hier gerechnet wird, ist eine Hilfe, keine
|
||
* Überwachung. Die analoge Überwachung bleibt führend.
|
||
*/
|
||
|
||
/** Ein Pressluftatmer hält 300 bar; darüber liegt ein Ablesefehler. */
|
||
export const DRUCK_HOECHSTWERT = 400;
|
||
|
||
/**
|
||
* Die Regel für den Rückzugsdruck: **doppelter Anmarschverbrauch**.
|
||
*
|
||
* Was der Weg hinein an Luft gekostet hat, kostet der Weg zurück noch einmal.
|
||
* Dazu kommt der Druck, bei dem die Warneinrichtung anspricht, und ein
|
||
* Sicherheitszuschlag, den der Kommandant festlegt:
|
||
*
|
||
* Rückzug = 2 × (Eingangsdruck − Druck am Ziel) + Warndruck + Zuschlag
|
||
*
|
||
* Solange der Trupp das Einsatzziel noch nicht gemeldet hat, gibt es keinen
|
||
* Anmarschverbrauch — dann greift ersatzweise die Zwei-Drittel-Regel: Rückzug,
|
||
* sobald ein Drittel verbraucht ist. Sie liegt in aller Regel höher als die
|
||
* spätere Rechnung und ist damit die vorsichtigere Annahme.
|
||
*/
|
||
export function rueckzugsdruck(angaben: {
|
||
eingangsdruck: number;
|
||
/** Druck beim Erreichen des Einsatzziels, falls schon gemeldet. */
|
||
zieldruck?: number | null;
|
||
warndruck: number;
|
||
sicherheitszuschlag: number;
|
||
}): { druck: number; regel: "anmarschverbrauch" | "zweidrittel" } {
|
||
const { eingangsdruck, zieldruck, warndruck, sicherheitszuschlag } = angaben;
|
||
|
||
if (typeof zieldruck === "number" && zieldruck > 0 && zieldruck <= eingangsdruck) {
|
||
const anmarsch = eingangsdruck - zieldruck;
|
||
return {
|
||
druck: Math.min(eingangsdruck, 2 * anmarsch + warndruck + sicherheitszuschlag),
|
||
regel: "anmarschverbrauch",
|
||
};
|
||
}
|
||
|
||
return {
|
||
druck: Math.round((eingangsdruck * 2) / 3) + sicherheitszuschlag,
|
||
regel: "zweidrittel",
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Der niedrigste Druck im Trupp.
|
||
*
|
||
* Ein Trupp geht gemeinsam zurück, und zwar wenn der **erste** den
|
||
* Rückzugsdruck erreicht. Deshalb zählt für jede Bewertung der kleinste Wert,
|
||
* nie der Durchschnitt.
|
||
*/
|
||
export function niedrigsterDruck(
|
||
messungen: Array<{ personId: string; druck: number; zeitpunkt: Date }>,
|
||
): { personId: string; druck: number; zeitpunkt: Date } | null {
|
||
const letzteJePerson = new Map<string, { personId: string; druck: number; zeitpunkt: Date }>();
|
||
|
||
for (const messung of messungen) {
|
||
const bisher = letzteJePerson.get(messung.personId);
|
||
if (!bisher || messung.zeitpunkt.getTime() > bisher.zeitpunkt.getTime()) {
|
||
letzteJePerson.set(messung.personId, messung);
|
||
}
|
||
}
|
||
|
||
let niedrigste: { personId: string; druck: number; zeitpunkt: Date } | null = null;
|
||
for (const messung of letzteJePerson.values()) {
|
||
if (!niedrigste || messung.druck < niedrigste.druck) niedrigste = messung;
|
||
}
|
||
|
||
return niedrigste;
|
||
}
|
||
|
||
/** Wie ein Trupp gerade dasteht. Farbe **und** Text — Farbe allein reicht nicht. */
|
||
export type Ampel = "gruen" | "gelb" | "rot";
|
||
|
||
export const AMPEL_TEXT: Record<Ampel, string> = {
|
||
gruen: "im Rahmen",
|
||
gelb: "Rückzugsdruck bald erreicht",
|
||
rot: "Rückzugsdruck erreicht — Trupp zurückziehen",
|
||
};
|
||
|
||
/**
|
||
* Die Ampel.
|
||
*
|
||
* Gelb ab zehn Prozent über dem Rückzugsdruck: der Trupp soll den Rückweg
|
||
* antreten, bevor der Wert erreicht ist, nicht wenn er erreicht ist.
|
||
*/
|
||
export function ampel(angaben: {
|
||
aktuellerDruck: number | null;
|
||
rueckzugsdruck: number | null;
|
||
status: TruppStatus;
|
||
}): Ampel {
|
||
const { aktuellerDruck, rueckzugsdruck: grenze, status } = angaben;
|
||
|
||
// Wer draußen ist, ist draußen.
|
||
if (status === TruppStatus.DRAUSSEN) return "gruen";
|
||
if (aktuellerDruck === null || grenze === null) return "gruen";
|
||
|
||
if (aktuellerDruck <= grenze) return "rot";
|
||
if (aktuellerDruck <= grenze * 1.1) return "gelb";
|
||
return "gruen";
|
||
}
|
||
|
||
/**
|
||
* Wie lange ein Trupp schon unter Atemschutz ist, in Sekunden.
|
||
*
|
||
* Aus zwei Zeitstempeln, nicht aus einem laufenden Zähler. `jetzt` gibt der
|
||
* Aufrufer mit — im Test ein fester Zeitpunkt, im Betrieb die Uhr.
|
||
*/
|
||
export function einsatzdauer(
|
||
trupp: { einstiegUm: Date | null; drausenUm: Date | null },
|
||
jetzt: Date,
|
||
): number | null {
|
||
if (!trupp.einstiegUm) return null;
|
||
const ende = trupp.drausenUm ?? jetzt;
|
||
return Math.max(0, Math.floor((ende.getTime() - trupp.einstiegUm.getTime()) / 1000));
|
||
}
|
||
|
||
/**
|
||
* Wann die nächste Druckabfrage fällig ist.
|
||
*
|
||
* Gerechnet ab der letzten Meldung, nicht ab dem Einstieg — sonst käme die
|
||
* Erinnerung nach einer verspäteten Meldung sofort wieder.
|
||
*/
|
||
export function naechsteAbfrage(angaben: {
|
||
einstiegUm: Date | null;
|
||
letzteMessungUm: Date | null;
|
||
intervallSekunden: number;
|
||
}): Date | null {
|
||
const basis = angaben.letzteMessungUm ?? angaben.einstiegUm;
|
||
if (!basis) return null;
|
||
return new Date(basis.getTime() + angaben.intervallSekunden * 1000);
|
||
}
|
||
|
||
/** Ist die Druckabfrage überfällig? */
|
||
export function abfrageUeberfaellig(angaben: {
|
||
einstiegUm: Date | null;
|
||
letzteMessungUm: Date | null;
|
||
intervallSekunden: number;
|
||
status: TruppStatus;
|
||
jetzt: Date;
|
||
}): boolean {
|
||
if (angaben.status !== TruppStatus.IM_EINSATZ && angaben.status !== TruppStatus.RUECKWEG) {
|
||
return false;
|
||
}
|
||
|
||
const faellig = naechsteAbfrage(angaben);
|
||
return faellig !== null && angaben.jetzt.getTime() >= faellig.getTime();
|
||
}
|
||
|
||
/**
|
||
* Der Luftverbrauch je Minute, aus den bisherigen Messungen.
|
||
*
|
||
* Nur eine Schätzung, und sie steht auch so in der Oberfläche: sie taugt für
|
||
* einen Anhaltspunkt, nicht für eine Entscheidung. Unter zwei Messungen oder
|
||
* unter einer Minute Abstand wird gar nichts gerechnet — eine Hochrechnung aus
|
||
* zwei Werten im Sekundenabstand wäre eine erfundene Zahl.
|
||
*/
|
||
export function verbrauchProMinute(
|
||
messungen: Array<{ druck: number; zeitpunkt: Date }>,
|
||
): number | null {
|
||
if (messungen.length < 2) return null;
|
||
|
||
const sortiert = [...messungen].sort((a, b) => a.zeitpunkt.getTime() - b.zeitpunkt.getTime());
|
||
const erste = sortiert[0]!;
|
||
const letzte = sortiert[sortiert.length - 1]!;
|
||
|
||
const minuten = (letzte.zeitpunkt.getTime() - erste.zeitpunkt.getTime()) / 60_000;
|
||
if (minuten < 1) return null;
|
||
|
||
const verbraucht = erste.druck - letzte.druck;
|
||
if (verbraucht <= 0) return null;
|
||
|
||
return Math.round((verbraucht / minuten) * 10) / 10;
|
||
}
|
||
|
||
/**
|
||
* Geschätzte Restzeit bis zum Rückzugsdruck, in Minuten.
|
||
*
|
||
* Ausdrücklich eine Schätzung: der Verbrauch steigt unter Belastung, und
|
||
* niemand soll sich darauf verlassen. Deshalb gibt die Funktion null zurück,
|
||
* sobald die Grundlage dünn wird — lieber keine Zahl als eine falsche.
|
||
*/
|
||
export function restzeitMinuten(angaben: {
|
||
aktuellerDruck: number | null;
|
||
rueckzugsdruck: number | null;
|
||
verbrauchProMinute: number | null;
|
||
}): number | null {
|
||
const { aktuellerDruck, rueckzugsdruck: grenze, verbrauchProMinute: rate } = angaben;
|
||
|
||
if (aktuellerDruck === null || grenze === null || rate === null || rate <= 0) return null;
|
||
if (aktuellerDruck <= grenze) return 0;
|
||
|
||
return Math.floor((aktuellerDruck - grenze) / rate);
|
||
}
|
||
|
||
/**
|
||
* Der nächste erlaubte Statusschritt.
|
||
*
|
||
* Ein Trupp geht immer denselben Weg: bereit, im Einsatz, Rückweg, draußen.
|
||
* Zurück geht es nicht — ein Trupp, der schon draußen war, ist ein neuer
|
||
* Trupp. Das hält das Protokoll gerade.
|
||
*/
|
||
export const STATUSFOLGE: Record<TruppStatus, TruppStatus[]> = {
|
||
[TruppStatus.BEREIT]: [TruppStatus.IM_EINSATZ],
|
||
[TruppStatus.IM_EINSATZ]: [TruppStatus.RUECKWEG, TruppStatus.DRAUSSEN],
|
||
[TruppStatus.RUECKWEG]: [TruppStatus.DRAUSSEN],
|
||
[TruppStatus.DRAUSSEN]: [],
|
||
};
|
||
|
||
export function statuswechselErlaubt(von: TruppStatus, nach: TruppStatus): boolean {
|
||
return STATUSFOLGE[von].includes(nach);
|
||
}
|
||
|
||
export const STATUS_TEXT: Record<TruppStatus, string> = {
|
||
BEREIT: "bereit",
|
||
IM_EINSATZ: "im Einsatz",
|
||
RUECKWEG: "auf dem Rückweg",
|
||
DRAUSSEN: "draußen",
|
||
};
|
||
|
||
export const DRUCKART_TEXT: Record<DruckArt, string> = {
|
||
EINGANG: "Eingangsdruck",
|
||
ZIEL: "am Einsatzziel",
|
||
ZWISCHEN: "Zwischenmeldung",
|
||
AUSGANG: "Ausgangsdruck",
|
||
};
|
||
|
||
/**
|
||
* Eine Sekundenzahl als `mm:ss` beziehungsweise `h:mm:ss`.
|
||
*
|
||
* Eigene Funktion statt der allgemeinen Formatierung: hier zählt jede Sekunde,
|
||
* und die Anzeige darf nicht runden.
|
||
*/
|
||
export function alsUhr(sekunden: number | null): string {
|
||
if (sekunden === null) return "—";
|
||
|
||
const stunden = Math.floor(sekunden / 3600);
|
||
const minuten = Math.floor((sekunden % 3600) / 60);
|
||
const rest = sekunden % 60;
|
||
|
||
const zweistellig = (wert: number) => String(wert).padStart(2, "0");
|
||
|
||
return stunden > 0
|
||
? `${stunden}:${zweistellig(minuten)}:${zweistellig(rest)}`
|
||
: `${zweistellig(minuten)}:${zweistellig(rest)}`;
|
||
}
|