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(); 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 = { 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.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 = { BEREIT: "bereit", IM_EINSATZ: "im Einsatz", RUECKWEG: "auf dem Rückweg", DRAUSSEN: "draußen", }; export const DRUCKART_TEXT: Record = { 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)}`; }