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
co-authored by Claude Fable 5
parent 4dc5ca7343
commit 1985a692bf
18 changed files with 1919 additions and 706 deletions
+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>
)
}