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.test.tsx b/src/screens/leitstand/needs/NeedsYou.test.tsx index e75e6efb..712b3b9c 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,9 +154,55 @@ 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(); 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); + }); }); diff --git a/src/screens/leitstand/needs/NeedsYou.tsx b/src/screens/leitstand/needs/NeedsYou.tsx index 99a3ff8e..3a3d2a30 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,20 @@ 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"); + + // 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")}

    ; 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 +60,10 @@ export function NeedsYou({ questions, workers }: NeedsYouProps) {

    {t("state.need")}

    {open.length > 0 ? fill(t("needs.open"), { n: open.length }) : t("needs.none")} + {listRead && + 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;