import { useCallback, useEffect, useMemo, useState } from "react"; import { useAblauf } from "../gemeinsam"; /** * Ablauf der Station „Öffnungszeiten live“ (Bäckerei Sonnenschein), Spezifikation 10. * Reine Logik, kein CSS und keine Adresse: die Komponenten werden später per React.lazy nachgeladen. * * Geschichte (Demo-Datum: Montag 5. Oktober 2026, die Uhr zählt Minuten ab Mo 00:00): * start (kurz Ruhe bei 05:50, „Geschlossen · öffnet heute um 6 Uhr“) * -> lauf (die Uhr läuft in Zehn-Minuten-Schritten bis 18:10; um 06:00 springt der Status auf „Jetzt geöffnet“, * um 18:00 auf „Geschlossen · öffnet morgen um 6 Uhr“) * -> tippen (auf dem Handy gleitet der Finger zu „Hinweis-Leiste anlegen“ und tippt) * -> leiste (die Hinweis-Leiste „Betriebsurlaub 12.–26. Oktober“ gleitet auf der Website ein, bleibt stehen) -> von vorn. * * Bedienen: Wer den Uhr-Regler anfasst (`setzeUhr(minute)`), stoppt den Ablauf. Die Demo zeigt dann den ganzen Stand * (mit Leiste) bei der gewählten Uhrzeit, ohne Timer. `setzeUhr(null)` gibt an den Ablauf zurück (von vorn). */ export type ZeitenPhase = "start" | "lauf" | "tippen" | "leiste"; export type ZeitenStatus = { offen: boolean; /** Langtext der Status-Pille, z. B. „Jetzt geöffnet · schließt um 18 Uhr“ (nie „24“). */ text: string; /** Kurztext: „Geöffnet“ oder „Geschlossen“. */ kurz: string; }; export type ZeitenZustand = { phase: ZeitenPhase; /** Minuten ab Montag, 5. Oktober 2026, 00:00. */ uhr: number; status: ZeitenStatus; /** Ob die Hinweis-Leiste auf der Website steht (Phase „leiste“, im Endzustand und beim Bedienen). */ leiste: boolean; /** * Zähler der Tipps für DemoZeiger: 0 solange der Finger nur gleitet oder nicht zu sehen ist, ab dem Tipp eine je * Durchlauf wachsende Zahl (so erscheint die Klickwelle in jeder Runde neu). Der Finger ist nur in Phase „tippen“ zu sehen. */ klick: number; /** true = der Regler wurde angefasst: der Ablauf steht, die Uhr zeigt den gewählten Wert. */ bedient: boolean; /** true = der Automatiklauf spielt gerade (nicht beim Bedienen, nicht ohne Bewegung). */ laeuft: boolean; /** true = keine Bewegung erlaubt: die Demo zeigt ihren Endzustand (18:10 mit Leiste). */ statisch: boolean; /** Uhr setzen (Regler bedient) oder mit `null` zurück an den Ablauf geben. Die Funktion bleibt über Renderings gleich. */ setzeUhr: (min: number | null) => void; }; // ── Konstanten ───────────────────────────────────────────────────────────────── /** Reglerbereich in Minuten ab Mo 00:00: 05:00 bis 22:00 in Schritten von 10 Minuten. */ export const ZEIT_MIN = 300; export const ZEIT_MAX = 1320; export const ZEIT_SCHRITT = 10; /** Uhrzeit, mit der der Automatiklauf beginnt (05:50) und bei der er endet (18:10). */ export const START_UHR = 350; export const ENDE_UHR = 1090; /** Text der Hinweis-Leiste (der Demo-Tag liegt im 7-Tage-Vorlauf des Urlaubs). */ export const HINWEIS_TEXT = "Betriebsurlaub 12.–26. Oktober"; /** * Mitte des Knopfes „Hinweis-Leiste anlegen“ im Portal-Screenshot phone-12-zeiten (Designpunkte der Handy-Fläche 402 x 874; * gemessen 135,3 / 487,9, gerundet). Die Bühne (Kundenportal.tsx, HINWEIS_PUNKT) übergibt denselben Wert als Prop `punkt`. */ export const HINWEIS_ANLEGEN_PUNKT = { x: 135, y: 488 } as const; // ── Status: Bäckerei Sonnenschein, Montag 6 bis 18 Uhr ────────────────────────── const OEFFNET = 6 * 60; const SCHLIESST = 18 * 60; const ZU_HEUTE: ZeitenStatus = Object.freeze({ offen: false, text: "Geschlossen · öffnet heute um 6 Uhr", kurz: "Geschlossen" }); const OFFEN: ZeitenStatus = Object.freeze({ offen: true, text: "Jetzt geöffnet · schließt um 18 Uhr", kurz: "Geöffnet" }); const ZU_MORGEN: ZeitenStatus = Object.freeze({ offen: false, text: "Geschlossen · öffnet morgen um 6 Uhr", kurz: "Geschlossen" }); /** Status am Montag (Minute 0 bis 1439 ab Mo 00:00): vor 06:00 zu, bis 17:59 offen, ab 18:00 zu bis morgen früh. */ export const statusFuer = (minute: number): ZeitenStatus => minute < OEFFNET ? ZU_HEUTE : minute < SCHLIESST ? OFFEN : ZU_MORGEN; /** Minuten ab Mitternacht als „05:50“ (die Stunde läuft 00 bis 23, nie „24:00“). */ export const formatUhr = (min: number): string => { const tag = ((Math.round(min) % 1440) + 1440) % 1440; return `${String(Math.floor(tag / 60)).padStart(2, "0")}:${String(tag % 60).padStart(2, "0")}`; }; // ── Schritte ─────────────────────────────────────────────────────────────────── /** Alle Dauern sind Vielfache von 50 ms (die Tests spulen in 50-ms-Schritten vor). */ const START_HALTEN = 1000; /** Dauer eines Uhr-Schrittes (10 Minuten) im Lauf. */ const TAKT = 100; /** Wie lange 18:10 („Geschlossen · öffnet morgen um 6 Uhr“) stehen bleibt, bevor der Finger kommt. */ const ENDE_HALTEN = 1500; /** Der Finger gleitet zum Knopf (Phase „tippen“), dann folgt der Tipp (ebenfalls Phase „tippen“). */ const GLEITEN = 1000; const TIPPEN = 450; /** Die Leiste gleitet ein und bleibt stehen. */ const LEISTE_HALTEN = 5000; type Schritt = { phase: ZeitenPhase; uhr: number; dauer: number; tipp?: boolean }; const SCHRITTE: readonly Schritt[] = [ { phase: "start", uhr: START_UHR, dauer: START_HALTEN }, ...Array.from({ length: (ENDE_UHR - START_UHR) / ZEIT_SCHRITT }, (_, i): Schritt => { const uhr = START_UHR + (i + 1) * ZEIT_SCHRITT; return { phase: "lauf", uhr, dauer: uhr === ENDE_UHR ? ENDE_HALTEN : TAKT }; }), { phase: "tippen", uhr: ENDE_UHR, dauer: GLEITEN }, { phase: "tippen", uhr: ENDE_UHR, dauer: TIPPEN, tipp: true }, { phase: "leiste", uhr: ENDE_UHR, dauer: LEISTE_HALTEN }, ]; const DAUERN = SCHRITTE.map((s) => s.dauer); const TIPP_SCHRITT = SCHRITTE.findIndex((s) => s.tipp); /** Gesamtdauer eines Durchlaufs in Millisekunden (für Tests und die Feinabstimmung der Bühne). */ export const ZEITEN_RUNDE_MS = DAUERN.reduce((a, b) => a + b, 0); /** Anfangszustand (Phase „start“, 05:50, ohne Leiste): die Bühne zeigt die Website damit sofort, bevor der Taktgeber geladen ist. */ export const ZEITEN_START: ZeitenZustand = { phase: "start", uhr: START_UHR, status: statusFuer(START_UHR), leiste: false, klick: 0, bedient: false, laeuft: false, statisch: false, setzeUhr: () => {}, }; // ── Hook ─────────────────────────────────────────────────────────────────────── /** * Taktgeber der Station. `laeuft` = Station aktiv, im Bild, Tab sichtbar, Bewegung erlaubt. * Läuft sie nicht, steht die Demo bei „start“ (05:50, nichts bewegt sich, kein Timer); eine Bedienung verfällt dann. * Mit `statisch` (keine Bewegung erlaubt) zeigt sie sofort den Endzustand (18:10 mit Leiste), ohne Timer; * der Regler bleibt auch dann bedienbar (eine Eingabe ist keine Animation). */ export const useZeitenAblauf = (laeuft: boolean, statisch: boolean): ZeitenZustand => { const [manuell, setManuell] = useState(null); // Eine Bedienung gilt nur, solange die Station läuft (oder statisch steht); sonst verfällt sie const gewaehlt = laeuft || statisch ? manuell : null; const spielt = laeuft && !statisch && gewaehlt === null; const { schritt, runde } = useAblauf(spielt, DAUERN, true); useEffect(() => { if (!laeuft && !statisch && manuell !== null) setManuell(null); }, [laeuft, statisch, manuell]); const setzeUhr = useCallback((min: number | null) => { if (min === null) { setManuell(null); return; } if (!Number.isFinite(min)) return; setManuell(Math.min(ZEIT_MAX, Math.max(ZEIT_MIN, Math.round(min)))); }, []); return useMemo(() => { if (statisch || gewaehlt !== null) { // Endzustand bzw. bedient: die Demo steht, die Leiste ist da const uhr = gewaehlt ?? ENDE_UHR; return { phase: "leiste", uhr, status: statusFuer(uhr), leiste: true, klick: 0, bedient: gewaehlt !== null, laeuft: false, statisch, setzeUhr, }; } const aktuell = SCHRITTE[spielt ? Math.min(schritt, SCHRITTE.length - 1) : 0]; return { phase: aktuell.phase, uhr: aktuell.uhr, status: statusFuer(aktuell.uhr), leiste: aktuell.phase === "leiste", klick: spielt && schritt >= TIPP_SCHRITT ? runde + 1 : 0, bedient: false, laeuft: spielt, statisch: false, setzeUhr, }; }, [statisch, gewaehlt, spielt, schritt, runde, setzeUhr]); };