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
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
-1
View File
@@ -1 +0,0 @@
import{i as e,r as t,s as n}from"./dist-aE7UmubC.js";import{t as r}from"./button-DwYvdWVr.js";import{a as i,i as a,n as o,o as s,r as c,t as l}from"./dialog-BEmPjtjX.js";var u=n({Bestaetigen:()=>p}),d=t(),f=e();function p(e){let t=(0,d.c)(32),{offen:n,titel:u,text:p,knopf:m,neinKnopf:h,gefahr:g,gesperrt:_,onJa:v,onNein:y,children:b}=e,x=h===void 0?`Abbrechen`:h,S;t[0]===y?S=t[1]:(S=e=>!e&&y(),t[0]=y,t[1]=S);let C;t[2]===u?C=t[3]:(C=(0,f.jsx)(s,{className:`pr-10 text-[19px] leading-snug font-semibold break-words`,children:u}),t[2]=u,t[3]=C);let w;t[4]===p?w=t[5]:(w=(0,f.jsx)(c,{className:`text-[15px] leading-relaxed text-text-2`,children:p}),t[4]=p,t[5]=w);let T;t[6]!==C||t[7]!==w?(T=(0,f.jsxs)(i,{children:[C,w]}),t[6]=C,t[7]=w,t[8]=T):T=t[8];let E;t[9]!==x||t[10]!==y?(E=(0,f.jsx)(r,{variant:`outline`,onClick:y,children:x}),t[9]=x,t[10]=y,t[11]=E):E=t[11];let D=g?`gefahr`:`default`,O=_||void 0,k;t[12]!==_||t[13]!==v?(k=()=>{_||v()},t[12]=_,t[13]=v,t[14]=k):k=t[14];let A;t[15]!==_||t[16]!==m||t[17]!==D||t[18]!==O||t[19]!==k?(A=(0,f.jsx)(r,{variant:D,disabled:_,"aria-busy":O,onClick:k,children:m}),t[15]=_,t[16]=m,t[17]=D,t[18]=O,t[19]=k,t[20]=A):A=t[20];let j;t[21]!==A||t[22]!==E?(j=(0,f.jsxs)(a,{className:`sticky bottom-0 mb-0 gap-2 bg-[#121722] sm:gap-2`,children:[E,A]}),t[21]=A,t[22]=E,t[23]=j):j=t[23];let M;t[24]!==b||t[25]!==j||t[26]!==T?(M=(0,f.jsxs)(o,{className:`max-h-[calc(100dvh-2rem)] overflow-y-auto border-white/10 bg-[#0e121a] pb-0 sm:max-w-lg`,children:[T,b,j]}),t[24]=b,t[25]=j,t[26]=T,t[27]=M):M=t[27];let N;return t[28]!==n||t[29]!==M||t[30]!==S?(N=(0,f.jsx)(l,{open:n,onOpenChange:S,children:M}),t[28]=n,t[29]=M,t[30]=S,t[31]=N):N=t[31],N}export{u as n,p as t};
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
import{i as e,r as t}from"./dist-aE7UmubC.js";import{F as n,I as r}from"./index-BUHDF9RN.js";import{a as i,i as a,n as o,r as s,t as c}from"./sheet-Dx0TG2uq.js";var l=t(),u=e();function d(e){let t=(0,l.c)(8),{onSchliessen:d}=e,f;t[0]===d?f=t[1]:(f=e=>!e&&d(),t[0]=d,t[1]=f);let p;t[2]===Symbol.for(`react.memo_cache_sentinel`)?(p=(0,u.jsxs)(a,{className:`p-0`,children:[(0,u.jsx)(i,{asChild:!0,children:(0,u.jsx)(`div`,{children:(0,u.jsx)(n,{})})}),(0,u.jsx)(s,{className:`sr-only`,children:`Bereiche und Seiten`})]}),t[2]=p):p=t[2];let m;t[3]===d?m=t[4]:(m=(0,u.jsxs)(o,{side:`left`,className:`flex flex-col gap-7 border-white/[0.06] bg-[#0a0d14] px-4 pt-5 pb-[calc(1.25rem+env(safe-area-inset-bottom))] data-[side=left]:w-[300px]`,children:[p,(0,u.jsx)(r,{onGewaehlt:d})]}),t[3]=d,t[4]=m);let h;return t[5]!==f||t[6]!==m?(h=(0,u.jsx)(c,{open:!0,onOpenChange:f,children:m}),t[5]=f,t[6]=m,t[7]=h):h=t[7],h}export{d as MenueSchublade};
import{i as e,r as t}from"./dist-aE7UmubC.js";import{G as n,W as r}from"./index-Bumw6krN.js";import{a as i,i as a,n as o,r as s,t as c}from"./sheet-DG7u1mB-.js";var l=t(),u=e();function d(e){let t=(0,l.c)(8),{onSchliessen:d}=e,f;t[0]===d?f=t[1]:(f=e=>!e&&d(),t[0]=d,t[1]=f);let p;t[2]===Symbol.for(`react.memo_cache_sentinel`)?(p=(0,u.jsxs)(a,{className:`p-0`,children:[(0,u.jsx)(i,{asChild:!0,children:(0,u.jsx)(`div`,{children:(0,u.jsx)(r,{})})}),(0,u.jsx)(s,{className:`sr-only`,children:`Bereiche und Seiten`})]}),t[2]=p):p=t[2];let m;t[3]===d?m=t[4]:(m=(0,u.jsxs)(o,{side:`left`,className:`flex flex-col gap-7 border-white/[0.06] bg-[#0a0d14] px-4 pt-5 pb-[calc(1.25rem+env(safe-area-inset-bottom))] data-[side=left]:w-[300px]`,children:[p,(0,u.jsx)(n,{onGewaehlt:d})]}),t[3]=d,t[4]=m);let h;return t[5]!==f||t[6]!==m?(h=(0,u.jsx)(c,{open:!0,onOpenChange:f,children:m}),t[5]=f,t[6]=m,t[7]=h):h=t[7],h}export{d as MenueSchublade};
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
-1
View File
@@ -1 +0,0 @@
import{i as e,r as t,t as n}from"./dist-aE7UmubC.js";var r=t(),i=e();function a(e){let t=(0,r.c)(12),{titel:a,rechts:o,children:s,className:c,id:l,symbol:u}=e,d;t[0]===c?d=t[1]:(d=n(`flaeche flex min-w-0 flex-col gap-4 rounded-2xl p-5 sm:p-6`,c),t[0]=c,t[1]=d);let f;t[2]!==u||t[3]!==o||t[4]!==a?(f=(a||o)&&(0,i.jsxs)(`div`,{className:`flex flex-wrap items-center justify-between gap-3`,children:[a&&(0,i.jsxs)(`h2`,{className:`flex items-center gap-2.5 text-[17px] leading-tight font-semibold tracking-[0.005em] text-foreground`,children:[u&&(0,i.jsx)(u,{className:`size-[18px] text-text-3`,"aria-hidden":!0}),a]}),o]}),t[2]=u,t[3]=o,t[4]=a,t[5]=f):f=t[5];let p;return t[6]!==s||t[7]!==l||t[8]!==d||t[9]!==f||t[10]!==a?(p=(0,i.jsxs)(`section`,{id:l,"aria-label":a,className:d,children:[f,s]}),t[6]=s,t[7]=l,t[8]=d,t[9]=f,t[10]=a,t[11]=p):p=t[11],p}export{a as t};
@@ -1 +1 @@
import{i as e,r as t}from"./dist-aE7UmubC.js";import{a as n,i as r,n as i,r as a,t as o}from"./sheet-Dx0TG2uq.js";var s=t(),c=e();function l(e){let t=(0,s.c)(14),{offen:l,titel:u,text:d,onSchliessen:f}=e,p;t[0]===f?p=t[1]:(p=e=>!e&&f(),t[0]=f,t[1]=p);let m;t[2]===Symbol.for(`react.memo_cache_sentinel`)?(m=(0,c.jsx)(n,{className:`text-[19px] font-semibold`,children:`Protokoll`}),t[2]=m):m=t[2];let h;t[3]===u?h=t[4]:(h=(0,c.jsxs)(r,{children:[m,(0,c.jsx)(a,{className:`text-text-2`,children:u})]}),t[3]=u,t[4]=h);let g;t[5]===d?g=t[6]:(g=(0,c.jsx)(`pre`,{className:`ziffern mx-4 mb-4 flex-1 overflow-auto rounded-lg bg-black/30 ring-1 ring-white/[0.07] ring-inset p-3 text-xs leading-relaxed whitespace-pre-wrap text-text-2`,children:d}),t[5]=d,t[6]=g);let _;t[7]!==h||t[8]!==g?(_=(0,c.jsxs)(i,{side:`right`,className:`border-white/10 bg-[#0e121a] data-[side=right]:w-full data-[side=right]:sm:max-w-2xl`,children:[h,g]}),t[7]=h,t[8]=g,t[9]=_):_=t[9];let v;return t[10]!==l||t[11]!==p||t[12]!==_?(v=(0,c.jsx)(o,{open:l,onOpenChange:p,children:_}),t[10]=l,t[11]=p,t[12]=_,t[13]=v):v=t[13],v}export{l as Protokollfenster};
import{i as e,r as t}from"./dist-aE7UmubC.js";import{a as n,i as r,n as i,r as a,t as o}from"./sheet-DG7u1mB-.js";var s=t(),c=e();function l(e){let t=(0,s.c)(14),{offen:l,titel:u,text:d,onSchliessen:f}=e,p;t[0]===f?p=t[1]:(p=e=>!e&&f(),t[0]=f,t[1]=p);let m;t[2]===Symbol.for(`react.memo_cache_sentinel`)?(m=(0,c.jsx)(n,{className:`text-[19px] font-semibold`,children:`Protokoll`}),t[2]=m):m=t[2];let h;t[3]===u?h=t[4]:(h=(0,c.jsxs)(r,{children:[m,(0,c.jsx)(a,{className:`text-text-2`,children:u})]}),t[3]=u,t[4]=h);let g;t[5]===d?g=t[6]:(g=(0,c.jsx)(`pre`,{className:`ziffern mx-4 mb-4 flex-1 overflow-auto rounded-lg bg-black/30 ring-1 ring-white/[0.07] ring-inset p-3 text-xs leading-relaxed whitespace-pre-wrap text-text-2`,children:d}),t[5]=d,t[6]=g);let _;t[7]!==h||t[8]!==g?(_=(0,c.jsxs)(i,{side:`right`,className:`border-white/10 bg-[#0e121a] data-[side=right]:w-full data-[side=right]:sm:max-w-2xl`,children:[h,g]}),t[7]=h,t[8]=g,t[9]=_):_=t[9];let v;return t[10]!==l||t[11]!==p||t[12]!==_?(v=(0,c.jsx)(o,{open:l,onOpenChange:p,children:_}),t[10]=l,t[11]=p,t[12]=_,t[13]=v):v=t[13],v}export{l as Protokollfenster};
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
-1
View File
@@ -1 +0,0 @@
import{a as e,i as t,r as n,t as r}from"./dist-aE7UmubC.js";import{Q as i}from"./index-BUHDF9RN.js";import{t as a}from"./button-DwYvdWVr.js";import{a as o,i as s,n as c,o as l,r as u,s as d,t as f}from"./dist-oje4j9hw.js";var p=n();e();var m=t();function h(e){let t=(0,p.c)(4),n;t[0]===e?n=t[1]:({...n}=e,t[0]=e,t[1]=n);let r;return t[2]===n?r=t[3]:(r=(0,m.jsx)(f,{"data-slot":`dialog`,...n}),t[2]=n,t[3]=r),r}function g(e){let t=(0,p.c)(4),n;t[0]===e?n=t[1]:({...n}=e,t[0]=e,t[1]=n);let r;return t[2]===n?r=t[3]:(r=(0,m.jsx)(l,{"data-slot":`dialog-portal`,...n}),t[2]=n,t[3]=r),r}function _(e){let t=(0,p.c)(8),n,i;t[0]===e?(n=t[1],i=t[2]):({className:n,...i}=e,t[0]=e,t[1]=n,t[2]=i);let a;t[3]===n?a=t[4]:(a=r(`fixed inset-0 isolate z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0`,n),t[3]=n,t[4]=a);let s;return t[5]!==i||t[6]!==a?(s=(0,m.jsx)(o,{"data-slot":`dialog-overlay`,className:a,...i}),t[5]=i,t[6]=a,t[7]=s):s=t[7],s}function v(e){let t=(0,p.c)(15),n,o,s,l;t[0]===e?(n=t[1],o=t[2],s=t[3],l=t[4]):({className:o,children:n,showCloseButton:l,...s}=e,t[0]=e,t[1]=n,t[2]=o,t[3]=s,t[4]=l);let d=l===void 0||l,f;t[5]===Symbol.for(`react.memo_cache_sentinel`)?(f=(0,m.jsx)(_,{}),t[5]=f):f=t[5];let h;t[6]===o?h=t[7]:(h=r(`fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-popover p-4 text-sm text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95`,o),t[6]=o,t[7]=h);let v;t[8]===d?v=t[9]:(v=d&&(0,m.jsx)(c,{"data-slot":`dialog-close`,asChild:!0,children:(0,m.jsxs)(a,{variant:`ghost`,className:`absolute top-2 right-2`,size:`icon-sm`,children:[(0,m.jsx)(i,{}),(0,m.jsx)(`span`,{className:`sr-only`,children:`Schließen`})]})}),t[8]=d,t[9]=v);let y;return t[10]!==n||t[11]!==s||t[12]!==h||t[13]!==v?(y=(0,m.jsxs)(g,{children:[f,(0,m.jsxs)(u,{"data-slot":`dialog-content`,className:h,...s,children:[n,v]})]}),t[10]=n,t[11]=s,t[12]=h,t[13]=v,t[14]=y):y=t[14],y}function y(e){let t=(0,p.c)(8),n,i;t[0]===e?(n=t[1],i=t[2]):({className:n,...i}=e,t[0]=e,t[1]=n,t[2]=i);let a;t[3]===n?a=t[4]:(a=r(`flex flex-col gap-2`,n),t[3]=n,t[4]=a);let o;return t[5]!==i||t[6]!==a?(o=(0,m.jsx)(`div`,{"data-slot":`dialog-header`,className:a,...i}),t[5]=i,t[6]=a,t[7]=o):o=t[7],o}function b(e){let t=(0,p.c)(14),n,i,o,s;t[0]===e?(n=t[1],i=t[2],o=t[3],s=t[4]):({className:i,showCloseButton:s,children:n,...o}=e,t[0]=e,t[1]=n,t[2]=i,t[3]=o,t[4]=s);let l=s!==void 0&&s,u;t[5]===i?u=t[6]:(u=r(`-mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t bg-muted/50 p-4 sm:flex-row sm:justify-end`,i),t[5]=i,t[6]=u);let d;t[7]===l?d=t[8]:(d=l&&(0,m.jsx)(c,{asChild:!0,children:(0,m.jsx)(a,{variant:`outline`,children:`Schließen`})}),t[7]=l,t[8]=d);let f;return t[9]!==n||t[10]!==o||t[11]!==u||t[12]!==d?(f=(0,m.jsxs)(`div`,{"data-slot":`dialog-footer`,className:u,...o,children:[n,d]}),t[9]=n,t[10]=o,t[11]=u,t[12]=d,t[13]=f):f=t[13],f}function x(e){let t=(0,p.c)(8),n,i;t[0]===e?(n=t[1],i=t[2]):({className:n,...i}=e,t[0]=e,t[1]=n,t[2]=i);let a;t[3]===n?a=t[4]:(a=r(`font-heading text-base leading-none font-medium`,n),t[3]=n,t[4]=a);let o;return t[5]!==i||t[6]!==a?(o=(0,m.jsx)(d,{"data-slot":`dialog-title`,className:a,...i}),t[5]=i,t[6]=a,t[7]=o):o=t[7],o}function S(e){let t=(0,p.c)(8),n,i;t[0]===e?(n=t[1],i=t[2]):({className:n,...i}=e,t[0]=e,t[1]=n,t[2]=i);let a;t[3]===n?a=t[4]:(a=r(`text-sm text-muted-foreground *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground`,n),t[3]=n,t[4]=a);let o;return t[5]!==i||t[6]!==a?(o=(0,m.jsx)(s,{"data-slot":`dialog-description`,className:a,...i}),t[5]=i,t[6]=a,t[7]=o):o=t[7],o}export{y as a,b as i,v as n,x as o,S as r,h as t};
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1
View File
@@ -0,0 +1 @@
import{a as e,i as t,r as n,t as r}from"./dist-aE7UmubC.js";import{a as i,c as a,i as o,n as s,o as c,r as l,s as u,t as d}from"./dist-CakcBaML.js";import{t as f}from"./button-BHVKhtQ6.js";var p=n();e();var m=t();function h(e){let t=(0,p.c)(4),n;t[0]===e?n=t[1]:({...n}=e,t[0]=e,t[1]=n);let r;return t[2]===n?r=t[3]:(r=(0,m.jsx)(d,{"data-slot":`sheet`,...n}),t[2]=n,t[3]=r),r}function g(e){let t=(0,p.c)(4),n;t[0]===e?n=t[1]:({...n}=e,t[0]=e,t[1]=n);let r;return t[2]===n?r=t[3]:(r=(0,m.jsx)(c,{"data-slot":`sheet-portal`,...n}),t[2]=n,t[3]=r),r}function _(e){let t=(0,p.c)(8),n,a;t[0]===e?(n=t[1],a=t[2]):({className:n,...a}=e,t[0]=e,t[1]=n,t[2]=a);let o;t[3]===n?o=t[4]:(o=r(`fixed inset-0 z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0`,n),t[3]=n,t[4]=o);let s;return t[5]!==a||t[6]!==o?(s=(0,m.jsx)(i,{"data-slot":`sheet-overlay`,className:o,...a}),t[5]=a,t[6]=o,t[7]=s):s=t[7],s}function v(e){let t=(0,p.c)(17),n,i,o,c,u;t[0]===e?(n=t[1],i=t[2],o=t[3],c=t[4],u=t[5]):({className:i,children:n,side:c,showCloseButton:u,...o}=e,t[0]=e,t[1]=n,t[2]=i,t[3]=o,t[4]=c,t[5]=u);let d=c===void 0?`right`:c,h=u===void 0||u,v;t[6]===Symbol.for(`react.memo_cache_sentinel`)?(v=(0,m.jsx)(_,{}),t[6]=v):v=t[6];let y;t[7]===i?y=t[8]:(y=r(`fixed z-50 flex flex-col gap-4 bg-popover bg-clip-padding text-sm text-popover-foreground shadow-lg transition duration-200 ease-in-out data-[side=bottom]:inset-x-0 data-[side=bottom]:bottom-0 data-[side=bottom]:h-auto data-[side=bottom]:border-t data-[side=left]:inset-y-0 data-[side=left]:left-0 data-[side=left]:h-full data-[side=left]:w-3/4 data-[side=left]:border-r data-[side=right]:inset-y-0 data-[side=right]:right-0 data-[side=right]:h-full data-[side=right]:w-3/4 data-[side=right]:border-l data-[side=top]:inset-x-0 data-[side=top]:top-0 data-[side=top]:h-auto data-[side=top]:border-b data-[side=left]:sm:max-w-sm data-[side=right]:sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-[side=bottom]:data-open:slide-in-from-bottom-10 data-[side=left]:data-open:slide-in-from-left-10 data-[side=right]:data-open:slide-in-from-right-10 data-[side=top]:data-open:slide-in-from-top-10 data-closed:animate-out data-closed:fade-out-0 data-[side=bottom]:data-closed:slide-out-to-bottom-10 data-[side=left]:data-closed:slide-out-to-left-10 data-[side=right]:data-closed:slide-out-to-right-10 data-[side=top]:data-closed:slide-out-to-top-10`,i),t[7]=i,t[8]=y);let b;t[9]===h?b=t[10]:(b=h&&(0,m.jsx)(s,{"data-slot":`sheet-close`,asChild:!0,children:(0,m.jsxs)(f,{variant:`ghost`,className:`absolute top-3 right-3`,size:`icon-sm`,children:[(0,m.jsx)(a,{}),(0,m.jsx)(`span`,{className:`sr-only`,children:`Schließen`})]})}),t[9]=h,t[10]=b);let x;return t[11]!==n||t[12]!==o||t[13]!==d||t[14]!==y||t[15]!==b?(x=(0,m.jsxs)(g,{children:[v,(0,m.jsxs)(l,{"data-slot":`sheet-content`,"data-side":d,className:y,...o,children:[n,b]})]}),t[11]=n,t[12]=o,t[13]=d,t[14]=y,t[15]=b,t[16]=x):x=t[16],x}function y(e){let t=(0,p.c)(8),n,i;t[0]===e?(n=t[1],i=t[2]):({className:n,...i}=e,t[0]=e,t[1]=n,t[2]=i);let a;t[3]===n?a=t[4]:(a=r(`flex flex-col gap-0.5 p-4`,n),t[3]=n,t[4]=a);let o;return t[5]!==i||t[6]!==a?(o=(0,m.jsx)(`div`,{"data-slot":`sheet-header`,className:a,...i}),t[5]=i,t[6]=a,t[7]=o):o=t[7],o}function b(e){let t=(0,p.c)(8),n,i;t[0]===e?(n=t[1],i=t[2]):({className:n,...i}=e,t[0]=e,t[1]=n,t[2]=i);let a;t[3]===n?a=t[4]:(a=r(`font-heading text-base font-medium text-foreground`,n),t[3]=n,t[4]=a);let o;return t[5]!==i||t[6]!==a?(o=(0,m.jsx)(u,{"data-slot":`sheet-title`,className:a,...i}),t[5]=i,t[6]=a,t[7]=o):o=t[7],o}function x(e){let t=(0,p.c)(8),n,i;t[0]===e?(n=t[1],i=t[2]):({className:n,...i}=e,t[0]=e,t[1]=n,t[2]=i);let a;t[3]===n?a=t[4]:(a=r(`text-sm text-muted-foreground`,n),t[3]=n,t[4]=a);let s;return t[5]!==i||t[6]!==a?(s=(0,m.jsx)(o,{"data-slot":`sheet-description`,className:a,...i}),t[5]=i,t[6]=a,t[7]=s):s=t[7],s}export{b as a,y as i,v as n,x as r,h as t};
-1
View File
@@ -1 +0,0 @@
import{a as e,i as t,r as n,t as r}from"./dist-aE7UmubC.js";import{Q as i}from"./index-BUHDF9RN.js";import{t as a}from"./button-DwYvdWVr.js";import{a as o,i as s,n as c,o as l,r as u,s as d,t as f}from"./dist-oje4j9hw.js";var p=n();e();var m=t();function h(e){let t=(0,p.c)(4),n;t[0]===e?n=t[1]:({...n}=e,t[0]=e,t[1]=n);let r;return t[2]===n?r=t[3]:(r=(0,m.jsx)(f,{"data-slot":`sheet`,...n}),t[2]=n,t[3]=r),r}function g(e){let t=(0,p.c)(4),n;t[0]===e?n=t[1]:({...n}=e,t[0]=e,t[1]=n);let r;return t[2]===n?r=t[3]:(r=(0,m.jsx)(l,{"data-slot":`sheet-portal`,...n}),t[2]=n,t[3]=r),r}function _(e){let t=(0,p.c)(8),n,i;t[0]===e?(n=t[1],i=t[2]):({className:n,...i}=e,t[0]=e,t[1]=n,t[2]=i);let a;t[3]===n?a=t[4]:(a=r(`fixed inset-0 z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0`,n),t[3]=n,t[4]=a);let s;return t[5]!==i||t[6]!==a?(s=(0,m.jsx)(o,{"data-slot":`sheet-overlay`,className:a,...i}),t[5]=i,t[6]=a,t[7]=s):s=t[7],s}function v(e){let t=(0,p.c)(17),n,o,s,l,d;t[0]===e?(n=t[1],o=t[2],s=t[3],l=t[4],d=t[5]):({className:o,children:n,side:l,showCloseButton:d,...s}=e,t[0]=e,t[1]=n,t[2]=o,t[3]=s,t[4]=l,t[5]=d);let f=l===void 0?`right`:l,h=d===void 0||d,v;t[6]===Symbol.for(`react.memo_cache_sentinel`)?(v=(0,m.jsx)(_,{}),t[6]=v):v=t[6];let y;t[7]===o?y=t[8]:(y=r(`fixed z-50 flex flex-col gap-4 bg-popover bg-clip-padding text-sm text-popover-foreground shadow-lg transition duration-200 ease-in-out data-[side=bottom]:inset-x-0 data-[side=bottom]:bottom-0 data-[side=bottom]:h-auto data-[side=bottom]:border-t data-[side=left]:inset-y-0 data-[side=left]:left-0 data-[side=left]:h-full data-[side=left]:w-3/4 data-[side=left]:border-r data-[side=right]:inset-y-0 data-[side=right]:right-0 data-[side=right]:h-full data-[side=right]:w-3/4 data-[side=right]:border-l data-[side=top]:inset-x-0 data-[side=top]:top-0 data-[side=top]:h-auto data-[side=top]:border-b data-[side=left]:sm:max-w-sm data-[side=right]:sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-[side=bottom]:data-open:slide-in-from-bottom-10 data-[side=left]:data-open:slide-in-from-left-10 data-[side=right]:data-open:slide-in-from-right-10 data-[side=top]:data-open:slide-in-from-top-10 data-closed:animate-out data-closed:fade-out-0 data-[side=bottom]:data-closed:slide-out-to-bottom-10 data-[side=left]:data-closed:slide-out-to-left-10 data-[side=right]:data-closed:slide-out-to-right-10 data-[side=top]:data-closed:slide-out-to-top-10`,o),t[7]=o,t[8]=y);let b;t[9]===h?b=t[10]:(b=h&&(0,m.jsx)(c,{"data-slot":`sheet-close`,asChild:!0,children:(0,m.jsxs)(a,{variant:`ghost`,className:`absolute top-3 right-3`,size:`icon-sm`,children:[(0,m.jsx)(i,{}),(0,m.jsx)(`span`,{className:`sr-only`,children:`Schließen`})]})}),t[9]=h,t[10]=b);let x;return t[11]!==n||t[12]!==s||t[13]!==f||t[14]!==y||t[15]!==b?(x=(0,m.jsxs)(g,{children:[v,(0,m.jsxs)(u,{"data-slot":`sheet-content`,"data-side":f,className:y,...s,children:[n,b]})]}),t[11]=n,t[12]=s,t[13]=f,t[14]=y,t[15]=b,t[16]=x):x=t[16],x}function y(e){let t=(0,p.c)(8),n,i;t[0]===e?(n=t[1],i=t[2]):({className:n,...i}=e,t[0]=e,t[1]=n,t[2]=i);let a;t[3]===n?a=t[4]:(a=r(`flex flex-col gap-0.5 p-4`,n),t[3]=n,t[4]=a);let o;return t[5]!==i||t[6]!==a?(o=(0,m.jsx)(`div`,{"data-slot":`sheet-header`,className:a,...i}),t[5]=i,t[6]=a,t[7]=o):o=t[7],o}function b(e){let t=(0,p.c)(8),n,i;t[0]===e?(n=t[1],i=t[2]):({className:n,...i}=e,t[0]=e,t[1]=n,t[2]=i);let a;t[3]===n?a=t[4]:(a=r(`font-heading text-base font-medium text-foreground`,n),t[3]=n,t[4]=a);let o;return t[5]!==i||t[6]!==a?(o=(0,m.jsx)(d,{"data-slot":`sheet-title`,className:a,...i}),t[5]=i,t[6]=a,t[7]=o):o=t[7],o}function x(e){let t=(0,p.c)(8),n,i;t[0]===e?(n=t[1],i=t[2]):({className:n,...i}=e,t[0]=e,t[1]=n,t[2]=i);let a;t[3]===n?a=t[4]:(a=r(`text-sm text-muted-foreground`,n),t[3]=n,t[4]=a);let o;return t[5]!==i||t[6]!==a?(o=(0,m.jsx)(s,{"data-slot":`sheet-description`,className:a,...i}),t[5]=i,t[6]=a,t[7]=o):o=t[7],o}export{b as a,y as i,v as n,x as r,h as t};
+5 -3
View File
@@ -9,13 +9,15 @@
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<title>Homelab Orchestrator</title>
<script type="module" crossorigin src="/assets/index-BUHDF9RN.js"></script>
<script type="module" crossorigin src="/assets/index-Bumw6krN.js"></script>
<link rel="modulepreload" crossorigin href="/assets/dist-aE7UmubC.js">
<link rel="modulepreload" crossorigin href="/assets/abfragen-DKcWnZ8a.js">
<link rel="modulepreload" crossorigin href="/assets/button-BHVKhtQ6.js">
<link rel="modulepreload" crossorigin href="/assets/dist-CakcBaML.js">
<link rel="modulepreload" crossorigin href="/assets/abfragen-pD_qBEtc.js">
<link rel="modulepreload" crossorigin href="/assets/useSelector-DBNxxw17.js">
<link rel="modulepreload" crossorigin href="/assets/useMatch-DDVpPKJI.js">
<link rel="modulepreload" crossorigin href="/assets/useRouterState-DJr4hrdj.js">
<link rel="stylesheet" crossorigin href="/assets/index-DcqanB_e.css">
<link rel="stylesheet" crossorigin href="/assets/index-D9NqFm_c.css">
</head>
<body>
<div id="root"></div>
@@ -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">