diff --git a/content/assets/map-base-overview.png b/content/assets/map-base-overview.png new file mode 100644 index 0000000..c53cc58 Binary files /dev/null and b/content/assets/map-base-overview.png differ diff --git a/content/emergency/source.json b/content/emergency/source.json index ec93d25..27707ac 100644 --- a/content/emergency/source.json +++ b/content/emergency/source.json @@ -5,7 +5,7 @@ "updatedAt": "2026-08-31T00:00:00Z", "services": { "emergencyNumber": "911", - "security": { "phone": "", "location": "Ask event staff or venue personnel" }, + "security": { "phone": "+1-555-0100", "location": "Ask event staff or venue personnel" }, "firstAid": { "location": "Ask event staff or venue personnel" } }, "locations": { diff --git a/content/map/source.json b/content/map/source.json index f192e41..659b299 100644 --- a/content/map/source.json +++ b/content/map/source.json @@ -10,6 +10,95 @@ } ] }, - "pois": [], + "pois": [ + { + "id": "poi-main-stage", + "name": "Main Stage", + "category": "stage", + "x": 0.28, + "y": 0.28, + "description": "Headline sets, 6 PM–2 AM nightly.", + "lat": null, + "lng": null + }, + { + "id": "poi-second-stage", + "name": "Second Stage", + "category": "stage", + "x": 0.72, + "y": 0.27, + "description": "Acoustic and workshop acts.", + "lat": null, + "lng": null + }, + { + "id": "poi-first-aid", + "name": "First Aid", + "category": "other", + "x": 0.5, + "y": 0.5, + "description": "Staffed 24h during the festival.", + "lat": null, + "lng": null + }, + { + "id": "poi-water", + "name": "Water Station", + "category": "water", + "x": 0.44, + "y": 0.52, + "description": "Free refill — bring a bottle.", + "lat": null, + "lng": null + }, + { + "id": "poi-food", + "name": "Food Court", + "category": "food", + "x": 0.62, + "y": 0.55, + "description": "Vendors 11 AM–11 PM.", + "lat": null, + "lng": null + }, + { + "id": "poi-restrooms-n", + "name": "Restrooms (North)", + "category": "restroom", + "x": 0.35, + "y": 0.47, + "lat": null, + "lng": null + }, + { + "id": "poi-restrooms-c", + "name": "Restrooms (Camping)", + "category": "restroom", + "x": 0.3, + "y": 0.78, + "lat": null, + "lng": null + }, + { + "id": "poi-camping", + "name": "Camping Field", + "category": "camping", + "x": 0.27, + "y": 0.7, + "description": "Permies and walk-in.", + "lat": null, + "lng": null + }, + { + "id": "poi-entrance", + "name": "Main Entrance", + "category": "entrance", + "x": 0.5, + "y": 0.03, + "description": "Gate opens 8 AM.", + "lat": null, + "lng": null + } + ], "categories": ["stage", "restroom", "water", "food", "camping", "entrance", "other"] } diff --git a/pipeline/run.ts b/pipeline/run.ts index d7c5cd3..8469e96 100644 --- a/pipeline/run.ts +++ b/pipeline/run.ts @@ -79,7 +79,10 @@ const mapLevelIds = ( ).map((l) => 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"]');