feat(frontend): v3-Umbau P2 — echtes Routing, Unterzustand in der URL

Dritte Etappe. Die Ansicht lag bisher im URL-Hash (`#models`), gespeist aus einem
useState in App.tsx. Das war fuer Reload und Lesezeichen brauchbar — aber nur auf
View-Ebene; fuer den Zustand DARUNTER war kein Platz.

## Was jetzt in der Adresse steht (Befund B-05)

  /modelle?reiter=routing     der Reiter war lokaler State, nicht teilbar
  /ideen?offen=<id>           die aufgeklappte Karte; wer waehrend eines
                              laufenden Workers neu lud, verlor seinen Platz
  /wissen?datei=<pfad>        jede Vault-Notiz verlinkbar
  <ueberall>?system=wartung|logs   die System-Schublade inkl. Reiter

Alle Suchparameter werden validiert (app/router.tsx) — ein Link mit Unsinn darin
faellt auf den Normalfall zurueck, statt die Ansicht in einen halben Zustand zu
bringen.

## Alte Lesezeichen brechen nicht

lib/hashUmleitung.ts biegt `#models` per replaceState auf `/modelle`, EINMAL vor
dem Router-Mount. Fuenf Tests decken das ab (jeder NAV-Eintrag, die Schreibweise
`#/models`, erhaltene Suchparameter, unbekannter Hash, kein Hash) — die Bruecke
sieht sonst wie toter Code aus und waere ein naheliegender Kandidat zum Wegraeumen.

Das Backend brauchte KEINE Zeile: der SPA-Catch-all in app.py:148-168 beantwortet
jede unbekannte Route schon immer mit index.html. Vorher geprueft, nicht gehofft.

## Fehlergrenze pro Route (Befund B-13)

Vorher hing eine einzige Grenze in main.tsx um alles — ein Renderfehler im
Modell-Manager nahm Cockpit, Konsole und Chronik mit. Jetzt faengt jede Route fuer
sich (components/RouteFehler.tsx: was passiert ist, was man tun kann, technische
Einzelheiten aufklappbar). AppErrorBoundary bleibt als letztes Netz fuer Fehler
ausserhalb jeder Route. Dazu eine 404-Seite, die Schiene und Kopfzeile stehen
laesst — im Browser geprueft.

## Budget: der Router kostete 29 kB, sie sind wieder drin

TanStack Router hob den Start-Chunk von 111 auf 141 kB gzip und riss damit das in
P0 gesetzte Ampel-Budget (125 kB). Statt das Budget hochzusetzen — was es als
Signal entwertet haette — den Platz zurueckgeholt: SystemDrawer und CommandPalette
sind beim Start UNSICHTBAR und liegen jetzt hinter lazy(). Die Palette laedt beim
ersten Strg+K und bleibt dann gemountet.

  Start-Chunk gzip  111 411 -> 114 978 B   (Budget 125 000, weiter scharf)
  dist gesamt                  1 565 521 B (Budget 3 145 728)

## Nebenbei

  · Befund B-09 erledigt: Der "Suchen"-Knopf baute ein GEFAELSCHTES KeyboardEvent
    (`new KeyboardEvent("keydown", { metaKey: true })`) und feuerte es aufs
    Dokument. Jetzt ein eigener Kanal; in P3 loest der Store auch den ab.
  · Der Schliessen-Knopf der System-Schublade hatte kein aria-label — ein reiner
    Icon-Knopf ohne Namen. Nachgeholt (beim Pruefen aufgefallen).
  · nav.ts fuehrt jetzt den Pfad je Eintrag + navFuerPfad() mit Laengen-Sortierung,
    damit /ideen/<id> die Sidebar bei "Ideen" markiert. Mit Tests.

## Verifiziert im Browser (lokales Backend, echtes Produktions-Buendel)

  /                        -> /cockpit, Sidebar markiert
  /#models                 -> /modelle, Sidebar markiert
  /modelle?reiter=routing  -> Reiter "Routing & Warm-Set" aktiv
  /cockpit?system=logs     -> Schublade offen, Reiter "System-Logs" aktiv;
                              Schliessen entfernt den Parameter wieder
  /gibtsnicht              -> 404-Seite, Schiene + Kopfzeile intakt
  Zurueck-Taste            -> voriger Pfad

26/26 Tests gruen · ESLint 0 Fehler · tsc sauber.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-08-28 08:55:58 +02:00
co-authored by Claude Opus 5
parent ae69c5ce31
commit 464b0d20b8
50 changed files with 1143 additions and 607 deletions
+70
View File
@@ -0,0 +1,70 @@
import { beforeEach, describe, expect, it } from "vitest"
import { hashUmleiten } from "./hashUmleitung"
import { NAV, navFuerPfad } from "@/nav"
function adresseSetzen(url: string) {
window.history.replaceState(null, "", url)
}
// Diese Brücke ist die einzige Verbindung zu Lesezeichen aus der Hash-Zeit. Sie sieht
// nach totem Code aus und wäre damit ein naheliegender Kandidat zum Wegräumen — die
// Tests machen sichtbar, was dabei kaputtginge.
describe("hashUmleiten()", () => {
beforeEach(() => adresseSetzen("/"))
it("biegt jedes alte Hash-Lesezeichen auf seinen neuen Pfad", () => {
for (const eintrag of NAV) {
adresseSetzen("/#" + eintrag.id)
hashUmleiten()
expect(window.location.pathname, `#${eintrag.id}`).toBe(eintrag.pfad)
}
})
it("verträgt die Schreibweise mit Schrägstrich (#/models)", () => {
adresseSetzen("/#/models")
hashUmleiten()
expect(window.location.pathname).toBe("/modelle")
})
it("behält vorhandene Suchparameter", () => {
adresseSetzen("/?system=logs#models")
hashUmleiten()
expect(window.location.pathname).toBe("/modelle")
expect(window.location.search).toBe("?system=logs")
})
it("fasst einen unbekannten Hash nicht an", () => {
adresseSetzen("/cockpit#irgendwas")
hashUmleiten()
expect(window.location.pathname).toBe("/cockpit")
})
it("tut ohne Hash gar nichts", () => {
adresseSetzen("/modelle?reiter=routing")
hashUmleiten()
expect(window.location.pathname + window.location.search).toBe("/modelle?reiter=routing")
})
})
describe("navFuerPfad()", () => {
it("findet den Eintrag zum exakten Pfad", () => {
expect(navFuerPfad("/modelle")?.id).toBe("models")
expect(navFuerPfad("/cockpit")?.id).toBe("dashboard")
})
// Damit /ideen/<id> die Sidebar weiterhin bei „Ideen" markiert und nicht ins Leere fällt.
it("ordnet Unterpfade dem übergeordneten Eintrag zu", () => {
expect(navFuerPfad("/ideen/id_9f3c")?.id).toBe("ideen")
})
it("liefert nichts für einen unbekannten Pfad", () => {
expect(navFuerPfad("/gibtsnicht")).toBeUndefined()
})
// Der Grund für die Längen-Sortierung: ein kurzer Pfad darf einen längeren nicht
// überstimmen, sonst gewinnt der erstbeste Präfix-Treffer.
it("nimmt bei mehreren Treffern den längsten Pfad", () => {
const treffer = navFuerPfad("/modelle")
expect(treffer?.pfad).toBe("/modelle")
})
})
+26
View File
@@ -0,0 +1,26 @@
// Alte Hash-Lesezeichen auf die neuen Pfade umbiegen (v3-Umbau P2).
//
// Bis zum 28.08.2026 lebte die Ansicht im URL-Hash (`#models`). Das war teilbar und
// reload-fest — aber nur auf View-Ebene; für den Zustand DARUNTER (offene Idee, Reiter,
// Drawer) war kein Platz. Mit dem Router zieht das in echte Pfade um.
//
// Wer ein altes Lesezeichen hat, soll nichts merken: Diese Funktion läuft EINMAL vor dem
// Router-Mount und ersetzt `#models` durch `/modelle` — per `replaceState`, damit die
// Zurück-Taste nicht in einer Umleitungs-Schleife landet.
//
// Die Tabelle darf mit der Zeit schrumpfen, aber nicht kommentarlos: Solange jemand ein
// Lesezeichen von früher haben könnte, ist sie die einzige Brücke dorthin.
import { NAV, type ViewId } from "@/nav"
const ALT_ZU_PFAD: Record<ViewId, string> = Object.fromEntries(
NAV.map((n) => [n.id, n.pfad]),
) as Record<ViewId, string>
export function hashUmleiten(): void {
const hash = window.location.hash.replace(/^#\/?/, "")
if (!hash) return
const ziel = ALT_ZU_PFAD[hash as ViewId]
if (!ziel) return
window.history.replaceState(null, "", ziel + window.location.search)
}