umbau(boxwart): neue Oberflaeche im Cockpit-Stil (Richtung A), Werkzeuge angehoben

Drei Seiten statt zehn: Start, Updates, Modelle — am PC mit Kopfnavigation, am Handy
mit Leiste unten. Umsetzung von Mockup A („Cockpit“), vom User am 23.09. gewaehlt.

- Start: Hauptwarnleuchte + 8 Warnlampen, Rundinstrumente mit Live-Werten aus dem
  Strom (Speicher, Temperatur, Platte) und Laufzeit-Zaehlwerk, Checkliste der
  Waechter-Hinweise mit ihren Knoepfen, Flugplan (heute gelaufen / geplant), Radar-Kasten.
- Updates: Bausteine mit „Laeuft → Neu“ und Zusammenfassung, laufende Auftraege,
  Verlauf der Sonntagslaeufe (neu: GET /api/updates/verlauf), Sicherungen samt
  Zurueckspielen mit Rueckfrage.
- Modelle: Speicherbalken, Rollen Hirn/Coder/Dritte Rolle, wer die Modelle nutzt
  (7 Tage + 24 h je Stunde), Modell-Radar, weitere Eintraege, Modelle selbst suchen.
- Schubladen: Dienste mit Protokoll und Neustart, Einstellungen (HF-Zugang), Hermes-Link.
- Werkzeuge: Vite 8, React 19.3 mit React Compiler 1.0 (Babel), vitest 5, Tailwind 4.3,
  shadcn 4 (Radix) fuer Dialog/Schublade/Knopf, Schriften Barlow/Barlow Condensed/
  JetBrains Mono. Entfernt: recharts, cmdk, Kraftgraph, zustand, Inter, Space Grotesk.
- Startbuendel 115 KB gzip (Budget 140); Updates/Modelle/Schubladen laden bei Bedarf.
- 16 Oberflaechen-Tests (Instrument-Bogen, Hauptleuchte, Zeitformate, Versionen).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-09-23 21:39:07 +02:00
co-authored by Claude Opus 5.5
parent 3d2c9549fb
commit 907289d7dc
223 changed files with 8572 additions and 15393 deletions
@@ -0,0 +1,37 @@
import type { ReactNode } from "react"
import { Button } from "@/components/ui/button"
import {
Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle,
} from "@/components/ui/dialog"
/** Rückfrage vor Schritten, die die Box umbauen (alles aktualisieren, zurückspielen, neu starten). */
export function Bestaetigen({ offen, titel, text, knopf, gefahr, onJa, onNein, children }: {
offen: boolean
titel: string
text: string
knopf: string
gefahr?: boolean
onJa: () => void
onNein: () => void
children?: ReactNode
}) {
return (
<Dialog open={offen} onOpenChange={(o) => !o && onNein()}>
<DialogContent className="border-linie bg-panel sm:max-w-lg">
<DialogHeader>
<DialogTitle className="schild text-xl">{titel}</DialogTitle>
<DialogDescription className="text-[15px] leading-relaxed text-text-2">{text}</DialogDescription>
</DialogHeader>
{children}
<DialogFooter className="gap-2 sm:gap-2">
<Button variant="outline" onClick={onNein}>
Abbrechen
</Button>
<Button variant={gefahr ? "gefahr" : "default"} onClick={onJa}>
{knopf}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
@@ -0,0 +1,97 @@
import { lazy, Suspense, useState } from "react"
import { cn } from "cn"
import { Button } from "@/components/ui/button"
import { useHinweisAktion } from "@/lib/abfragen"
import { post } from "@/lib/api"
import { melden } from "@/lib/meldungen"
import type { AktionsErgebnis, Hinweis, VerlaufEintrag } from "@/lib/typen"
import { seit, vor } from "@/lib/zeit"
// Erst laden, wenn jemand ein Protokoll öffnet (Radix-Dialog gehört nicht ins Start-Bündel).
const Protokollfenster = lazy(() => import("./Protokollfenster").then((m) => ({ default: m.Protokollfenster })))
/** Ein offener Punkt: Titel · Leitpunkte · PRÜFEN/JETZT, darunter Text und Knöpfe. */
function Punkt({ h }: { h: Hinweis }) {
const aktion = useHinweisAktion()
const [protokoll, setProtokoll] = useState<{ titel: string; text: string } | null>(null)
const rot = h.stufe === "rot"
async function ausloesen(aid: string, label: string) {
if (aid === "protokoll") {
try {
const e = await post<AktionsErgebnis>(`/api/hinweise/${encodeURIComponent(h.id)}/aktion/protokoll`)
setProtokoll({ titel: h.titel, text: e.text || e.out || e.err || "Kein Protokoll vorhanden." })
} catch (f) {
melden("fehler", (f as Error).message)
}
return
}
aktion.mutate({ hinweis: h.id, aktion: aid, label })
}
return (
<article className="flex flex-col gap-2.5 border-t border-linie pt-4">
<div className="flex items-center gap-3">
<h3 className="min-w-0 font-sans text-[17px] leading-snug font-semibold">{h.titel}</h3>
<span aria-hidden className="hidden h-0 flex-grow border-b-2 border-dotted border-linie-stark sm:block" />
<span className={cn("schild shrink-0 text-base", rot ? "text-rot" : "text-bernstein")}>
{rot ? "Jetzt" : "Prüfen"}
</span>
</div>
<p className="ziffern text-[13px] text-text-3">
{seit(h.seit)} · zuletzt {vor(h.zuletzt)}
</p>
{h.text && <p className="text-[15px] leading-relaxed break-words text-text-2">{h.text}</p>}
{h.aktionen.length > 0 && (
<div className="flex flex-wrap gap-2.5 pt-1">
{h.aktionen.map((a, i) => (
<Button
key={a.id}
variant={i === 0 && a.id !== "protokoll" ? "default" : "outline"}
size="sm"
disabled={aktion.isPending}
onClick={() => ausloesen(a.id, a.label)}
>
{a.label}
</Button>
))}
</div>
)}
{protokoll && (
<Suspense fallback={null}>
<Protokollfenster offen titel={protokoll.titel} text={protokoll.text} onSchliessen={() => setProtokoll(null)} />
</Suspense>
)}
</article>
)
}
export function Checkliste({ hinweise, verlauf }: { hinweise: Hinweis[]; verlauf: VerlaufEintrag[] }) {
const heute = new Date().toDateString()
const erledigtHeute = verlauf.filter(
(v) => (v.art === "erledigt" || v.art === "auto") && new Date(v.ts * 1000).toDateString() === heute,
)
if (hinweise.length === 0) {
return (
<div className="flex flex-col gap-2 border-t border-linie pt-4">
<p className="text-[15px] text-text-2">Keine offenen Punkte. Der Wächter prüft jede Minute.</p>
{erledigtHeute.length > 0 && (
<ul className="flex flex-col gap-1 text-sm text-text-3">
{erledigtHeute.slice(0, 4).map((v) => (
<li key={`${v.id}-${v.ts}`}>
<span className="ziffern">{vor(v.ts)}</span> · {v.text}
</li>
))}
</ul>
)}
</div>
)
}
return (
<div className="flex flex-col gap-4">
{hinweise.map((h) => (
<Punkt key={h.id} h={h} />
))}
</div>
)
}
+36
View File
@@ -0,0 +1,36 @@
import type { ReactNode } from "react"
import { cn } from "cn"
export type ChipArt = "gruen" | "bernstein" | "cyan" | "rot" | "grau"
const ART: Record<ChipArt, string> = {
gruen: "border-gruen-rand bg-gruen-grund text-gruen",
bernstein: "border-bernstein/60 bg-bernstein-grund text-bernstein",
cyan: "border-cyan-rand bg-cyan-grund text-cyan",
rot: "border-rot-rand bg-rot-grund text-rot-text",
grau: "border-linie bg-erhaben text-text-2",
}
/** Kleines Zustandsschild („GELADEN“, „PASST“, „FEHLGESCHLAGEN“). */
export function Chip({ art = "grau", children }: { art?: ChipArt; children: ReactNode }) {
return (
<span className={cn("schild inline-flex h-7 items-center rounded-full border px-2.5 text-xs whitespace-nowrap", ART[art])}>
{children}
</span>
)
}
/** Einheitlicher Platzhalter, solange Daten laden oder wenn die Box nicht antwortet. */
export function Zustandsfeld({ fehler, text }: { fehler?: boolean; text: string }) {
return (
<div
role={fehler ? "alert" : "status"}
className={cn(
"rounded-2xl border px-5 py-8 text-center text-[15px]",
fehler ? "border-rot-rand bg-rot-grund text-rot-text" : "border-linie bg-panel text-text-2",
)}
>
{text}
</div>
)
}
@@ -0,0 +1,31 @@
import { cn } from "cn"
import type { FlugplanEintrag } from "@/lib/typen"
import { flugplanZeit } from "@/lib/zeit"
const STATUS: Record<FlugplanEintrag["status"], { text: string; farbe: string }> = {
erledigt: { text: "Erledigt", farbe: "text-gruen" },
fehler: { text: "Fehler", farbe: "text-rot" },
geplant: { text: "Geplant", farbe: "text-text-3" },
}
function Zeile({ e }: { e: FlugplanEintrag }) {
const s = STATUS[e.status] ?? STATUS.geplant
// Updates sind das Wichtigste im Plan — sie stehen in Cyan.
const farbe = e.status === "geplant" && /update/i.test(e.titel) ? "text-cyan" : s.farbe
return (
<li className="grid grid-cols-[112px_minmax(0,1fr)_auto] items-center gap-3 border-t border-linie py-2.5">
<span className="ziffern text-sm text-text-3">{flugplanZeit(e.zeit)}</span>
<span className="min-w-0 text-base">
{e.titel}
{e.text && <span className="text-text-3">, {e.text}</span>}
</span>
<span className={cn("schild text-[13px]", farbe)}>{s.text}</span>
</li>
)
}
export function Flugplan({ gelaufen, geplant }: { gelaufen: FlugplanEintrag[]; geplant: FlugplanEintrag[] }) {
const alle = [...gelaufen, ...geplant.slice(0, 5)]
if (alle.length === 0) return <p className="text-[15px] text-text-2">Heute ist noch nichts gelaufen, und nichts ist geplant.</p>
return <ol className="m-0 flex list-none flex-col p-0">{alle.map((e) => <Zeile key={`${e.titel}-${e.zeit}`} e={e} />)}</ol>
}
@@ -0,0 +1,50 @@
// Rundinstrument: 240°-Bogen wie ein Zeigerinstrument, Wert als Leuchtbogen, Zahl in der Mitte.
import { bogenPfad } from "@/lib/anzeige"
export interface Zone {
von: number
bis: number
farbe: string
}
export function Instrument({
anteil,
farbe = "var(--cyan)",
zonen = [],
mitte,
unter,
beschriftung,
ariaText,
}: {
anteil: number | null
farbe?: string
zonen?: Zone[]
mitte: string
unter?: string
beschriftung: string
ariaText: string
}) {
const zeigen = anteil != null && anteil > 0.004
return (
<figure className="m-0 flex min-w-0 flex-col items-center gap-1">
<svg viewBox="0 0 200 150" className="h-auto w-full max-w-[200px]" role="img" aria-label={ariaText}>
<path d={bogenPfad(0, 1, 75)} fill="none" stroke="var(--linie)" strokeWidth={12} strokeLinecap="round" />
{zonen.map((z) => (
<path key={`${z.von}-${z.bis}`} d={bogenPfad(z.von, z.bis, 86)} fill="none" stroke={z.farbe} strokeWidth={4} strokeLinecap="round" />
))}
{zeigen && (
<path d={bogenPfad(0, anteil, 75)} fill="none" stroke={farbe} strokeWidth={12} strokeLinecap="round" />
)}
<text x="100" y="100" textAnchor="middle" className="ziffern" style={{ fontSize: 34, fontWeight: 500, fill: "var(--foreground)" }}>
{mitte}
</text>
{unter && (
<text x="100" y="122" textAnchor="middle" style={{ fontFamily: "var(--font-anzeige)", fontSize: 14, fontWeight: 600, letterSpacing: "0.12em", fill: "var(--text-3)" }}>
{unter.toUpperCase()}
</text>
)}
</svg>
<figcaption className="schild text-[15px] text-text-3">{beschriftung}</figcaption>
</figure>
)
}
+42
View File
@@ -0,0 +1,42 @@
import { cn } from "cn"
import type { Lampe as LampenDaten, LampenZustand } from "@/lib/typen"
// Warnlampen wie im Flugzeug: Grün = läuft, dunkel = aus/auf Abruf, Bernstein = kümmern,
// Cyan = Info, Rot = Störung. Bernstein und Rot leuchten, alles andere bleibt ruhig.
const LAMPEN_STIL: Record<LampenZustand, { feld: string; name: string; wert: string }> = {
ok: { feld: "border-gruen-rand bg-gruen-grund", name: "text-gruen", wert: "text-gruen-text" },
aus: { feld: "border-aus-rand bg-aus-grund", name: "text-aus-text", wert: "text-text-3" },
warn: { feld: "border-bernstein bg-bernstein-lampe lampe-atmet", name: "text-bernstein", wert: "text-[#f2c46b]" },
info: { feld: "border-cyan-rand bg-cyan-grund", name: "text-cyan", wert: "text-cyan-text" },
fehler: {
feld: "border-rot bg-rot-grund shadow-[0_0_18px_rgba(255,90,79,0.35)]",
name: "text-rot",
wert: "text-rot-text",
},
}
const ZUSTAND_TEXT: Record<LampenZustand, string> = {
ok: "in Ordnung",
aus: "aus",
warn: "braucht Aufmerksamkeit",
info: "Info",
fehler: "Störung",
}
export function Lampe({ lampe }: { lampe: LampenDaten }) {
const stil = LAMPEN_STIL[lampe.zustand] ?? LAMPEN_STIL.aus
return (
<li
className={cn(
"flex min-h-[58px] flex-col items-center justify-center gap-0.5 rounded-lg border px-2 py-2 text-center",
stil.feld,
)}
>
<span className={cn("schild text-[17px] leading-none font-bold tracking-[0.18em]", stil.name)}>
{lampe.label}
</span>
<span className={cn("ziffern text-xs uppercase", stil.wert)}>{lampe.wert}</span>
<span className="sr-only">{ZUSTAND_TEXT[lampe.zustand]}</span>
</li>
)
}
+33
View File
@@ -0,0 +1,33 @@
import type { ReactNode } from "react"
import { cn } from "cn"
/** Ein Feld des Instrumentenbretts: dunkle Platte, schmale Beschriftung oben links. */
export function Panel({
titel,
rechts,
children,
className,
id,
}: {
titel?: string
rechts?: ReactNode
children: ReactNode
className?: string
id?: string
}) {
return (
<section
id={id}
aria-label={titel}
className={cn("flex min-w-0 flex-col gap-4 rounded-2xl border border-linie bg-panel p-5 sm:p-6", className)}
>
{(titel || rechts) && (
<div className="flex flex-wrap items-center justify-between gap-3">
{titel && <h2 className="schild text-lg font-bold tracking-[0.22em] text-foreground">{titel}</h2>}
{rechts}
</div>
)}
{children}
</section>
)
}
@@ -0,0 +1,25 @@
import {
Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle,
} from "@/components/ui/sheet"
/** Seitenschublade mit Protokolltext (Journal oder Hermes-Fehlerzeilen), unten zuerst gelesen. */
export function Protokollfenster({ offen, titel, text, onSchliessen }: {
offen: boolean
titel: string
text: string
onSchliessen: () => void
}) {
return (
<Sheet open={offen} onOpenChange={(o) => !o && onSchliessen()}>
<SheetContent side="right" className="w-full border-linie bg-panel sm:max-w-2xl">
<SheetHeader>
<SheetTitle className="schild text-lg">Protokoll</SheetTitle>
<SheetDescription className="text-text-2">{titel}</SheetDescription>
</SheetHeader>
<pre className="ziffern mx-4 mb-4 flex-1 overflow-auto rounded-lg border border-linie bg-[#0b0d0f] p-3 text-xs leading-relaxed whitespace-pre-wrap text-text-2">
{text}
</pre>
</SheetContent>
</Sheet>
)
}
@@ -0,0 +1,47 @@
import { cn } from "cn"
import type { Lampe as LampenDaten, Start } from "@/lib/typen"
import { hauptleuchte } from "@/lib/anzeige"
import { Lampe } from "./Lampe"
type Zustand = Start["zustand"]
const LEUCHTE: Record<ReturnType<typeof hauptleuchte>["art"], string> = {
ok: "border-gruen-rand bg-gruen-grund text-gruen",
info: "border-cyan-rand bg-cyan-grund text-cyan",
stumm: "border-aus-rand bg-aus-grund text-aus-text",
gelb: "border-2 border-bernstein bg-bernstein-grund text-bernstein-hell lampe-atmet",
rot: "border-2 border-rot bg-rot-grund text-rot shadow-[0_0_32px_rgba(255,90,79,0.3)]",
}
export function Warnpanel({ zustand, lampen, onAnsehen }: {
zustand: Zustand
lampen: LampenDaten[]
onAnsehen: () => void
}) {
const h = hauptleuchte(zustand)
const aktiv = h.art === "gelb" || h.art === "rot"
return (
<section aria-label="Warnpanel" className="grid gap-4 md:grid-cols-[260px_minmax(0,1fr)] md:gap-5">
<button
type="button"
onClick={onAnsehen}
disabled={!aktiv}
aria-label={`${h.oben} ${h.mitte}`}
className={cn(
"flex min-h-[130px] flex-col items-center justify-center gap-1 rounded-2xl border px-4 py-4 font-anzeige transition-colors",
LEUCHTE[h.art],
aktiv ? "cursor-pointer" : "cursor-default",
)}
>
<span className="schild text-sm tracking-[0.26em]">{h.oben}</span>
<span className="text-[40px] leading-none font-bold tracking-[0.04em] sm:text-[44px]">{h.mitte}</span>
<span className="font-sans text-sm opacity-80">{h.unten}</span>
</button>
<ul className="grid grid-cols-2 gap-2.5 rounded-2xl border border-linie bg-panel p-3 sm:grid-cols-4">
{lampen.map((l) => (
<Lampe key={l.id} lampe={l} />
))}
</ul>
</section>
)
}
@@ -0,0 +1,30 @@
import { laufzeit } from "@/lib/zeit"
function Ziffer({ z }: { z: string }) {
return (
<span className="ziffern inline-flex h-14 w-[38px] items-center justify-center rounded-md border border-[#2a3036] bg-[#0b0d0f] text-[32px] font-medium">
{z}
</span>
)
}
/** Laufzeit als mechanisches Zählwerk: TT T SS H. */
export function Zaehlwerk({ sekunden }: { sekunden: number | null | undefined }) {
const lz = laufzeit(sekunden)
const tage = String(Math.min(lz?.[0] ?? 0, 99)).padStart(2, "0")
const std = String(lz?.[1] ?? 0).padStart(2, "0")
const text = lz ? `Laufzeit ohne Neustart: ${lz[0]} Tage, ${lz[1]} Stunden` : "Laufzeit unbekannt"
return (
<figure className="m-0 flex min-w-0 flex-col items-center justify-end gap-1">
<div aria-label={text} role="img" className="flex h-[150px] max-w-full items-center gap-1.5 pb-5">
<Ziffer z={lz ? tage[0] : "–"} />
<Ziffer z={lz ? tage[1] : "–"} />
<span className="schild mr-2 ml-0.5 text-lg font-bold text-text-3">T</span>
<Ziffer z={lz ? std[0] : "–"} />
<Ziffer z={lz ? std[1] : "–"} />
<span className="schild ml-0.5 text-lg font-bold text-text-3">H</span>
</div>
<figcaption className="schild text-[15px] text-text-3">Ohne Neustart</figcaption>
</figure>
)
}
@@ -0,0 +1,55 @@
import { render, screen } from "@testing-library/react"
import { bogenPfad, hauptleuchte } from "@/lib/anzeige"
import { Warnpanel } from "./Warnpanel"
import type { Start } from "@/lib/typen"
const ruhig: Start["zustand"] = { stufe: "ok", anzahl: 0, waechter_wach: true, stand: 1, update_laeuft: false }
describe("bogenPfad", () => {
it("beginnt links unten und endet bei vollem Ausschlag rechts unten", () => {
expect(bogenPfad(0, 1, 75)).toBe("M 35.05 132.5 A 75 75 0 1 1 164.95 132.5")
})
it("zeichnet 45 % Speicher als kleinen Bogen bis kurz vor die Spitze", () => {
// 45 % von 240° = 108° → Endwinkel 102°, also knapp links der Senkrechten.
expect(bogenPfad(0, 0.45, 75)).toBe("M 35.05 132.5 A 75 75 0 0 1 84.41 21.64")
})
it("klemmt Werte außerhalb von 0…1", () => {
expect(bogenPfad(-1, 2, 75)).toBe(bogenPfad(0, 1, 75))
})
})
describe("hauptleuchte", () => {
it("bleibt ruhig, wenn nichts offen ist", () => {
expect(hauptleuchte(ruhig)).toMatchObject({ art: "ok", mitte: "IN ORDNUNG" })
})
it("zeigt Bernstein bei gelben und Rot bei roten Hinweisen", () => {
expect(hauptleuchte({ ...ruhig, stufe: "gelb", anzahl: 2 })).toMatchObject({ art: "gelb", mitte: "2 HINWEISE" })
expect(hauptleuchte({ ...ruhig, stufe: "rot", anzahl: 1 })).toMatchObject({ art: "rot", mitte: "1 HINWEIS" })
})
it("meldet einen schweigenden Wächter vor allem anderen", () => {
expect(hauptleuchte({ ...ruhig, waechter_wach: false, anzahl: 3, stufe: "rot" }).art).toBe("stumm")
})
})
describe("Warnpanel", () => {
it("zeigt jede Lampe mit Name und Wert", () => {
render(
<Warnpanel
zustand={{ ...ruhig, stufe: "gelb", anzahl: 2 }}
lampen={[
{ id: "motor", label: "Motor", zustand: "ok", wert: "b11057" },
{ id: "jobs", label: "Jobs", zustand: "warn", wert: "2 Hinweise" },
]}
onAnsehen={() => {}}
/>,
)
expect(screen.getByText("Motor")).toBeInTheDocument()
expect(screen.getByText("b11057")).toBeInTheDocument()
expect(screen.getByText("braucht Aufmerksamkeit")).toBeInTheDocument()
expect(screen.getByRole("button", { name: /Achtung 2 HINWEISE/ })).toBeEnabled()
})
})