feat(notruf): Wiederauslöse-Sperre + grüner Hinweis; "Neues Gespräch" in die Kopfzeile

1) Notruf-Sperre (Cooldown) gegen Mehrfach-Alarme verwirrter/verängstigter
   Senioren:
   - Neue Einstellung emergency_cooldown_minutes (Default 5), zur Laufzeit im
     Admin-Bereich änderbar (RUNTIME_SETTABLE), 0 = aus.
   - record_manual_emergency unterdrückt innerhalb der Sperre jeden weiteren
     Alarm (kein zweiter Versand, kein Log-Eintrag) und liefert status=cooldown
     mit minutes_ago/remaining_seconds. Neuer Endpunkt GET /api/emergency/status
     für die Anzeige nach einem Seiten-Neuladen. Store: last_manual_emergency_at.
   - UI: Nach dem Alarm wird der Button grün, zeigt „✓" und pulsiert für die
     Dauer der Sperre. Erneuter Druck öffnet einen grünen Hinweis-Dialog:
     „Ich habe Deine Helfer vor <n> Minuten alarmiert. Wahrscheinlich ist Deine
     Hilfe schon unterwegs." Der grüne Zustand übersteht ein Neuladen
     (Status-Abruf beim Start).

2) „Neues Gespräch" als Symbol (Stift) zurück in die Kopfzeile — zwischen
   Sprachauswahl und Menü; löst dieselbe Aktion aus wie der Menüeintrag.

Tests: neue test_emergency_cooldown.py (Status/Suppression/Endpoint); zwei
bestehende Notruf-Tests an die neue Interface-Methode bzw. die Sperre
angepasst. 250 Offline-Tests grün. Frontend via Headless-Chrome verifiziert
(Alarm->grün/pulsierend, Hinweis-Dialog, Reload-Persistenz, Header-Button).
Bump app.js v=50 -> v=51.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Dieter Schlüter 2026-06-26 15:58:17 +02:00
commit 8c152ee77c
10 changed files with 290 additions and 22 deletions

View file

@ -422,6 +422,7 @@ function loadMe() {
applyAllowedLanguages(prefs);
applyPlaybackChoice(prefs.tts_provider);
applyGenderChoice(prefs.voice_gender || 'f');
initSosCooldown(); // grünen Sperr-Zustand nach Reload wiederherstellen
return me;
} catch (e) {
$("#identity").textContent = "Verbindung fehlgeschlagen";
@ -840,6 +841,9 @@ function newConversation() {
}, 2500);
}
$("#new-chat").addEventListener("click", () => { closeMenu(); newConversation(); });
// Gleiche Aktion als Symbol oben in der Kopfzeile (zwischen Sprachwahl und Menü).
const newChatTop = $("#new-chat-top");
if (newChatTop) newChatTop.addEventListener("click", newConversation);
// ---------- Menü (⋮) verdrahten ----------
$("#menu-toggle").addEventListener("click", () =>
@ -2245,24 +2249,23 @@ function deviceInfo() {
return { mobile: /Mobi|Android|iPhone|iPad|iPod/i.test(ua), platform: ua.slice(0, 120) };
}
// Sichtbarer Zustand am SOS-Button selbst: idle | calling | done | error.
// So erkennt der Senior unmittelbar am Knopf, dass der Alarm ausgelöst wurde.
// Button bleibt ein kleines Quadrat (so groß wie die Sprachauswahl) -> Feedback
// über Farbe/Symbol statt langer Wörter, damit es nicht aus der Form läuft.
// Sichtbarer Zustand am SOS-Button (Quadrat in Höhe der Sprachauswahl):
// idle (rot „SOS") | calling (rot, pulsierend) | done (grün ✓, pulsierend —
// während der Sperre) | error. Feedback über Farbe/Symbol, nicht über Wörter.
function setSosState(state) {
if (!emergencyBtn) return;
emergencyBtn.classList.remove(
"bg-red-600", "hover:bg-red-700", "bg-green-600", "animate-pulse",
"text-sm", "text-lg"
"bg-red-600", "hover:bg-red-700", "bg-green-600", "hover:bg-green-700",
"animate-pulse", "text-sm", "text-lg"
);
if (state === "calling") {
emergencyBtn.textContent = "SOS"; // pulsiert = „wird gerufen“
emergencyBtn.disabled = true;
emergencyBtn.classList.add("bg-red-600", "animate-pulse", "text-sm");
} else if (state === "done") {
emergencyBtn.textContent = "✓"; // grün + Haken = Alarm raus
emergencyBtn.disabled = true;
emergencyBtn.classList.add("bg-green-600", "text-lg");
emergencyBtn.textContent = "✓"; // grün + Haken + pulsiert = Alarm raus
emergencyBtn.disabled = false; // klickbar -> beruhigender Hinweis
emergencyBtn.classList.add("bg-green-600", "hover:bg-green-700", "animate-pulse", "text-lg");
} else if (state === "error") {
emergencyBtn.textContent = "!";
emergencyBtn.disabled = false;
@ -2274,6 +2277,34 @@ function setSosState(state) {
}
}
// ----- Wiederauslöse-Sperre (Cooldown) -----
// Nach einem Alarm bleibt der Button für die (im Admin einstellbare) Dauer grün
// und pulsierend; erneutes Drücken zeigt nur einen beruhigenden Hinweis, statt
// einen zweiten Alarm auszulösen — Schutz vor Mehrfach-Alarmen.
let sosCooldownActive = false;
let sosAlertedAtMs = 0;
let sosCooldownTimer = null;
function enterCooldown(remainingSec, minutesAgo) {
sosCooldownActive = true;
// Auslösezeitpunkt rekonstruieren, damit „vor X Minuten" stimmt (auch nach Reload).
sosAlertedAtMs = Date.now() - Math.max(0, minutesAgo || 0) * 60000;
setSosState("done");
if (sosCooldownTimer) clearTimeout(sosCooldownTimer);
if (remainingSec > 0) sosCooldownTimer = setTimeout(exitCooldown, remainingSec * 1000);
else exitCooldown();
}
function exitCooldown() {
sosCooldownActive = false;
sosCooldownTimer = null;
setSosState("idle");
}
function sosMinutesAgo() {
return Math.max(0, Math.floor((Date.now() - sosAlertedAtMs) / 60000));
}
async function runEmergency() {
const lang = (langSel && langSel.value) || "de";
setSosState("calling");
@ -2288,15 +2319,26 @@ async function runEmergency() {
body: JSON.stringify(body),
});
const data = await res.json().catch(() => ({}));
if (data && data.status === "cooldown") {
// Server hat den (Doppel-)Alarm unterdrückt -> nur beruhigen, kein neuer Alarm.
enterCooldown(data.remaining_seconds || 0, data.minutes_ago || 0);
openSosCooldown();
return;
}
const notice = (data && data.notice) ||
"ACHTUNG: Es ist noch keine Benachrichtigung eingebaut.";
addMessage("emergency", "🆘 " + notice);
setSosState("done");
setTimeout(() => setSosState("idle"), 6000); // danach zurück zu „SOS"
const cd = (data && data.cooldown_seconds) || 0;
if (cd > 0) {
enterCooldown(cd, 0); // grün/pulsierend für die Sperre
} else {
setSosState("done"); // Sperre aus -> nur kurzes Erfolgs-Feedback
setTimeout(() => { if (!sosCooldownActive) setSosState("idle"); }, 6000);
}
} catch (e) {
addMessage("emergency", "🆘 Fehler beim Auslösen des Notrufs.");
setSosState("error");
setTimeout(() => setSosState("idle"), 4000);
setTimeout(() => { if (!sosCooldownActive) setSosState("idle"); }, 4000);
}
}
@ -2305,17 +2347,47 @@ const sosConfirm = $("#sos-confirm");
function openSosConfirm() { if (sosConfirm) sosConfirm.classList.remove("hidden"); }
function closeSosConfirm() { if (sosConfirm) sosConfirm.classList.add("hidden"); }
// Beruhigender grüner Hinweis, wenn während der Sperre erneut gedrückt wird.
const sosCooldownDlg = $("#sos-cooldown");
function openSosCooldown() {
const m = sosMinutesAgo();
const when = m < 1 ? "gerade eben" : ("vor " + m + (m === 1 ? " Minute" : " Minuten"));
const txtEl = $("#sos-cooldown-text");
if (txtEl) txtEl.textContent =
"Ich habe Deine Helfer " + when + " alarmiert. " +
"Wahrscheinlich ist Deine Hilfe schon unterwegs.";
if (sosCooldownDlg) sosCooldownDlg.classList.remove("hidden");
}
function closeSosCooldown() { if (sosCooldownDlg) sosCooldownDlg.classList.add("hidden"); }
// Beim Laden den Sperr-Status vom Server holen, damit der grüne Zustand auch
// ein Seiten-Neuladen übersteht (verwirrte Senioren laden gern neu).
async function initSosCooldown() {
try {
const res = await fetch("/api/emergency/status", { headers: { Accept: "application/json" } });
if (!res.ok) return;
const s = await res.json();
if (s && s.active) enterCooldown(s.remaining_seconds || 0, s.minutes_ago || 0);
} catch (e) { /* ignore */ }
}
if (emergencyBtn) {
emergencyBtn.addEventListener("click", openSosConfirm);
emergencyBtn.addEventListener("click", () => {
if (sosCooldownActive) openSosCooldown(); // schon alarmiert -> nur beruhigen
else openSosConfirm(); // sonst Notruf bestätigen
});
}
const sosYesBtn = $("#sos-confirm-yes");
const sosNoBtn = $("#sos-confirm-no");
if (sosYesBtn) sosYesBtn.addEventListener("click", () => { closeSosConfirm(); runEmergency(); });
if (sosNoBtn) sosNoBtn.addEventListener("click", closeSosConfirm);
// Klick auf den Hintergrund oder Escape bricht ab (schließt nur den Dialog).
if (sosConfirm) {
sosConfirm.addEventListener("click", (e) => { if (e.target === sosConfirm) closeSosConfirm(); });
}
const sosCooldownOkBtn = $("#sos-cooldown-ok");
if (sosCooldownOkBtn) sosCooldownOkBtn.addEventListener("click", closeSosCooldown);
// Klick auf den Hintergrund oder Escape schließt die Dialoge (kein Alarm).
if (sosConfirm) sosConfirm.addEventListener("click", (e) => { if (e.target === sosConfirm) closeSosConfirm(); });
if (sosCooldownDlg) sosCooldownDlg.addEventListener("click", (e) => { if (e.target === sosCooldownDlg) closeSosCooldown(); });
document.addEventListener("keydown", (e) => {
if (e.key === "Escape" && sosConfirm && !sosConfirm.classList.contains("hidden")) closeSosConfirm();
if (e.key !== "Escape") return;
if (sosConfirm && !sosConfirm.classList.contains("hidden")) closeSosConfirm();
if (sosCooldownDlg && !sosCooldownDlg.classList.contains("hidden")) closeSosCooldown();
});