UI: Auftragsbuch, Chronik+Zeitmaschine, Wissens-Wiki, Morgenlage, Erinnerungen

Neue Views: Auftragsbuch (Karten mit Diff/Kritiker-Kontext, Annehmen/Ablehnen mit
Fangnetz-Dialog), Chronik (Tages-Timeline aus dem Briefkasten + Zeitmaschine mit
Ein-Klick-Restore), Wissen (Vault als klickbares Wiki, [[Links]] springen).
Cockpit: Auftragsbuch-Kachel + Braucht-dich-Eintrag, Morgenlage-Karte (juengstes
Chef-Verdikt), Anstehendes (Erinnerungen anlegen/loeschen), Wissen/Chronik-Kacheln.
Gedaechtnis: Dubletten-Dialog zeigt jetzt konkret, was bleibt und was wegfaellt.
dist mitgebaut (tsc+vite gruen).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-07-08 18:42:22 +02:00
parent 4dc5ca7343
commit 1985a692bf
18 changed files with 1919 additions and 706 deletions
+318
View File
@@ -0,0 +1,318 @@
import { useState } from "react"
import {
Inbox, Check, X, GitBranch, FileDiff, Loader2, AlertTriangle, Sparkles,
ChevronDown, ChevronRight, Clock, Hammer, RefreshCw,
} from "lucide-react"
import { api, type AuftragItem, type SkillKandidat } from "@/lib/api"
import { useAuftragsbuch, useQueryClient, qk } from "@/lib/queries"
import { useDialog } from "@/lib/useDialog"
import { cn } from "@/lib/utils"
// Auftragsbuch = die Vorschlags-Inbox der Box. Werkstatt/Orchestrator liefern propose-only-
// Branches, der Traum liefert Skill-Kandidaten — hier fällt die Entscheidung per Klick.
// Annehmen (Branch) = Merge→Push→Deploy→Health mit Auto-Rollback; alles detached auf der Box.
const KIND_LABEL: Record<string, string> = {
wartung: "Werkstatt",
orchestrator: "Orchestrator",
doku: "Doku",
feature: "Feature",
}
const STATE_UI: Record<string, { label: string; cls: string; spin?: boolean }> = {
laeuft: { label: "wird eingespielt …", cls: "text-sky-300 border-sky-500/40 bg-sky-500/10", spin: true },
rollback: { label: "Rollback läuft …", cls: "text-amber-300 border-amber-500/40 bg-amber-500/10", spin: true },
eingespielt: { label: "eingespielt ✓", cls: "text-emerald-300 border-emerald-500/40 bg-emerald-500/10" },
fehlgeschlagen: { label: "fehlgeschlagen", cls: "text-red-300 border-red-500/40 bg-red-500/10" },
zurueckgerollt: { label: "zurückgerollt", cls: "text-amber-300 border-amber-500/40 bg-amber-500/10" },
kritisch: { label: "KRITISCH — Box prüfen", cls: "text-red-200 border-red-500/60 bg-red-500/20" },
abgelehnt: { label: "abgelehnt", cls: "text-muted-foreground border-border/60 bg-background/30" },
}
function fmtWhen(ts: number | null | undefined): string {
if (!ts) return "—"
return new Date(ts * 1000).toLocaleString("de-DE", { day: "2-digit", month: "2-digit", hour: "2-digit", minute: "2-digit" })
}
export function AuftragsbuchView() {
const { data, isLoading, refetch, isFetching } = useAuftragsbuch()
const qc = useQueryClient()
const { showAlert, showConfirm, dialogElement } = useDialog()
const [busy, setBusy] = useState<Record<string, boolean>>({})
const reload = () => qc.invalidateQueries({ queryKey: qk.auftragsbuch })
async function act(key: string, path: string, body: object, done?: string) {
setBusy((b) => ({ ...b, [key]: true }))
try {
await api(path, { method: "POST", body: JSON.stringify(body) })
if (done) showAlert("Erledigt", done)
reload()
} catch (e: any) {
showAlert("Das hat nicht geklappt", String(e?.message || e))
} finally {
setBusy((b) => ({ ...b, [key]: false }))
}
}
const items = data?.items ?? []
const kandidaten = data?.skill_kandidaten ?? []
const empty = items.length === 0 && kandidaten.length === 0
return (
<div className="space-y-6">
<div className="flex items-center justify-between gap-3">
<div>
<h1 className="bg-gradient-to-r from-foreground via-foreground to-primary bg-clip-text font-space text-2xl font-bold tracking-tight text-transparent">
Auftragsbuch
</h1>
<p className="text-sm text-muted-foreground">
Was die Box gebaut oder sich ausgedacht hat du entscheidest. Annehmen spielt es mit Fangnetz ein (Health-Check, automatischer Rollback).
</p>
</div>
<button onClick={() => refetch()}
className="flex h-9 items-center gap-1.5 rounded-lg border border-border/60 bg-card/45 px-3 text-xs font-semibold text-muted-foreground hover:text-foreground hover:border-primary/40 transition-all cursor-pointer">
<RefreshCw className={cn("h-3.5 w-3.5", isFetching && "animate-spin")} /> Aktualisieren
</button>
</div>
{data && !data.available && (
<div className="rounded-2xl border border-amber-500/30 bg-amber-500/5 p-4 text-xs text-amber-300">
Das Auftragsbuch lebt auf der Box (liest die Gitea-Branches dort). Im lokalen Dev-Modus ist hier nichts zu sehen.
</div>
)}
{isLoading && (
<div className="flex items-center gap-2 rounded-2xl border border-border/60 bg-card/30 p-6 text-xs text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" /> Vorschläge werden geladen
</div>
)}
{data?.available && empty && (
<div className="flex items-center gap-3 rounded-2xl border border-emerald-500/25 bg-emerald-500/5 p-5">
<Check className="h-5 w-5 shrink-0 text-emerald-400" />
<div>
<p className="text-sm font-semibold text-foreground">Nichts zu entscheiden</p>
<p className="text-xs text-muted-foreground">Keine offenen Vorschläge. Werkstatt, Orchestrator und Traum melden sich hier, sobald es etwas gibt.</p>
</div>
</div>
)}
{items.length > 0 && (
<section className="space-y-3">
<SectionLabel icon={GitBranch}>Fertige Patches ({items.length})</SectionLabel>
{items.map((it) => (
<ProposalCard key={it.branch} item={it} busy={busy} onAct={act} showConfirm={showConfirm} />
))}
</section>
)}
{kandidaten.length > 0 && (
<section className="space-y-3">
<SectionLabel icon={Sparkles}>Skill-Kandidaten aus dem Traum ({kandidaten.length})</SectionLabel>
{kandidaten.map((k) => (
<KandidatCard key={k.file} k={k} busy={busy} onAct={act} showConfirm={showConfirm} />
))}
</section>
)}
{dialogElement}
</div>
)
}
function SectionLabel({ icon: Icon, children }: { icon: any; children: React.ReactNode }) {
return (
<p className="flex items-center gap-1.5 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/60">
<Icon className="h-3.5 w-3.5" /> {children}
</p>
)
}
function StateBadge({ item }: { item: AuftragItem }) {
const st = item.status
if (!st) return null
const ui = STATE_UI[st.state]
if (!ui) return null
return (
<span className={cn("flex items-center gap-1.5 rounded-lg border px-2 py-0.5 text-[10px] font-bold", ui.cls)} title={st.detail}>
{ui.spin && <Loader2 className="h-3 w-3 animate-spin" />}
{ui.label}
</span>
)
}
function ProposalCard({
item, busy, onAct, showConfirm,
}: {
item: AuftragItem
busy: Record<string, boolean>
onAct: (key: string, path: string, body: object, done?: string) => void
showConfirm: (title: string, msg: string, ok: () => void) => void
}) {
const [open, setOpen] = useState(false)
const [diff, setDiff] = useState<string | null>(null)
const [diffLoading, setDiffLoading] = useState(false)
const running = item.status?.state === "laeuft" || item.status?.state === "rollback"
const decided = item.status?.state === "eingespielt"
async function loadDiff() {
const next = !open
setOpen(next)
if (next && diff === null) {
setDiffLoading(true)
try {
const r = await api<{ diff: string; truncated: boolean }>(`/api/auftragsbuch/diff?branch=${encodeURIComponent(item.branch)}`)
setDiff(r.diff + (r.truncated ? "\n… (gekürzt)" : ""))
} catch (e: any) {
setDiff(`(Diff nicht ladbar: ${e?.message || e})`)
} finally {
setDiffLoading(false)
}
}
}
return (
<div className="rounded-2xl border border-border/60 bg-card/45 p-4 shadow-lg shadow-black/15 backdrop-blur-md space-y-3">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<span className="rounded-md border border-primary/30 bg-primary/10 px-1.5 py-0.5 text-[10px] font-bold uppercase tracking-wider text-primary">
{KIND_LABEL[item.kind] ?? item.kind}
</span>
<span className="font-mono text-[11px] text-muted-foreground truncate" title={item.branch}>{item.branch}</span>
<span className="flex items-center gap-1 text-[10px] text-muted-foreground/70"><Clock className="h-3 w-3" />{fmtWhen(item.ts)}</span>
<StateBadge item={item} />
</div>
<p className="mt-1.5 text-sm font-semibold text-foreground">{item.subject || "(ohne Titel)"}</p>
{item.body && <p className="mt-1 whitespace-pre-wrap text-xs leading-relaxed text-muted-foreground line-clamp-6">{item.body}</p>}
</div>
{!running && !decided && (
<div className="flex shrink-0 items-center gap-2">
<button
disabled={!!busy[item.branch]}
onClick={() =>
showConfirm("Vorschlag annehmen?",
`'${item.branch}' wird auf main gemergt und live eingespielt. Die Zentrale startet dabei kurz neu. Geht der Health-Check danach rot, rollt die Box automatisch zurück.${item.frontend_ohne_build ? "\n\nAchtung: Enthält Frontend-Quelltext ohne gebautes Bundle — die Oberfläche bleibt alt, bis am PC gebaut wird." : ""}`,
() => onAct(item.branch, "/api/auftragsbuch/annehmen", { branch: item.branch }))}
className="flex h-9 items-center gap-1.5 rounded-lg border border-emerald-500/40 bg-emerald-500/10 px-3 text-[11px] font-bold uppercase tracking-wide text-emerald-300 transition-all hover:bg-emerald-500/20 cursor-pointer disabled:opacity-50">
{busy[item.branch] ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Check className="h-3.5 w-3.5" />} Annehmen
</button>
<button
disabled={!!busy["ab:" + item.branch]}
onClick={() =>
showConfirm("Vorschlag ablehnen?",
`Der Branch '${item.branch}' wird auf Gitea gelöscht. Die Arbeit bleibt in der Historie, aber die Karte verschwindet.`,
() => onAct("ab:" + item.branch, "/api/auftragsbuch/ablehnen", { branch: item.branch }))}
className="flex h-9 items-center gap-1.5 rounded-lg border border-red-500/30 bg-red-500/5 px-3 text-[11px] font-bold uppercase tracking-wide text-red-300/90 transition-all hover:bg-red-500/15 cursor-pointer disabled:opacity-50">
{busy["ab:" + item.branch] ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <X className="h-3.5 w-3.5" />} Ablehnen
</button>
</div>
)}
</div>
{item.status && (running || !decided) && item.status.detail && (
<p className="text-[11px] text-muted-foreground">{item.status.detail}</p>
)}
<div className="flex flex-wrap items-center gap-2 text-[11px] text-muted-foreground">
<span className="rounded bg-background/40 px-1.5 py-0.5 font-mono">{item.ahead} Commit{item.ahead === 1 ? "" : "s"}</span>
{item.shortstat && <span className="rounded bg-background/40 px-1.5 py-0.5 font-mono">{item.shortstat.trim()}</span>}
{item.behind > 0 && (
<span className="flex items-center gap-1 rounded bg-amber-500/10 px-1.5 py-0.5 text-amber-300" title="main ist seit diesem Vorschlag weitergelaufen">
<AlertTriangle className="h-3 w-3" /> {item.behind} hinter main
</span>
)}
{item.frontend_ohne_build && (
<span className="flex items-center gap-1 rounded bg-amber-500/10 px-1.5 py-0.5 text-amber-300" title="Die Box kann kein Frontend bauen (kein Node) — nach dem Annehmen bleibt die Oberfläche alt, bis am PC gebaut wird.">
<AlertTriangle className="h-3 w-3" /> Frontend ohne Build
</span>
)}
</div>
<div>
<button onClick={loadDiff}
className="flex items-center gap-1.5 text-[11px] font-semibold text-muted-foreground hover:text-foreground transition-colors cursor-pointer">
{open ? <ChevronDown className="h-3.5 w-3.5" /> : <ChevronRight className="h-3.5 w-3.5" />}
<FileDiff className="h-3.5 w-3.5" /> {item.files.length} Datei{item.files.length === 1 ? "" : "en"} Änderungen ansehen
</button>
{open && (
<div className="mt-2 space-y-2">
<div className="flex flex-wrap gap-1.5">
{item.files.map((f) => (
<span key={f.path} className="rounded bg-background/40 px-1.5 py-0.5 font-mono text-[10px] text-muted-foreground">
<b className={cn(f.status.startsWith("A") && "text-emerald-400", f.status.startsWith("D") && "text-red-400", f.status.startsWith("M") && "text-sky-400")}>{f.status}</b> {f.path}
</span>
))}
{item.files_truncated && <span className="text-[10px] text-muted-foreground/60"></span>}
</div>
<div className="max-h-96 overflow-auto rounded-xl border border-border/50 bg-background/50 p-3">
{diffLoading
? <span className="text-[11px] text-muted-foreground">Diff wird geladen </span>
: <pre className="text-[10px] leading-relaxed text-foreground/80 whitespace-pre">{
(diff ?? "").split("\n").map((ln, i) => (
<span key={i} className={cn("block", ln.startsWith("+") && !ln.startsWith("+++") && "text-emerald-300/90 bg-emerald-500/5", ln.startsWith("-") && !ln.startsWith("---") && "text-red-300/90 bg-red-500/5", (ln.startsWith("@@") || ln.startsWith("diff ")) && "text-sky-300/90")}>{ln || " "}</span>
))
}</pre>}
</div>
</div>
)}
</div>
</div>
)
}
function KandidatCard({
k, busy, onAct, showConfirm,
}: {
k: SkillKandidat
busy: Record<string, boolean>
onAct: (key: string, path: string, body: object, done?: string) => void
showConfirm: (title: string, msg: string, ok: () => void) => void
}) {
const [open, setOpen] = useState(false)
return (
<div className="rounded-2xl border border-violet-500/25 bg-violet-500/[0.04] p-4 shadow-lg shadow-black/15 backdrop-blur-md space-y-3">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<span className="rounded-md border border-violet-500/30 bg-violet-500/10 px-1.5 py-0.5 text-[10px] font-bold uppercase tracking-wider text-violet-300">Traum-Idee</span>
<span className="font-mono text-[11px] text-muted-foreground truncate">{k.file}</span>
<span className="flex items-center gap-1 text-[10px] text-muted-foreground/70"><Clock className="h-3 w-3" />{fmtWhen(k.mtime)}</span>
</div>
<p className="mt-1.5 text-sm font-semibold text-foreground">{k.title}</p>
</div>
<div className="flex shrink-0 items-center gap-2">
<button
disabled={!!busy["sk:" + k.file]}
onClick={() =>
showConfirm("Werkstatt beauftragen?",
`Der Kandidat '${k.title}' geht als Auftrag an die Werkstatt (Orchestrator, propose-only). Das Ergebnis erscheint später als neuer Patch-Vorschlag hier im Auftragsbuch — nichts geht ungefragt live.`,
() => onAct("sk:" + k.file, "/api/auftragsbuch/skill/annehmen", { file: k.file },
"Die Werkstatt arbeitet. Das Ergebnis taucht als neuer Vorschlag im Auftragsbuch auf (das kann einige Minuten dauern)."))}
className="flex h-9 items-center gap-1.5 rounded-lg border border-violet-500/40 bg-violet-500/10 px-3 text-[11px] font-bold uppercase tracking-wide text-violet-300 transition-all hover:bg-violet-500/20 cursor-pointer disabled:opacity-50">
{busy["sk:" + k.file] ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Hammer className="h-3.5 w-3.5" />} Beauftragen
</button>
<button
disabled={!!busy["skab:" + k.file]}
onClick={() =>
showConfirm("Kandidat verwerfen?",
`'${k.title}' wandert ins Archiv des Wissens-Vaults (verworfen/). Der Traum schlägt ihn nicht erneut vor.`,
() => onAct("skab:" + k.file, "/api/auftragsbuch/skill/ablehnen", { file: k.file }))}
className="flex h-9 items-center gap-1.5 rounded-lg border border-red-500/30 bg-red-500/5 px-3 text-[11px] font-bold uppercase tracking-wide text-red-300/90 transition-all hover:bg-red-500/15 cursor-pointer disabled:opacity-50">
{busy["skab:" + k.file] ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <X className="h-3.5 w-3.5" />} Verwerfen
</button>
</div>
</div>
<button onClick={() => setOpen((o) => !o)}
className="flex items-center gap-1.5 text-[11px] font-semibold text-muted-foreground hover:text-foreground transition-colors cursor-pointer">
{open ? <ChevronDown className="h-3.5 w-3.5" /> : <ChevronRight className="h-3.5 w-3.5" />}
<Inbox className="h-3.5 w-3.5" /> Vorschlag lesen
</button>
{open && (
<pre className="max-h-72 overflow-auto rounded-xl border border-border/50 bg-background/50 p-3 text-[11px] leading-relaxed text-foreground/85 whitespace-pre-wrap">{k.preview}</pre>
)}
</div>
)
}
+214
View File
@@ -0,0 +1,214 @@
import { useMemo, useState } from "react"
import {
History, HeartPulse, ShieldAlert, Bell, Moon, Radar, Inbox, Loader2,
Archive, RotateCcw, AlarmClock, MessageCircle, Sparkles, type LucideIcon,
} from "lucide-react"
import { api, type ChronikItem, type Snapshot } from "@/lib/api"
import { useChronik, useZeitmaschine, useQueryClient, qk } from "@/lib/queries"
import { useDialog } from "@/lib/useDialog"
import { cn } from "@/lib/utils"
// Chronik = die lesbare Geschichte dessen, was die Box von allein getan und gemeldet hat
// (Quelle: Melde-Briefkasten). Daneben die Zeitmaschine: Snapshots ansehen und per Klick
// zu einem Stand zurückkehren — mit hartem Bestätigungsdialog und Pre-Restore-Sicherung.
const SOURCE_UI: Record<string, { icon: LucideIcon; label: string; cls: string }> = {
sentry: { icon: HeartPulse, label: "Health-Wächter", cls: "text-red-300 bg-red-500/10 border-red-500/25" },
alarm: { icon: ShieldAlert, label: "Alarm", cls: "text-red-200 bg-red-500/15 border-red-500/40" },
notify: { icon: Bell, label: "Meldung", cls: "text-sky-300 bg-sky-500/10 border-sky-500/25" },
"chef-gutachter": { icon: Moon, label: "Morgenlage", cls: "text-violet-300 bg-violet-500/10 border-violet-500/25" },
radar: { icon: Radar, label: "Radar", cls: "text-teal-300 bg-teal-500/10 border-teal-500/25" },
auftragsbuch: { icon: Inbox, label: "Auftragsbuch", cls: "text-primary bg-primary/10 border-primary/25" },
zeitmaschine: { icon: RotateCcw, label: "Zeitmaschine", cls: "text-amber-300 bg-amber-500/10 border-amber-500/25" },
reminder: { icon: AlarmClock, label: "Erinnerung", cls: "text-amber-300 bg-amber-500/10 border-amber-500/25" },
traum: { icon: Sparkles, label: "Traum", cls: "text-violet-300 bg-violet-500/10 border-violet-500/25" },
}
const SOURCE_FALLBACK = { icon: MessageCircle, label: "Box", cls: "text-muted-foreground bg-background/40 border-border/50" }
function dayKey(ts: number): string {
return new Date(ts * 1000).toLocaleDateString("de-DE", { weekday: "long", day: "2-digit", month: "long" })
}
export function ChronikView() {
const { data: items = [], isLoading } = useChronik()
const [limitPerDay, setLimitPerDay] = useState(true)
const grouped = useMemo(() => {
const g: { day: string; items: ChronikItem[] }[] = []
for (const it of items) {
const day = dayKey(it.ts)
const last = g[g.length - 1]
if (last && last.day === day) last.items.push(it)
else g.push({ day, items: [it] })
}
return g
}, [items])
return (
<div className="space-y-7">
<div>
<h1 className="bg-gradient-to-r from-foreground via-foreground to-primary bg-clip-text font-space text-2xl font-bold tracking-tight text-transparent">
Chronik
</h1>
<p className="text-sm text-muted-foreground">
Was deine Box von allein getan und gemeldet hat und die Zeitmaschine, falls du zu einem früheren Stand zurück willst.
</p>
</div>
<Zeitmaschine />
<section className="space-y-4">
<p className="flex items-center gap-1.5 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/60">
<History className="h-3.5 w-3.5" /> Verlauf
</p>
{isLoading && (
<div className="flex items-center gap-2 rounded-2xl border border-border/60 bg-card/30 p-6 text-xs text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" /> Chronik wird geladen
</div>
)}
{!isLoading && items.length === 0 && (
<div className="rounded-2xl border border-dashed border-border/60 bg-card/20 p-8 text-center text-xs text-muted-foreground">
Noch keine Einträge. Sobald die Box etwas von allein tut oder meldet (Updates, Wächter, Träume), steht es hier.
</div>
)}
{grouped.map(({ day, items: dayItems }) => (
<div key={day}>
<p className="mb-2 text-xs font-bold text-foreground/70">{day}</p>
<div className="space-y-2 border-l-2 border-border/40 pl-4">
{(limitPerDay ? dayItems.slice(0, 8) : dayItems).map((it) => {
const ui = SOURCE_UI[it.source] ?? SOURCE_FALLBACK
const Icon = ui.icon
return (
<div key={it.id} className="relative rounded-xl border border-border/50 bg-card/40 p-3 backdrop-blur-sm">
<span className="absolute -left-[23px] top-4 h-2.5 w-2.5 rounded-full bg-border ring-4 ring-background/60" />
<div className="flex flex-wrap items-center gap-2">
<span className={cn("flex items-center gap-1 rounded-md border px-1.5 py-0.5 text-[10px] font-bold", ui.cls)}>
<Icon className="h-3 w-3" /> {ui.label}
</span>
{it.subject && <span className="text-[10px] font-semibold text-muted-foreground">{it.subject}</span>}
<span className="ml-auto font-mono text-[10px] text-muted-foreground/60">
{new Date(it.ts * 1000).toLocaleTimeString("de-DE", { hour: "2-digit", minute: "2-digit" })}
</span>
</div>
<p className="mt-1.5 whitespace-pre-wrap text-xs leading-relaxed text-foreground/85">{it.text}</p>
</div>
)
})}
{limitPerDay && dayItems.length > 8 && (
<button onClick={() => setLimitPerDay(false)} className="text-[11px] text-muted-foreground underline hover:text-foreground">
{dayItems.length - 8} weitere anzeigen
</button>
)}
</div>
</div>
))}
</section>
</div>
)
}
// ── Zeitmaschine: Snapshots + Ein-Klick-Restore ───────────────────────────────
function Zeitmaschine() {
const { data } = useZeitmaschine()
const qc = useQueryClient()
const { showAlert, showConfirm, dialogElement } = useDialog()
const [busy, setBusy] = useState<string | null>(null)
const [backupBusy, setBackupBusy] = useState(false)
const [showAll, setShowAll] = useState(false)
const backups = data?.backups ?? []
const shown = showAll ? backups : backups.slice(0, 4)
function fmtSnap(s: Snapshot): string {
// Zeitstempel-Format aus backup.sh: 2026-07-08T03-30-01 o. ä. → lesbar machen.
const m = s.snapshot.match(/^(\d{4})-(\d{2})-(\d{2})[T_-](\d{2})[-:](\d{2})/)
if (!m) return s.snapshot
return `${m[3]}.${m[2]}.${m[1]}, ${m[4]}:${m[5]} Uhr`
}
async function restore(s: Snapshot) {
setBusy(s.file)
try {
await api("/api/zeitmaschine/restore", { method: "POST", body: JSON.stringify({ file: s.file }) })
showAlert("Zeitmaschine läuft",
"Die Wiederherstellung wurde gestartet. Die Dienste starten gleich neu — die Zentrale ist kurz weg und meldet sich von selbst zurück. Vorher wurde automatisch ein Sicherheits-Backup des jetzigen Zustands gemacht.")
} catch (e: any) {
showAlert("Start fehlgeschlagen", String(e?.message || e))
} finally {
setBusy(null)
}
}
async function backupNow() {
setBackupBusy(true)
try {
const r = await api<{ ok: boolean; snapshot?: string; error?: string }>("/api/system/backup", { method: "POST" })
if (r.ok) showAlert("Gesichert", `Snapshot ${r.snapshot} wurde angelegt.`)
else showAlert("Backup fehlgeschlagen", r.error || "Unbekannter Fehler")
qc.invalidateQueries({ queryKey: qk.zeitmaschine })
} catch (e: any) {
showAlert("Backup fehlgeschlagen", String(e?.message || e))
} finally {
setBackupBusy(false)
}
}
return (
<section className="space-y-3">
<div className="flex items-center justify-between">
<p className="flex items-center gap-1.5 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/60">
<Archive className="h-3.5 w-3.5" /> Zeitmaschine gesicherte Stände
</p>
<button onClick={backupNow} disabled={backupBusy || !data?.available}
className="flex h-8 items-center gap-1.5 rounded-lg border border-border/60 bg-card/45 px-2.5 text-[11px] font-semibold text-muted-foreground hover:text-foreground hover:border-primary/40 transition-all cursor-pointer disabled:opacity-50">
{backupBusy ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Archive className="h-3.5 w-3.5" />} Jetzt sichern
</button>
</div>
{data && !data.available && (
<div className="rounded-2xl border border-amber-500/30 bg-amber-500/5 p-4 text-xs text-amber-300">
Die Snapshots liegen auf der Box im lokalen Dev-Modus nicht verfügbar.
</div>
)}
{data?.available && backups.length === 0 && (
<div className="rounded-2xl border border-dashed border-border/60 bg-card/20 p-5 text-center text-xs text-muted-foreground">
Noch keine Snapshots. Das nächtliche Backup (03:30) legt sie automatisch an oder Jetzt sichern".
</div>
)}
{shown.length > 0 && (
<div className="grid gap-2 sm:grid-cols-2">
{shown.map((s, idx) => (
<div key={s.file} className="flex items-center justify-between gap-3 rounded-xl border border-border/50 bg-card/40 p-3">
<div className="min-w-0">
<p className="text-xs font-semibold text-foreground">
{fmtSnap(s)} {idx === 0 && <span className="ml-1 rounded bg-emerald-500/10 px-1.5 py-0.5 text-[9px] font-bold text-emerald-300">neuester</span>}
</p>
<p className="font-mono text-[10px] text-muted-foreground/70 truncate" title={s.file}>{s.size_mb.toLocaleString("de-DE")} MB · {s.file}</p>
</div>
<button
disabled={busy !== null}
onClick={() =>
showConfirm("Wirklich zu diesem Stand zurück?",
`Die Box wird auf den Snapshot vom ${fmtSnap(s)} zurückgesetzt (Gedächtnis, Hermes-Config, Modell-Router). Der JETZIGE Zustand wird vorher automatisch gesichert — der Schritt ist also umkehrbar. Die Dienste starten neu, die Zentrale ist kurz weg.`,
() => restore(s))}
className="flex h-8 shrink-0 items-center gap-1.5 rounded-lg border border-amber-500/40 bg-amber-500/10 px-2.5 text-[10px] font-bold uppercase tracking-wide text-amber-300 transition-all hover:bg-amber-500/20 cursor-pointer disabled:opacity-50">
{busy === s.file ? <Loader2 className="h-3 w-3 animate-spin" /> : <RotateCcw className="h-3 w-3" />} Zurück
</button>
</div>
))}
</div>
)}
{backups.length > 4 && !showAll && (
<button onClick={() => setShowAll(true)} className="text-[11px] text-muted-foreground underline hover:text-foreground">
Alle {backups.length} Snapshots anzeigen
</button>
)}
{dialogElement}
</section>
)
}
+6 -1
View File
@@ -121,8 +121,13 @@ export function MemoryView() {
try {
const dry = await api<DedupeResult>("/api/memory/dedupe", { method: "POST", body: JSON.stringify({ apply: false }) })
if (dry.duplicate_count === 0) { showAlert("Ergebnis", "Keine Dubletten gefunden — alles sauber."); return }
// Konkrete Vorschau statt bloßer Zahl: was bleibt, was fliegt (erste 5 Gruppen).
const shorten = (s: string) => (s.length > 90 ? s.slice(0, 90) + "…" : s)
const preview = dry.groups.slice(0, 5).map((g) =>
`✓ bleibt: „${shorten(g.keep.content)}"\n✗ weg: ${g.remove.map((r) => `${shorten(r.content)}"`).join(", ")}`
).join("\n\n") + (dry.groups.length > 5 ? `\n\n… und ${dry.groups.length - 5} weitere Gruppe(n).` : "")
showConfirm("Deduplizierung bestätigen",
`${dry.duplicate_count} Dublette(n) in ${dry.groups.length} Gruppe(n) gefunden. Entfernen?`,
`${dry.duplicate_count} Dublette(n) in ${dry.groups.length} Gruppe(n) gefunden:\n\n${preview}\n\nZusammenführen?`,
async () => {
try { await api("/api/memory/dedupe", { method: "POST", body: JSON.stringify({ apply: true }) }); reloadMemory() }
catch (e: any) { showAlert("Fehler", `Fehler beim Löschen: ${e.message}`) }
+236
View File
@@ -0,0 +1,236 @@
import { useEffect, useMemo, useState } from "react"
import { Library, FileText, Sparkles, Loader2, FolderOpen, Search } from "lucide-react"
import { api, type WissenDatei, type WissenFile } from "@/lib/api"
import { useWissen } from "@/lib/queries"
import { cn } from "@/lib/utils"
// Wissens-Vault: die nächtlichen Traum-Notizen der Box als klickbares Wiki.
// [[wiki-links]] springen zur verlinkten Notiz; Markdown wird leichtgewichtig gerendert
// (bewusst ohne externe Markdown-Lib — Überschriften, Listen, fett/kursiv, Code reichen hier).
const DIR_LABEL: Record<string, string> = {
"": "Allgemein",
traeume: "Träume",
muster: "Muster",
"skill-kandidaten": "Skill-Kandidaten",
"skill-kandidaten/beauftragt": "Skill-Kandidaten · beauftragt",
"skill-kandidaten/verworfen": "Skill-Kandidaten · verworfen",
}
export function WissenView() {
const { data, isLoading } = useWissen()
const [active, setActive] = useState<string | null>(null)
const [doc, setDoc] = useState<WissenDatei | null>(null)
const [docLoading, setDocLoading] = useState(false)
const [q, setQ] = useState("")
const files = useMemo(() => data?.files ?? [], [data])
// Name → Pfad für [[wiki-links]] (Dateiname ohne .md, case-insensitive).
const byName = useMemo(() => {
const m = new Map<string, string>()
for (const f of files) m.set(f.name.toLowerCase(), f.path)
return m
}, [files])
const filtered = useMemo(() => {
if (!q.trim()) return files
const ql = q.toLowerCase()
return files.filter((f) => f.title.toLowerCase().includes(ql) || f.path.toLowerCase().includes(ql))
}, [files, q])
const groups = useMemo(() => {
const g: { dir: string; files: WissenFile[] }[] = []
for (const f of filtered) {
const found = g.find((x) => x.dir === f.dir)
if (found) found.files.push(f)
else g.push({ dir: f.dir, files: [f] })
}
// INDEX zuerst, dann Träume, dann Rest alphabetisch
return g.sort((a, b) => (a.dir === "" ? -1 : b.dir === "" ? 1 : a.dir.localeCompare(b.dir)))
}, [filtered])
// Beim ersten Laden: INDEX.md öffnen, falls vorhanden.
useEffect(() => {
if (!active && files.length) {
const index = files.find((f) => f.path.toLowerCase() === "index.md")
setActive(index?.path ?? files[0].path)
}
}, [files, active])
useEffect(() => {
if (!active) return
setDocLoading(true)
api<WissenDatei>(`/api/wissen/datei?pfad=${encodeURIComponent(active)}`)
.then(setDoc)
.catch(() => setDoc({ path: active, content: "*(Notiz nicht ladbar)*", mtime: 0 }))
.finally(() => setDocLoading(false))
}, [active])
const openWikiLink = (name: string) => {
const path = byName.get(name.toLowerCase())
if (path) setActive(path)
}
return (
<div className="space-y-5">
<div>
<h1 className="bg-gradient-to-r from-foreground via-foreground to-primary bg-clip-text font-space text-2xl font-bold tracking-tight text-transparent">
Wissen
</h1>
<p className="text-sm text-muted-foreground">
Was sich die Box nachts erschließt Traum-Notizen, Muster und Skill-Ideen aus dem Wissens-Vault, verlinkt wie ein Wiki.
</p>
</div>
{data && !data.available && (
<div className="rounded-2xl border border-amber-500/30 bg-amber-500/5 p-4 text-xs text-amber-300">
Der Wissens-Vault liegt auf der Box (~/wissens-vault) im lokalen Dev-Modus nicht verfügbar.
</div>
)}
{isLoading && (
<div className="flex items-center gap-2 rounded-2xl border border-border/60 bg-card/30 p-6 text-xs text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" /> Vault wird geladen
</div>
)}
{data?.available && files.length === 0 && (
<div className="rounded-2xl border border-dashed border-border/60 bg-card/20 p-8 text-center text-xs text-muted-foreground">
Noch keine Notizen. Der nächtliche Traum (03:15) legt sie an morgen früh steht hier die erste.
</div>
)}
{files.length > 0 && (
<div className="flex flex-col gap-4 lg:flex-row">
{/* Datei-Liste */}
<aside className="w-full shrink-0 space-y-3 lg:w-72">
<div className="relative">
<input value={q} onChange={(e) => setQ(e.target.value)} type="search"
placeholder="Notizen durchsuchen…" aria-label="Notizen durchsuchen"
className="h-9 w-full rounded-lg border border-border/60 bg-card/45 pl-8 pr-3 text-xs text-foreground outline-none focus:ring-1 focus:ring-primary/50" />
<Search className="absolute left-2.5 top-2.5 h-3.5 w-3.5 text-muted-foreground" />
</div>
<div className="max-h-[70vh] space-y-3 overflow-y-auto pr-1 scrollbar-thin">
{groups.map(({ dir, files: gf }) => (
<div key={dir || "__root"}>
<p className="mb-1 flex items-center gap-1 text-[10px] font-bold uppercase tracking-wider text-muted-foreground/60">
<FolderOpen className="h-3 w-3" /> {DIR_LABEL[dir] ?? dir}
</p>
<div className="space-y-0.5">
{gf.map((f) => (
<button key={f.path} onClick={() => setActive(f.path)}
className={cn("flex w-full items-center gap-1.5 rounded-lg px-2 py-1.5 text-left text-xs transition-all cursor-pointer",
active === f.path ? "bg-primary/15 text-primary" : "text-muted-foreground hover:bg-accent hover:text-foreground")}>
<FileText className="h-3.5 w-3.5 shrink-0" />
<span className="truncate" title={f.title}>{f.name}</span>
{f.neu && <span className="ml-auto flex items-center gap-0.5 rounded bg-violet-500/15 px-1 py-0.5 text-[8px] font-bold uppercase text-violet-300"><Sparkles className="h-2.5 w-2.5" />neu</span>}
</button>
))}
</div>
</div>
))}
</div>
</aside>
{/* Inhalt */}
<div className="min-w-0 flex-1 rounded-2xl border border-border/60 bg-card/45 p-5 shadow-lg shadow-black/15 backdrop-blur-md">
{docLoading ? (
<div className="flex items-center gap-2 text-xs text-muted-foreground"><Loader2 className="h-4 w-4 animate-spin" /> Notiz wird geladen </div>
) : doc ? (
<>
<div className="mb-3 flex items-center justify-between border-b border-border/40 pb-2">
<span className="flex items-center gap-1.5 font-mono text-[11px] text-muted-foreground"><Library className="h-3.5 w-3.5" /> {doc.path}</span>
{doc.mtime > 0 && (
<span className="text-[10px] text-muted-foreground/60">
Stand {new Date(doc.mtime * 1000).toLocaleString("de-DE", { day: "2-digit", month: "2-digit", hour: "2-digit", minute: "2-digit" })}
</span>
)}
</div>
<Markdown text={doc.content} onWikiLink={openWikiLink} known={byName} />
</>
) : null}
</div>
</div>
)}
</div>
)
}
// ── Leichtgewichtiger Markdown-Renderer (Zeilen-basiert, XSS-frei da reines JSX) ──
function Markdown({ text, onWikiLink, known }: {
text: string
onWikiLink: (name: string) => void
known: Map<string, string>
}) {
const lines = text.split("\n")
const out: React.ReactNode[] = []
let list: React.ReactNode[] = []
let code: string[] | null = null
const flushList = (key: string) => {
if (list.length) {
out.push(<ul key={key} className="mb-3 ml-4 list-disc space-y-1">{list}</ul>)
list = []
}
}
lines.forEach((raw, i) => {
const key = `l${i}`
if (code !== null) {
if (raw.trimEnd() === "```") {
out.push(<pre key={key} className="mb-3 overflow-x-auto rounded-xl border border-border/50 bg-background/50 p-3 font-mono text-[11px] leading-relaxed text-foreground/85">{code.join("\n")}</pre>)
code = null
} else code.push(raw)
return
}
if (raw.trimStart().startsWith("```")) { flushList(key); code = []; return }
const line = raw.trimEnd()
if (!line.trim()) { flushList(key); return }
const h = line.match(/^(#{1,4})\s+(.*)$/)
if (h) {
flushList(key)
const level = h[1].length
const cls = level === 1 ? "text-lg font-bold mt-1 mb-3" : level === 2 ? "text-base font-bold mt-4 mb-2" : "text-sm font-bold mt-3 mb-1.5"
out.push(<p key={key} className={cn(cls, "font-space text-foreground")}>{inline(h[2], onWikiLink, known, key)}</p>)
return
}
const li = line.match(/^\s*[-*•]\s+(.*)$/)
if (li) {
list.push(<li key={key} className="text-xs leading-relaxed text-foreground/85">{inline(li[1], onWikiLink, known, key)}</li>)
return
}
flushList(key)
out.push(<p key={key} className="mb-2 text-xs leading-relaxed text-foreground/85">{inline(line, onWikiLink, known, key)}</p>)
})
flushList("end")
if (code !== null) out.push(<pre key="code-end" className="mb-3 overflow-x-auto rounded-xl border border-border/50 bg-background/50 p-3 font-mono text-[11px]">{(code as string[]).join("\n")}</pre>)
return <div className="max-w-3xl">{out}</div>
}
// Inline: [[wiki-links]], **fett**, *kursiv*, `code` — per Regex-Split, reines JSX (kein HTML-Inject).
function inline(text: string, onWikiLink: (n: string) => void, known: Map<string, string>, keyBase: string): React.ReactNode[] {
const parts = text.split(/(\[\[[^\]]+\]\]|\*\*[^*]+\*\*|\*[^*]+\*|`[^`]+`)/g)
return parts.map((p, i) => {
const key = `${keyBase}-${i}`
const wiki = p.match(/^\[\[([^\]|]+)(?:\|([^\]]+))?\]\]$/)
if (wiki) {
const target = wiki[1].trim()
const label = (wiki[2] ?? wiki[1]).trim()
const exists = known.has(target.toLowerCase())
return exists ? (
<button key={key} onClick={() => onWikiLink(target)}
className="rounded bg-primary/10 px-1 text-primary underline decoration-primary/40 underline-offset-2 hover:bg-primary/20 cursor-pointer">{label}</button>
) : (
<span key={key} className="rounded bg-background/40 px-1 text-muted-foreground" title="Notiz existiert (noch) nicht">{label}</span>
)
}
if (p.startsWith("**") && p.endsWith("**")) return <b key={key} className="font-semibold text-foreground">{p.slice(2, -2)}</b>
if (p.startsWith("*") && p.endsWith("*") && p.length > 2) return <i key={key}>{p.slice(1, -1)}</i>
if (p.startsWith("`") && p.endsWith("`")) return <code key={key} className="rounded bg-background/50 px-1 font-mono text-[11px] text-teal-300">{p.slice(1, -1)}</code>
return <span key={key}>{p}</span>
})
}
+58 -5
View File
@@ -2,17 +2,20 @@ import { useState } from "react"
import {
Boxes, Brain, Server, ShieldAlert, Plug, Bot, TerminalSquare, ScrollText,
HeartPulse, AlertTriangle, Loader2, Wrench, Check, HardDrive, ChevronRight,
Inbox, History, Library,
} from "lucide-react"
import { api } from "@/lib/api"
import {
useModels, useServices, useUpdates, useMemory, useAgentStatus, useConnectHealth,
useQueryClient, qk,
useAuftragsbuch, useQueryClient, qk,
} from "@/lib/queries"
import { SquareTerminal } from "lucide-react"
import { useLucyHealth, type Repair } from "@/lib/useLucyHealth"
import { useDialog } from "@/lib/useDialog"
import { cn } from "@/lib/utils"
import { CockpitTile } from "./CockpitTile"
import { MorgenlageCard } from "./MorgenlageCard"
import { ErinnerungenCard } from "./ErinnerungenCard"
import { SystemStatusCard } from "@/components/dashboard/SystemStatusCard"
import { TokenPerformanceCard } from "@/components/dashboard/TokenPerformanceCard"
import { LatencyCard } from "@/components/dashboard/LatencyCard"
@@ -31,6 +34,7 @@ export function CockpitView({ onNavigate }: { onNavigate: (v: string) => void })
const { data: memory } = useMemory()
const { data: agent } = useAgentStatus()
const { data: connect } = useConnectHealth()
const { data: auftraege } = useAuftragsbuch(20_000)
const health = useLucyHealth()
const { showAlert, dialogElement } = useDialog()
@@ -69,6 +73,7 @@ export function CockpitView({ onNavigate }: { onNavigate: (v: string) => void })
(updates ? (updates.os > 0 ? 1 : 0) + (updates.engine > 0 ? 1 : 0) + (updates.swap > 0 ? 1 : 0) + (updates.models > 0 ? 1 : 0) : 0) +
(updates?.components?.filter((c) => c.update === true).length ?? 0)
const connectOk = connect?.gateway.ok && connect?.memory.ok
const openAuftraege = auftraege?.available ? auftraege.open_count : 0
return (
<div className="space-y-7">
@@ -83,18 +88,30 @@ export function CockpitView({ onNavigate }: { onNavigate: (v: string) => void })
{/* Ehrliche Status-Zeile */}
<StatusLine verdict={health.verdict} memory={health.memory} warns={health.warns} />
{/* Morgenlage — das jüngste Nacht-Gutachten (verschwindet, wenn keines da ist) */}
<MorgenlageCard />
{/* „Braucht dich" — offene Aktionen, sonst Ruhe */}
<NeedsYou
problems={health.problems}
pendingCount={pendingCount}
openAuftraege={openAuftraege}
busy={busy}
onRepair={runRepair}
onNavigate={onNavigate}
/>
{/* Bereichs-Kacheln */}
<section>
<ZoneLabel>Bereiche</ZoneLabel>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
<CockpitTile
icon={Inbox} title="Auftragsbuch" value={auftraege?.available ? openAuftraege : "—"}
unit={openAuftraege === 1 ? "Vorschlag" : "Vorschläge"}
tone={!auftraege ? "loading" : openAuftraege > 0 ? "warn" : "ok"}
hint="Annehmen oder ablehnen"
onClick={() => onNavigate("auftraege")}
/>
<CockpitTile
icon={Boxes} title="Modelle" value={running} unit="warm"
tone={running > 0 ? "ok" : "muted"} hint="Speicher, laden & Rollen"
@@ -129,6 +146,12 @@ export function CockpitView({ onNavigate }: { onNavigate: (v: string) => void })
</div>
</section>
{/* Anstehendes — Erinnerungen & Routinen (GUI-Hälfte der Wecker-API) */}
<section>
<ZoneLabel>Anstehendes</ZoneLabel>
<ErinnerungenCard />
</section>
{/* Leistung — die Live-Graphen zurück auf der Startseite (User-Wunsch 07.07.:
System-Verlauf, tok/s und Turn-Latenz waren nach dem UI-v3-Umbau nur noch in
der alten Zentrale erreichbar). Karten sind autark (eigene Hooks/Polling). */}
@@ -162,6 +185,16 @@ export function CockpitView({ onNavigate }: { onNavigate: (v: string) => void })
tone={!agent ? "loading" : agent.box_console_reachable ? "ok" : "warn"} hint="Direkte Box-Shell (SSH)"
onClick={() => onNavigate("konsole")}
/>
<CockpitTile
icon={Library} title="Wissen" value="öffnen"
tone="muted" hint="Lucys Wissens-Vault (Traum-Notizen)"
onClick={() => onNavigate("wissen")}
/>
<CockpitTile
icon={History} title="Chronik" value="öffnen"
tone="muted" hint="Autonome Taten + Zeitmaschine"
onClick={() => onNavigate("chronik")}
/>
</div>
</section>
@@ -225,18 +258,21 @@ function StatusLine({ verdict, memory, warns }: {
)
}
// „Braucht dich": kritische Probleme (mit 1-Klick-Reparatur) + bereitliegende Updates.
// Nichts offen → ruhige Bestätigung statt leerer Fläche.
// „Braucht dich": kritische Probleme (mit 1-Klick-Reparatur) + bereitliegende Updates
// + offene Vorschläge im Auftragsbuch. Nichts offen → ruhige Bestätigung statt leerer Fläche.
function NeedsYou({
problems, pendingCount, busy, onRepair,
problems, pendingCount, openAuftraege, busy, onRepair, onNavigate,
}: {
problems: ReturnType<typeof useLucyHealth>["problems"]
pendingCount: number
openAuftraege: number
busy: Record<string, boolean>
onRepair: (id: string, r: Repair) => void
onNavigate: (v: string) => void
}) {
const hasUpdates = pendingCount > 0
const nothing = problems.length === 0 && !hasUpdates
const hasAuftraege = openAuftraege > 0
const nothing = problems.length === 0 && !hasUpdates && !hasAuftraege
return (
<section>
@@ -274,6 +310,23 @@ function NeedsYou({
)}
</div>
))}
{hasAuftraege && (
<button
onClick={() => onNavigate("auftraege")}
className="flex w-full items-center justify-between gap-3 rounded-2xl border border-primary/30 bg-primary/5 p-4 text-left transition-all hover:bg-primary/10 cursor-pointer"
>
<div className="flex items-center gap-3">
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg border border-primary/30 bg-background/30 text-primary">
<Inbox className="h-4.5 w-4.5" />
</span>
<div>
<p className="text-sm font-bold text-foreground">{openAuftraege} Vorschl{openAuftraege === 1 ? "ag" : "äge"} im Auftragsbuch</p>
<p className="text-xs text-muted-foreground">Die Box hat etwas gebaut oder sich ausgedacht annehmen oder ablehnen.</p>
</div>
</div>
<ChevronRight className="h-4 w-4 shrink-0 text-primary/70" />
</button>
)}
{hasUpdates && (
<button
onClick={() => openDrawer("maintenance")}
@@ -0,0 +1,117 @@
import { useState } from "react"
import { AlarmClock, Plus, Trash2, Loader2, X, Repeat } from "lucide-react"
import { api } from "@/lib/api"
import { useReminders, useQueryClient, qk } from "@/lib/queries"
import { useDialog } from "@/lib/useDialog"
import { cn } from "@/lib/utils"
// „Anstehendes": Erinnerungen & Routinen sichtbar und klickbar machen. Die API kann
// längst alles (Lucy legt per Stimme an) — hier kommt nur die GUI-Hälfte dazu.
const REPEAT_LABEL: Record<string, string> = {
"": "einmalig", daily: "täglich", weekdays: "werktags", weekly: "wöchentlich",
}
export function ErinnerungenCard() {
const { data: items = [], isLoading } = useReminders()
const qc = useQueryClient()
const { showAlert, showConfirm, dialogElement } = useDialog()
const [showAdd, setShowAdd] = useState(false)
const [text, setText] = useState("")
const [when, setWhen] = useState("")
const [repeat, setRepeat] = useState("")
const [saving, setSaving] = useState(false)
const reload = () => qc.invalidateQueries({ queryKey: qk.reminders })
async function add() {
if (!text.trim() || !when) return
setSaving(true)
try {
await api("/api/reminders", { method: "POST", body: JSON.stringify({ text, when, repeat }) })
setText(""); setWhen(""); setRepeat(""); setShowAdd(false)
reload()
} catch (e: any) {
showAlert("Anlegen fehlgeschlagen", String(e?.message || e))
} finally {
setSaving(false)
}
}
function del(id: number, t: string) {
showConfirm("Erinnerung löschen?", `${t}" wird entfernt.`, async () => {
try { await api(`/api/reminders/${id}`, { method: "DELETE" }); reload() }
catch (e: any) { showAlert("Fehler", String(e?.message || e)) }
})
}
const sorted = [...items].sort((a, b) => a.next_ts - b.next_ts)
return (
<div className="rounded-2xl border border-border/60 bg-card/45 p-4 shadow-lg shadow-black/15 backdrop-blur-md">
<div className="mb-3 flex items-center justify-between">
<span className="flex items-center gap-2 text-sm font-bold text-foreground">
<span className="flex h-8 w-8 items-center justify-center rounded-lg border border-border/40 bg-background/30 text-amber-300">
<AlarmClock className="h-4 w-4" />
</span>
Anstehendes
</span>
<button onClick={() => setShowAdd((s) => !s)}
className="flex h-8 items-center gap-1 rounded-lg border border-border/60 bg-background/30 px-2.5 text-[11px] font-semibold text-muted-foreground hover:text-foreground hover:border-primary/40 transition-all cursor-pointer">
{showAdd ? <X className="h-3.5 w-3.5" /> : <Plus className="h-3.5 w-3.5" />} {showAdd ? "Abbrechen" : "Neu"}
</button>
</div>
{showAdd && (
<div className="mb-3 space-y-2 rounded-xl border border-border/50 bg-background/30 p-3">
<input value={text} onChange={(e) => setText(e.target.value)} placeholder="Woran soll Lucy erinnern?"
aria-label="Erinnerungstext"
className="h-9 w-full rounded-lg border border-border/60 bg-card/45 px-3 text-xs text-foreground outline-none focus:ring-1 focus:ring-primary/50" />
<div className="flex flex-wrap items-center gap-2">
<input type="datetime-local" value={when} onChange={(e) => setWhen(e.target.value)}
aria-label="Zeitpunkt"
className="h-9 rounded-lg border border-border/60 bg-card/45 px-2 text-xs text-foreground outline-none focus:ring-1 focus:ring-primary/50 [color-scheme:dark]" />
<select value={repeat} onChange={(e) => setRepeat(e.target.value)} aria-label="Wiederholung"
className="h-9 cursor-pointer rounded-lg border border-border/60 bg-card/45 px-2 text-xs font-semibold text-foreground outline-none">
{Object.entries(REPEAT_LABEL).map(([v, l]) => <option key={v} value={v} className="bg-popover">{l}</option>)}
</select>
<button onClick={add} disabled={saving || !text.trim() || !when}
className="ml-auto flex h-9 items-center gap-1.5 rounded-lg bg-primary px-3 text-xs font-semibold text-primary-foreground hover:opacity-90 transition-all cursor-pointer disabled:opacity-50">
{saving ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Plus className="h-3.5 w-3.5" />} Anlegen
</button>
</div>
</div>
)}
{isLoading ? (
<p className="text-xs text-muted-foreground">Wird geladen </p>
) : sorted.length === 0 ? (
<p className="text-xs text-muted-foreground">Keine Erinnerungen. Lucy sagt dir hier (und per Stimme) Bescheid, wenn etwas ansteht.</p>
) : (
<div className="space-y-1.5">
{sorted.slice(0, 6).map((r) => {
const overdue = r.next_ts * 1000 < Date.now() && !r.repeat
return (
<div key={r.id} className="group flex items-center justify-between gap-2 rounded-lg border border-border/40 bg-background/20 px-2.5 py-2">
<div className="min-w-0">
<p className="truncate text-xs text-foreground">{r.text}</p>
<p className={cn("flex items-center gap-1.5 text-[10px]", overdue ? "text-amber-300" : "text-muted-foreground/70")}>
{new Date(r.next_ts * 1000).toLocaleString("de-DE", { weekday: "short", day: "2-digit", month: "2-digit", hour: "2-digit", minute: "2-digit" })}
{r.repeat && <span className="flex items-center gap-0.5"><Repeat className="h-2.5 w-2.5" />{REPEAT_LABEL[r.repeat] ?? r.repeat}</span>}
{overdue && "· überfällig"}
</p>
</div>
<button onClick={() => del(r.id, r.text)} aria-label="Erinnerung löschen"
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-lg border border-border/40 text-muted-foreground opacity-0 transition-all hover:bg-red-500/5 hover:text-red-400 group-hover:opacity-100 cursor-pointer">
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
)
})}
{sorted.length > 6 && <p className="text-[10px] text-muted-foreground/60">+{sorted.length - 6} weitere</p>}
</div>
)}
{dialogElement}
</div>
)
}
@@ -0,0 +1,43 @@
import { useState } from "react"
import { Moon, ChevronDown, ChevronRight } from "lucide-react"
import { useChronik } from "@/lib/queries"
// Morgenlage: das jüngste Verdikt des nächtlichen Chef-Gutachters (gpt-oss urteilt im
// Leerlauf über die autonome Arbeit; der Feed spiegelt es still in den Briefkasten).
// Keine Lage vorhanden → Karte verschwindet ganz (Ruhe statt leerer Fläche).
export function MorgenlageCard() {
const { data: items = [] } = useChronik(150, 60_000)
const [open, setOpen] = useState(false)
const lage = items.find((i) => i.source === "chef-gutachter")
if (!lage) return null
const when = new Date(lage.ts * 1000).toLocaleString("de-DE", {
weekday: "long", day: "2-digit", month: "2-digit", hour: "2-digit", minute: "2-digit",
})
// VERDIKT-Zeile als Kurzfassung herausziehen (Raster des Chef-Gutachters).
const verdikt = lage.text.split("\n").find((l) => l.trim().startsWith("VERDIKT:"))?.replace(/^\s*VERDIKT:\s*/, "")
return (
<div className="rounded-2xl border border-violet-500/25 bg-violet-500/[0.05] p-4 shadow-lg shadow-black/15 backdrop-blur-md">
<button onClick={() => setOpen((o) => !o)} className="flex w-full items-center gap-3 text-left cursor-pointer">
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-xl border border-violet-500/30 bg-background/30 text-violet-300">
<Moon className="h-4.5 w-4.5" />
</span>
<span className="min-w-0 flex-1">
<span className="block text-sm font-bold text-foreground">Morgenlage</span>
<span className="block truncate text-xs text-muted-foreground">
{verdikt || lage.subject || "Nächtliches Gutachten über die autonome Arbeit"}
</span>
</span>
<span className="shrink-0 text-[10px] text-muted-foreground/60">{when}</span>
{open ? <ChevronDown className="h-4 w-4 shrink-0 text-muted-foreground" /> : <ChevronRight className="h-4 w-4 shrink-0 text-muted-foreground" />}
</button>
{open && (
<div className="mt-3 border-t border-violet-500/15 pt-3">
{lage.subject && <p className="mb-1 text-[10px] font-semibold uppercase tracking-wider text-violet-300/80">{lage.subject}</p>}
<p className="whitespace-pre-wrap text-xs leading-relaxed text-foreground/85">{lage.text}</p>
</div>
)}
</div>
)
}