Welle 3 · Oberfläche: Lucys Freiheiten, Rückfragen mit Ja/Nein, Wochenbericht; dist gebaut

- Einstellungen → Lucy: Freiheit je Aktionsart (freier als der Start nur nach Nachfrage), offene und zuletzt
  beantwortete Rückfragen, Wochenbericht (nächster Termin, letzten lesen, jetzt schicken)
- Übersicht: Feld „Rückfrage an dich“ ganz oben, nur wenn eine offen ist; Ja erst nach Bestätigung
- Strg+K: Lucys Freiheiten, Rückfragen, Wochenbericht
- Einstieg 126,1 KB gzip

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-09-26 09:14:07 +02:00
co-authored by Claude Opus 5.5
parent 12cefb427a
commit 1915813cc0
50 changed files with 589 additions and 84 deletions
@@ -0,0 +1,60 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react"
import { api, post } from "@/lib/api"
import type { LucyAutonomie, Wochenbericht } from "@/lib/typen"
import { Freiheiten, WochenberichtAbschnitt } from "./LucyEinstellungen"
vi.mock("@/lib/api", async (original) => ({
...(await original<typeof import("@/lib/api")>()), api: vi.fn(), post: vi.fn(),
}))
// Einstellungen → Lucy (Ausbauplan Welle 3, Punkte 14 und 16, 25.09.2026).
const AUTONOMIE: LucyAutonomie = {
arten: [
{ id: "neustart", name: "Dienste neu starten", text: "…", stufe: "selbst", standard: "selbst" },
{ id: "host_neustart", name: "Proxmox-Host neu starten", text: "Alle Container sind weg.", stufe: "fragen", standard: "fragen" },
],
stufen: { lesen: "Nur lesen und erklären", fragen: "Erst fragen", selbst: "Selbst handeln, Meldung danach" },
}
function zeigen(ui: React.ReactElement, daten: LucyAutonomie | Wochenbericht) {
vi.mocked(api).mockResolvedValue(daten)
vi.mocked(post).mockResolvedValue({ ok: true, text: "Gespeichert." })
render(<QueryClientProvider client={new QueryClient()}>{ui}</QueryClientProvider>)
}
describe("Lucys Freiheiten", () => {
beforeEach(() => vi.clearAllMocks())
it("strenger stellen geht sofort", async () => {
zeigen(<Freiheiten />, AUTONOMIE)
const wahl = await screen.findByRole("combobox", { name: "Freiheit: Dienste neu starten" })
expect(within(wahl).getByRole("option", { name: "Selbst handeln (Start)" })).toBeInTheDocument()
fireEvent.change(wahl, { target: { value: "fragen" } })
await waitFor(() => expect(post).toHaveBeenCalledWith("/api/lucy/autonomie", { stufen: { neustart: "fragen" } }))
})
it("freier als der Start erst nach Nachfrage", async () => {
zeigen(<Freiheiten />, AUTONOMIE)
fireEvent.change(await screen.findByRole("combobox", { name: "Freiheit: Proxmox-Host neu starten" }),
{ target: { value: "selbst" } })
expect(post).not.toHaveBeenCalled()
const dialog = await screen.findByRole("dialog")
fireEvent.click(within(dialog).getByRole("button", { name: "Selbst handeln lassen" }))
await waitFor(() => expect(post).toHaveBeenCalledWith("/api/lucy/autonomie", { stufen: { host_neustart: "selbst" } }))
})
})
describe("Wochenbericht", () => {
beforeEach(() => vi.clearAllMocks())
it("nächster Termin, der letzte zum Nachlesen, jetzt schicken", async () => {
const jetzt = Math.floor(Date.now() / 1000)
zeigen(<WochenberichtAbschnitt />, { naechster: jetzt + 3 * 86400, zuletzt: { zeit: jetzt - 86400, text: "Commander, ruhige Woche." } })
expect(await screen.findByText(/Nächster: .* · zuletzt gestern/)).toBeInTheDocument()
fireEvent.click(screen.getByRole("button", { name: "Letzten lesen" }))
expect(screen.getByText("Commander, ruhige Woche.")).toBeInTheDocument()
fireEvent.click(screen.getByRole("button", { name: "Jetzt schicken" }))
await waitFor(() => expect(post).toHaveBeenCalledWith("/api/lucy/wochenbericht"))
})
})
@@ -0,0 +1,107 @@
import { useState } from "react"
import { cn } from "cn"
import { Bestaetigen } from "@/components/cockpit/Bestaetigen"
import { Button } from "@/components/ui/button"
import { useLucyAutonomie, useLucyAutonomieSetzen, useWochenbericht, useWochenberichtSenden } from "@/lib/abfragen"
import type { LucyStufe } from "@/lib/typen"
import { flugplanZeit } from "@/lib/zeit"
// Einstellungen → Lucy (Ausbauplan Welle 3, Punkte 14 und 16, seit 25.09.2026): wie selbstständig Lucy je Aktionsart
// handelt und der Wochenbericht. Start ist „mehr Freiheit“ (User-Entscheid 25.09.): selbst neu starten,
// Sicherheitsupdates, Rückweg bei Rot; andere Updates, Aufräumen, Modell-Tests und der Host-Neustart nur nach Ja.
const STUFEN: { id: LucyStufe; kurz: string }[] = [
{ id: "lesen", kurz: "Nur lesen" },
{ id: "fragen", kurz: "Erst fragen" },
{ id: "selbst", kurz: "Selbst handeln" },
]
const feld = "h-10 rounded-xl border border-white/12 bg-white/[0.04] px-3 text-[15px] text-foreground outline-none focus:border-lab/60"
/** Die Freiheiten je Aktionsart. Wird eine Art freier als ihr Start (etwa der Host-Neustart auf „selbst“), fragt die
* Seite vorher nach — das ist die eine Stelle, an der Lucy mehr darf, als der Commander am 25.09. entschieden hat. */
export function Freiheiten() {
const q = useLucyAutonomie()
const setzen = useLucyAutonomieSetzen()
const [frage, setFrage] = useState<{ id: string; name: string; stufe: LucyStufe } | null>(null)
if (!q.data) return <p className="text-sm text-text-3">{q.isPending ? "Wird gelesen …" : "Lucys Freiheiten lassen sich gerade nicht lesen."}</p>
const rang = (s: LucyStufe) => STUFEN.findIndex((x) => x.id === s)
return (
<div className="flex flex-col gap-3">
<p className="m-0 text-sm text-text-2">
Bittest du Lucy ausdrücklich um etwas, tut sie es. Hier stellst du ein, was sie aus eigenem Antrieb darf: nur
erklären, dich vorher fragen (in Telegram antwortest du mit „ja“ und der Nummer) oder selbst handeln — dann
bekommst du danach eine Meldung.
</p>
<div role="table" aria-label="Lucys Freiheiten" className="flex flex-col">
{q.data.arten.map((a) => (
<div role="row" key={a.id} className="flex flex-col gap-2 border-t border-white/[0.06] py-3 first:border-t-0 sm:flex-row sm:items-center sm:justify-between">
<span role="rowheader" className="flex min-w-0 flex-col gap-0.5">
<span className="text-[15px]">{a.name}</span>
<span className="text-xs text-text-3">{a.text}</span>
</span>
<span role="cell" className="shrink-0">
<select aria-label={`Freiheit: ${a.name}`} value={a.stufe} disabled={setzen.isPending}
className={cn(feld, "min-w-44", a.stufe !== a.standard && "border-lab/50")}
onChange={(e) => {
const stufe = e.target.value as LucyStufe
if (rang(stufe) > rang(a.standard) && stufe === "selbst") setFrage({ id: a.id, name: a.name, stufe })
else setzen.mutate({ [a.id]: stufe })
}}>
{STUFEN.map((s) => (
<option key={s.id} value={s.id}>{s.kurz}{s.id === a.standard ? " (Start)" : ""}</option>
))}
</select>
</span>
</div>
))}
</div>
<Bestaetigen
offen={!!frage}
titel={frage ? `„${frage.name}“: Lucy selbst handeln lassen?` : ""}
text="Dann tut sie es aus eigenem Antrieb, ohne dich vorher zu fragen, und meldet es erst danach. Zurückstellen kannst du es hier jederzeit."
knopf="Selbst handeln lassen"
gesperrt={setzen.isPending}
onNein={() => setFrage(null)}
onJa={() => {
if (frage) setzen.mutate({ [frage.id]: frage.stufe })
setFrage(null)
}}
/>
</div>
)
}
/** Der Wochenbericht: wann der nächste kommt, der letzte zum Nachlesen, „Jetzt schicken“. */
export function WochenberichtAbschnitt() {
const q = useWochenbericht()
const senden = useWochenberichtSenden()
const [offen, setOffen] = useState(false)
const zuletzt = q.data?.zuletzt
return (
<div className="flex flex-col gap-3">
<p className="m-0 text-sm text-text-2">
Sonntags um 07:30 schickt Lucy eine Nachricht statt vieler: was eingespielt wurde, was offen ist, Platten,
Sicherungen, Zertifikate — und offene App-Updates zum Beantworten mit „ja“.
</p>
<div className="flex flex-wrap items-center gap-2">
<Button size="sm" variant="outline" disabled={senden.isPending} onClick={() => senden.mutate()}>
{senden.isPending ? "Lucy schreibt …" : "Jetzt schicken"}
</Button>
<span className="text-xs text-text-3">
{q.data ? `Nächster: ${flugplanZeit(new Date(q.data.naechster * 1000).toISOString()).toLowerCase()}` : ""}
{zuletzt ? ` · zuletzt ${flugplanZeit(new Date(zuletzt.zeit * 1000).toISOString()).toLowerCase()}` : ""}
</span>
{zuletzt && (
<button type="button" className="text-xs text-cyan hover:underline" onClick={() => setOffen(!offen)}>
{offen ? "Letzten zuklappen" : "Letzten lesen"}
</button>
)}
</div>
{offen && zuletzt && (
<pre className="flaeche-innen m-0 max-h-96 overflow-y-auto rounded-2xl p-4 font-sans text-sm whitespace-pre-wrap text-text-2">
{zuletzt.text}
</pre>
)}
</div>
)
}
@@ -0,0 +1,72 @@
import type { ReactElement } from "react"
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react"
import { api, post } from "@/lib/api"
import type { Rueckfrage, Rueckfragen } from "@/lib/typen"
import { ausgangText, nochText, OffeneRueckfragen, RueckfragenPanel, RueckfragenVerlauf } from "./Rueckfragen"
vi.mock("@/lib/api", async (original) => ({
...(await original<typeof import("@/lib/api")>()), api: vi.fn(), post: vi.fn(),
}))
// Rückfragen an den Commander (Ausbauplan Welle 3, Punkt 15, 25.09.2026): Ja führt genau die gefragte Aktion aus —
// in der Oberfläche erst nach der Bestätigung, Nein sofort.
const jetzt = Math.floor(Date.now() / 1000)
const frage = (nr: number, mehr: Partial<Rueckfrage> = {}): Rueckfrage => ({
nr, zeit: jetzt - 600, frage: `Soll ich Gitea 1.27.${nr} einspielen?`, text: "Snapshot vorher, Prüfung danach.",
pfad: "/api/partner/homelab/ziele/ct-104/update", von: "wochenbericht", stufe: "normal", ablauf: jetzt + 20 * 3600 - 60,
status: "offen", befund: null, ...mehr,
})
function zeigen(ui: ReactElement, daten: Rueckfragen) {
vi.mocked(api).mockResolvedValue(daten)
vi.mocked(post).mockResolvedValue({ ok: true, text: "Rückfrage 7 ausgeführt: Gestartet." })
render(<QueryClientProvider client={new QueryClient()}>{ui}</QueryClientProvider>)
}
describe("Rückfragen", () => {
beforeEach(() => vi.clearAllMocks())
it("Ja erst nach der Bestätigung, dann genau diese Rückfrage", async () => {
zeigen(<RueckfragenPanel />, { offen: [frage(7)], zuletzt: [] })
expect(await screen.findByText("Eine Rückfrage an dich")).toBeInTheDocument()
expect(screen.getByText(/Rückfrage 7 · Wochenbericht · vor 10 min · gilt noch 19 h/)).toBeInTheDocument()
fireEvent.click(screen.getByRole("button", { name: "Ja, ausführen" }))
expect(post).not.toHaveBeenCalled()
const dialog = await screen.findByRole("dialog")
expect(within(dialog).getByText(/wie „ja 7“ in Telegram/)).toBeInTheDocument()
fireEvent.click(within(dialog).getByRole("button", { name: "Ja, ausführen" }))
await waitFor(() => expect(post).toHaveBeenCalledWith("/api/rueckfragen/7/ja"))
})
it("Nein ohne Umweg", async () => {
zeigen(<OffeneRueckfragen />, { offen: [frage(7), frage(8, { von: "lucy", stufe: "dringend" })], zuletzt: [] })
const nein = await screen.findAllByRole("button", { name: "Nein" })
fireEvent.click(nein[1])
await waitFor(() => expect(post).toHaveBeenCalledWith("/api/rueckfragen/8/nein"))
})
it("ohne offene Rückfrage zeigt die Übersicht nichts, die Einstellungen einen Satz", async () => {
zeigen(<><RueckfragenPanel /><OffeneRueckfragen leerText="Gerade wartet keine Rückfrage auf dich." /></>,
{ offen: [], zuletzt: [] })
expect(await screen.findByText("Gerade wartet keine Rückfrage auf dich.")).toBeInTheDocument()
expect(screen.queryByText(/Rückfrage an dich/)).not.toBeInTheDocument()
})
it("Verlauf: was daraus wurde und wer geantwortet hat", async () => {
zeigen(<RueckfragenVerlauf />, { offen: [], zuletzt: [
frage(5, { status: "erledigt", antwort: { zeit: jetzt, wer: "lucy", ja: true } }),
frage(4, { status: "fehler", ergebnis: "Es läuft gerade ‚Alle aktualisieren‘.", antwort: { zeit: jetzt, wer: "oberflaeche", ja: true } }),
] })
expect(await screen.findByText(/ausgeführt · per Telegram/)).toBeInTheDocument()
expect(screen.getByText(/gescheitert · hier — Es läuft gerade/)).toBeInTheDocument()
})
it("Texte", () => {
expect(nochText(jetzt + 90 * 60, jetzt * 1000)).toBe("gilt noch 1 h")
expect(nochText(jetzt + 20, jetzt * 1000)).toBe("gilt noch 1 min")
expect(ausgangText(frage(1, { status: "abgelaufen" }))).toBe("abgelaufen")
expect(ausgangText(frage(1, { status: "erledigt", nachgeprueft: "besteht", antwort: { zeit: 0, wer: "oberflaeche", ja: true } })))
.toBe("ausgeführt · hier · der Hinweis blieb")
})
})
@@ -0,0 +1,128 @@
import { useState } from "react"
import { MessageCircleQuestionMark } from "lucide-react"
import { cn } from "cn"
import { Bestaetigen } from "@/components/cockpit/Bestaetigen"
import { Panel } from "@/components/cockpit/Panel"
import { Button } from "@/components/ui/button"
import { useRueckfrageAntwort, useRueckfragen } from "@/lib/abfragen"
import type { Rueckfrage, RueckfrageStatus } from "@/lib/typen"
import { vor } from "@/lib/zeit"
// Rückfragen an den Commander (Ausbauplan Welle 3, Punkt 15, seit 25.09.2026): Lucy, der Wächter und der Wochenbericht
// fragen, bevor etwas passiert, das er bestätigen soll. Dieselben Fragen kommen in Telegram mit „Antworte „ja 7““ und
// zwei Antworttasten; hier geht es per Knopf. Ja führt genau die gefragte Aktion aus — sonst nichts.
const VON: Record<string, string> = {
lucy: "Lucy", waechter: "Wächter", wochenbericht: "Wochenbericht", wartung: "Wartungsfenster",
}
const STATUS: Record<RueckfrageStatus, string> = {
offen: "offen", laeuft: "läuft", erledigt: "ausgeführt", fehler: "gescheitert", abgelehnt: "abgelehnt",
abgelaufen: "abgelaufen", hinfaellig: "hatte sich erledigt",
}
export const vonText = (von: string) => VON[von] ?? von
/** Wie lange eine Rückfrage noch gilt: „gilt noch 22 h“, „gilt noch 40 min“. */
export function nochText(ablauf: number, jetzt = Date.now()): string {
const s = Math.max(0, Math.round(ablauf - jetzt / 1000))
return s >= 3600 ? `gilt noch ${Math.floor(s / 3600)} h` : `gilt noch ${Math.max(1, Math.round(s / 60))} min`
}
/** Eine Zeile des Verlaufs: was daraus wurde, wer geantwortet hat. */
export function ausgangText(r: Rueckfrage): string {
const wer = r.antwort ? (r.antwort.wer === "oberflaeche" ? "hier" : r.antwort.wer === "lucy" ? "per Telegram" : r.antwort.wer) : null
const teile = [STATUS[r.status] ?? r.status]
if (wer && r.status !== "abgelaufen") teile.push(wer)
if (r.nachgeprueft === "besteht") teile.push("der Hinweis blieb")
return teile.join(" · ")
}
function Eintrag({ r, gesperrt, onJa, onNein }: {
r: Rueckfrage
gesperrt: boolean
onJa: () => void
onNein: () => void
}) {
return (
<li className={cn("flaeche-innen flex flex-col gap-3 rounded-2xl p-4", r.stufe === "dringend" && "ring-1 ring-rot/40 ring-inset")}>
<div className="flex flex-col gap-1">
<span className="etikett text-[10.5px] text-text-3">
Rückfrage {r.nr} · {vonText(r.von)} · {vor(r.zeit)} · {nochText(r.ablauf)}
</span>
<span className="text-[15px] font-semibold break-words">{r.frage}</span>
{r.text && <span className="text-sm break-words text-text-2">{r.text}</span>}
</div>
<div className="flex flex-wrap gap-2">
<Button size="sm" disabled={gesperrt} onClick={onJa}>Ja, ausführen</Button>
<Button variant="outline" size="sm" disabled={gesperrt} onClick={onNein}>Nein</Button>
</div>
</li>
)
}
/** Die offenen Rückfragen mit Ja und Nein. Ohne offene Rückfrage zeigt das Feld nichts (Übersicht) bzw. einen Satz. */
export function OffeneRueckfragen({ leerText }: { leerText?: string }) {
const q = useRueckfragen()
const antwort = useRueckfrageAntwort()
const [frage, setFrage] = useState<Rueckfrage | null>(null)
const offen = q.data?.offen ?? []
if (offen.length === 0) return leerText ? <p className="text-sm text-text-2">{leerText}</p> : null
return (
<>
<ul className="m-0 grid list-none gap-3 p-0 md:grid-cols-2">
{offen.map((r) => (
<Eintrag key={r.nr} r={r} gesperrt={antwort.isPending}
onJa={() => setFrage(r)} onNein={() => antwort.mutate({ nr: r.nr, ja: false })} />
))}
</ul>
<Bestaetigen
offen={!!frage}
titel={frage ? frage.frage : ""}
text={frage ? `${frage.text ? `${frage.text} ` : ""}Das führt genau diese Aktion aus — wie „ja ${frage.nr}“ in Telegram.` : ""}
knopf="Ja, ausführen"
gesperrt={antwort.isPending}
onNein={() => setFrage(null)}
onJa={() => {
if (frage) antwort.mutate({ nr: frage.nr, ja: true })
setFrage(null)
}}
/>
</>
)
}
/** Für die Übersicht: ein Feld, das nur erscheint, wenn eine Rückfrage offen ist. */
export function RueckfragenPanel() {
const q = useRueckfragen()
const anzahl = q.data?.offen.length ?? 0
if (anzahl === 0) return null
return (
<Panel titel={anzahl === 1 ? "Eine Rückfrage an dich" : `${anzahl} Rückfragen an dich`} symbol={MessageCircleQuestionMark} id="rueckfragen">
<p className="-mt-2 text-[15px] text-text-2">
Lucy wartet auf dein Ja. In Telegram reicht „ja“ mit der Nummer — oder hier ein Klick.
</p>
<OffeneRueckfragen />
</Panel>
)
}
/** Die zuletzt beantworteten oder abgelaufenen Rückfragen (Einstellungen → Lucy). */
export function RueckfragenVerlauf() {
const q = useRueckfragen()
const zuletzt = (q.data?.zuletzt ?? []).slice(0, 8)
if (zuletzt.length === 0) return <p className="text-sm text-text-3">Noch keine beantwortete Rückfrage.</p>
return (
<ul className="m-0 flex list-none flex-col p-0">
{zuletzt.map((r) => (
<li key={r.nr} className="flex flex-col gap-0.5 border-t border-white/[0.06] py-2 first:border-t-0">
<span className="text-sm break-words">{r.frage}</span>
<span className="text-xs text-text-3">
Rückfrage {r.nr} · {vonText(r.von)} · {vor(r.zeit)} · {ausgangText(r)}
{r.status === "fehler" && r.ergebnis ? ` — ${r.ergebnis}` : ""}
</span>
</li>
))}
</ul>
)
}
+49 -1
View File
@@ -10,7 +10,8 @@ import { melden } from "./meldungen"
import { stromVerbunden } from "./strom"
import type {
Ablauf, AktionsErgebnis, Altrest, AufraeumKandidat, BoxMesswerte, EinstellungenStand, Hinweis, HomelabEinstellungen,
HomelabLauf, HomelabMesswerte, HomelabSpeicher, HomelabStand, HomelabZertifikate, NeuesGeraet, NeuerStack, NeuWahl, DockerLage, Neuerungen, HomelabWartung, WartungsRegel, Instanz, InstanzMitStand, Job, Modell, Nutzung, PartnerStatus,
HomelabLauf, HomelabMesswerte, HomelabSpeicher, HomelabStand, HomelabZertifikate, NeuesGeraet, NeuerStack, NeuWahl, DockerLage, Neuerungen, HomelabWartung, WartungsRegel,
LucyAutonomie, LucyStufe, Rueckfragen, Wochenbericht, Instanz, InstanzMitStand, Job, Modell, Nutzung, PartnerStatus,
ProtokollEintrag, Radar, SammelPlanSchritt, Sammellauf, Sicherung, Start, TelegramTest, UpdateDetails, ZielAktion,
ZielStand, Zeitraum,
} from "./typen"
@@ -511,6 +512,53 @@ export const useNeuerungen = (zid: string, baustein: string, aktiv: boolean) =>
retry: false,
})
/** Lucys Freiheiten je Aktionsart (seit 25.09.2026, Einstellungen → Lucy). */
export const useLucyAutonomie = (aktiv = true) =>
useQuery({
queryKey: ["lucy-autonomie"],
queryFn: () => api<LucyAutonomie>("/api/lucy/autonomie"),
enabled: aktiv,
retry: false,
})
export const useLucyAutonomieSetzen = () =>
useAktion((stufen: Record<string, LucyStufe>) => post<AktionsErgebnis>("/api/lucy/autonomie", { stufen }), {
erfolg: (_, e) => (e as AktionsErgebnis).text ?? "Gespeichert.",
neuLaden: ["lucy-autonomie"],
})
/** Rückfragen an den Commander (seit 25.09.2026): offene und die zuletzt beantworteten. */
export const useRueckfragen = (aktiv = true) =>
useQuery({
queryKey: ["rueckfragen"],
queryFn: () => api<Rueckfragen>("/api/rueckfragen"),
enabled: aktiv,
refetchInterval: takt(30_000),
retry: false,
})
/** Ja oder Nein auf eine Rückfrage — wie „ja 7“ in Telegram: Ja führt genau die gefragte Aktion aus. */
export const useRueckfrageAntwort = () =>
useAktion(({ nr, ja }: { nr: number; ja: boolean }) => post<AktionsErgebnis>(`/api/rueckfragen/${nr}/${ja ? "ja" : "nein"}`), {
erfolg: (_, e) => (e as AktionsErgebnis).text ?? "Erledigt.",
neuLaden: ["rueckfragen", "start", "jobs", "homelab", "homelab-laeufe"],
})
/** Der Wochenbericht (seit 25.09.2026): der letzte und wann der nächste kommt. */
export const useWochenbericht = (aktiv = true) =>
useQuery({
queryKey: ["wochenbericht"],
queryFn: () => api<Wochenbericht>("/api/lucy/wochenbericht"),
enabled: aktiv,
retry: false,
})
export const useWochenberichtSenden = () =>
useAktion(() => post<AktionsErgebnis>("/api/lucy/wochenbericht"), {
erfolg: (_, e) => (e as AktionsErgebnis).text ?? "Der Wochenbericht ist unterwegs.",
neuLaden: ["wochenbericht", "rueckfragen"],
})
/** Wartungsfenster und Regel je Gerät (seit 25.09.2026). */
export const useWartung = (aktiv: boolean) =>
useQuery({
+6
View File
@@ -34,4 +34,10 @@ describe("Befehlssuche", () => {
expect(treffer.filter((b) => b.text === "Updates").map((b) => b.unter)).toEqual(["KI-Box", "Homelab"])
expect(suchen(alle, "gibtesnicht")).toEqual([])
})
it("findet Lucys Freiheiten, Rückfragen und den Wochenbericht (seit 25.09.2026)", () => {
expect(suchen(alle, "lucy freiheit")[0]).toMatchObject({ to: "/einstellungen", hash: "lucy" })
expect(suchen(alle, "rückfragen")[0]).toMatchObject({ text: "Rückfragen an dich" })
expect(suchen(alle, "wochenbericht")[0]).toMatchObject({ text: "Wochenbericht", hash: "lucy" })
})
})
+9
View File
@@ -26,6 +26,15 @@ export function befehle(ziele: ZielStand[]): Befehl[] {
id: `seite:${s.to}`, text: s.label, unter: ORT_NAME[s.ort], to: s.to,
stichworte: s.to.replace(/\//g, " "),
}))
// Seit 25.09.2026 (Ausbauplan Welle 3): Lucys Freiheiten, ihre Rückfragen und der Wochenbericht.
liste.push(
{ id: "lucy:freiheiten", text: "Lucys Freiheiten", unter: "Einstellungen → Lucy", to: "/einstellungen", hash: "lucy",
stichworte: "lucy autonomie selbst fragen" },
{ id: "lucy:rueckfragen", text: "Rückfragen an dich", unter: "Einstellungen → Lucy", to: "/einstellungen", hash: "lucy",
stichworte: "lucy ja nein telegram rueckfrage" },
{ id: "lucy:wochenbericht", text: "Wochenbericht", unter: "Einstellungen → Lucy", to: "/einstellungen", hash: "lucy",
stichworte: "lucy bericht sonntag" },
)
for (const z of ziele) {
const geraet = { art: z.art, offen: z.offen }
const stichworte = `${ART_WORT[z.art] ?? ""} ${z.id}`
+50
View File
@@ -841,3 +841,53 @@ export interface Ablauf {
status: "ok" | "ueberfaellig" | "gestoppt" | "aus"
text: string | null
}
// --- Lucy: Freiheiten, Rückfragen, Wochenbericht (seit 25.09.2026, Ausbauplan Welle 3) ------------------------------
export type LucyStufe = "lesen" | "fragen" | "selbst"
/** Eine Aktionsart mit Lucys Freiheit (GET /api/lucy/autonomie). */
export interface LucyArt {
id: string
name: string
text: string
stufe: LucyStufe
standard: LucyStufe
}
export interface LucyAutonomie {
arten: LucyArt[]
stufen: Record<LucyStufe, string>
}
export type RueckfrageStatus = "offen" | "laeuft" | "erledigt" | "fehler" | "abgelehnt" | "abgelaufen" | "hinfaellig"
/** Eine Rückfrage an den Commander (GET /api/rueckfragen) — auch in Telegram mit „ja 7“ zu beantworten. */
export interface Rueckfrage {
nr: number
/** Unix-Sekunden */
zeit: number
frage: string
text: string
pfad: string
von: string
stufe: "dringend" | "normal" | "leise"
/** Unix-Sekunden: danach gilt die Rückfrage nicht mehr */
ablauf: number
status: RueckfrageStatus
befund: { bereich: "box" | "homelab"; id: string; titel: string } | null
antwort?: { zeit: number; wer: string; ja: boolean }
ergebnis?: string
nachgeprueft?: "behoben" | "besteht"
}
export interface Rueckfragen {
offen: Rueckfrage[]
zuletzt: Rueckfrage[]
}
/** GET /api/lucy/wochenbericht: der letzte Bericht und wann der nächste kommt (Unix-Sekunden). */
export interface Wochenbericht {
zuletzt: { zeit: number; text: string } | null
naechster: number
}
+25 -3
View File
@@ -1,10 +1,12 @@
import { type ReactNode, useState } from "react"
import { type ReactNode, useEffect, useState } from "react"
import { useQuery, useQueryClient } from "@tanstack/react-query"
import { cn } from "cn"
import { ExternalLink, KeyRound } from "lucide-react"
import { Bestaetigen } from "@/components/cockpit/Bestaetigen"
import { Chip } from "@/components/cockpit/Chip"
import { Wartungsfenster } from "@/components/homelab/Wartungsfenster"
import { Freiheiten, WochenberichtAbschnitt } from "@/components/lucy/LucyEinstellungen"
import { OffeneRueckfragen, RueckfragenVerlauf } from "@/components/lucy/Rueckfragen"
import { Button } from "@/components/ui/button"
import {
useAdguardZugang, useArcaneEcht, useArcaneSchluessel, useEinstellungenStand, useHomelabEinstellungen, useInstanz, usePartner,
@@ -62,10 +64,11 @@ function Abschnitt({ titel, children }: { titel: string; children: ReactNode })
}
/** Eine Karte der Einstellungsseite; KI-Box und Homelab tragen ihre Bereichsfarbe als Schein und Rand (Design v5). */
function Karte({ titel, text, farbe, children }: {
function Karte({ titel, text, farbe, id, children }: {
titel: string
text: string
farbe?: "box" | "homelab"
id?: string
children: ReactNode
}) {
const glut = farbe === "box" ? "var(--box)" : farbe === "homelab" ? "var(--lab)" : null
@@ -73,7 +76,7 @@ function Karte({ titel, text, farbe, children }: {
const rand = farbe === "box" ? "[--rand:color-mix(in_srgb,var(--box)_28%,transparent)]"
: farbe === "homelab" ? "[--rand:color-mix(in_srgb,var(--lab)_28%,transparent)]" : ""
return (
<section aria-label={titel} className={cn("flaeche relative flex min-w-0 flex-col gap-4 overflow-hidden rounded-3xl p-5 sm:p-6", rand)}>
<section id={id} aria-label={titel} className={cn("flaeche relative flex min-w-0 flex-col gap-4 overflow-hidden rounded-3xl p-5 sm:p-6", rand)}>
{glut && (
<div aria-hidden className="pointer-events-none absolute -top-40 -left-32 size-[400px] rounded-full"
style={{ background: `radial-gradient(closest-side, color-mix(in srgb, ${glut} 16%, transparent), transparent)` }} />
@@ -625,11 +628,29 @@ function HomelabKarte() {
)
}
// --- Lucy: Freiheiten, Rückfragen, Wochenbericht (seit 25.09.2026, Ausbauplan Welle 3) --------------------------------
function LucyKarte() {
return (
<Karte id="lucy" titel="Lucy" text="Was sie aus eigenem Antrieb darf, ihre Rückfragen an dich und der Wochenbericht.">
<Abschnitt titel="Wie selbstständig Lucy handelt"><Freiheiten /></Abschnitt>
<Abschnitt titel="Offene Rückfragen"><OffeneRueckfragen leerText="Gerade wartet keine Rückfrage auf dich." /></Abschnitt>
<Abschnitt titel="Zuletzt beantwortet"><RueckfragenVerlauf /></Abschnitt>
<Abschnitt titel="Wochenbericht"><WochenberichtAbschnitt /></Abschnitt>
</Karte>
)
}
// --- Seite ----------------------------------------------------------------------------------------------
export function EinstellungenSeite() {
const box = useEinstellungenStand(true)
const boxProps = { e: box.data, laedt: box.isPending, fehler: box.error?.message }
// Strg+K „Lucys Freiheiten“ führt mit #lucy hierher (beim Öffnen der Seite; ohne Router-Hook, damit die Seite auch
// allein testbar bleibt).
useEffect(() => {
if (window.location.hash === "#lucy") document.getElementById("lucy")?.scrollIntoView({ block: "start" })
}, [])
return (
<div className="grid gap-5 md:gap-6 xl:grid-cols-2">
<Karte titel="KI-Box" farbe="box" text="Wann sie sich aktualisiert, das Modell-Radar und Zugänge.">
@@ -644,6 +665,7 @@ export function EinstellungenSeite() {
</Abschnitt>
</Karte>
<HomelabKarte />
<LucyKarte />
<Karte titel="Meldungen" text="Telegram, Morgenmeldung und was sofort kommt.">
<Meldungen />
</Karte>
+4
View File
@@ -7,6 +7,7 @@ import { cn } from "cn"
import { Chip, type ChipArt, Zustandsfeld } from "@/components/cockpit/Chip"
import { Geraetesymbol } from "@/components/cockpit/Geraetesymbol"
import { Messring } from "@/components/diagramme/Messring"
import { RueckfragenPanel } from "@/components/lucy/Rueckfragen"
import { Lagering, type Segment } from "@/components/uebersicht/Lagering"
import {
useBoxMesswerte, useBoxZiele, useHomelab, useHomelabHinweise, useHomelabLaeufe, useHomelabMesswerte, useHomelabSpeicher,
@@ -650,6 +651,9 @@ export function UebersichtSeite() {
</div>
</section>
{/* Rückfragen an den Commander (seit 25.09.2026): nur, wenn eine offen ist — ganz oben, weil sie auf ihn warten. */}
<RueckfragenPanel />
<Instrumente homelabAktiv={homelabAktiv} />
<div className="grid gap-5 md:gap-6 xl:grid-cols-2">