From ab0f43260f2544747fa573e516aa4cac904edd6d Mon Sep 17 00:00:00 2001 From: Cuarroc <47299143+Cuarroc@users.noreply.github.com> Date: Wed, 14 Oct 2026 17:18:08 +0200 Subject: [PATCH 1/4] test(needs): expect three decision rows above the question list Failing tests: the pane must always show the Geld, Installieren and Loeschen sentences, answer a delete question inside its row, and keep an unmatched question in the list. Test-First: src/screens/leitstand/needs/NeedsYou.test.tsx::shows the money one-liner while no money question is open Test-First: src/screens/leitstand/needs/NeedsYou.test.tsx::answers a delete question from its own row Test-First: src/screens/leitstand/needs/NeedsYou.test.tsx::keeps a question that matches no row in the list Co-Authored-By: Claude Sonnet 5.5 --- src/screens/leitstand/needs/NeedsYou.test.tsx | 33 ++++++++++++++++++- 1 file changed, 32 insertions(+), 1 deletion(-) diff --git a/src/screens/leitstand/needs/NeedsYou.test.tsx b/src/screens/leitstand/needs/NeedsYou.test.tsx index e75e6efb..44a02d26 100644 --- a/src/screens/leitstand/needs/NeedsYou.test.tsx +++ b/src/screens/leitstand/needs/NeedsYou.test.tsx @@ -1,4 +1,4 @@ -import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { fireEvent, render, screen, waitFor, within } from "@testing-library/react"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { useQuestions } from "../../../lib/useQuestions"; @@ -154,6 +154,37 @@ describe("NeedsYou", () => { expect(container.textContent).not.toMatch(/\d/); }); + it("shows the money one-liner while no money question is open", async () => { + backend([], () => null); + const { container } = render(); + expect(await screen.findByText("Nichts offen")).toBeInTheDocument(); + expect(screen.getByText("Geld ausgeben braucht deine Freigabe.")).toBeInTheDocument(); + expect(screen.getByText("Installieren braucht deine Freigabe.")).toBeInTheDocument(); + expect(screen.getByText("Löschen braucht deine Freigabe.")).toBeInTheDocument(); + expect(screen.getAllByText("Keine offene Entscheidung.")).toHaveLength(3); + expect(container.textContent).not.toMatch(/\d/); + }); + + it("answers a delete question from its own row", async () => { + const del = { ...row, id: "q-del", question: "Darf ich die Datei löschen?", optionsJson: "[]" }; + backend([del], ({ id, answer }) => ({ ...del, id, status: "answered", answer })); + render(); + const lane = within(await screen.findByRole("region", { name: "Löschen" })); + expect(lane.getByText("Darf ich die Datei löschen?")).toBeInTheDocument(); + expect(screen.getAllByText("Darf ich die Datei löschen?")).toHaveLength(1); + fireEvent.change(lane.getByRole("textbox", { name: "Antwort" }), { target: { value: "Ja" } }); + fireEvent.click(lane.getByRole("button", { name: "Antworten" })); + await screen.findByText("Beantwortet: Ja"); + expect(mocks.invoke).toHaveBeenCalledWith("answer_question", { id: "q-del", answer: "Ja" }); + }); + + it("keeps a question that matches no row in the list", async () => { + backend([row], () => null); + render(); + expect(await screen.findByRole("list", { name: "Braucht dich" })).toContainElement(screen.getByText("Cover only this case?")); + expect(screen.getAllByText("Keine offene Entscheidung.")).toHaveLength(3); + }); + it("says it is not connected when the list cannot be read", async () => { mocks.invoke.mockRejectedValue(new Error("core unreachable")); render(); From 8def567753ae597cc4d5973a08075be0d2a47d40 Mon Sep 17 00:00:00 2001 From: Cuarroc <47299143+Cuarroc@users.noreply.github.com> Date: Wed, 14 Oct 2026 17:36:29 +0200 Subject: [PATCH 2/4] feat(needs): always show the Geld, Installieren and Loeschen decision rows Three rows sit above the question list, each with one sentence and "Keine offene Entscheidung." while empty. A question classified as money, install or delete moves into its row and is answered with the same Ask field (moved to Ask.tsx unchanged); other questions stay in the list. Test-First: src/screens/leitstand/needs/NeedsYou.test.tsx::shows the money one-liner while no money question is open Test-First: src/screens/leitstand/needs/NeedsYou.test.tsx::answers a delete question from its own row Test-First: src/screens/leitstand/needs/NeedsYou.test.tsx::keeps a question that matches no row in the list Co-Authored-By: Claude Sonnet 5.5 --- src/screens/leitstand/needs/Ask.tsx | 96 +++++++++++++++++ src/screens/leitstand/needs/DecisionLane.tsx | 36 +++++++ src/screens/leitstand/needs/NeedsYou.tsx | 108 +++---------------- src/screens/leitstand/needs/needs.css | 3 + src/screens/leitstand/needs/texts.ts | 8 ++ 5 files changed, 156 insertions(+), 95 deletions(-) create mode 100644 src/screens/leitstand/needs/Ask.tsx create mode 100644 src/screens/leitstand/needs/DecisionLane.tsx create mode 100644 src/screens/leitstand/needs/texts.ts diff --git a/src/screens/leitstand/needs/Ask.tsx b/src/screens/leitstand/needs/Ask.tsx new file mode 100644 index 00000000..789b5920 --- /dev/null +++ b/src/screens/leitstand/needs/Ask.tsx @@ -0,0 +1,96 @@ +import { useState, type KeyboardEvent } from "react"; + +import { Button } from "../../../design/controls/Button"; +import { ErrorDetails } from "../../../errors/ErrorDetails"; +import { texts, userError } from "../../../errors/texts"; +import { Input } from "../../../design/inputs/Input"; +import { fill } from "../leitstand"; +import { useT } from "../../../i18n/useT"; +import { describeError } from "../../../lib/ipc"; +import type { Question, Worker } from "../../../types"; + +/** The asker's offered answers; a string that will not parse means none. */ +function parseOptions(json: string | null): string[] { + try { + const value: unknown = JSON.parse(json ?? "[]"); + return Array.isArray(value) ? value.filter((o): o is string => typeof o === "string" && o.trim() !== "") : []; + } catch { + return []; + } +} + +function age(createdAt: number, nowMs: number, t: ReturnType): string { + const minutes = Math.max(0, Math.floor((nowMs / 1000 - createdAt) / 60)); + if (minutes < 1) return t("needs.justNow"); + return minutes < 60 ? fill(t("needs.minutes"), { n: minutes }) : fill(t("needs.hours"), { n: Math.floor(minutes / 60) }); +} + +export function Ask({ q, worker, onAnswer }: { q: Question; worker?: Worker; onAnswer: (id: string, text: string) => Promise }) { + const t = useT(); + const [draft, setDraft] = useState(""); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const preflight = q.scope === "preflight"; + + const send = async (text: string) => { + if (busy || text.trim() === "") return; + setBusy(true); + setError(null); + try { + await onAnswer(q.id, text); + } catch (cause) { + setError(describeError(cause)); + } finally { + // The card may outlive a successful answer until the next poll. + setBusy(false); + } + }; + const onKeyDown = (e: KeyboardEvent) => { + if (e.key === "Escape") setDraft(""); + else if (e.key === "Enter") { + e.preventDefault(); + void send(draft); + } + }; + + return ( +
  • +
    + {worker?.branch ?? (preflight ? t("needs.preflight") : t("needs.noAgent"))} + {worker?.task} + {age(q.createdAt, Date.now(), t)} +
    +

    {q.question}

    +
    + {parseOptions(q.optionsJson).map((option, i) => ( + + ))} +
    +
    + setDraft(e.target.value)} + onKeyDown={onKeyDown} + /> + +
    + {error !== null && ( +
    +

    {userError(error, texts.actionFailed).text}

    + +
    + )} +

    {preflight ? t("needs.keys") : `${t("needs.direct")} ${t("needs.keys")}`}

    +
  • + ); +} diff --git a/src/screens/leitstand/needs/DecisionLane.tsx b/src/screens/leitstand/needs/DecisionLane.tsx new file mode 100644 index 00000000..3da461c6 --- /dev/null +++ b/src/screens/leitstand/needs/DecisionLane.tsx @@ -0,0 +1,36 @@ +import type { Question, Worker } from "../../../types"; +import { Ask } from "./Ask"; +import type { QuestionClass } from "./classify"; +import { T } from "./texts"; + +export const LANES = ["money", "install", "delete"] as const satisfies readonly Exclude[]; +export type Lane = (typeof LANES)[number]; + +/** One always-visible decision row; its questions are answered with the same field as the list. */ +export function DecisionLane({ + lane, + questions, + workers, + onAnswer, +}: { + lane: Lane; + questions: Question[]; + workers: Worker[]; + onAnswer: (id: string, text: string) => Promise; +}) { + return ( +
    +

    {T[lane].title}

    +

    {T[lane].rule}

    + {questions.length === 0 ? ( +

    {T.none}

    + ) : ( +
      + {questions.map((q) => ( + w.id === q.workerId)} onAnswer={onAnswer} /> + ))} +
    + )} +
    + ); +} diff --git a/src/screens/leitstand/needs/NeedsYou.tsx b/src/screens/leitstand/needs/NeedsYou.tsx index 99a3ff8e..514b50fe 100644 --- a/src/screens/leitstand/needs/NeedsYou.tsx +++ b/src/screens/leitstand/needs/NeedsYou.tsx @@ -1,108 +1,20 @@ -import { useRef, useState, type KeyboardEvent } from "react"; +import { useRef, useState } from "react"; -import { Button } from "../../../design/controls/Button"; -import { ErrorDetails } from "../../../errors/ErrorDetails"; -import { texts, userError } from "../../../errors/texts"; import { HonestState } from "../../../design/data/HonestState"; -import { Input } from "../../../design/inputs/Input"; import { StateGlyph } from "../../../design/state/StateMark"; import { fill } from "../leitstand"; +import { Ask } from "./Ask"; +import { classifyQuestion } from "./classify"; +import { DecisionLane, LANES } from "./DecisionLane"; import { useT } from "../../../i18n/useT"; -import { describeError } from "../../../lib/ipc"; import type { QuestionsState } from "../../../lib/useQuestions"; -import type { Question, Worker } from "../../../types"; +import type { Worker } from "../../../types"; import "../../../design/glass.css"; import "./needs.css"; /** How many "answered" lines stay under the list. */ const SAID_LIMIT = 20; -/** The asker's offered answers; a string that will not parse means none. */ -function parseOptions(json: string | null): string[] { - try { - const value: unknown = JSON.parse(json ?? "[]"); - return Array.isArray(value) ? value.filter((o): o is string => typeof o === "string" && o.trim() !== "") : []; - } catch { - return []; - } -} - -function age(createdAt: number, nowMs: number, t: ReturnType): string { - const minutes = Math.max(0, Math.floor((nowMs / 1000 - createdAt) / 60)); - if (minutes < 1) return t("needs.justNow"); - return minutes < 60 ? fill(t("needs.minutes"), { n: minutes }) : fill(t("needs.hours"), { n: Math.floor(minutes / 60) }); -} - -function Ask({ q, worker, onAnswer }: { q: Question; worker?: Worker; onAnswer: (id: string, text: string) => Promise }) { - const t = useT(); - const [draft, setDraft] = useState(""); - const [busy, setBusy] = useState(false); - const [error, setError] = useState(null); - const preflight = q.scope === "preflight"; - - const send = async (text: string) => { - if (busy || text.trim() === "") return; - setBusy(true); - setError(null); - try { - await onAnswer(q.id, text); - } catch (cause) { - setError(describeError(cause)); - } finally { - // The card may outlive a successful answer until the next poll. - setBusy(false); - } - }; - const onKeyDown = (e: KeyboardEvent) => { - if (e.key === "Escape") setDraft(""); - else if (e.key === "Enter") { - e.preventDefault(); - void send(draft); - } - }; - - return ( -
  • -
    - {worker?.branch ?? (preflight ? t("needs.preflight") : t("needs.noAgent"))} - {worker?.task} - {age(q.createdAt, Date.now(), t)} -
    -

    {q.question}

    -
    - {parseOptions(q.optionsJson).map((option, i) => ( - - ))} -
    -
    - setDraft(e.target.value)} - onKeyDown={onKeyDown} - /> - -
    - {error !== null && ( -
    -

    {userError(error, texts.actionFailed).text}

    - -
    - )} -

    {preflight ? t("needs.keys") : `${t("needs.direct")} ${t("needs.keys")}`}

    -
  • - ); -} - export interface NeedsYouProps { questions: QuestionsState; /** The workers the questions can be resolved against (name and task). */ @@ -122,14 +34,17 @@ export function NeedsYou({ questions, workers }: NeedsYouProps) { setSaid((prev) => [{ key: seq.current++, text }, ...prev].slice(0, SAID_LIMIT)); }; + const classes = open.map((q) => classifyQuestion(q.question)); + const others = open.filter((_, i) => classes[i] === "other"); + let body; if (error !== null && open.length === 0) body = ; else if (loading && open.length === 0) body =

    {t("needs.loading")}

    ; else if (open.length === 0) body = ; - else + else if (others.length > 0) body = (
      - {open.map((q) => ( + {others.map((q) => ( w.id === q.workerId)} onAnswer={onAnswer} /> ))}
    @@ -142,6 +57,9 @@ export function NeedsYou({ questions, workers }: NeedsYouProps) {

    {t("state.need")}

    {open.length > 0 ? fill(t("needs.open"), { n: open.length }) : t("needs.none")} + {LANES.map((lane) => ( + classes[i] === lane)} workers={workers} onAnswer={onAnswer} /> + ))} {body} {said.map((s) => (

    diff --git a/src/screens/leitstand/needs/needs.css b/src/screens/leitstand/needs/needs.css index fc11d965..7e3fab4b 100644 --- a/src/screens/leitstand/needs/needs.css +++ b/src/screens/leitstand/needs/needs.css @@ -22,3 +22,6 @@ .n-answered { display: flex; align-items: center; gap: 8px; margin: 0; font-size: 12px; font-weight: 500; color: var(--g-ok); } +.n-lane { display: flex; flex-direction: column; gap: 8px; padding: 12px 16px; border: 1px solid var(--g-hair); border-radius: var(--g-r-card); } +.n-lane h3 { margin: 0; font: 600 13px/1.3 var(--g-font-sans); color: var(--g-ink); } +.n-lane p.n-note { margin: 0; font-size: 12px; line-height: 1.45; color: var(--g-muted); } diff --git a/src/screens/leitstand/needs/texts.ts b/src/screens/leitstand/needs/texts.ts new file mode 100644 index 00000000..c429b615 --- /dev/null +++ b/src/screens/leitstand/needs/texts.ts @@ -0,0 +1,8 @@ +// Words of the three decision rows in "Braucht dich". German is hard-wired here +// until the dictionary takes over (see src/i18n/hardcoded.test.ts). +export const T = { + none: "Keine offene Entscheidung.", + money: { title: "Geld", rule: "Geld ausgeben braucht deine Freigabe." }, + install: { title: "Installieren", rule: "Installieren braucht deine Freigabe." }, + delete: { title: "Löschen", rule: "Löschen braucht deine Freigabe." }, +} as const; From 0ced7749f9ddbdaa84713820b26aeef8716aed70 Mon Sep 17 00:00:00 2001 From: Cuarroc <47299143+Cuarroc@users.noreply.github.com> Date: Thu, 15 Oct 2026 01:50:25 +0200 Subject: [PATCH 3/4] test(needs): expect no decision lanes while the question list is loading or unreadable Finding R1455-G1: the three lanes said "Keine offene Entscheidung." next to the loading and offline states, although nothing had been read. Test-First: src/screens/leitstand/needs/NeedsYou.test.tsx::shows no decision lane claiming nothing is open while the list cannot be read Test-First: src/screens/leitstand/needs/NeedsYou.test.tsx::shows no decision lane claiming nothing is open while the list is loading Co-Authored-By: Claude Sonnet 5.5 --- src/screens/leitstand/needs/NeedsYou.test.tsx | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/src/screens/leitstand/needs/NeedsYou.test.tsx b/src/screens/leitstand/needs/NeedsYou.test.tsx index 44a02d26..712b3b9c 100644 --- a/src/screens/leitstand/needs/NeedsYou.test.tsx +++ b/src/screens/leitstand/needs/NeedsYou.test.tsx @@ -190,4 +190,19 @@ describe("NeedsYou", () => { render(); expect(await screen.findByText("Nicht verbunden")).toBeInTheDocument(); }); + + it("shows no decision lane claiming nothing is open while the list cannot be read", async () => { + mocks.invoke.mockRejectedValue(new Error("core unreachable")); + render(); + expect(await screen.findByText("Nicht verbunden")).toBeInTheDocument(); + expect(screen.queryAllByText("Keine offene Entscheidung.")).toHaveLength(0); + expect(screen.queryByRole("region", { name: "Löschen" })).toBeNull(); + }); + + it("shows no decision lane claiming nothing is open while the list is loading", () => { + mocks.invoke.mockImplementation(() => new Promise(() => {})); + render(); + expect(screen.getByText("Fragen werden gelesen …")).toBeInTheDocument(); + expect(screen.queryAllByText("Keine offene Entscheidung.")).toHaveLength(0); + }); }); From d49a4b0c4b945df9880f358d3493eb66a1b20604 Mon Sep 17 00:00:00 2001 From: Cuarroc <47299143+Cuarroc@users.noreply.github.com> Date: Thu, 15 Oct 2026 01:51:31 +0200 Subject: [PATCH 4/4] fix(needs): hide the decision lanes until the question list was read While the list loads or cannot be read, the Geld, Installieren and Loeschen lanes claimed "Keine offene Entscheidung." beside the loading or offline state. They now render only when questions are open or the read finished without error (finding R1455-G1). Test-First: src/screens/leitstand/needs/NeedsYou.test.tsx::shows no decision lane claiming nothing is open while the list cannot be read Test-First: src/screens/leitstand/needs/NeedsYou.test.tsx::shows no decision lane claiming nothing is open while the list is loading Co-Authored-By: Claude Sonnet 5.5 --- src/screens/leitstand/needs/NeedsYou.tsx | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/screens/leitstand/needs/NeedsYou.tsx b/src/screens/leitstand/needs/NeedsYou.tsx index 514b50fe..3a3d2a30 100644 --- a/src/screens/leitstand/needs/NeedsYou.tsx +++ b/src/screens/leitstand/needs/NeedsYou.tsx @@ -37,6 +37,9 @@ export function NeedsYou({ questions, workers }: NeedsYouProps) { const classes = open.map((q) => classifyQuestion(q.question)); const others = open.filter((_, i) => classes[i] === "other"); + // The lanes say "Keine offene Entscheidung." — only true once the list was read. + const listRead = open.length > 0 || (error === null && !loading); + let body; if (error !== null && open.length === 0) body = ; else if (loading && open.length === 0) body =

    {t("needs.loading")}

    ; @@ -57,9 +60,10 @@ export function NeedsYou({ questions, workers }: NeedsYouProps) {

    {t("state.need")}

    {open.length > 0 ? fill(t("needs.open"), { n: open.length }) : t("needs.none")} - {LANES.map((lane) => ( - classes[i] === lane)} workers={workers} onAnswer={onAnswer} /> - ))} + {listRead && + LANES.map((lane) => ( + classes[i] === lane)} workers={workers} onAnswer={onAnswer} /> + ))} {body} {said.map((s) => (