From 819725a3c16858ba122ce4e657762ff9c576163b Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 15:56:10 +0000 Subject: [PATCH] Kundenansicht neu P1: Router mit Anker-Kette, Overlay und Verlaufsstempel Co-Authored-By: Claude Sonnet 5.5 --- public/neu/router.js | 156 ++++++++++++++++++++++++++++++++++++ test/knHuelleRouter.test.js | 99 +++++++++++++++++++++++ 2 files changed, 255 insertions(+) create mode 100644 public/neu/router.js create mode 100644 test/knHuelleRouter.test.js diff --git a/public/neu/router.js b/public/neu/router.js new file mode 100644 index 0000000..1b129ac --- /dev/null +++ b/public/neu/router.js @@ -0,0 +1,156 @@ +/* Kundenansicht neu: Router mit den History-Regeln der Hülle (Spec 5.2). Kein DOM-Zugriff beim Import. + Verlaufsstempel: history.state = { kn: { tiefe, overlay } }; tiefe = Zahl der Einträge unter diesem innerhalb der Seite. + Die URL der Website ist pathname + search ohne #. + R1 Bereichs- und Reiterwechsel ersetzen den Eintrag (nav 'tab'), immer mit dem kanonischen Hash (#wartung wird #abo). + R2 Von Ebene 1 in eine Etage und von Etage zu Etage wird ein Eintrag geschoben (nav 'push'); ersetzen: true erzwingt replaceState. + R3 popstate: tiefer als vorher ist 'push', sonst 'back'. + R4 Start: startHash anwenden, zeige einmal mit 'tab'; fehlt der Stempel, legt der Router die Anker-Kette an. + R5 zurueck(): mit eigenem Eintrag history.back(), sonst das Elternziel per replaceState. + R6 Overlay (Konto-Sheet, Bestätigungsseite): ein Eintrag mit overlay: true; Zurück schließt, Navigation ersetzt den Eintrag. + R7 stempeln() schreibt den Stempel neu, nachdem ein Modul mit replaceState(null, …) die URL angefasst hat. */ + +import { routeAusHash, hashFuerRoute, startHash, ankerKette, elternVon } from './routen.js' + +const fenster = () => window // erst beim Aufruf, nie beim Import + +const leerer = () => ({ umg: null, zeige: null, aktuell: null, vorher: '', tiefe: 0, overlay: null, abmelden: null }) +let z = leerer() + +const hashTeil = (hash) => (hash === '#' ? '' : hash) +// ?abo= bleibt am Zieleintrag stehen (P2 liest es dort); die Anker davor tragen es nicht. Andere Parameter bleiben Zeichen für Zeichen. +const ohneAbo = (search) => { + const teile = (search || '').replace(/^\?/, '').split('&').filter((t) => t && t.split('=')[0] !== 'abo') + return teile.length ? `?${teile.join('&')}` : '' +} +const urlFuer = (pfad, search, hash) => `${pfad}${search}${hashTeil(hash)}` +const leseStempel = (state) => { + const s = state?.kn + return s && Number.isInteger(s.tiefe) && s.tiefe >= 0 ? { tiefe: s.tiefe, overlay: s.overlay === true } : null +} +const stempelFuer = (state, tiefe, overlay) => ({ ...(state && typeof state === 'object' ? state : {}), kn: { tiefe, overlay } }) + +function legeKetteAn(route) { + const { location, history } = z.umg + const pfad = location.pathname + const search = location.search + const kette = ankerKette(route) + kette.forEach((hash, i) => { + const url = urlFuer(pfad, i === kette.length - 1 ? search : ohneAbo(search), hash) + if (i === 0) history.replaceState(stempelFuer(history.state, 0, false), '', url) + else history.pushState(stempelFuer(null, i, false), '', url) + }) + z.tiefe = kette.length - 1 +} + +// Stempel vorhanden (Neuladen): nichts anlegen, nur Overlay-Reste löschen und den Hash kanonisch schreiben +function bereinige(route, stempel) { + const { location, history } = z.umg + const soll = urlFuer(location.pathname, location.search, hashFuerRoute(route)) + z.tiefe = stempel.tiefe + if (stempel.overlay || soll !== location.pathname + location.search + location.hash) history.replaceState(stempelFuer(history.state, stempel.tiefe, false), '', soll) +} + +export function starteRouter({ zeige, umgebung = fenster() }) { + z.abmelden?.() + const eigener = leerer() + const beiPopstate = () => { if (z === eigener) popstate() } + umgebung.addEventListener('popstate', beiPopstate) + Object.assign(eigener, { umg: umgebung, zeige, abmelden: () => umgebung.removeEventListener('popstate', beiPopstate) }) + z = eigener + const route = routeAusHash(startHash({ search: umgebung.location.search, hash: umgebung.location.hash })) + const stempel = leseStempel(umgebung.history.state) + if (stempel) bereinige(route, stempel) + else legeKetteAn(route) + z.aktuell = route + zeige(route, { nav: 'tab' }) + return { beende() { if (z === eigener) { eigener.abmelden(); z = leerer() } } } +} + +function popstate() { + const { location, history } = z.umg + const stempel = leseStempel(history.state) + const route = routeAusHash(location.hash) + const ziel = hashFuerRoute(route) + const alteTiefe = z.tiefe + if (z.overlay) { const schliesse = z.overlay; z.overlay = null; schliesse() } + // Mit Stempel entscheidet er; ohne Stempel ist es ein neuer Eintrag des Browsers (Fragment-Link im Modul): eine Ebene tiefer + const tiefe = stempel ? stempel.tiefe : alteTiefe + 1 + const nav = stempel ? (tiefe > alteTiefe ? 'push' : 'back') : (route.etage ? 'push' : 'tab') + const soll = urlFuer(location.pathname, location.search, ziel) + if (!stempel || stempel.overlay || soll !== location.pathname + location.search + location.hash) history.replaceState(stempelFuer(history.state, tiefe, false), '', soll) + z.tiefe = tiefe + if (ziel === hashFuerRoute(z.aktuell)) return // gleiche Route (z. B. Overlay geschlossen): nichts neu zeichnen + z.vorher = hashFuerRoute(z.aktuell) + z.aktuell = route + z.zeige(route, { nav }) +} + +function navigiere(route, { ersetzen = false, nav = null } = {}) { + const { location, history } = z.umg + const ziel = hashFuerRoute(route) + const overlay = z.overlay + if (overlay) { z.overlay = null; overlay() } // genau einmal + if (ziel === hashFuerRoute(z.aktuell)) { + if (overlay) { z.tiefe = Math.max(0, z.tiefe - 1); history.back() } // den Overlay-Eintrag loswerden + z.zeige(route, { nav: 'same' }) + return + } + const schieben = !overlay && !ersetzen && Boolean(route.etage) + const tiefe = schieben ? z.tiefe + 1 : z.tiefe + const url = urlFuer(location.pathname, location.search, ziel) + if (schieben) history.pushState(stempelFuer(null, tiefe, false), '', url) + else history.replaceState(stempelFuer(history.state, tiefe, false), '', url) + z.tiefe = tiefe + z.vorher = hashFuerRoute(z.aktuell) + z.aktuell = route + z.zeige(route, { nav: nav ?? (route.etage ? 'push' : 'tab') }) +} + +/** Verlauf schreiben und danach zeige rufen. Reiter und Bereiche ersetzen, Etage 2 schiebt (ersetzen: true erzwingt replaceState). */ +export function geheZu(hash, { ersetzen = false } = {}) { + if (!z.umg) return + navigiere(routeAusHash(hash), { ersetzen }) +} + +/** Zurück der Etage: mit eigenem Verlaufseintrag history.back(), sonst das Elternziel per replaceState mit nav 'back'. */ +export function zurueck() { + if (!z.umg) return + if (z.tiefe > 0) { z.umg.history.back(); return } + const eltern = elternVon(z.aktuell, z.vorher) + if (eltern) navigiere(eltern, { ersetzen: true, nav: 'back' }) +} + +/** Overlay öffnen: schiebt einen Eintrag; Zurück ruft schliesse(). Ist schon eines offen, wechselt nur der Besitzer. */ +export function oeffneOverlay({ schliesse }) { + if (!z.umg) return + const alt = z.overlay + z.overlay = null + if (alt) { alt(); z.overlay = schliesse; return } + const { location, history } = z.umg + z.tiefe += 1 + history.pushState(stempelFuer(null, z.tiefe, true), '', location.pathname + location.search + location.hash) + z.overlay = schliesse +} + +/** Overlay vom Besitzer aus schließen: ruft schliesse() und nimmt den Verlaufseintrag zurück. */ +export function beendeOverlay() { + if (!z.umg || !z.overlay) return + const schliesse = z.overlay + z.overlay = null + z.tiefe = Math.max(0, z.tiefe - 1) + schliesse() + z.umg.history.back() +} + +/** Stempel neu schreiben, nachdem ein Modul mit replaceState(null, …) die URL angefasst hat (z. B. um die Query zu löschen). */ +export function stempeln() { + if (!z.umg) return + const { location, history } = z.umg + history.replaceState(stempelFuer(history.state, z.tiefe, z.overlay !== null), '') + const route = routeAusHash(location.hash) + if (hashFuerRoute({ ...route, query: '' }) === hashFuerRoute({ ...z.aktuell, query: '' })) z.aktuell = route // nur die Query hat sich geändert +} + +export function aktuelleRoute() { + return z.aktuell ? { ...z.aktuell } : null +} diff --git a/test/knHuelleRouter.test.js b/test/knHuelleRouter.test.js new file mode 100644 index 0000000..5e8fc94 --- /dev/null +++ b/test/knHuelleRouter.test.js @@ -0,0 +1,99 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { starteRouter, geheZu, oeffneOverlay, beendeOverlay, zurueck, stempeln } from '../public/neu/router.js' +import { hashFuerRoute } from '../public/neu/routen.js' + +// Verlauf des Browsers im Kleinen: Einträge { url, state } mit Index; pushState schneidet Vorwärtseinträge ab, +// zurueck()/vor() springen und rufen die popstate-Handler synchron; location und history.state kommen aus dem aktuellen Eintrag. +function baueVerlauf(url, state = null) { + const eintraege = [{ url, state }] + let index = 0 + const handler = new Set() + const teile = () => { + const u = eintraege[index].url; const h = u.indexOf('#'); const vorHash = h === -1 ? u : u.slice(0, h); const q = vorHash.indexOf('?') + return { pathname: q === -1 ? vorHash : vorHash.slice(0, q), search: q === -1 ? '' : vorHash.slice(q), hash: h === -1 ? '' : u.slice(h) } + } + const springe = (neu) => { if (neu < 0 || neu >= eintraege.length) return; index = neu; for (const f of [...handler]) f({ type: 'popstate' }) } + const location = { get pathname() { return teile().pathname }, get search() { return teile().search }, get hash() { return teile().hash } } + const history = { + get state() { return eintraege[index].state }, + pushState(s, _t, u) { eintraege.splice(index + 1); eintraege.push({ url: u ?? eintraege[index].url, state: s }); index += 1 }, + replaceState(s, _t, u) { eintraege[index] = { url: u ?? eintraege[index].url, state: s } }, + back() { springe(index - 1) }, + } + const umgebung = { location, history, addEventListener(typ, f) { if (typ === 'popstate') handler.add(f) }, removeEventListener(typ, f) { handler.delete(f) } } + return { + umgebung, urls: () => eintraege.map((e) => e.url), aktuell: () => eintraege[index].url, zurueck: () => springe(index - 1), vor: () => springe(index + 1), + // Klick auf einen Fragment-Link (): neuer Eintrag ohne Stempel, der Browser feuert popstate + link: (u) => { eintraege.splice(index + 1); eintraege.push({ url: u, state: null }); index += 1; for (const f of [...handler]) f({ type: 'popstate' }) }, + } +} +function start(url, state = null) { + const v = baueVerlauf(url, state); const log = [] + starteRouter({ zeige: (route, { nav }) => log.push([hashFuerRoute(route), nav]), umgebung: v.umgebung }) + return { v, log } +} + +test('Start auf Chat legt den Anker an; Zurück führt zur Website (Review Focus 2)', () => { + const { v, log } = start('/dashboard.html#chat') + assert.deepEqual(v.urls(), ['/dashboard.html', '/dashboard.html#chat']); assert.deepEqual(log, [['#chat', 'tab']]) + v.zurueck(); assert.deepEqual(log.at(-1), ['#', 'back']) +}) + +test('Start in Etage 2: Kette Website, Funktionen, Terminplaner; Query bleibt (Review Focus 1)', () => { + assert.deepEqual(start('/dashboard.html#termine?bestellt=1&projekt=x').v.urls(), ['/dashboard.html', '/dashboard.html#funktionen', '/dashboard.html#termine?bestellt=1&projekt=x']) +}) + +test('Alias wird kanonisch (#wartung wird #abo, #abo bleibt #abo), ?abo= führt zum Reiter Abo und bleibt am Zieleintrag; Neuladen mit Stempel baut keine zweite Kette', () => { + assert.equal(start('/dashboard.html#wartung').v.aktuell(), '/dashboard.html#abo'); assert.equal(start('/dashboard.html#abo').v.aktuell(), '/dashboard.html#abo') + const a = start('/dashboard.html?abo=success#chat') + assert.deepEqual([a.v.urls(), a.log[0]], [['/dashboard.html', '/dashboard.html?abo=success#abo'], ['#abo', 'tab']]) + assert.equal(start('/dashboard.html#termine', { kn: { tiefe: 2, overlay: false } }).v.urls().length, 1) +}) + +test('Reiter und Bereiche ersetzen, #abo wird nie #vertraege, Etage 2 schiebt, same, ersetzen erzwingt replaceState', () => { + const { v, log } = start('/dashboard.html#vertraege'); const n = v.urls().length + geheZu('#rechnungen'); geheZu('#chat'); assert.equal(v.urls().length, n); assert.deepEqual(log.slice(1), [['#rechnungen', 'tab'], ['#chat', 'tab']]) + geheZu('#chat'); assert.equal(log.at(-1)[1], 'same') + geheZu('#vertraege'); geheZu('#abo'); assert.deepEqual([v.aktuell(), log.at(-1), v.urls().length], ['/dashboard.html#abo', ['#abo', 'tab'], n]) // Reiterklick Abo: ausdrücklicher Hash (Review Focus 1) + geheZu('#funktionen'); geheZu('#termine'); assert.equal(v.urls().length, n + 1); assert.deepEqual(log.at(-1), ['#termine', 'push']) + zurueck(); assert.deepEqual(log.at(-1), ['#funktionen', 'back']); assert.equal(v.urls().length, n + 1) + geheZu('#termine', { ersetzen: true }); assert.equal(v.urls().length, n + 1) +}) + +test('Stempel: Modul löscht ihn mit replaceState(null), stempeln() stellt ihn her, Zurück bleibt history.back', () => { + const { v } = start('/dashboard.html#funktionen'); geheZu('#termine') + v.umgebung.history.replaceState(null, '', '/dashboard.html#termine') // wie termine.js:40 + assert.equal(v.umgebung.history.state, null); stempeln(); assert.ok(v.umgebung.history.state.kn.tiefe >= 1) + const n = v.urls().length; zurueck(); assert.deepEqual([v.urls().length, v.aktuell()], [n, '/dashboard.html#funktionen']) +}) + +test('Zurück ohne eigenen Eintrag ersetzt durch das Elternziel', () => { + const { v, log } = start('/dashboard.html#termine', { kn: { tiefe: 0, overlay: false } }); const n = v.urls().length + zurueck(); assert.deepEqual([v.aktuell(), v.urls().length, log.at(-1)], ['/dashboard.html#funktionen', n, ['#funktionen', 'back']]) +}) + +test('Overlay: Zurück schließt zuerst das Sheet; Navigation ersetzt den Overlay-Eintrag; Neuladen mit Overlay-Stempel', () => { + const { v, log } = start('/dashboard.html#chat'); let zu = 0; const sch = { schliesse: () => { zu += 1 } } + oeffneOverlay(sch); assert.equal(v.urls().length, 3) + v.zurueck(); assert.deepEqual([zu, log.length], [1, 1]) // zeige nicht erneut gerufen + oeffneOverlay(sch); geheZu('#passwort') + assert.deepEqual([zu, v.aktuell(), v.urls().length, log.at(-1)[1]], [2, '/dashboard.html#passwort', 3, 'push']) + const b = start('/dashboard.html#chat', { kn: { tiefe: 1, overlay: true } }); assert.equal(b.v.umgebung.history.state.kn.overlay, false) + oeffneOverlay(sch); beendeOverlay(); assert.equal(zu, 3) +}) + +test('Overlay auf der gleichen Route: geheZu schließt genau einmal und lässt keinen Eintrag zurück', () => { + const { v, log } = start('/dashboard.html#chat'); let zu = 0 + oeffneOverlay({ schliesse: () => { zu += 1 } }); geheZu('#chat') + assert.deepEqual([zu, v.aktuell(), log.at(-1)], [1, '/dashboard.html#chat', ['#chat', 'same']]) + geheZu('#vertraege'); assert.equal(v.umgebung.history.state.kn.tiefe, 1) // der nächste Bereichswechsel ersetzt den Chat-Eintrag, kein Rest vom Overlay +}) + +test('Vorwärts in die Etage ist push; ein Fragment-Link ohne Stempel bekommt Stempel, Ebene und nav', () => { + const { v, log } = start('/dashboard.html#funktionen'); geheZu('#termine'); v.zurueck(); v.vor() + assert.deepEqual(log.slice(-2), [['#funktionen', 'back'], ['#termine', 'push']]) + v.link('/dashboard.html#zeiten') + assert.deepEqual([log.at(-1), v.umgebung.history.state.kn.tiefe], [['#zeiten', 'push'], 3]) + v.link('/dashboard.html#wartung'); assert.deepEqual([v.aktuell(), log.at(-1)], ['/dashboard.html#abo', ['#abo', 'tab']]) // kanonisch umgeschrieben +})