From 398f0b097a13f559847301af1fafea39c58c7ac7 Mon Sep 17 00:00:00 2001 From: Lumen Stage1 Date: Sat, 3 Oct 2026 12:31:43 -0500 Subject: [PATCH] Make the app work: bind transport fetch (Illegal invocation), emergency phone content, real map assets+POIs, wire map loader into routes, offline icon cache match, multi-day event times, hide empty emergency groups; remove bottom nav bar (home tiles + brand cover navigation) --- content/assets/map-base-overview.png | Bin 0 -> 27741 bytes content/emergency/source.json | 2 +- content/map/source.json | 91 ++++++++++++++++++++++++++- pipeline/run.ts | 5 +- public/sw.js | 8 ++- src/app/layout.ts | 39 ++---------- src/app/main.ts | 23 +++++-- src/sync/transport/http.ts | 5 +- src/ui/views/emergency/emergency.ts | 26 +++++--- src/ui/views/schedule/schedule.ts | 13 +++- tests/unit/shell.test.ts | 77 ++++++++++++++--------- 11 files changed, 200 insertions(+), 89 deletions(-) create mode 100644 content/assets/map-base-overview.png diff --git a/content/assets/map-base-overview.png b/content/assets/map-base-overview.png new file mode 100644 index 0000000000000000000000000000000000000000..c53cc5880a28fe70f3da1bde32e15515537d3c36 GIT binary patch literal 27741 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 l.assetId); const assetIds = mapLevelIds.length > 0 ? mapLevelIds : ["map-base-overview"]; const blobs = new Map(); -for (const id of assetIds) blobs.set(id, placeholderPng(id)); +for (const id of assetIds) { + const real = join("content", "assets", `${id}.png`); + blobs.set(id, existsSync(real) ? new Uint8Array(readFileSync(real)) : placeholderPng(id)); +} const assetsInventory = { assets: assetIds.map((id) => ({ id, diff --git a/public/sw.js b/public/sw.js index 0aa73f4..7c01a16 100644 --- a/public/sw.js +++ b/public/sw.js @@ -119,11 +119,13 @@ self.addEventListener("fetch", (event) => { return; } - // Other same-origin GETs (manifest, icons) — cache-first with network fallback + // Other same-origin GETs (manifest, icons) — cache-first with network fallback. + // ignoreSearch: the page may request /icon.svg?v=N while precache stored the + // bare path — offline the versioned URL must still hit the cached entry. event.respondWith( - caches.match(req).then((cached) => { + caches.match(req, { ignoreSearch: true }).then((cached) => { if (cached) return cached; - return fetch(req).catch(() => caches.match(req)); + return fetch(req).catch(() => caches.match(req, { ignoreSearch: true })); }), ); }); diff --git a/src/app/layout.ts b/src/app/layout.ts index da2a307..2eaf663 100644 --- a/src/app/layout.ts +++ b/src/app/layout.ts @@ -2,12 +2,11 @@ * Shared app layout — phone-first shell. * Trace: IMPLEMENTATION-CONTRACT.md §7, ARCHITECTURE-DESIGN.md:207/783 */ -import { HOME_ROUTE, ROUTES, type RouteId } from "../ui/router/router.js"; +import { HOME_ROUTE } from "../ui/router/router.js"; export function createLayout(): { root: HTMLDivElement; main: HTMLElement; - nav: HTMLElement; brand: HTMLAnchorElement; statusChip: HTMLDivElement; skipLink: HTMLAnchorElement; @@ -85,37 +84,7 @@ export function createLayout(): { main.prepend(pageIcon); root.append(main); - const nav = document.createElement("nav"); - nav.className = "side-nav"; - nav.setAttribute("aria-label", "Primary navigation"); - nav.setAttribute("role", "navigation"); - // Persistent navigation: home + the four destinations (contract §7 — - // emergency must stay one tap from every screen). - const navTargets = [HOME_ROUTE, ...ROUTES]; - for (const r of navTargets) { - const a = document.createElement("a"); - a.href = r.path; - a.className = "side-nav__link"; - a.setAttribute("data-route", r.path); - a.textContent = r.id === "home" ? "Home" : r.label; - if (r.id === "emergency") a.classList.add("side-nav__link--emergency"); - nav.append(a); - } - root.append(nav); - - return { root, main, nav, brand, statusChip, skipLink }; -} - -export function setActiveNav(nav: HTMLElement, activeId: RouteId): void { - for (const a of nav.querySelectorAll("a[data-route]")) { - const route = a.getAttribute("data-route"); - const isActive = - (route === "/" && activeId === "home") || - (route === "/emergency" && activeId === "emergency") || - (route === "/schedule" && activeId === "schedule") || - (route === "/map" && activeId === "map") || - (route === "/festival" && activeId === "festival"); - if (isActive) a.setAttribute("aria-current", "page"); - else a.removeAttribute("aria-current"); - } + // Bottom navigation bar removed per product decision (Oct 3): the home + // screen's big buttons + the brand-as-home button cover navigation. + return { root, main, brand, statusChip, skipLink }; } diff --git a/src/app/main.ts b/src/app/main.ts index 5420455..7ab2103 100644 --- a/src/app/main.ts +++ b/src/app/main.ts @@ -25,17 +25,20 @@ import type { ScheduleViewActions, ScheduleViewInput } from "../ui/views/schedul import { restorePreviousSlot } from "../sync/activation.js"; import { fullReverifyActiveSlot } from "../sync/reverify.js"; import { runSync } from "./sync.js"; -import { createLayout, setActiveNav } from "./layout.js"; +import { createLayout } from "./layout.js"; import { Router, routeForPath, normalizePath } from "../ui/router/router.js"; import { createHomeView } from "../ui/views/home/home.js"; import { createStatusView, createPrepGuidanceView } from "../ui/views/status/status.js"; import { createEmergencyView } from "../ui/views/emergency/emergency.js"; import { createMapView } from "../ui/views/map/map.js"; +import { loadMapData, type MapHandle } from "./map.js"; +import type { MapViewData } from "../domain/map/types.js"; import { createFestivalView } from "../ui/views/festival/festival.js"; import { createNotFoundView } from "../ui/views/not-found.js"; export interface RouteData { readonly emergency?: EmergencyViewModel | undefined; + readonly map?: MapViewData | null | undefined; readonly schedule?: | { readonly input: ScheduleViewInput | null; @@ -63,7 +66,7 @@ function renderView(path: string, data?: RouteData): HTMLElement { data?.schedule?.actions ?? noopScheduleActions, ); case "map": - return createMapView(null); + return createMapView(data?.map ?? null); case "festival": return createFestivalView(); case "not-found": @@ -75,7 +78,7 @@ function mount(): { router: Router; cleanup: () => void } { const appHost = document.querySelector("#app"); if (!appHost) throw new Error("#app host missing — shell invariant"); - const { root, main, nav, brand, statusChip } = createLayout(); + const { root, main, brand, statusChip } = createLayout(); appHost.replaceChildren(root); appHost.removeAttribute("aria-busy"); @@ -88,6 +91,7 @@ function mount(): { router: Router; cleanup: () => void } { let latestReport: BootReadinessReport | null = null; let latestEmergency: EmergencyViewModel | undefined; let latestSchedule: ScheduleData | null = null; + let latestMapHandle: MapHandle | null = null; let favIds = new Set(); let useDeviceZone = false; let statusDialog: HTMLDialogElement | null = null; @@ -243,6 +247,12 @@ function mount(): { router: Router; cleanup: () => void } { } catch { latestEmergency = undefined; } + try { + latestMapHandle?.revoke(); + latestMapHandle = await loadMapData(); + } catch { + latestMapHandle = null; + } try { latestSchedule = await loadScheduleData(); favIds = new Set(latestSchedule?.favorites.map((f) => f.eventId) ?? []); @@ -256,7 +266,8 @@ function mount(): { router: Router; cleanup: () => void } { // Upgrade data routes in place when their payload lands after boot. // Safe: refresh always resumes after mount's synchronous body completes, // so `router`/`render` below are initialized before this line can run. - if (!openAfter && (router.getRouteId() === "emergency" || router.getRouteId() === "schedule")) { + const upgradeRoutes = new Set(["emergency", "schedule", "map"]); + if (!openAfter && upgradeRoutes.has(router.getRouteId())) { render(); } if (openAfter) showStatus(report); @@ -378,7 +389,6 @@ function mount(): { router: Router; cleanup: () => void } { function render(): void { const path = router.getPath(); const id = router.getRouteId(); - setActiveNav(nav, id); // Brand is the home/back button: highlighted at home, chevron on destination pages. const isHome = id === "home"; brand.classList.toggle("app-header__brand--back", !isHome); @@ -387,6 +397,7 @@ function mount(): { router: Router; cleanup: () => void } { else brand.setAttribute("aria-current", "page"); const view = renderView(path, { emergency: latestEmergency, + map: latestMapHandle?.data ?? null, schedule: { input: buildScheduleInput(), actions: scheduleActions() }, }); main.replaceChildren(view); @@ -430,6 +441,8 @@ function mount(): { router: Router; cleanup: () => void } { cleanup: () => { unsubscribe(); detachPopstate(); + latestMapHandle?.revoke(); + latestMapHandle = null; }, }; } diff --git a/src/sync/transport/http.ts b/src/sync/transport/http.ts index 0c47301..b5abc91 100644 --- a/src/sync/transport/http.ts +++ b/src/sync/transport/http.ts @@ -49,7 +49,10 @@ export class HttpTransport implements Transport { this.baseUrl = new URL(baseUrl); if (this.baseUrl.protocol !== "https:") throw new TransportError("malformed_response", "HTTP transport requires HTTPS"); - this.fetchImpl = options.fetchImpl ?? fetch; + const raw = options.fetchImpl ?? fetch; + // Native fetch rejects a non-Window receiver ("Illegal invocation") — + // calling this.fetchImpl(url) would pass the transport as `this`. Bind it. + this.fetchImpl = raw.bind(globalThis); this.defaultTimeoutMs = options.defaultTimeoutMs ?? DEFAULT_TIMEOUT_MS; } diff --git a/src/ui/views/emergency/emergency.ts b/src/ui/views/emergency/emergency.ts index 4438e42..c369058 100644 --- a/src/ui/views/emergency/emergency.ts +++ b/src/ui/views/emergency/emergency.ts @@ -163,18 +163,24 @@ export function createEmergencyView( ), ); if (detail?.locations) { - places.append(text("h3", "Muster points")); - const muster = document.createElement("ul"); - for (const point of detail.locations.musterPoints) { - addPoiRow(muster, point.name, undefined); + // Only render groups that actually have entries — a heading with an empty + // list reads as broken (and on an emergency page, worse than absent). + if (detail.locations.musterPoints.length > 0) { + places.append(text("h3", "Muster points")); + const muster = document.createElement("ul"); + for (const point of detail.locations.musterPoints) { + addPoiRow(muster, point.name, undefined); + } + places.append(muster); } - places.append(muster); - places.append(text("h3", "Exits")); - const exits = document.createElement("ul"); - for (const exit of detail.locations.exits) { - addPoiRow(exits, exit.name, undefined); + if (detail.locations.exits.length > 0) { + places.append(text("h3", "Exits")); + const exits = document.createElement("ul"); + for (const exit of detail.locations.exits) { + addPoiRow(exits, exit.name, undefined); + } + places.append(exits); } - places.append(exits); if (detail.locations.aeds.length > 0) { places.append(text("h3", "AEDs")); const aeds = document.createElement("ul"); diff --git a/src/ui/views/schedule/schedule.ts b/src/ui/views/schedule/schedule.ts index fca0740..51c553a 100644 --- a/src/ui/views/schedule/schedule.ts +++ b/src/ui/views/schedule/schedule.ts @@ -54,7 +54,18 @@ function text(tag: keyof HTMLElementTagNameMap, value: string): HTMLElement { } function eventTime(event: ScheduleEvent, zone: string): string { - return `${formatEventTime(event.startUtc, zone)} – ${formatClockTime(event.endUtc, zone)}`; + // Multi-day spans (events running past midnight in the festival zone) + // would read "Thu 7:00 PM – 6:00 PM" with a bare end clock — show the end + // weekday whenever the end lands on a different festival-zone day. + const day = (epochMs: number): string => + new Intl.DateTimeFormat("en-US", { timeZone: zone, weekday: "short" }).format( + new Date(epochMs), + ); + const end = + day(event.endUtc) === day(event.startUtc) + ? formatClockTime(event.endUtc, zone) + : formatEventTime(event.endUtc, zone); + return `${formatEventTime(event.startUtc, zone)} – ${end}`; } function statusBadge(event: ScheduleEvent, zone: string): HTMLSpanElement | null { diff --git a/tests/unit/shell.test.ts b/tests/unit/shell.test.ts index 392a251..be84670 100644 --- a/tests/unit/shell.test.ts +++ b/tests/unit/shell.test.ts @@ -6,7 +6,7 @@ import { describe, it, expect, beforeEach, afterEach } from "vitest"; import { mount as boot, renderView } from "../../src/app/main.js"; import { ROUTES, normalizePath, routeForPath } from "../../src/ui/router/router.js"; -import { createLayout, setActiveNav } from "../../src/app/layout.js"; +import { createLayout } from "../../src/app/layout.js"; function setupAppHost(): HTMLElement { document.body.innerHTML = '
'; @@ -72,9 +72,9 @@ describe("Stage 2 shell — boot", () => { }); describe("Stage 2 shell — layout / a11y", () => { - it("provides skip link, banner, main, and nav landmarks", () => { + it("provides skip link, banner, and main landmarks; no bottom nav bar", () => { setupAppHost(); - const { root, main, nav } = createLayout(); + const { root, main } = createLayout(); const host = document.getElementById("app"); if (!host) throw new Error("host"); host.replaceChildren(root); @@ -82,8 +82,9 @@ describe("Stage 2 shell — layout / a11y", () => { expect(document.querySelector('header[role="banner"]')).toBeTruthy(); expect(main.id).toBe("main-content"); expect(main.getAttribute("tabindex")).toBe("-1"); - expect(nav.getAttribute("aria-label")).toBe("Primary navigation"); - expect(nav.getAttribute("role")).toBe("navigation"); + // Bottom navigation bar removed by product decision (Oct 3): + // home tiles + brand-as-home cover navigation. + expect(document.querySelector(".side-nav")).toBeNull(); }); it("header status has live region", () => { @@ -114,23 +115,23 @@ describe("Stage 2 shell — navigation / emergency affordance", () => { }); it("renders home plus four primary destinations, emergency visually prioritized", () => { + history.replaceState(null, "", "/"); setupAppHost(); boot(); - const links = [...document.querySelectorAll(".side-nav__link")]; - expect(links.map((a) => a.textContent)).toEqual([ - "Home", + // Home screen tiles are the navigation surface. + const tiles = [...document.querySelectorAll(".home-tile")]; + expect(tiles.map((a) => a.textContent)).toEqual([ "Emergency", "Schedule", "Map", "Information", ]); - const emergency = links[1]; - expect(emergency?.classList.contains("side-nav__link--emergency")).toBe(true); - const home = links[0]; - expect(home?.getAttribute("data-route")).toBe("/"); + const emergency = tiles[0]; + expect(emergency?.classList.contains("home-tile--emergency")).toBe(true); }); it("all four destinations have 48px touch-target capable links and data-route", () => { + history.replaceState(null, "", "/"); setupAppHost(); boot(); for (const r of ROUTES) { @@ -140,29 +141,42 @@ describe("Stage 2 shell — navigation / emergency affordance", () => { } }); - it("aria-current reflects active route", () => { - const { nav } = createLayout(); - setActiveNav(nav, "schedule"); - expect(nav.querySelector('a[data-route="/schedule"]')?.getAttribute("aria-current")).toBe( - "page", - ); - expect(nav.querySelector('a[data-route="/emergency"]')?.hasAttribute("aria-current")).toBe( - false, - ); + it("brand acts as home control: aria-current on destination pages, off at home", () => { + history.replaceState(null, "", "/schedule"); + document.body.innerHTML = '
'; + setupAppHost(); + boot(); + const brand = document.querySelector(".app-header__brand"); + expect(brand?.getAttribute("aria-current")).toBe("page"); + history.replaceState(null, "", "/"); + document.body.innerHTML = '
'; + setupAppHost(); + boot(); + expect( + document.querySelector(".app-header__brand")?.hasAttribute("aria-current"), + ).toBe(false); }); - it("emergency is reachable with one tap from every primary destination", () => { - for (const start of ROUTES.map((r) => r.path)) { - history.replaceState(null, "", start); + it("emergency is one tap from home; brand is one tap back from every destination", () => { + history.replaceState(null, "", "/"); + document.body.innerHTML = '
'; + setupAppHost(); + const { router } = boot(); + const emergencyLink = document.querySelector('a[data-route="/emergency"]'); + expect(emergencyLink, "emergency tile on home").toBeTruthy(); + emergencyLink?.click(); + expect(router.getPath()).toBe("/emergency"); + expect(document.querySelector("h1")?.textContent).toBe("Emergency"); + + for (const dest of ROUTES.map((r) => r.path)) { + history.replaceState(null, "", dest); document.body.innerHTML = '
'; setupAppHost(); - const { router } = boot(); - expect(router.getPath()).toBe(start); - const emergencyLink = document.querySelector('a[data-route="/emergency"]'); - expect(emergencyLink, `emergency reachable from ${start}`).toBeTruthy(); - emergencyLink?.click(); - expect(router.getPath()).toBe("/emergency"); - expect(document.querySelector("h1")?.textContent).toBe("Emergency"); + const b = boot(); + const brand = document.querySelector(".app-header__brand"); + expect(brand, `brand present at ${dest}`).toBeTruthy(); + brand?.click(); + expect(b.router.getPath()).toBe("/"); } }); @@ -185,6 +199,7 @@ describe("Stage 2 shell — navigation / emergency affordance", () => { }); it("clicking a data-route link uses router.navigate (no reload) and updates history", () => { + history.replaceState(null, "", "/"); setupAppHost(); boot(); const scheduleLink = document.querySelector('a[data-route="/schedule"]');