Später · 19 Handy-Ansicht und installierbare App (ohne Web-Push)

- /handy: Ampel, „Braucht Aufmerksamkeit“ (Rückfragen mit Ja/Nein, rote vor gelben Hinweisen, Geräte ohne Antwort,
  bereite Updates), vier Wege weiter, Knopf „Als App auf dieses Gerät legen“, wo Chrome es anbietet
- Manifest: startet in der Handy-Ansicht, Sprache, Beschreibung, Kurzwege
- Seitenleiste: Handy-Ansicht nur in der Handy-Schublade; Strg+K kennt sie
- BEDIENUNG.md: App aufs Handy legen

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-09-26 09:39:30 +02:00
co-authored by Claude Opus 5.5
parent 67c82f5f73
commit 241e91c03f
31 changed files with 332 additions and 28 deletions
+7
View File
@@ -29,6 +29,13 @@ Adressen wie `/updates` und `/modelle` leiten auf die neuen weiter.
**Suchen mit Strg+K** (oder die Lupe oben): findet Seiten, Geräte und Aktionen — etwa „gitea zeit“ für die Zeitleiste
von Gitea oder „lucy“ für ihre Freiheiten. `typ:lxc`, `typ:vm` oder `update:ja` filtern die Geräte.
**Aufs Handy als App** (seit 26.09.): die Seite über die https-Adresse öffnen (`https://mc.tobisniceshomelab.ddnsfree.com`;
über die IP im Heimnetz geht nur ein Lesezeichen), dann im Browser-Menü „App installieren“ (Android, Chrome) bzw.
„Zum Home-Bildschirm“ (iPhone, Safari); in der Handy-Ansicht bietet Chrome dafür auch einen Knopf an. Die App startet
in der **Handy-Ansicht**: oben eine Ampel, darunter nur, was dich braucht — Rückfragen mit Ja/Nein, Hinweise, Geräte,
die nicht antworten, bereite Updates — und vier Wege weiter. Im Browser erreichst du sie über das Menü (am Handy
unter „Übersicht“) oder mit Strg+K „Handy“. Push-Nachrichten kommen weiter über Telegram.
## Übersicht
- **Gesamtlage** oben: ein Satz, wie es steht („Alles läuft“, „1 Hinweis“, „Störung“), dazu vier Zahlen — Updates
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{G as n,W as r}from"./index-DRAkqVZT.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};
import{i as e,r as t}from"./dist-aE7UmubC.js";import{G as n,K as r}from"./index-B_hvuxH0.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)(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};
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
+2 -2
View File
@@ -9,7 +9,7 @@
<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-DRAkqVZT.js"></script>
<script type="module" crossorigin src="/assets/index-B_hvuxH0.js"></script>
<link rel="modulepreload" crossorigin href="/assets/dist-aE7UmubC.js">
<link rel="modulepreload" crossorigin href="/assets/button-BHVKhtQ6.js">
<link rel="modulepreload" crossorigin href="/assets/dist-CakcBaML.js">
@@ -17,7 +17,7 @@
<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-D9NqFm_c.css">
<link rel="stylesheet" crossorigin href="/assets/index-Cz-7Ak8T.css">
</head>
<body>
<div id="root"></div>
+19 -1
View File
@@ -1,7 +1,11 @@
{
"id": "/",
"name": "Homelab Orchestrator",
"short_name": "Orchestrator",
"start_url": "/",
"description": "KI-Box und Homelab auf einen Blick: Ampel, Rückfragen von Lucy, Updates.",
"lang": "de",
"start_url": "/handy",
"scope": "/",
"display": "standalone",
"background_color": "#0e1012",
"theme_color": "#0e1012",
@@ -21,5 +25,19 @@
"sizes": "any",
"type": "image/svg+xml"
}
],
"shortcuts": [
{
"name": "Übersicht",
"url": "/"
},
{
"name": "Updates im Homelab",
"url": "/homelab/updates"
},
{
"name": "Lucys Freiheiten",
"url": "/einstellungen#lucy"
}
]
}
+19 -1
View File
@@ -1,7 +1,11 @@
{
"id": "/",
"name": "Homelab Orchestrator",
"short_name": "Orchestrator",
"start_url": "/",
"description": "KI-Box und Homelab auf einen Blick: Ampel, Rückfragen von Lucy, Updates.",
"lang": "de",
"start_url": "/handy",
"scope": "/",
"display": "standalone",
"background_color": "#0e1012",
"theme_color": "#0e1012",
@@ -21,5 +25,19 @@
"sizes": "any",
"type": "image/svg+xml"
}
],
"shortcuts": [
{
"name": "Übersicht",
"url": "/"
},
{
"name": "Updates im Homelab",
"url": "/homelab/updates"
},
{
"name": "Lucys Freiheiten",
"url": "/einstellungen#lucy"
}
]
}
+4
View File
@@ -78,7 +78,11 @@ export function Seitenleiste({ onGewaehlt }: {
const seite = (to: Seite["to"]) => SEITEN.find((s) => s.to === to)!
return (
<nav aria-label="Hauptnavigation" className="flex flex-1 flex-col gap-7">
<div className="flex flex-col gap-0.5">
<SeitenLink s={seite("/")} onGewaehlt={onGewaehlt} />
{/* Nur in der Handy-Schublade: die kompakte Startansicht mit Ampel (seit 26.09.2026). */}
<div className="lg:hidden"><SeitenLink s={seite("/handy")} onGewaehlt={onGewaehlt} /></div>
</div>
{BEREICHE.map((b) => {
const v = VERBINDUNG[stand[b.id].verbindung]
const k = KOPF[b.id]
+7 -1
View File
@@ -58,6 +58,12 @@ const homelabProtokoll = createRoute({
component: lazyRouteComponent(() => import("@/views/HomelabProtokoll"), "HomelabProtokollSeite"),
})
// Handy-Startansicht (seit 26.09.2026): Ampel und „Braucht Aufmerksamkeit“; die installierte App startet hier.
const handy = createRoute({
getParentRoute: () => wurzel, path: "/handy",
component: lazyRouteComponent(() => import("@/views/Handy"), "HandySeite"),
})
const einstellungen = createRoute({
getParentRoute: () => wurzel, path: "/einstellungen",
component: lazyRouteComponent(() => import("@/views/Einstellungen"), "EinstellungenSeite"),
@@ -83,7 +89,7 @@ export const router = createRouter({
routeTree: wurzel.addChildren([
uebersicht, box, boxUpdates, boxModelle, boxMonitoring, boxDienste, homelab, homelabUpdates, homelabMonitoring,
homelabProtokoll, einstellungen, altUpdates, altModelle,
homelabSnapshots,
homelabSnapshots, handy,
]),
defaultPreload: "intent",
defaultErrorComponent: Absturz,
+4 -2
View File
@@ -1,6 +1,6 @@
import { useRouterState } from "@tanstack/react-router"
import {
Activity, Cpu, Gauge, LayoutGrid, type LucideIcon, RefreshCw, ScrollText, Settings,
Activity, Cpu, Gauge, LayoutGrid, type LucideIcon, RefreshCw, ScrollText, Settings, Smartphone,
} from "lucide-react"
import { History } from "lucide-react"
import { useBoxZiele, useHomelab, useInstanz, usePartner } from "@/lib/abfragen"
@@ -21,7 +21,7 @@ export const BEREICHE: { id: Bereich; name: string }[] = [
export type SeitenPfad =
| "/" | "/box" | "/box/updates" | "/box/modelle" | "/box/monitoring" | "/box/dienste"
| "/homelab" | "/homelab/updates" | "/homelab/monitoring" | "/homelab/protokoll" | "/einstellungen"
| "/homelab/snapshots"
| "/homelab/snapshots" | "/handy"
export interface Seite {
to: SeitenPfad
@@ -34,6 +34,8 @@ export interface Seite {
export const SEITEN: Seite[] = [
{ to: "/", label: "Übersicht", Icon: LayoutGrid, ort: "uebersicht" },
// Seit 26.09.2026: die Handy-Startansicht (die installierte App startet dort); in der Seitenleiste nur am Handy.
{ to: "/handy", label: "Handy-Ansicht", Icon: Smartphone, ort: "uebersicht" },
{ to: "/box", label: "Cockpit", Icon: Gauge, ort: "box" },
{ to: "/box/updates", label: "Updates", Icon: RefreshCw, ort: "box", zaehlt: true },
{ to: "/box/modelle", label: "Modelle", Icon: Cpu, ort: "box" },
+77
View File
@@ -0,0 +1,77 @@
import type { ReactNode } from "react"
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
import { render, screen } from "@testing-library/react"
import { api } from "@/lib/api"
import type { Hinweis, Rueckfrage } from "@/lib/typen"
import { HandySeite } from "./Handy"
vi.mock("@/lib/api", async (original) => ({ ...(await original<typeof import("@/lib/api")>()), api: vi.fn(), post: vi.fn() }))
vi.mock("@tanstack/react-router", async (original) => ({
...(await original<typeof import("@tanstack/react-router")>()),
Link: ({ to, hash, children, className }: { to: string; hash?: string; children: ReactNode; className?: string }) => (
<a href={hash ? `${to}#${hash}` : to} className={className}>{children}</a>
),
}))
// Handy-Startansicht (Ausbauplan „Später“, Punkt 19, 26.09.2026): Ampel und „Braucht Aufmerksamkeit“.
const jetzt = Math.floor(Date.now() / 1000)
const hinweis = (id: string, stufe: "rot" | "gelb", titel: string, zustand: Hinweis["zustand"] = "neu"): Hinweis => ({
id, stufe, titel, text: "", quelle: "x", seit: jetzt - 60, zuletzt: jetzt, aktionen: [], zustand,
})
const frage: Rueckfrage = {
nr: 7, zeit: jetzt - 60, frage: "Soll ich Gitea 1.27.4 einspielen?", text: "", pfad: "/x", von: "wochenbericht",
stufe: "normal", ablauf: jetzt + 3600, status: "offen", befund: null,
}
function zeigen(d: { box?: Hinweis[]; lab?: Hinweis[]; fragen?: Rueckfrage[]; wartung?: boolean; stumm?: boolean }) {
const daten: Record<string, unknown> = {
"/api/instanz": { rolle: "box", instanz: "Box-Wart", partner: { eingerichtet: true, name: "Homelab" } },
"/api/partner": { eingerichtet: true, name: "Homelab", erreichbar: true },
"/api/ziele": { ziele: [] },
"/api/homelab/ziele": {
ziele: [{ id: "ct-104", name: "Gitea", art: "container", instanz: "homelab", erreichbar: !d.stumm, bausteine: [],
geprueft: null, rueckweg: null, zaehlung: {}, offen: 0 }],
bericht: null, ausfuehrer: { verbunden: true, zuletzt: jetzt },
wartung: { laeuft: !!d.wartung, fenster: "So 03:00–04:15" },
},
"/api/start": { hinweise: d.box ?? [], updates: { gelesen: true, bausteine: [], unklar: [], festgehalten: [] } },
"/api/homelab/hinweise": { hinweise: d.lab ?? [] },
"/api/rueckfragen": { offen: d.fragen ?? [], zuletzt: [] },
}
vi.mocked(api).mockImplementation((async (pfad: string) => {
if (pfad in daten) return daten[pfad]
throw new Error(`unerwartete Abfrage ${pfad}`)
}) as typeof api)
render(<QueryClientProvider client={new QueryClient()}><HandySeite /></QueryClientProvider>)
}
describe("Handy-Startansicht", () => {
beforeEach(() => vi.clearAllMocks())
it("alles ruhig: grüne Ampel, nichts braucht dich", async () => {
zeigen({ lab: [hinweis("a", "gelb", "Stummer Hinweis", "stumm")] })
expect(await screen.findByText("Nichts — alles läuft, und nichts wartet auf dich.")).toBeInTheDocument()
expect(screen.getByRole("heading", { level: 2, name: "Alles läuft" })).toBeInTheDocument()
})
it("rote Hinweise zuerst, dazu stumme Geräte", async () => {
zeigen({ box: [hinweis("g", "gelb", "Job „Daily News Report“ mit Fehlern")], lab: [hinweis("r", "rot", "Gitea antwortet nicht")], stumm: true })
expect(await screen.findByText("Gitea antwortet nicht")).toBeInTheDocument()
const eintraege = (await screen.findAllByRole("link")).map((l) => l.textContent)
expect(eintraege.indexOf("Gitea antwortet nichtHomelab")).toBeLessThan(eintraege.indexOf("Job „Daily News Report“ mit FehlernKI-Box"))
expect(screen.getByRole("heading", { level: 2, name: "Störung" })).toBeInTheDocument()
})
it("Rückfragen stehen oben mit Ja und Nein", async () => {
zeigen({ fragen: [frage] })
expect(await screen.findByRole("heading", { level: 2, name: "Lucy wartet auf dich" })).toBeInTheDocument()
expect(screen.getByText("Soll ich Gitea 1.27.4 einspielen?")).toBeInTheDocument()
expect(screen.getByRole("button", { name: "Ja, ausführen" })).toBeInTheDocument()
})
it("im Wartungsfenster: Wartung statt Störung-Farbe", async () => {
zeigen({ wartung: true })
expect(await screen.findByRole("heading", { level: 2, name: "Wartung läuft" })).toBeInTheDocument()
expect(screen.getByText("Fenster So 03:00–04:15 — Meldung am Ende.")).toBeInTheDocument()
})
})
+171
View File
@@ -0,0 +1,171 @@
import { useEffect, useState } from "react"
import { Link } from "@tanstack/react-router"
import {
CircleAlert, CircleCheck, Download, Gauge, LayoutGrid, type LucideIcon, RefreshCw, Sparkles, TriangleAlert, Wrench,
} from "lucide-react"
import { cn } from "cn"
import { OffeneRueckfragen } from "@/components/lucy/Rueckfragen"
import { Button } from "@/components/ui/button"
import { useHomelab, useHomelabHinweise, useRueckfragen, useStart } from "@/lib/abfragen"
import { useBereichStand } from "@/lib/navigation"
import type { Hinweis } from "@/lib/typen"
import { gesamtlage, type Lagestufe } from "@/lib/uebersicht"
// Die Handy-Startansicht (Ausbauplan „Später“, Punkt 19, seit 26.09.2026). Die Website lässt sich als App aufs Handy
// legen (manifest.webmanifest startet hier): oben eine Ampel, darunter nur, was dich braucht — Rückfragen mit Ja/Nein,
// rote und gelbe Hinweise, Geräte, die nicht antworten, bereite Updates. Push bleibt Telegram (User-Entscheid 25.09.:
// kein Web-Push). Die volle Übersicht ist einen Tipp entfernt.
const AMPEL: Record<Lagestufe | "wartung", { kreis: string; titel: string; Zeichen: LucideIcon }> = {
ok: { kreis: "bg-gruen/15 text-gruen ring-gruen/40", titel: "text-foreground", Zeichen: CircleCheck },
info: { kreis: "bg-gruen/15 text-gruen ring-gruen/40", titel: "text-foreground", Zeichen: CircleCheck },
wartung: { kreis: "bg-cyan/15 text-cyan ring-cyan/40", titel: "text-foreground", Zeichen: Wrench },
gelb: { kreis: "bg-bernstein/15 text-bernstein ring-bernstein/45", titel: "text-bernstein", Zeichen: CircleAlert },
rot: { kreis: "bg-rot/15 text-rot ring-rot/50", titel: "text-rot-text", Zeichen: TriangleAlert },
}
/** Chrome bietet „App installieren“ über ein Ereignis an; wer es abfängt, darf den Knopf selbst zeigen. */
type InstallAngebot = Event & { prompt: () => Promise<void> }
function useInstallAngebot(): [InstallAngebot | null, () => void] {
const [angebot, setAngebot] = useState<InstallAngebot | null>(null)
useEffect(() => {
const merken = (e: Event) => {
e.preventDefault()
setAngebot(e as InstallAngebot)
}
const fertig = () => setAngebot(null)
window.addEventListener("beforeinstallprompt", merken)
window.addEventListener("appinstalled", fertig)
return () => {
window.removeEventListener("beforeinstallprompt", merken)
window.removeEventListener("appinstalled", fertig)
}
}, [])
return [angebot, () => setAngebot(null)]
}
interface Punkt {
id: string
stufe: "rot" | "gelb" | "info"
titel: string
text?: string
to: "/box" | "/homelab" | "/box/updates" | "/homelab/updates"
}
const PUNKT_FARBE: Record<Punkt["stufe"], string> = { rot: "bg-rot", gelb: "bg-bernstein", info: "bg-cyan" }
function hinweisPunkte(hinweise: Hinweis[], to: "/box" | "/homelab", wo: string): Punkt[] {
return hinweise
.filter((h) => h.zustand !== "stumm")
.map((h) => ({ id: `${to}:${h.id}`, stufe: h.stufe === "rot" ? "rot" : "gelb", titel: h.titel, text: wo, to }))
}
export function HandySeite() {
const stand = useBereichStand()
const start = useStart()
const homelabAktiv = stand.homelab.verbindung === "online"
const homelab = useHomelab(homelabAktiv)
const labHinweise = useHomelabHinweise(homelabAktiv)
const offen = useRueckfragen().data?.offen.length ?? 0
const [angebot, angebotWeg] = useInstallAngebot()
const getrennt = [
...(stand.box.verbindung === "getrennt" && start.isError ? ["Die KI-Box"] : []),
...(stand.homelab.verbindung === "getrennt" ? ["Der Homelab-Teil"] : []),
]
const geraete = homelab.data?.ziele ?? []
const stumm = geraete.filter((z) => z.erreichbar === false)
// Ein Gerät, das nicht antwortet, hat meist schon einen roten Hinweis mit demselben Satz — dann nur einmal.
const gesehen = new Set<string>()
const punkte: Punkt[] = [
...hinweisPunkte(start.data?.hinweise ?? [], "/box", "KI-Box"),
...hinweisPunkte(labHinweise.data?.hinweise ?? [], "/homelab", "Homelab"),
...stumm.map((z): Punkt => ({ id: `stumm:${z.id}`, stufe: "rot", titel: `${z.name} antwortet nicht`, text: "Homelab", to: "/homelab" })),
].filter((p) => !gesehen.has(p.titel) && gesehen.add(p.titel))
.sort((a, b) => (a.stufe === "rot" ? 0 : 1) - (b.stufe === "rot" ? 0 : 1))
const updates = stand.box.updates + stand.homelab.updates
if (stand.box.updates) punkte.push({ id: "upd:box", stufe: "info", titel: `${stand.box.updates} ${stand.box.updates === 1 ? "Update" : "Updates"} bereit`, text: "KI-Box", to: "/box/updates" })
if (stand.homelab.updates) punkte.push({ id: "upd:lab", stufe: "info", titel: `${stand.homelab.updates} ${stand.homelab.updates === 1 ? "Update" : "Updates"} bereit`, text: "Homelab", to: "/homelab/updates" })
const hinweise = punkte.filter((p) => p.stufe !== "info")
const lage = gesamtlage({
getrennt, stumm: stumm.map((z) => z.name), hinweiseRot: hinweise.filter((p) => p.stufe === "rot").length,
hinweise: hinweise.length, updates,
})
const wartung = homelab.data?.wartung
const stufe = wartung?.laeuft && lage.stufe !== "rot" ? "wartung" : lage.stufe
const a = AMPEL[stufe]
const titel = stufe === "wartung" ? "Wartung läuft" : offen && lage.stufe !== "rot" ? "Lucy wartet auf dich" : lage.titel
const text = stufe === "wartung" ? `Fenster ${wartung?.fenster ?? ""} — Meldung am Ende.`
: offen ? `${offen === 1 ? "Eine Rückfrage" : `${offen} Rückfragen`}. ${lage.text}` : lage.text
return (
<div className="mx-auto flex w-full max-w-xl flex-col gap-4">
<section aria-label="Ampel" className="flaeche flex items-center gap-5 rounded-3xl p-5">
<span aria-hidden className={cn("flex size-20 shrink-0 items-center justify-center rounded-full ring-2 ring-inset", a.kreis)}>
<a.Zeichen className="size-9" strokeWidth={1.8} />
</span>
<div className="flex min-w-0 flex-col gap-1">
<h2 className={cn("text-[26px] leading-tight font-semibold", a.titel)}>{titel}</h2>
<p className="m-0 text-[15px] text-text-2">{text}</p>
</div>
</section>
{offen > 0 && (
<section aria-label="Rückfragen" id="rueckfragen" className="flaeche flex flex-col gap-3 rounded-3xl p-5">
<h2 className="text-[17px] font-semibold">{offen === 1 ? "Eine Rückfrage an dich" : `${offen} Rückfragen an dich`}</h2>
<OffeneRueckfragen />
</section>
)}
<section aria-label="Braucht Aufmerksamkeit" className="flaeche flex flex-col gap-3 rounded-3xl p-5">
<h2 className="text-[17px] font-semibold">Braucht Aufmerksamkeit</h2>
{punkte.length === 0 ? (
<p className="m-0 text-[15px] text-text-2">Nichts — alles läuft, und nichts wartet auf dich.</p>
) : (
<ul className="m-0 flex list-none flex-col p-0">
{punkte.map((p) => (
<li key={p.id} className="border-t border-white/[0.06] first:border-t-0">
<Link to={p.to} className="flex items-center gap-3 py-3">
<span aria-hidden className={cn("size-2.5 shrink-0 rounded-full", PUNKT_FARBE[p.stufe])} />
<span className="flex min-w-0 flex-col">
<span className="text-[15px] break-words">{p.titel}</span>
{p.text && <span className="text-xs text-text-3">{p.text}</span>}
</span>
</Link>
</li>
))}
</ul>
)}
</section>
<nav aria-label="Weiter" className="grid grid-cols-2 gap-2">
{([
{ to: "/", label: "Übersicht", Zeichen: LayoutGrid },
{ to: "/box", label: "KI-Box", Zeichen: Gauge },
{ to: "/homelab", label: "Homelab", Zeichen: Gauge },
{ to: "/homelab/updates", label: "Updates", Zeichen: RefreshCw },
] as const).map((l) => (
<Link key={l.to} to={l.to} className="flaeche-innen flex h-14 items-center gap-2.5 rounded-2xl px-4 text-[15px] font-medium">
<l.Zeichen className="size-[18px] text-text-3" aria-hidden />
{l.label}
</Link>
))}
<Link to="/einstellungen" hash="lucy" className="flaeche-innen col-span-2 flex h-14 items-center gap-2.5 rounded-2xl px-4 text-[15px] font-medium">
<Sparkles className="size-[18px] text-text-3" aria-hidden />
Lucys Freiheiten und Wochenbericht
</Link>
</nav>
{angebot && (
<Button variant="outline" className="self-start" onClick={() => {
void angebot.prompt()
angebotWeg()
}}>
<Download className="size-4" aria-hidden /> Als App auf dieses Gerät legen
</Button>
)}
</div>
)
}