Skip to content
Merged
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
2 changes: 1 addition & 1 deletion docs/PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -296,7 +296,7 @@ DS-T-01 (#1623), DS-T-02 (#1610), DS-T-03 (#1624), DS-T-04 (#1609) und DS-X-01 b
| NAV-0 | Doku zur Navigation: `docs/plan/v2.0/ia.md` §1/§3 beschreibt die sechs Bereiche mit der Tabelle „Bereich → Route-IDs“ (IDs unverändert), `docs/decisions.md` „2026-10-16 - NAV-0“ bucht E-1 B, die Sichtbarkeit (drei Bereiche) und den Reiternamen „Linie“; PLAN bucht die Entscheidungen und diese Zeilen | Root disponiert | – | Server | C | Erledigt: #1630 (Doku, Stand: PR-Text, Merge durch Root) | prepush Exit 0; `node scripts/plan-check.mjs --offline` Exit 0; Doc-Link-Gate Exit 0 | – |
| NAV-1a | Seitenleiste nach Bereichen: Feld `area` je Route und Liste der Bereiche, ein Link je Bereich auf seine erste sichtbare Route, `aria-current` je Bereich; neue Werte `nav.leitstand` „Fabrik“, `nav.eingangPlan` „Aufträge“, `nav.gedaechtnis` „Wissen“, neu `nav.betrieb`; zugleich der Reiter `leitstand.tab.fabrik` → „Linie“ (früher NAV-1b), damit kein Linkname doppelt vorkommt; Routen-IDs und Pfade bleiben (`src/shell/routes.ts`, `Shell.tsx` (nur `Sidebar`), `src/i18n/de.ts`, `src/screens/start/texts.ts`, `LeitstandRoute.test.tsx` und die Shell-Tests) | Root disponiert | NAV-0; #1606 (`Shell.tsx`) und #1604 gemergt oder `main` eingemergt | Server | B | Erledigt: #1636; die Seitenleiste zeigt einen Link je Bereich (Schalter aus: Fabrik und Einstellungen, Betrieb ab NAV-2a), der Fluss-Reiter heißt „Linie“ | benannte Tests rot→grün (sechs Bereiche ohne Ersteinrichtung, Betrieb aktiv auf jeder Mitgliedsroute, Schalter aus zeigt Fabrik und Einstellungen, Reiter „Linie“); prepush; zwei Reviews; Zeilen ≤ 300 | – |
| NAV-2a | Bereich Betrieb bekommt die Route `betrieb` (`/betrieb`) mit dem heutigen `BetriebPanel`; kleine Alias-Tabelle in `mount/routing.ts` leitet `/leitstand/betrieb` um; `betrieb` kommt in die sichtbaren Routen von v2.0 (Entscheid „Drei“): `routes.ts`, `mount/routing.ts`, `mount/ShellMount.tsx`, `screens/betrieb/BetriebRoute.tsx`, Tests, `e2e/shell.core.spec.ts` | Root disponiert | NAV-1a; seriell mit den Routing-Dateien von KR-07a/11c/11d/12b/12c | Server | B | Erledigt: #1641; der Bereich Betrieb hat die Route `/betrieb` mit dem bisherigen Panel, `/leitstand/betrieb` leitet dorthin um, Betrieb steht in der v2.0-Seitenleiste | benannte Tests rot→grün (alter Pfad löst auf `/betrieb` auf, Panel öffnet unter `/betrieb` mit aktivem Bereich Betrieb); prepush; zwei Reviews; Zeilen ≤ 300 | – |
| NAV-2b | Die Fabrik verliert den Reiter Betrieb: Zweig und Reiter aus `LeitstandRoute` entfernen, Tests umziehen, Schlüssel `leitstand.tab.betrieb` entfernen (`LeitstandRoute.tsx`, `LeitstandRoute.test.tsx`, `de.ts`) | Root disponiert | NAV-2a; DS-T-01 und DS-T-05 (gleiche Dateien) | Server | B | Geplant: nicht disponiert (16.10.) | benannter Test rot→grün (Reiterleiste der Fabrik ohne Betrieb); prepush; zwei Reviews; Zeilen ≤ 300 | – |
| NAV-2b | Die Fabrik verliert den Reiter Betrieb: Zweig und Reiter aus `LeitstandRoute` entfernen, Tests umziehen, Schlüssel `leitstand.tab.betrieb` entfernen (`LeitstandRoute.tsx`, `LeitstandRoute.test.tsx`, `de.ts`) | Root disponiert | NAV-2a; DS-T-01 und DS-T-05 (gleiche Dateien) | Server | B | Erledigt: #1647; die Fabrik hat keinen Reiter Betrieb mehr, Betrieb liegt nur unter `/betrieb`; dazu die Nachträge aus dem NAV-2a-Review (Alias mit Schrägstrich und Abfrage, Hinweis bei fehlgeschlagenem Profil-Lesen, e2e für den alten Pfad) | benannter Test rot→grün (Reiterleiste der Fabrik ohne Betrieb); prepush; zwei Reviews; Zeilen ≤ 300 | – |
| DS-WF-1 | Workflow-Ansicht, Bildvertrag (Doku): Objekte Verteiler, Sammelstelle, Weiche, Tor, Rückweg, „nicht nötig“, gruppiert, neu; Ehrlichkeitsregel „ohne Daten: noch nicht verbunden“; Bewegungs- und Tastaturregeln: `docs/design/hall/WORKFLOWS.md`; Entscheid DS-WF-0 (Band plus Fahrplan, keine Helferfiguren) | Root disponiert | DS-H-07 (Urteil des Nutzers über die Probe-Halle), DS-H-00 | Server | C | Geplant: wartet auf die Abnahme der Probe-Halle (Nutzer 16.10., Q3) | prepush Exit 0 | – |
| DS-WF-2 | Workflow-Ansicht, reine Projektion: Bänder aus `QueueEntry`, `BoardCard` und `spawnedBy`, Gruppe ab fünf Zweigen, Sammelstelle, Runde und Weiche bleiben `unknown`, solange kein Motor sie liefert: `src/design/hall/workflow/project.ts` | Root disponiert | DS-H-07; DS-WF-1; DS-T-03 | Server | B | Geplant: wartet auf die Abnahme der Probe-Halle (Nutzer 16.10., Q3) | benannte Tests rot→grün; prepush; zwei Reviews | – |
| DS-WF-3 | Workflow-Ansicht, Kunst: Bandkacheln (gerade, Ecke, Teilung, Zusammenführung, Rückweg) und Verteiler, Sammelstelle, Weiche im Spriteformat von DS-H-01 | Root disponiert | DS-H-07; DS-H-01; DS-H-02a | Server | B | Geplant: wartet auf die Abnahme der Probe-Halle (Nutzer 16.10., Q3) | benannte Tests rot→grün; prepush; zwei Reviews | – |
Expand Down
10 changes: 10 additions & 0 deletions e2e/shell.core.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,3 +68,13 @@ test("hidden areas redirect to the Leitstand until weitere Bereiche is on", asyn
await expect.poll(() => page.evaluate(() => window.location.hash), { message: route.path }).toBe(`#${DEFAULT_PATH}`);
}
});

test("the retired /leitstand/betrieb path lands on /betrieb", async ({ page }) => {
await page.addInitScript(() => {
localStorage.setItem("projecta.settings.featureFlag.d1_neue_oberflaeche", "1");
});
await page.goto("/#/leitstand/betrieb", { waitUntil: "networkidle" });
await expect(page.locator(".g-shell")).toBeVisible();
await expect.poll(() => page.evaluate(() => window.location.hash)).toBe("#/betrieb");
await expect(page.getByRole("link", { name: "Betrieb" })).toHaveAttribute("aria-current", "page");
});
2 changes: 1 addition & 1 deletion src/i18n/de.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,9 +107,9 @@ export const de = {
"leitstand.tabs": "Ansichten",
"leitstand.tab.classic": "Klassisch",
"leitstand.tab.tools": "Werkzeuge",
"leitstand.tab.betrieb": "Betrieb",
"leitstand.tab.fabrik": "Linie",
"leitstand.profilesFailed": "Agenten konnten nicht geladen werden",
"betrieb.profilesHint": "Einträge zeigen vorerst die Profil-Kennung.",
"leitstand.profilesHint": "Die Karten zeigen vorerst die Profil-Kennung.",
"leitstand.noProject": "Kein Projekt gewählt",
"leitstand.noProjectHint": "Wähle oben ein Projekt, dann erscheint hier der Betrieb.",
Expand Down
5 changes: 3 additions & 2 deletions src/screens/betrieb/BetriebRoute.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,16 +17,17 @@ export default function BetriebRoute() {
return subscribeActiveProject(setProjectId);
}, []);
const [profiles, setProfiles] = useState<AgentProfile[]>([]);
const [profilesFailed, setProfilesFailed] = useState(false);
useEffect(() => {
let live = true;
// shortcut: a failed profile read shows an empty list, as the panel only labels entries with it; upgrade if the form needs a notice.
listAgentProfiles().then((list) => live && setProfiles(list), () => {});
listAgentProfiles().then((list) => live && setProfiles(list), () => live && setProfilesFailed(true));
return () => { live = false; };
}, []);

return (
<div className="g-lr">
<div className="g-lr-head"><h1>{t("nav.betrieb")}</h1></div>
{profilesFailed && <div role="status"><HonestState kind="offline" title={t("leitstand.profilesFailed")} hint={t("betrieb.profilesHint")} /></div>}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Render one status region when no project is selected

When list_agent_profiles fails while no project is selected, this new status is rendered alongside the existing no-project role="status" on line 33. Both live regions appear during the same render, so assistive technology can announce two competing state messages rather than one coherent explanation. Suppress the profile warning until a project exists or combine both messages under a single status region.

Useful? React with 👍 / 👎.

{projectId !== null
? <BetriebPanel projectId={projectId} profiles={profiles} />
: <div role="status"><HonestState kind="empty" title={t("leitstand.noProject")} hint={t("leitstand.noProjectHint")} /></div>}
Expand Down
36 changes: 12 additions & 24 deletions src/screens/leitstand/LeitstandRoute.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -112,15 +112,15 @@ describe("Leitstand route behind D1", () => {
});

// DS-T-05: the empty main view names the first step and only navigates; it starts nothing.
it("an empty board offers Auftrag einreihen and opens the Betrieb tab", async () => {
it("an empty board offers Auftrag einreihen and opens the Betrieb area", async () => {
const base = currentInvoke();
mocks.invoke.mockImplementation((command: string) =>
command === "get_board_state" ? Promise.resolve({ cards: [], coordinators: [] }) : base(command));
render(<LeitstandRoute />);
const step = await screen.findByRole("button", { name: "Auftrag einreihen" });
expect(screen.getByText(de["leitstand.emptyTitle"])).toBeInTheDocument();
fireEvent.click(step);
expect(window.location.hash).toBe("#/leitstand/betrieb");
expect(window.location.hash).toBe("#/betrieb");
});

it("without a project the main view offers Projekt anlegen", async () => {
Expand Down Expand Up @@ -186,28 +186,22 @@ describe("Leitstand route behind D1", () => {
expect(await screen.findByRole("region", { name: "Agenten" })).toBeInTheDocument();
});

it("betrieb without a project shows an empty state", async () => {
localStorage.removeItem("projecta.activeProjectId");
render(<LeitstandRoute path="/leitstand/betrieb" />);
const note = within(await screen.findByRole("status"));
expect(note.getByText("Kein Projekt gewählt")).toBeInTheDocument();
expect(note.getByText("Wähle oben ein Projekt, dann erscheint hier der Betrieb.")).toBeInTheDocument();
expect(screen.queryByRole("region", { name: "Warteschlange" })).toBeNull();
// Choosing a project replaces the note by the panel.
await act(async () => { writeActiveProjectId("p1"); });
expect(await screen.findByRole("region", { name: "Warteschlange" })).toBeInTheDocument();
expect(screen.queryByText("Kein Projekt gewählt")).toBeNull();
it("the Fabrik tab bar has no Betrieb tab", async () => {
localStorage.setItem(FLAG_KEY, "1");
render(<App />);
await screen.findByRole("region", { name: "Agenten" });
expect(tabs().getAllByRole("link").map((link) => link.textContent)).toEqual(["Haupt", "Linie", "Werkzeuge", "Klassisch"]);
expect(tabs().queryByRole("link", { name: "Betrieb" })).toBeNull();
});

it("reaches Betrieb from Haupt by its tab link and goes back by the sidebar", async () => {
it("reaches Betrieb by the sidebar and goes back to Fabrik by the sidebar", async () => {
localStorage.setItem(FLAG_KEY, "1");
render(<App />);
await screen.findByRole("region", { name: "Agenten" });
expect(tabs().getByRole("link", { name: "Haupt" })).toHaveAttribute("aria-current", "page");
fireEvent.click(tabs().getByRole("link", { name: "Betrieb" }));
// NAV-2a: the retired tab path lands on the Betrieb area; NAV-2b removes the tab.
fireEvent.click(screen.getByRole("link", { name: "Betrieb" }));
await waitFor(() => expect(window.location.hash).toBe("#/betrieb"));
expect(await screen.findByRole("region", { name: "Warteschlange" })).toBeInTheDocument();
expect(screen.queryByRole("navigation", { name: "Ansichten" })).toBeNull();
fireEvent.click(screen.getByRole("link", { name: "Fabrik" }));
expect(await screen.findByRole("region", { name: "Agenten" })).toBeInTheDocument();
expect(screen.queryByRole("region", { name: "Warteschlange" })).toBeNull();
Expand Down Expand Up @@ -263,7 +257,7 @@ describe("Leitstand route behind D1", () => {
render(<App />);
await screen.findByRole("region", { name: "Agenten" });
expect(tabs().getAllByRole("link").map((link) => link.textContent)).toEqual([
"Haupt", "Linie", "Betrieb", "Werkzeuge", "Klassisch",
"Haupt", "Linie", "Werkzeuge", "Klassisch",
]);
expect(tabs().getByRole("link", { name: "Linie" })).toHaveAttribute("href", "#/leitstand/fabrik");
fireEvent.click(tabs().getByRole("link", { name: "Linie" }));
Expand Down Expand Up @@ -338,12 +332,6 @@ describe("Leitstand route behind D1", () => {
});

// The shell trims the hash before it reaches the route, but the route also takes `path` as a prop.
it("marks the Betrieb tab as the current one for a trailing-slash path", async () => {
render(<LeitstandRoute path="/leitstand/betrieb/" />);
expect(await screen.findByRole("region", { name: "Warteschlange" })).toBeInTheDocument();
expect(tabs().getByRole("link", { name: "Betrieb" })).toHaveAttribute("aria-current", "page");
});

it("shows the Fabrik tab as active for a trailing-slash path", async () => {
render(<LeitstandRoute path="/leitstand/fabrik/" />);
expect(await screen.findByRole("region", { name: "Ablauf der Agenten" })).toBeInTheDocument();
Expand Down
29 changes: 7 additions & 22 deletions src/screens/leitstand/LeitstandRoute.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,34 +9,31 @@ import "../../design/inputs/inputs.css";
import { useT } from "../../i18n/useT";
import type { AgentProfile } from "../../types";
import { LeitstandCards } from "./LeitstandCards";
import { BetriebPanel } from "./betrieb/BetriebPanel";
import { FlowTab } from "./flow/FlowTab";
import { NeedsYou } from "./needs/NeedsYou";
import { FABRIK_PATH, TOOLS_PATH, trim } from "../../shell/mount/routing";
import { BETRIEB_ROUTE, FABRIK_PATH, TOOLS_PATH, trim } from "../../shell/mount/routing";

export const MAIN_PATH = "/leitstand";
export const CLASSIC_PATH = "/leitstand/klassisch";
export const BETRIEB_PATH = "/leitstand/betrieb";

/** The first steps only navigate: adding a project lives in the old sidebar on Werkzeuge, the queue form on Betrieb; nothing starts here (DS-T-05). */
const go = (target: string) => void (window.location.hash = `#${target}`);

/** Relative ages ("seit 4 min") move on minute scale. */
const TICK_MS = 30_000;

/** The classic view and its persistent tools stay reachable beside Haupt, Fabrik and Betrieb. */
/** The classic view and its persistent tools stay reachable beside Haupt and Fabrik. */
export function LeitstandTabs({ path }: { path: string }) {
const t = useT();
const tab = (target: string, label: string) => (
<a href={`#${target}`} aria-current={target === trim(path) ? "page" : undefined}>{label}</a>
);
return <nav className="g-shell-tabs" aria-label={t("leitstand.tabs")}>{tab(MAIN_PATH, t("tab.main"))}{tab(FABRIK_PATH, t("leitstand.tab.fabrik"))}{tab(BETRIEB_PATH, t("leitstand.tab.betrieb"))}{tab(TOOLS_PATH, t("leitstand.tab.tools"))}{tab(CLASSIC_PATH, t("leitstand.tab.classic"))}</nav>;
return <nav className="g-shell-tabs" aria-label={t("leitstand.tabs")}>{tab(MAIN_PATH, t("tab.main"))}{tab(FABRIK_PATH, t("leitstand.tab.fabrik"))}{tab(TOOLS_PATH, t("leitstand.tab.tools"))}{tab(CLASSIC_PATH, t("leitstand.tab.classic"))}</nav>;
}

/** The new Leitstand (V2-S01a): agent cards with filter chips and "Braucht dich" on Haupt, the flow graph on Fabrik, the read-only Betrieb panel beside it. */
/** The new Leitstand (V2-S01a): agent cards with filter chips and "Braucht dich" on Haupt, the flow graph on Fabrik; Betrieb has its own area (NAV-2b). */
export default function LeitstandRoute({ path = MAIN_PATH }: { path?: string }) {
const here = trim(path);
const betrieb = here === BETRIEB_PATH;
const fabrik = here === FABRIK_PATH || here === "/leitstand/fluss";
const t = useT();
const [projectId, setProjectId] = useState(readActiveProjectId);
Expand All @@ -45,8 +42,8 @@ export default function LeitstandRoute({ path = MAIN_PATH }: { path?: string })
setProjectId(readActiveProjectId());
return subscribeActiveProject(setProjectId);
}, []);
const board = useBoard(projectId, !betrieb);
const questions = useQuestions(projectId, !betrieb && !fabrik);
const board = useBoard(projectId, true);
const questions = useQuestions(projectId, !fabrik);
const [profiles, setProfiles] = useState<AgentProfile[]>([]);
const [profilesFailed, setProfilesFailed] = useState(false);
const [now, setNow] = useState(() => Date.now() / 1000);
Expand All @@ -63,18 +60,6 @@ export default function LeitstandRoute({ path = MAIN_PATH }: { path?: string })
};
}, []);

if (betrieb) {
return (
<div className="g-lr">
<div className="g-lr-head"><h1>{t("nav.leitstand")}</h1><LeitstandTabs path={BETRIEB_PATH} /></div>
{/* No project yet: the subscription above picks it up as soon as a project is chosen. */}
{projectId !== null
? <BetriebPanel projectId={projectId} profiles={profiles} />
: <div role="status"><HonestState kind="empty" title={t("leitstand.noProject")} hint={t("leitstand.noProjectHint")} /></div>}
</div>
);
}

if (fabrik) {
return (
<div className="g-lr">
Expand Down Expand Up @@ -107,7 +92,7 @@ export default function LeitstandRoute({ path = MAIN_PATH }: { path?: string })
cards={board.cards}
profiles={profiles}
now={now}
emptyAction={{ label: t("leitstand.queueTask"), onClick: () => go(BETRIEB_PATH) }}
emptyAction={{ label: t("leitstand.queueTask"), onClick: () => go(BETRIEB_ROUTE) }}
onOpen={({ worker }) => void (window.location.hash = `#/leitstand/terminal?worker=${encodeURIComponent(worker.id)}`)}
/>
</>}
Expand Down
16 changes: 16 additions & 0 deletions src/shell/mount/betrieb.mount.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,22 @@ it("replaces /leitstand/betrieb by /betrieb", async () => {
expect(sidebar().getByRole("link", { name: "Betrieb" })).toHaveAttribute("aria-current", "page");
});

it("replaces the retired hash without adding a history entry", async () => {
window.location.hash = "#/leitstand/betrieb";
const before = window.history.length;
render(<App />);
await waitFor(() => expect(window.location.hash).toBe("#/betrieb"));
expect(window.history.length).toBe(before);
});

it("a failed profile read shows a notice", async () => {
const base = mocks.invoke.getMockImplementation()!;
mocks.invoke.mockImplementation((command: string) => command === "list_agent_profiles" ? Promise.reject(new Error("no profiles")) : base(command));
render(<App />);
expect(await screen.findByText("Agenten konnten nicht geladen werden", {}, { timeout: 5000 })).toBeInTheDocument();
expect(screen.getByRole("region", { name: "Warteschlange" })).toBeInTheDocument();
});

it("shows an empty state at /betrieb while no project is chosen", async () => {
localStorage.removeItem("projecta.activeProjectId");
render(<App />);
Expand Down
18 changes: 16 additions & 2 deletions src/shell/mount/routing.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,21 @@ import { describe, expect, it } from "vitest";
import { GOAL_ROUTE, goalForPath, hasLegacyPanel, hashForGoal, resolveAlias, shouldMountLegacyTerminal, isTerminal, isVerlauf, isNewEinstellungen, isNewLeitstand, isNewSteuerung } from "./routing";

describe("retired path aliases (NAV-2a)", () => {
it("retired path with trailing slash resolves to /betrieb", () => {
expect(resolveAlias("/leitstand/betrieb/")).toBe("/betrieb");
expect(resolveAlias("/leitstand/fluss//")).toBe("/leitstand/fabrik");
});

it("retired path with a query string keeps the query and resolves to /betrieb", () => {
expect(resolveAlias("/leitstand/betrieb?x=1")).toBe("/betrieb?x=1");
expect(resolveAlias("/leitstand/betrieb/?x=1")).toBe("/betrieb?x=1");
expect(resolveAlias("/leitstand/terminal?worker=w1")).toBe("/leitstand/terminal?worker=w1");
});

it("the retired Betrieb path is no Leitstand route any more", () => {
expect(isNewLeitstand("/leitstand/betrieb")).toBe(false);
});

it("retired leitstand betrieb path resolves to /betrieb", () => {
expect(resolveAlias("/leitstand/betrieb")).toBe("/betrieb");
expect(resolveAlias("/leitstand/fluss")).toBe("/leitstand/fabrik");
Expand Down Expand Up @@ -34,7 +49,7 @@ describe("goal and route mapping (ia.md section 4)", () => {
for (const path of [undefined, "/leitstand/klassisch", "/leitstand/werkzeuge", "/leitstand/werkzeuge/", "/beweise/diff", "/einstellungen/klassisch"]) {
expect(shouldMountLegacyTerminal(path)).toBe(true);
}
for (const path of ["/leitstand/terminal", "/leitstand/terminal/", "/leitstand/verlauf", "/leitstand/verlauf/", "/leitstand", "/leitstand/betrieb", "/steuerung", "/einstellungen", "/start", "/team"]) {
for (const path of ["/leitstand/terminal", "/leitstand/terminal/", "/leitstand/verlauf", "/leitstand/verlauf/", "/leitstand", "/steuerung", "/einstellungen", "/start", "/team"]) {
expect(shouldMountLegacyTerminal(path)).toBe(false);
}
});
Expand Down Expand Up @@ -103,7 +118,6 @@ describe("goal and route mapping (ia.md section 4)", () => {
it("gives the new Leitstand exactly /leitstand and leaves the rest to the old view", () => {
expect(isNewLeitstand("/leitstand")).toBe(true);
expect(isNewLeitstand("/leitstand/")).toBe(true);
expect(isNewLeitstand("/leitstand/betrieb")).toBe(true);
expect(isNewLeitstand("/leitstand/klassisch")).toBe(false);
expect(isNewLeitstand("/beweise")).toBe(false);
expect(hasLegacyPanel("/leitstand/klassisch")).toBe(true);
Expand Down
Loading
Loading