Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
96 changes: 96 additions & 0 deletions src/screens/leitstand/needs/Ask.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof useT>): 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<void> }) {
const t = useT();
const [draft, setDraft] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(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<HTMLInputElement>) => {
if (e.key === "Escape") setDraft("");
else if (e.key === "Enter") {
e.preventDefault();
void send(draft);
}
};

return (
<li className="n-ask">
<div className="n-from">
<b>{worker?.branch ?? (preflight ? t("needs.preflight") : t("needs.noAgent"))}</b>
{worker?.task}
<span className="n-age">{age(q.createdAt, Date.now(), t)}</span>
</div>
<p>{q.question}</p>
<div className="n-opts">
{parseOptions(q.optionsJson).map((option, i) => (
<Button key={`${i}-${option}`} size="sm" disabled={busy} onClick={() => void send(option)}>
{option}
</Button>
))}
</div>
<div className="n-compose">
<Input
compact
name="needs-answer"
aria-label={t("needs.answerLabel")}
aria-invalid={error !== null || undefined}
placeholder={preflight ? t("needs.placeholderPreflight") : t("needs.placeholder")}
value={draft}
disabled={busy}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={onKeyDown}
/>
<Button size="sm" variant="tint" disabled={busy || draft.trim() === ""} onClick={() => void send(draft)}>
{busy ? t("needs.sending") : t("needs.send")}
</Button>
</div>
{error !== null && (
<div className="n-error" role="alert">
<p>{userError(error, texts.actionFailed).text}</p>
<ErrorDetails raw={userError(error).detail} />
</div>
)}
<p className="n-note">{preflight ? t("needs.keys") : `${t("needs.direct")} ${t("needs.keys")}`}</p>
</li>
);
}
36 changes: 36 additions & 0 deletions src/screens/leitstand/needs/DecisionLane.tsx
Original file line number Diff line number Diff line change
@@ -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<QuestionClass, "other">[];
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<void>;
}) {
return (
<section className="n-lane" aria-labelledby={`n-lane-${lane}`}>
<h3 id={`n-lane-${lane}`}>{T[lane].title}</h3>
<p className="n-note">{T[lane].rule}</p>
{questions.length === 0 ? (
<p className="n-note">{T.none}</p>
) : (
<ul className="n-list">
{questions.map((q) => (
<Ask key={q.id} q={q} worker={workers.find((w) => w.id === q.workerId)} onAnswer={onAnswer} />
))}
</ul>
)}
</section>
);
}
48 changes: 47 additions & 1 deletion src/screens/leitstand/needs/NeedsYou.test.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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(<Harness />);
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(<Harness />);
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(<Harness />);
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(<Harness />);
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(<Harness />);
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(<Harness />);
expect(screen.getByText("Fragen werden gelesen …")).toBeInTheDocument();
expect(screen.queryAllByText("Keine offene Entscheidung.")).toHaveLength(0);
});
});
112 changes: 17 additions & 95 deletions src/screens/leitstand/needs/NeedsYou.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof useT>): 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<void> }) {
const t = useT();
const [draft, setDraft] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(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<HTMLInputElement>) => {
if (e.key === "Escape") setDraft("");
else if (e.key === "Enter") {
e.preventDefault();
void send(draft);
}
};

return (
<li className="n-ask">
<div className="n-from">
<b>{worker?.branch ?? (preflight ? t("needs.preflight") : t("needs.noAgent"))}</b>
{worker?.task}
<span className="n-age">{age(q.createdAt, Date.now(), t)}</span>
</div>
<p>{q.question}</p>
<div className="n-opts">
{parseOptions(q.optionsJson).map((option, i) => (
<Button key={`${i}-${option}`} size="sm" disabled={busy} onClick={() => void send(option)}>
{option}
</Button>
))}
</div>
<div className="n-compose">
<Input
compact
name="needs-answer"
aria-label={t("needs.answerLabel")}
aria-invalid={error !== null || undefined}
placeholder={preflight ? t("needs.placeholderPreflight") : t("needs.placeholder")}
value={draft}
disabled={busy}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={onKeyDown}
/>
<Button size="sm" variant="tint" disabled={busy || draft.trim() === ""} onClick={() => void send(draft)}>
{busy ? t("needs.sending") : t("needs.send")}
</Button>
</div>
{error !== null && (
<div className="n-error" role="alert">
<p>{userError(error, texts.actionFailed).text}</p>
<ErrorDetails raw={userError(error).detail} />
</div>
)}
<p className="n-note">{preflight ? t("needs.keys") : `${t("needs.direct")} ${t("needs.keys")}`}</p>
</li>
);
}

export interface NeedsYouProps {
questions: QuestionsState;
/** The workers the questions can be resolved against (name and task). */
Expand All @@ -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 = <HonestState kind="offline" hint={t("needs.offlineHint")} />;
else if (loading && open.length === 0) body = <p className="n-note">{t("needs.loading")}</p>;
else if (open.length === 0) body = <HonestState kind="empty" title={t("needs.emptyTitle")} hint={t("needs.emptyHint")} />;
else
else if (others.length > 0)
body = (
<ul className="n-list" aria-label={t("state.need")}>
{open.map((q) => (
{others.map((q) => (
<Ask key={q.id} q={q} worker={workers.find((w) => w.id === q.workerId)} onAnswer={onAnswer} />
))}
</ul>
Expand All @@ -142,6 +60,10 @@ export function NeedsYou({ questions, workers }: NeedsYouProps) {
<h2 id="n-title">{t("state.need")}</h2>
<span className="n-count">{open.length > 0 ? fill(t("needs.open"), { n: open.length }) : t("needs.none")}</span>
</div>
{listRead &&
LANES.map((lane) => (
<DecisionLane key={lane} lane={lane} questions={open.filter((_, i) => classes[i] === lane)} workers={workers} onAnswer={onAnswer} />
))}
{body}
{said.map((s) => (
<p key={s.key} className="n-answered" role="status">
Expand Down
3 changes: 3 additions & 0 deletions src/screens/leitstand/needs/needs.css
Original file line number Diff line number Diff line change
Expand Up @@ -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); }
8 changes: 8 additions & 0 deletions src/screens/leitstand/needs/texts.ts
Original file line number Diff line number Diff line change
@@ -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;
Loading