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:
co-authored by
Claude Opus 5.5
parent
67c82f5f73
commit
241e91c03f
@@ -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
|
**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.
|
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
|
## Übersicht
|
||||||
|
|
||||||
- **Gesamtlage** oben: ein Satz, wie es steht („Alles läuft“, „1 Hinweis“, „Störung“), dazu vier Zahlen — Updates
|
- **Gesamtlage** oben: ein Satz, wie es steht („Alles läuft“, „1 Hinweis“, „Störung“), dazu vier Zahlen — Updates
|
||||||
|
|||||||
-2
File diff suppressed because one or more lines are too long
+2
File diff suppressed because one or more lines are too long
Vendored
+1
-1
File diff suppressed because one or more lines are too long
-1
File diff suppressed because one or more lines are too long
+1
File diff suppressed because one or more lines are too long
Vendored
+1
-1
File diff suppressed because one or more lines are too long
+1
File diff suppressed because one or more lines are too long
+1
File diff suppressed because one or more lines are too long
-1
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
+2
-2
File diff suppressed because one or more lines are too long
Vendored
+1
-1
@@ -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};
|
||||||
+1
-1
File diff suppressed because one or more lines are too long
-1
File diff suppressed because one or more lines are too long
+1
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
-2
File diff suppressed because one or more lines are too long
+2
File diff suppressed because one or more lines are too long
+2
-2
File diff suppressed because one or more lines are too long
+2
File diff suppressed because one or more lines are too long
-2
File diff suppressed because one or more lines are too long
Vendored
+2
-2
@@ -9,7 +9,7 @@
|
|||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||||
<title>Homelab Orchestrator</title>
|
<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/dist-aE7UmubC.js">
|
||||||
<link rel="modulepreload" crossorigin href="/assets/button-BHVKhtQ6.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/dist-CakcBaML.js">
|
||||||
@@ -17,7 +17,7 @@
|
|||||||
<link rel="modulepreload" crossorigin href="/assets/useSelector-DBNxxw17.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/useMatch-DDVpPKJI.js">
|
||||||
<link rel="modulepreload" crossorigin href="/assets/useRouterState-DJr4hrdj.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>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>␍
|
<div id="root"></div>␍
|
||||||
|
|||||||
Vendored
+19
-1
@@ -1,7 +1,11 @@
|
|||||||
{
|
{
|
||||||
|
"id": "/",
|
||||||
"name": "Homelab Orchestrator",
|
"name": "Homelab Orchestrator",
|
||||||
"short_name": "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",
|
"display": "standalone",
|
||||||
"background_color": "#0e1012",
|
"background_color": "#0e1012",
|
||||||
"theme_color": "#0e1012",
|
"theme_color": "#0e1012",
|
||||||
@@ -21,5 +25,19 @@
|
|||||||
"sizes": "any",
|
"sizes": "any",
|
||||||
"type": "image/svg+xml"
|
"type": "image/svg+xml"
|
||||||
}
|
}
|
||||||
|
],
|
||||||
|
"shortcuts": [
|
||||||
|
{
|
||||||
|
"name": "Übersicht",
|
||||||
|
"url": "/"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Updates im Homelab",
|
||||||
|
"url": "/homelab/updates"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Lucys Freiheiten",
|
||||||
|
"url": "/einstellungen#lucy"
|
||||||
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
{
|
{
|
||||||
|
"id": "/",
|
||||||
"name": "Homelab Orchestrator",
|
"name": "Homelab Orchestrator",
|
||||||
"short_name": "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",
|
"display": "standalone",
|
||||||
"background_color": "#0e1012",
|
"background_color": "#0e1012",
|
||||||
"theme_color": "#0e1012",
|
"theme_color": "#0e1012",
|
||||||
@@ -21,5 +25,19 @@
|
|||||||
"sizes": "any",
|
"sizes": "any",
|
||||||
"type": "image/svg+xml"
|
"type": "image/svg+xml"
|
||||||
}
|
}
|
||||||
|
],
|
||||||
|
"shortcuts": [
|
||||||
|
{
|
||||||
|
"name": "Übersicht",
|
||||||
|
"url": "/"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Updates im Homelab",
|
||||||
|
"url": "/homelab/updates"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Lucys Freiheiten",
|
||||||
|
"url": "/einstellungen#lucy"
|
||||||
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -78,7 +78,11 @@ export function Seitenleiste({ onGewaehlt }: {
|
|||||||
const seite = (to: Seite["to"]) => SEITEN.find((s) => s.to === to)!
|
const seite = (to: Seite["to"]) => SEITEN.find((s) => s.to === to)!
|
||||||
return (
|
return (
|
||||||
<nav aria-label="Hauptnavigation" className="flex flex-1 flex-col gap-7">
|
<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} />
|
<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) => {
|
{BEREICHE.map((b) => {
|
||||||
const v = VERBINDUNG[stand[b.id].verbindung]
|
const v = VERBINDUNG[stand[b.id].verbindung]
|
||||||
const k = KOPF[b.id]
|
const k = KOPF[b.id]
|
||||||
|
|||||||
@@ -58,6 +58,12 @@ const homelabProtokoll = createRoute({
|
|||||||
component: lazyRouteComponent(() => import("@/views/HomelabProtokoll"), "HomelabProtokollSeite"),
|
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({
|
const einstellungen = createRoute({
|
||||||
getParentRoute: () => wurzel, path: "/einstellungen",
|
getParentRoute: () => wurzel, path: "/einstellungen",
|
||||||
component: lazyRouteComponent(() => import("@/views/Einstellungen"), "EinstellungenSeite"),
|
component: lazyRouteComponent(() => import("@/views/Einstellungen"), "EinstellungenSeite"),
|
||||||
@@ -83,7 +89,7 @@ export const router = createRouter({
|
|||||||
routeTree: wurzel.addChildren([
|
routeTree: wurzel.addChildren([
|
||||||
uebersicht, box, boxUpdates, boxModelle, boxMonitoring, boxDienste, homelab, homelabUpdates, homelabMonitoring,
|
uebersicht, box, boxUpdates, boxModelle, boxMonitoring, boxDienste, homelab, homelabUpdates, homelabMonitoring,
|
||||||
homelabProtokoll, einstellungen, altUpdates, altModelle,
|
homelabProtokoll, einstellungen, altUpdates, altModelle,
|
||||||
homelabSnapshots,
|
homelabSnapshots, handy,
|
||||||
]),
|
]),
|
||||||
defaultPreload: "intent",
|
defaultPreload: "intent",
|
||||||
defaultErrorComponent: Absturz,
|
defaultErrorComponent: Absturz,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useRouterState } from "@tanstack/react-router"
|
import { useRouterState } from "@tanstack/react-router"
|
||||||
import {
|
import {
|
||||||
Activity, Cpu, Gauge, LayoutGrid, type LucideIcon, RefreshCw, ScrollText, Settings,
|
Activity, Cpu, Gauge, LayoutGrid, type LucideIcon, RefreshCw, ScrollText, Settings, Smartphone,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
import { History } from "lucide-react"
|
import { History } from "lucide-react"
|
||||||
import { useBoxZiele, useHomelab, useInstanz, usePartner } from "@/lib/abfragen"
|
import { useBoxZiele, useHomelab, useInstanz, usePartner } from "@/lib/abfragen"
|
||||||
@@ -21,7 +21,7 @@ export const BEREICHE: { id: Bereich; name: string }[] = [
|
|||||||
export type SeitenPfad =
|
export type SeitenPfad =
|
||||||
| "/" | "/box" | "/box/updates" | "/box/modelle" | "/box/monitoring" | "/box/dienste"
|
| "/" | "/box" | "/box/updates" | "/box/modelle" | "/box/monitoring" | "/box/dienste"
|
||||||
| "/homelab" | "/homelab/updates" | "/homelab/monitoring" | "/homelab/protokoll" | "/einstellungen"
|
| "/homelab" | "/homelab/updates" | "/homelab/monitoring" | "/homelab/protokoll" | "/einstellungen"
|
||||||
| "/homelab/snapshots"
|
| "/homelab/snapshots" | "/handy"
|
||||||
|
|
||||||
export interface Seite {
|
export interface Seite {
|
||||||
to: SeitenPfad
|
to: SeitenPfad
|
||||||
@@ -34,6 +34,8 @@ export interface Seite {
|
|||||||
|
|
||||||
export const SEITEN: Seite[] = [
|
export const SEITEN: Seite[] = [
|
||||||
{ to: "/", label: "Übersicht", Icon: LayoutGrid, ort: "uebersicht" },
|
{ 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", label: "Cockpit", Icon: Gauge, ort: "box" },
|
||||||
{ to: "/box/updates", label: "Updates", Icon: RefreshCw, ort: "box", zaehlt: true },
|
{ to: "/box/updates", label: "Updates", Icon: RefreshCw, ort: "box", zaehlt: true },
|
||||||
{ to: "/box/modelle", label: "Modelle", Icon: Cpu, ort: "box" },
|
{ to: "/box/modelle", label: "Modelle", Icon: Cpu, ort: "box" },
|
||||||
|
|||||||
@@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user