This commit is contained in:
parent
fcc18ddcc8
commit
e83946c207
57 changed files with 6257 additions and 775 deletions
File diff suppressed because it is too large
Load diff
|
|
@ -1,42 +1,42 @@
|
||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||||
<link rel="stylesheet" href="/.shots/styles-light.css" />
|
<link rel="stylesheet" href="/.shots/styles-light.css" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app">
|
<div id="app">
|
||||||
<a class="skip-link" href="#main-content">Skip to content</a>
|
<a class="skip-link" href="#main-content">Skip to content</a>
|
||||||
<header class="app-header" role="banner">
|
<header class="app-header" role="banner">
|
||||||
<a class="app-header__brand" href="/">LUMEN</a>
|
<a class="app-header__brand" href="/">LUMEN</a>
|
||||||
<div class="app-header__status" role="status">offline</div>
|
<div class="app-header__status" role="status">offline</div>
|
||||||
</header>
|
</header>
|
||||||
<main id="main-content" class="app-main home-view">
|
<main id="main-content" class="app-main home-view">
|
||||||
<span class="eyebrow">Festival companion</span>
|
<span class="eyebrow">Festival companion</span>
|
||||||
<h1>Lumen</h1>
|
<h1>Lumen</h1>
|
||||||
<p class="home-view__subtitle">Offline-first guide to the festival grounds</p>
|
<p class="home-view__subtitle">Offline-first guide to the festival grounds</p>
|
||||||
<div class="home-grid">
|
<div class="home-grid">
|
||||||
<a class="home-tile" href="/schedule">Schedule</a>
|
<a class="home-tile" href="/schedule">Schedule</a>
|
||||||
<a class="home-tile" href="/map">Map</a>
|
<a class="home-tile" href="/map">Map</a>
|
||||||
<a class="home-tile" href="/festival">Festival</a>
|
<a class="home-tile" href="/festival">Festival</a>
|
||||||
<a class="home-tile home-tile--emergency" href="/emergency">Emergency</a>
|
<a class="home-tile home-tile--emergency" href="/emergency">Emergency</a>
|
||||||
</div>
|
</div>
|
||||||
<section class="view-placeholder">
|
<section class="view-placeholder">
|
||||||
<span class="eyebrow">Status</span>
|
<span class="eyebrow">Status</span>
|
||||||
<h2>Day 1 — Friday</h2>
|
<h2>Day 1 — Friday</h2>
|
||||||
<p>Gates open at noon. First set on the main stage at 2:00.</p>
|
<p>Gates open at noon. First set on the main stage at 2:00.</p>
|
||||||
<span class="chip chip--green">Synced</span>
|
<span class="chip chip--green">Synced</span>
|
||||||
<span class="chip chip--amber">12 updates</span>
|
<span class="chip chip--amber">12 updates</span>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
<nav class="side-nav" role="navigation" aria-label="Primary navigation">
|
<nav class="side-nav" role="navigation" aria-label="Primary navigation">
|
||||||
<a class="side-nav__link" href="/" aria-current="page">Home</a>
|
<a class="side-nav__link" href="/" aria-current="page">Home</a>
|
||||||
<a class="side-nav__link" href="/schedule">Schedule</a>
|
<a class="side-nav__link" href="/schedule">Schedule</a>
|
||||||
<a class="side-nav__link" href="/map">Map</a>
|
<a class="side-nav__link" href="/map">Map</a>
|
||||||
<a class="side-nav__link" href="/festival">Festival</a>
|
<a class="side-nav__link" href="/festival">Festival</a>
|
||||||
<a class="side-nav__link side-nav__link--emergency" href="/emergency">Emergency</a>
|
<a class="side-nav__link side-nav__link--emergency" href="/emergency">Emergency</a>
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
@ -1,4 +1,12 @@
|
||||||
<!doctype html><html><body style="margin:0"><div id="d" style="width:400px;height:300px;background:#fff"></div>
|
<!doctype html>
|
||||||
<script>
|
<html>
|
||||||
document.getElementById('d').style.background = matchMedia('(prefers-color-scheme: dark)').matches ? '#0b1411' : '#f6f9f7';
|
<body style="margin: 0">
|
||||||
</script></body></html>
|
<div id="d" style="width: 400px; height: 300px; background: #fff"></div>
|
||||||
|
<script>
|
||||||
|
document.getElementById("d").style.background = matchMedia("(prefers-color-scheme: dark)")
|
||||||
|
.matches
|
||||||
|
? "#0b1411"
|
||||||
|
: "#f6f9f7";
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
|
||||||
|
|
@ -61,8 +61,6 @@
|
||||||
--content-max: 48rem;
|
--content-max: 48rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
* {
|
* {
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
10
CHECKPOINT-encryption.md
Normal file
10
CHECKPOINT-encryption.md
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
# CHECKPOINT — Lumen (2025-09-28)
|
||||||
|
|
||||||
|
- **Project:** /home/avi/Projects/Lumen/
|
||||||
|
- **Latest commit:** Work in session — added ClockService interface to src/domain/clock/logic.ts; fixed TypeScript errors; build clean; 273 tests pass.
|
||||||
|
- **Working tree:** Clean build (dist/ rebuilt); uncommitted feature edit (ClockService interface).
|
||||||
|
- **Completed:** ClockService pure logic interface added (offline-first, no network, no persistence per SPIKE-08 / contract §3). Type errors resolved (main.ts createMapView call + map.ts narrowing). Production build verified.
|
||||||
|
- **New commits in session:** Feature addition — ClockService interface; verification (build + typecheck + test all pass).
|
||||||
|
- **Verification commands:** cargo/test analogs: `npm run typecheck` (green), `npm run build` (green, 330ms), `npm test` (273 passed). No Rust frontend to check (PWA only).
|
||||||
|
- **How to resume / reproduce:** `npm run dev` (localhost:5173 default) or `bash share/start.sh` + open on phone via same-network IP.
|
||||||
|
- **Outstanding / next step:** Implement ClockService logic (correctedNow, hasDrift, shouldWarnOutsideWindow, renderInZone, classifyNowNext) + readiness service (C1-C8) + feature views (emergency/schedule/map/festival) per IMPLEMENTATION-CONTRACT.md Stage 1.
|
||||||
|
|
@ -6,17 +6,17 @@
|
||||||
<meta name="color-scheme" content="light dark" />
|
<meta name="color-scheme" content="light dark" />
|
||||||
<meta name="theme-color" content="#f6f9f7" />
|
<meta name="theme-color" content="#f6f9f7" />
|
||||||
<meta name="theme-color" media="(prefers-color-scheme: dark)" content="#0b1411" />
|
<meta name="theme-color" media="(prefers-color-scheme: dark)" content="#0b1411" />
|
||||||
<meta name="description" content="Lumen — offline-first festival companion" />
|
<meta name="description" content="Solarpunk — offline-first festival companion" />
|
||||||
<link rel="manifest" href="/manifest.webmanifest" />
|
<link rel="manifest" href="/manifest.webmanifest" />
|
||||||
<link rel="icon" href="/sol_lunar_icon.svg" type="image/svg+xml" />
|
<link rel="icon" href="/sol_lunar_icon.svg" type="image/svg+xml" />
|
||||||
<title>Lumen</title>
|
<title>Solarpunk</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app" aria-busy="true">Loading…</div>
|
<div id="app" aria-busy="true">Loading…</div>
|
||||||
<script type="module" src="/src/app/main.ts"></script>
|
<script type="module" src="/src/app/main.ts"></script>
|
||||||
<noscript>
|
<noscript>
|
||||||
<p role="alert">
|
<p role="alert">
|
||||||
Lumen requires JavaScript to run the app shell. Emergency information is available on the
|
Solarpunk requires JavaScript to run the app shell. Emergency information is available on the
|
||||||
printed festival materials.
|
printed festival materials.
|
||||||
</p>
|
</p>
|
||||||
</noscript>
|
</noscript>
|
||||||
|
|
|
||||||
BIN
lumen-share.zip
Normal file
BIN
lumen-share.zip
Normal file
Binary file not shown.
5
share/README.md
Normal file
5
share/README.md
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
# public
|
||||||
|
|
||||||
|
Static shell entry (index.html no-cache, manifest.webmanifest, fallback.html Ring-0, sw.js precache). ARCH §24.
|
||||||
|
|
||||||
|
Stage 1: directory exists with .gitkeep; no feature code yet per IMPLEMENTATION-CONTRACT.md Stage 1. See IMPLEMENTATION-CONTRACT.md §4 responsibilities and §6 B-1…B-7.
|
||||||
2
share/assets/index-CaoEjGwW.js
Normal file
2
share/assets/index-CaoEjGwW.js
Normal file
File diff suppressed because one or more lines are too long
1
share/assets/index-CaoEjGwW.js.map
Normal file
1
share/assets/index-CaoEjGwW.js.map
Normal file
File diff suppressed because one or more lines are too long
1
share/assets/style-D9rb7TfV.css
Normal file
1
share/assets/style-D9rb7TfV.css
Normal file
File diff suppressed because one or more lines are too long
33
share/fallback.html
Normal file
33
share/fallback.html
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>Lumen — offline</title>
|
||||||
|
<meta name="color-scheme" content="light dark" />
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
font-family: system-ui, sans-serif;
|
||||||
|
max-width: 40rem;
|
||||||
|
margin: 2rem auto;
|
||||||
|
padding: 0 1rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
h1 {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
.note {
|
||||||
|
border: 2px solid currentColor;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>Lumen</h1>
|
||||||
|
<p class="note" role="alert">
|
||||||
|
You are offline and the app shell is not cached. Emergency information is available from
|
||||||
|
festival staff and printed materials.
|
||||||
|
</p>
|
||||||
|
<p>When you are back online, open <a href="/">Lumen</a> to reload the app shell.</p>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
25
share/index.html
Normal file
25
share/index.html
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||||
|
<meta name="color-scheme" content="light dark" />
|
||||||
|
<meta name="theme-color" content="#f6f9f7" />
|
||||||
|
<meta name="theme-color" media="(prefers-color-scheme: dark)" content="#0b1411" />
|
||||||
|
<meta name="description" content="Lumen — offline-first festival companion" />
|
||||||
|
<link rel="manifest" href="/manifest.webmanifest" />
|
||||||
|
<link rel="icon" href="/sol_lunar_icon.svg" type="image/svg+xml" />
|
||||||
|
<title>Lumen</title>
|
||||||
|
<script type="module" crossorigin src="/assets/index-CaoEjGwW.js"></script>
|
||||||
|
<link rel="stylesheet" crossorigin href="/assets/style-D9rb7TfV.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app" aria-busy="true">Loading…</div>
|
||||||
|
<noscript>
|
||||||
|
<p role="alert">
|
||||||
|
Lumen requires JavaScript to run the app shell. Emergency information is available on the
|
||||||
|
printed festival materials.
|
||||||
|
</p>
|
||||||
|
</noscript>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
30
share/manifest.webmanifest
Normal file
30
share/manifest.webmanifest
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
{
|
||||||
|
"name": "Lumen",
|
||||||
|
"short_name": "Lumen",
|
||||||
|
"description": "Offline-first festival companion — Emergency, Schedule, Map, Festival",
|
||||||
|
"display": "standalone",
|
||||||
|
"scope": "/",
|
||||||
|
"start_url": "/",
|
||||||
|
"background_color": "#0b1411",
|
||||||
|
"theme_color": "#f6f9f7",
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "/sol_lunar_icon.svg",
|
||||||
|
"sizes": "any",
|
||||||
|
"type": "image/svg+xml",
|
||||||
|
"purpose": "any"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/icon-192.png",
|
||||||
|
"sizes": "192x192",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "any maskable"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/icon-512.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "any maskable"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
16
share/precache-manifest.json
Normal file
16
share/precache-manifest.json
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
{
|
||||||
|
"cacheName": "lumen-shell-0.1.0-fcc18ddc",
|
||||||
|
"precache": [
|
||||||
|
"/",
|
||||||
|
"/index.html",
|
||||||
|
"/fallback.html",
|
||||||
|
"/manifest.webmanifest",
|
||||||
|
"/sol_lunar_icon.svg",
|
||||||
|
"/icon.png",
|
||||||
|
"/icon-192.png",
|
||||||
|
"/icon-512.png",
|
||||||
|
"/assets/index-CaoEjGwW.js",
|
||||||
|
"/assets/index-CaoEjGwW.js.map",
|
||||||
|
"/assets/style-D9rb7TfV.css"
|
||||||
|
]
|
||||||
|
}
|
||||||
4
share/sol_lunar_icon.svg
Normal file
4
share/sol_lunar_icon.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 14 KiB |
3
share/start.sh
Executable file
3
share/start.sh
Executable file
|
|
@ -0,0 +1,3 @@
|
||||||
|
#!/bin/bash
|
||||||
|
python3 -m http.server 8080 --directory .
|
||||||
|
echo "Open http://localhost:8080 in your browser"
|
||||||
140
share/sw.js
Normal file
140
share/sw.js
Normal file
|
|
@ -0,0 +1,140 @@
|
||||||
|
/**
|
||||||
|
* Lumen — minimal hand-written service worker (Stage 3)
|
||||||
|
* Scope: application shell only. No IDB, no dataset, no sync.
|
||||||
|
* Trace: ARCHITECTURE-DESIGN.md §8, IMPLEMENTATION-CONTRACT.md §16–§18
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* eslint-disable no-restricted-globals -- SW global scope is intentional */
|
||||||
|
|
||||||
|
/** Cache version — replaced at build time via Vite define/plugin. Deterministic per shell build. */
|
||||||
|
const CACHE_NAME = "lumen-shell-0.1.0-fcc18ddc";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Precache list — replaced at build time with versioned shell list
|
||||||
|
* (index.html, hashed JS/CSS, manifest, icons, fallback.html).
|
||||||
|
* Placeholder is replaced by Vite plugin after bundle.
|
||||||
|
*/
|
||||||
|
const PRECACHE_URLS = [
|
||||||
|
"/",
|
||||||
|
"/index.html",
|
||||||
|
"/fallback.html",
|
||||||
|
"/manifest.webmanifest",
|
||||||
|
"/sol_lunar_icon.svg",
|
||||||
|
"/icon.png",
|
||||||
|
"/icon-192.png",
|
||||||
|
"/icon-512.png",
|
||||||
|
"/assets/index-CaoEjGwW.js",
|
||||||
|
"/assets/index-CaoEjGwW.js.map",
|
||||||
|
"/assets/style-D9rb7TfV.css"
|
||||||
|
];
|
||||||
|
|
||||||
|
self.addEventListener("install", (event) => {
|
||||||
|
// Safe population: atomically cache all shell URLs; if any fails, install fails
|
||||||
|
// and the new cache is not treated as valid (existing shell remains usable).
|
||||||
|
event.waitUntil(
|
||||||
|
caches
|
||||||
|
.open(CACHE_NAME)
|
||||||
|
.then((cache) => cache.addAll(PRECACHE_URLS))
|
||||||
|
// Do NOT call skipWaiting here — wait for explicit message or next full start
|
||||||
|
// per ARCHITECTURE-DESIGN.md:255 (no mid-session clients.claim).
|
||||||
|
.catch((err) => {
|
||||||
|
// Ensure failed install does not leave partial cache as valid:
|
||||||
|
// delete the newly created cache on failure.
|
||||||
|
caches.delete(CACHE_NAME).finally(() => {
|
||||||
|
// re-throw to signal install failure to the browser
|
||||||
|
throw err;
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener("activate", (event) => {
|
||||||
|
// Safe replacement: delete old shell caches only after new shell is installed.
|
||||||
|
// Old shell remains usable until this runs (next full start).
|
||||||
|
event.waitUntil(
|
||||||
|
caches
|
||||||
|
.keys()
|
||||||
|
.then((keys) =>
|
||||||
|
Promise.all(
|
||||||
|
keys
|
||||||
|
.filter((k) => k.startsWith("lumen-shell-") && k !== CACHE_NAME)
|
||||||
|
.map((k) => caches.delete(k)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener("fetch", (event) => {
|
||||||
|
const req = event.request;
|
||||||
|
const url = new URL(req.url);
|
||||||
|
|
||||||
|
// Only handle same-origin GET
|
||||||
|
if (req.method !== "GET" || url.origin !== self.location.origin) return;
|
||||||
|
|
||||||
|
// Package endpoints are NOT intercepted — app-layer sync controls staging (C-21)
|
||||||
|
if (url.pathname === "/latest.json" || url.pathname.startsWith("/editions/")) {
|
||||||
|
return; // let network handle it
|
||||||
|
}
|
||||||
|
|
||||||
|
// Navigation requests — cache-first against shell cache, fallback to network, then fallback.html
|
||||||
|
const isNavigation =
|
||||||
|
req.mode === "navigate" ||
|
||||||
|
req.destination === "document" ||
|
||||||
|
req.headers.get("accept")?.includes("text/html");
|
||||||
|
|
||||||
|
if (isNavigation) {
|
||||||
|
event.respondWith(
|
||||||
|
caches.match(req).then((cached) => {
|
||||||
|
if (cached) return cached;
|
||||||
|
return fetch(req)
|
||||||
|
.then((res) => {
|
||||||
|
// Optional: opportunistically re-fill cache with fresh index.html on navigation fetch
|
||||||
|
// but do not block response on cache put.
|
||||||
|
if (res.ok) {
|
||||||
|
const clone = res.clone();
|
||||||
|
caches.open(CACHE_NAME).then((c) => c.put(req, clone));
|
||||||
|
}
|
||||||
|
return res;
|
||||||
|
})
|
||||||
|
.catch(() =>
|
||||||
|
caches
|
||||||
|
.match("/fallback.html")
|
||||||
|
.then((fb) => fb ?? new Response("Offline", { status: 503 })),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hashed shell assets (/assets/*) — cache-first (immutable, 1 year per ARCH §24)
|
||||||
|
if (url.pathname.startsWith("/assets/")) {
|
||||||
|
event.respondWith(
|
||||||
|
caches.match(req).then((cached) => {
|
||||||
|
if (cached) return cached;
|
||||||
|
return fetch(req).then((res) => {
|
||||||
|
if (res.ok) {
|
||||||
|
const clone = res.clone();
|
||||||
|
caches.open(CACHE_NAME).then((c) => c.put(req, clone));
|
||||||
|
}
|
||||||
|
return res;
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Other same-origin GETs (manifest, icons) — cache-first with network fallback
|
||||||
|
event.respondWith(
|
||||||
|
caches.match(req).then((cached) => {
|
||||||
|
if (cached) return cached;
|
||||||
|
return fetch(req).catch(() => caches.match(req));
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener("message", (event) => {
|
||||||
|
const data = event.data;
|
||||||
|
if (typeof data === "object" && data !== null && "type" in data && data.type === "SKIP_WAITING") {
|
||||||
|
self.skipWaiting();
|
||||||
|
}
|
||||||
|
});
|
||||||
55
src/app/clock.ts
Normal file
55
src/app/clock.ts
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
/**
|
||||||
|
* App clock — session monotonic base plus persisted-skew access.
|
||||||
|
* The base lives in module memory (performance.now resets per page load);
|
||||||
|
* skew persists in lumen-system across boots. No timers (C-19).
|
||||||
|
* Trace: IMPLEMENTATION-CONTRACT.md §21, SPIKE-08 §3.5.
|
||||||
|
*/
|
||||||
|
import { openSystemDB, readSystemMeta, writeSystemMeta } from "../data/system-meta/store.js";
|
||||||
|
import { resolveClock } from "../domain/clock/service.js";
|
||||||
|
import type { ClockReading, ClockSession } from "../domain/clock/service.js";
|
||||||
|
import type { ClockSkew } from "../domain/clock/types.js";
|
||||||
|
|
||||||
|
let session: ClockSession | null = null;
|
||||||
|
|
||||||
|
/** Test seam — drop the in-memory monotonic anchor. */
|
||||||
|
export function resetClockSession(): void {
|
||||||
|
session = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function currentClock(
|
||||||
|
reading: ClockReading,
|
||||||
|
skew: ClockSkew | null,
|
||||||
|
): { readonly now: number; readonly driftWarn: boolean } {
|
||||||
|
const resolved = resolveClock(reading, skew, session);
|
||||||
|
session = resolved.session;
|
||||||
|
return resolved;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Device wall clock + monotonic reading for the current render. */
|
||||||
|
export function deviceReading(): ClockReading {
|
||||||
|
return { deviceNow: Date.now(), monoNow: performance.now() };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadPersistedSkew(): Promise<ClockSkew | null> {
|
||||||
|
try {
|
||||||
|
const db = await openSystemDB();
|
||||||
|
try {
|
||||||
|
return (await readSystemMeta(db)).clockSkew;
|
||||||
|
} finally {
|
||||||
|
db.close();
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Persist a captured skew sample (Stage 15 sync calls this per response). */
|
||||||
|
export async function persistSkew(skew: ClockSkew): Promise<void> {
|
||||||
|
const db = await openSystemDB();
|
||||||
|
try {
|
||||||
|
const meta = await readSystemMeta(db);
|
||||||
|
await writeSystemMeta(db, { ...meta, clockSkew: skew });
|
||||||
|
} finally {
|
||||||
|
db.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
59
src/app/emergency.ts
Normal file
59
src/app/emergency.ts
Normal file
|
|
@ -0,0 +1,59 @@
|
||||||
|
/**
|
||||||
|
* Stage 10 app-layer loader — reads the active slot's emergency section for
|
||||||
|
* Tier 2 rendering. Local IDB only, never network. Never throws: any failure
|
||||||
|
* degrades to the floor (the view's resolveEmergency handles the rest).
|
||||||
|
* Trace: IMPLEMENTATION-CONTRACT.md §14, SPIKE-06.
|
||||||
|
*/
|
||||||
|
import { openSystemDB, readSystemMeta } from "../data/system-meta/store.js";
|
||||||
|
import { openSlotDB, readSlotFile, readSlotManifest } from "../data/slot/store.js";
|
||||||
|
import { isSupportedEmergencySchema, narrowEmergencySection } from "../domain/emergency/logic.js";
|
||||||
|
import type { VerifiedEmergencyData } from "../domain/emergency/types.js";
|
||||||
|
|
||||||
|
export type DatasetEmergencyLoad =
|
||||||
|
/** No active dataset — floor with no-dataset guidance. */
|
||||||
|
| { readonly status: "absent" }
|
||||||
|
/** Active dataset but its emergency section is unusable. */
|
||||||
|
| { readonly status: "unusable"; readonly reason: "unsupported-schema" | "malformed-section" }
|
||||||
|
/** Verified-shape section ready for Tier 2 resolution. */
|
||||||
|
| { readonly status: "ready"; readonly data: VerifiedEmergencyData };
|
||||||
|
|
||||||
|
export async function loadDatasetEmergency(): Promise<DatasetEmergencyLoad> {
|
||||||
|
try {
|
||||||
|
const system = await openSystemDB();
|
||||||
|
let activeSlot;
|
||||||
|
let packageVersion: number | null;
|
||||||
|
try {
|
||||||
|
const meta = await readSystemMeta(system);
|
||||||
|
activeSlot = meta.activeSlot;
|
||||||
|
packageVersion = meta.activePackageVersion;
|
||||||
|
} finally {
|
||||||
|
system.close();
|
||||||
|
}
|
||||||
|
if (!activeSlot || packageVersion === null) return { status: "absent" };
|
||||||
|
|
||||||
|
const slot = await openSlotDB(activeSlot);
|
||||||
|
try {
|
||||||
|
const json = await readSlotFile(slot, "emergency");
|
||||||
|
const section = narrowEmergencySection(json);
|
||||||
|
if (!section) return { status: "unusable", reason: "malformed-section" };
|
||||||
|
if (!isSupportedEmergencySchema(section.emergencySchemaVersion)) {
|
||||||
|
return { status: "unusable", reason: "unsupported-schema" };
|
||||||
|
}
|
||||||
|
const manifest = await readSlotManifest(slot).catch(() => undefined);
|
||||||
|
return {
|
||||||
|
status: "ready",
|
||||||
|
data: {
|
||||||
|
section,
|
||||||
|
packageVersion,
|
||||||
|
generatedAt: manifest?.generatedAt ?? "unknown date",
|
||||||
|
emergencySchemaVersion: section.emergencySchemaVersion,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
slot.close();
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Storage errors degrade to the floor — emergency never blanks (I-1).
|
||||||
|
return { status: "absent" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -2,11 +2,12 @@
|
||||||
* Shared app layout — phone-first shell.
|
* Shared app layout — phone-first shell.
|
||||||
* Trace: IMPLEMENTATION-CONTRACT.md §7, ARCHITECTURE-DESIGN.md:207/783
|
* Trace: IMPLEMENTATION-CONTRACT.md §7, ARCHITECTURE-DESIGN.md:207/783
|
||||||
*/
|
*/
|
||||||
import { HOME_ROUTE, type RouteId } from "../ui/router/router.js";
|
import { HOME_ROUTE, ROUTES, type RouteId } from "../ui/router/router.js";
|
||||||
|
|
||||||
export function createLayout(): {
|
export function createLayout(): {
|
||||||
root: HTMLDivElement;
|
root: HTMLDivElement;
|
||||||
main: HTMLElement;
|
main: HTMLElement;
|
||||||
|
nav: HTMLElement;
|
||||||
brand: HTMLAnchorElement;
|
brand: HTMLAnchorElement;
|
||||||
statusChip: HTMLDivElement;
|
statusChip: HTMLDivElement;
|
||||||
skipLink: HTMLAnchorElement;
|
skipLink: HTMLAnchorElement;
|
||||||
|
|
@ -28,11 +29,25 @@ export function createLayout(): {
|
||||||
const brand = document.createElement("a");
|
const brand = document.createElement("a");
|
||||||
brand.href = HOME_ROUTE.path;
|
brand.href = HOME_ROUTE.path;
|
||||||
brand.className = "app-header__brand";
|
brand.className = "app-header__brand";
|
||||||
brand.setAttribute("aria-label", "Lumen — home");
|
brand.setAttribute("aria-label", "Solarpunk — home");
|
||||||
brand.setAttribute("data-route", HOME_ROUTE.path);
|
brand.setAttribute("data-route", HOME_ROUTE.path);
|
||||||
brand.textContent = "LUMEN";
|
brand.textContent = "SOLARPUNK";
|
||||||
header.append(brand);
|
header.append(brand);
|
||||||
|
|
||||||
|
const themeBtn = document.createElement("button");
|
||||||
|
themeBtn.className = "theme-btn";
|
||||||
|
themeBtn.textContent = "☀";
|
||||||
|
themeBtn.setAttribute("aria-label", "Toggle light/dark theme");
|
||||||
|
themeBtn.onclick = () => {
|
||||||
|
const html = document.documentElement;
|
||||||
|
if (html.classList.contains("dark")) { html.classList.remove("dark"); html.classList.add("light"); localStorage.setItem("theme","light"); themeBtn.textContent="☀"; }
|
||||||
|
else { document.documentElement.classList.remove("light"); document.documentElement.classList.add("dark"); localStorage.setItem("theme","dark"); themeBtn.textContent="◐"; }
|
||||||
|
};
|
||||||
|
// Apply saved theme on load
|
||||||
|
if (localStorage.getItem("theme") === "dark") { document.documentElement.classList.add("dark"); document.documentElement.classList.remove("light"); themeBtn.textContent="◐"; }
|
||||||
|
else { document.documentElement.classList.add("light"); document.documentElement.classList.remove("dark"); themeBtn.textContent="☀"; }
|
||||||
|
header.append(themeBtn);
|
||||||
|
|
||||||
const statusChip = document.createElement("div");
|
const statusChip = document.createElement("div");
|
||||||
statusChip.className = "app-header__status";
|
statusChip.className = "app-header__status";
|
||||||
statusChip.setAttribute("role", "status");
|
statusChip.setAttribute("role", "status");
|
||||||
|
|
@ -54,7 +69,14 @@ export function createLayout(): {
|
||||||
main.prepend(pageIcon);
|
main.prepend(pageIcon);
|
||||||
root.append(main);
|
root.append(main);
|
||||||
|
|
||||||
return { root, main, brand, statusChip, skipLink };
|
const nav = document.createElement("nav");
|
||||||
|
nav.className = "side-nav";
|
||||||
|
nav.setAttribute("aria-label", "Primary navigation");
|
||||||
|
nav.setAttribute("role", "navigation");
|
||||||
|
// Bottom navigation removed — redundant with main-screen buttons.
|
||||||
|
root.append(nav);
|
||||||
|
|
||||||
|
return { root, main, nav, brand, statusChip, skipLink };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function setActiveNav(nav: HTMLElement, activeId: RouteId): void {
|
export function setActiveNav(nav: HTMLElement, activeId: RouteId): void {
|
||||||
|
|
|
||||||
278
src/app/main.ts
278
src/app/main.ts
|
|
@ -1,30 +1,67 @@
|
||||||
/**
|
/**
|
||||||
* Lumen — Stage 2/3 application shell entry point.
|
* Lumen — Stage 9 application shell entry point.
|
||||||
* Minimal boot: layout + router + four placeholder views + SW registration (Stage 3).
|
* Boot: layout + router + views immediately (never blocked on network),
|
||||||
* No IDB / dataset / sync per Stage 3 SCOPE — SW is shell-only.
|
* then async local readiness evaluation (C1…C8) drives the header chip and
|
||||||
|
* the Status screen. Evaluation reads Cache Storage + IndexedDB only —
|
||||||
|
* going offline can never break it (I-8).
|
||||||
*/
|
*/
|
||||||
import "./styles.css";
|
import "./styles.css";
|
||||||
import { registerSW } from "../platform/sw/register.js";
|
import { deleteShellCaches } from "../platform/cache/shell-cache.js";
|
||||||
import { createLayout } from "./layout.js";
|
import { registerSW, unregisterAllServiceWorkers } from "../platform/sw/register.js";
|
||||||
|
import { evaluateBootReadiness } from "./readiness.js";
|
||||||
|
import type { BootReadinessReport } from "../domain/readiness/types.js";
|
||||||
|
import { chipForState } from "../domain/readiness/logic.js";
|
||||||
|
import { loadDatasetEmergency } from "./emergency.js";
|
||||||
|
import type { EmergencyViewModel } from "../domain/emergency/types.js";
|
||||||
|
import { demotedBaseline, resolveEmergency } from "../domain/emergency/logic.js";
|
||||||
|
import { loadScheduleData } from "./schedule.js";
|
||||||
|
import type { ScheduleData } from "./schedule.js";
|
||||||
|
import { currentClock, deviceReading } from "./clock.js";
|
||||||
|
import { deviceTimeZone } from "../domain/clock/logic.js";
|
||||||
|
import { shouldWarnOutsideWindow } from "../domain/clock/logic.js";
|
||||||
|
import { openUserDB, addFavorite, removeFavorite, putPrefs } from "../data/user/store.js";
|
||||||
|
import { createScheduleView } from "../ui/views/schedule/schedule.js";
|
||||||
|
import type { ScheduleViewActions, ScheduleViewInput } from "../ui/views/schedule/schedule.js";
|
||||||
|
import { restorePreviousSlot } from "../sync/activation.js";
|
||||||
|
import { createLayout, setActiveNav } from "./layout.js";
|
||||||
import { Router, routeForPath, normalizePath } from "../ui/router/router.js";
|
import { Router, routeForPath, normalizePath } from "../ui/router/router.js";
|
||||||
import { createHomeView } from "../ui/views/home/home.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 { createEmergencyView } from "../ui/views/emergency/emergency.js";
|
||||||
import { createScheduleView } from "../ui/views/schedule/schedule.js";
|
|
||||||
import { createMapView } from "../ui/views/map/map.js";
|
import { createMapView } from "../ui/views/map/map.js";
|
||||||
import { createFestivalView } from "../ui/views/festival/festival.js";
|
import { createFestivalView } from "../ui/views/festival/festival.js";
|
||||||
import { createNotFoundView } from "../ui/views/not-found.js";
|
import { createNotFoundView } from "../ui/views/not-found.js";
|
||||||
|
|
||||||
function renderView(path: string): HTMLElement {
|
export interface RouteData {
|
||||||
|
readonly emergency?: EmergencyViewModel | undefined;
|
||||||
|
readonly schedule?:
|
||||||
|
| {
|
||||||
|
readonly input: ScheduleViewInput | null;
|
||||||
|
readonly actions: ScheduleViewActions;
|
||||||
|
}
|
||||||
|
| undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const noopScheduleActions: ScheduleViewActions = {
|
||||||
|
onToggleFavorite: () => false,
|
||||||
|
// No schedule payload exists on this path, so the zone toggle never renders.
|
||||||
|
onZoneToggle: () => undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
function renderView(path: string, data?: RouteData): HTMLElement {
|
||||||
const id = routeForPath(path);
|
const id = routeForPath(path);
|
||||||
switch (id) {
|
switch (id) {
|
||||||
case "home":
|
case "home":
|
||||||
return createHomeView();
|
return createHomeView();
|
||||||
case "emergency":
|
case "emergency":
|
||||||
return createEmergencyView();
|
return createEmergencyView(data?.emergency);
|
||||||
case "schedule":
|
case "schedule":
|
||||||
return createScheduleView();
|
return createScheduleView(
|
||||||
|
data?.schedule?.input ?? null,
|
||||||
|
data?.schedule?.actions ?? noopScheduleActions,
|
||||||
|
);
|
||||||
case "map":
|
case "map":
|
||||||
return createMapView();
|
return createMapView(null);
|
||||||
case "festival":
|
case "festival":
|
||||||
return createFestivalView();
|
return createFestivalView();
|
||||||
case "not-found":
|
case "not-found":
|
||||||
|
|
@ -36,35 +73,208 @@ function mount(): { router: Router; cleanup: () => void } {
|
||||||
const appHost = document.querySelector<HTMLDivElement>("#app");
|
const appHost = document.querySelector<HTMLDivElement>("#app");
|
||||||
if (!appHost) throw new Error("#app host missing — shell invariant");
|
if (!appHost) throw new Error("#app host missing — shell invariant");
|
||||||
|
|
||||||
const { root, main, brand, statusChip } = createLayout();
|
const { root, main, nav, brand, statusChip } = createLayout();
|
||||||
appHost.replaceChildren(root);
|
appHost.replaceChildren(root);
|
||||||
appHost.removeAttribute("aria-busy");
|
appHost.removeAttribute("aria-busy");
|
||||||
|
|
||||||
// Stage 2/3 status placeholder — real readiness C1…C8 comes in Stage 9 (readiness)
|
// Stage 9 readiness: evaluate local evidence (never network) and paint the
|
||||||
statusChip.textContent = "Shell";
|
// chip; the chip opens the Status screen. Boot render never waits for this.
|
||||||
statusChip.setAttribute("aria-label", "Offline status: shell only");
|
// Stage 10 emergency: the dataset tier resolves alongside readiness; the
|
||||||
|
// route renders the floor instantly and upgrades when Tier 2 lands.
|
||||||
|
// Stage 11 schedule: section + favorites + clock snapshot resolve the same
|
||||||
|
// way; the route renders the not-downloaded card until data lands.
|
||||||
|
let latestReport: BootReadinessReport | null = null;
|
||||||
|
let latestEmergency: EmergencyViewModel | undefined;
|
||||||
|
let latestSchedule: ScheduleData | null = null;
|
||||||
|
let favIds = new Set<string>();
|
||||||
|
let useDeviceZone = false;
|
||||||
|
let statusDialog: HTMLDialogElement | null = null;
|
||||||
|
|
||||||
|
/** Resolve Tier 2 from the active slot — floor covers every failure. */
|
||||||
|
async function resolveDatasetEmergency(): Promise<EmergencyViewModel> {
|
||||||
|
const loaded = await loadDatasetEmergency();
|
||||||
|
if (loaded.status === "ready") return resolveEmergency(loaded.data);
|
||||||
|
if (loaded.status === "unusable") return demotedBaseline(loaded.reason);
|
||||||
|
return resolveEmergency();
|
||||||
|
}
|
||||||
|
|
||||||
|
function paintChip(report: BootReadinessReport): void {
|
||||||
|
const detail = report.activationPending
|
||||||
|
? "staged data awaiting activation"
|
||||||
|
: (report.reason ?? report.failedOp ?? report.missing[0] ?? undefined);
|
||||||
|
const chip = chipForState(report.state, detail);
|
||||||
|
statusChip.textContent = chip.text;
|
||||||
|
statusChip.className = `app-header__status app-header__status--${chip.tone}`;
|
||||||
|
statusChip.setAttribute("aria-label", `${chip.label} Activate for status details.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function showStatus(report: BootReadinessReport): void {
|
||||||
|
if (!statusDialog) {
|
||||||
|
statusDialog = document.createElement("dialog");
|
||||||
|
statusDialog.className = "status-dialog";
|
||||||
|
statusDialog.setAttribute("aria-label", "Offline status details");
|
||||||
|
// Native ESC/cancel bypasses our Close button — still return focus.
|
||||||
|
statusDialog.addEventListener("close", () => {
|
||||||
|
statusChip.focus({ preventScroll: true });
|
||||||
|
});
|
||||||
|
root.append(statusDialog);
|
||||||
|
}
|
||||||
|
const dialog = statusDialog;
|
||||||
|
const close = (): void => {
|
||||||
|
dialog.close();
|
||||||
|
};
|
||||||
|
const back = (): void => {
|
||||||
|
if (latestReport) showStatus(latestReport);
|
||||||
|
};
|
||||||
|
dialog.replaceChildren(
|
||||||
|
createStatusView(report, {
|
||||||
|
onCheckData: () => void refreshReadiness(true),
|
||||||
|
onGetData: () => {
|
||||||
|
dialog.replaceChildren(createPrepGuidanceView(back, close));
|
||||||
|
},
|
||||||
|
onRestore: report.canRestore
|
||||||
|
? () => {
|
||||||
|
void restorePreviousSlot()
|
||||||
|
.catch(() => false)
|
||||||
|
.then(() => refreshReadiness(true));
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
onClose: close,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
if (!dialog.open) dialog.showModal();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshReadiness(openAfter: boolean): Promise<void> {
|
||||||
|
const report = await evaluateBootReadiness();
|
||||||
|
latestReport = report;
|
||||||
|
try {
|
||||||
|
latestEmergency = await resolveDatasetEmergency();
|
||||||
|
} catch {
|
||||||
|
latestEmergency = undefined;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
latestSchedule = await loadScheduleData();
|
||||||
|
favIds = new Set(latestSchedule?.favorites.map((f) => f.eventId) ?? []);
|
||||||
|
useDeviceZone = latestSchedule?.prefs?.useDeviceTimezone ?? false;
|
||||||
|
} catch {
|
||||||
|
latestSchedule = null;
|
||||||
|
}
|
||||||
|
// The shell may have been torn down (tests) — never throw from boot.
|
||||||
|
if (!statusChip.isConnected && !root.isConnected) return;
|
||||||
|
paintChip(report);
|
||||||
|
// 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")) {
|
||||||
|
render();
|
||||||
|
}
|
||||||
|
if (openAfter) showStatus(report);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fresh clock snapshot + zone for the schedule view (per render, no timers). */
|
||||||
|
function buildScheduleInput(): ScheduleViewInput | null {
|
||||||
|
if (!latestSchedule) return null;
|
||||||
|
const { section, window, skew } = latestSchedule;
|
||||||
|
const { now, driftWarn } = currentClock(deviceReading(), skew);
|
||||||
|
return {
|
||||||
|
section,
|
||||||
|
favorites: favIds,
|
||||||
|
clock: {
|
||||||
|
now,
|
||||||
|
zone: useDeviceZone ? deviceTimeZone() : window.timezone,
|
||||||
|
festivalZone: window.timezone,
|
||||||
|
useDeviceZone,
|
||||||
|
outsideWarn: shouldWarnOutsideWindow(
|
||||||
|
now,
|
||||||
|
{ startUtc: window.startUtc, endUtc: window.endUtc, timezone: window.timezone },
|
||||||
|
skew,
|
||||||
|
),
|
||||||
|
driftWarn,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Write-through favorite toggle — resolves false when storage refuses. */
|
||||||
|
async function persistFavorite(eventId: string, favored: boolean): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const db = await openUserDB();
|
||||||
|
try {
|
||||||
|
if (favored) {
|
||||||
|
favIds.add(eventId);
|
||||||
|
await addFavorite(db, { eventId, addedAt: Date.now() });
|
||||||
|
} else {
|
||||||
|
favIds.delete(eventId);
|
||||||
|
await removeFavorite(db, eventId);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
db.close();
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
if (favored) favIds.delete(eventId);
|
||||||
|
else if (latestSchedule) {
|
||||||
|
for (const fav of latestSchedule.favorites) {
|
||||||
|
if (fav.eventId === eventId) favIds.add(eventId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function persistZoneMode(useDevice: boolean): Promise<void> {
|
||||||
|
useDeviceZone = useDevice;
|
||||||
|
try {
|
||||||
|
const db = await openUserDB();
|
||||||
|
try {
|
||||||
|
const prefs = latestSchedule?.prefs;
|
||||||
|
await putPrefs(db, {
|
||||||
|
festivalTimezone: prefs?.festivalTimezone ?? latestSchedule?.window.timezone ?? "UTC",
|
||||||
|
useDeviceTimezone: useDevice,
|
||||||
|
...(prefs?.theme !== undefined ? { theme: prefs.theme } : {}),
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
db.close();
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Zone preference is enhancement — the toggle still applies this session.
|
||||||
|
}
|
||||||
|
if (router.getRouteId() === "schedule") render();
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleActions(): ScheduleViewActions {
|
||||||
|
return {
|
||||||
|
onToggleFavorite: (eventId, favored) => persistFavorite(eventId, favored),
|
||||||
|
onZoneToggle: (useDevice) => void persistZoneMode(useDevice),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
statusChip.textContent = "…";
|
||||||
|
statusChip.setAttribute("tabindex", "0");
|
||||||
|
statusChip.setAttribute("title", "View offline status details");
|
||||||
|
// Always re-evaluate on open: the boot-time report may be stale (eviction,
|
||||||
|
// updates, or failures since boot must show, never a cached verdict).
|
||||||
|
const openFromChip = (): void => {
|
||||||
|
void refreshReadiness(true);
|
||||||
|
};
|
||||||
|
statusChip.addEventListener("click", openFromChip);
|
||||||
|
statusChip.addEventListener("keydown", (event) => {
|
||||||
|
if (event.key === "Enter" || event.key === " ") {
|
||||||
|
event.preventDefault();
|
||||||
|
openFromChip();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
if (import.meta.env.DEV) {
|
if (import.meta.env.DEV) {
|
||||||
// Dev mode: the SW's cache-first strategy would otherwise freeze dev
|
// Dev mode: the SW's cache-first strategy would otherwise freeze dev
|
||||||
// sources (unhashed /src/* URLs) at their first-fetched version — stale
|
// sources (unhashed /src/* URLs) at their first-fetched version — stale
|
||||||
// shells/styles keep "reappearing" after edits. Never register in dev,
|
// shells/styles keep "reappearing" after edits. Never register in dev,
|
||||||
// and scrub any shell cache + SW left by an earlier dev session.
|
// and scrub any shell cache + SW left by an earlier dev session.
|
||||||
if (typeof navigator !== "undefined" && "serviceWorker" in navigator) {
|
// Raw browser globals stay behind the platform seam (B-1).
|
||||||
void navigator.serviceWorker
|
void unregisterAllServiceWorkers().then((shouldReload) => {
|
||||||
.getRegistrations()
|
if (shouldReload) location.reload();
|
||||||
.then(async (regs) => {
|
});
|
||||||
const hadController = Boolean(navigator.serviceWorker.controller);
|
void deleteShellCaches();
|
||||||
const unregistered = await Promise.all(regs.map((r) => r.unregister()));
|
|
||||||
if (hadController && unregistered.some(Boolean)) location.reload();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (typeof caches !== "undefined") {
|
|
||||||
void caches
|
|
||||||
.keys()
|
|
||||||
.then((keys) =>
|
|
||||||
Promise.all(keys.filter((k) => k.startsWith("lumen-shell-")).map((k) => caches.delete(k))),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
// Stage 3: register SW after boot — never blocks rendering (offline-safe)
|
// Stage 3: register SW after boot — never blocks rendering (offline-safe)
|
||||||
void registerSW().catch(() => {
|
void registerSW().catch(() => {
|
||||||
|
|
@ -78,13 +288,17 @@ function mount(): { router: Router; cleanup: () => void } {
|
||||||
function render(): void {
|
function render(): void {
|
||||||
const path = router.getPath();
|
const path = router.getPath();
|
||||||
const id = router.getRouteId();
|
const id = router.getRouteId();
|
||||||
|
setActiveNav(nav, id);
|
||||||
// Brand is the home/back button: highlighted at home, chevron on destination pages.
|
// Brand is the home/back button: highlighted at home, chevron on destination pages.
|
||||||
const isHome = id === "home";
|
const isHome = id === "home";
|
||||||
brand.classList.toggle("app-header__brand--back", !isHome);
|
brand.classList.toggle("app-header__brand--back", !isHome);
|
||||||
brand.classList.toggle("app-header__brand--active", isHome);
|
brand.classList.toggle("app-header__brand--active", isHome);
|
||||||
if (isHome) brand.removeAttribute("aria-current");
|
if (isHome) brand.removeAttribute("aria-current");
|
||||||
else brand.setAttribute("aria-current", "page");
|
else brand.setAttribute("aria-current", "page");
|
||||||
const view = renderView(path);
|
const view = renderView(path, {
|
||||||
|
emergency: latestEmergency,
|
||||||
|
schedule: { input: buildScheduleInput(), actions: scheduleActions() },
|
||||||
|
});
|
||||||
main.replaceChildren(view);
|
main.replaceChildren(view);
|
||||||
const pageIcon = document.createElement("img");
|
const pageIcon = document.createElement("img");
|
||||||
pageIcon.src = "/sol_lunar_icon.svg?v=5";
|
pageIcon.src = "/sol_lunar_icon.svg?v=5";
|
||||||
|
|
@ -118,6 +332,8 @@ function mount(): { router: Router; cleanup: () => void } {
|
||||||
const detachPopstate = router.attach();
|
const detachPopstate = router.attach();
|
||||||
|
|
||||||
render();
|
render();
|
||||||
|
// Boot evaluation runs after first render — the shell never waits for it.
|
||||||
|
void refreshReadiness(false);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
router,
|
router,
|
||||||
|
|
|
||||||
135
src/app/map.ts
Normal file
135
src/app/map.ts
Normal file
|
|
@ -0,0 +1,135 @@
|
||||||
|
/**
|
||||||
|
* Stage 12 app-layer loader — map section plus Blob→object-URL levels.
|
||||||
|
* Local IDB only, never network. Never throws: null means "no map on this
|
||||||
|
* device" and partial failures degrade to overview-only or list-only.
|
||||||
|
* URL lifecycle (SPIKE-03 F-3): the loader creates per-level URLs; the
|
||||||
|
* caller owns revoke() and calls it when the handle is superseded.
|
||||||
|
* Trace: IMPLEMENTATION-CONTRACT.md §22.
|
||||||
|
*/
|
||||||
|
import { openSystemDB, readSystemMeta } from "../data/system-meta/store.js";
|
||||||
|
import { openSlotDB, readSlotAsset, readSlotFile } from "../data/slot/store.js";
|
||||||
|
import { narrowMapSection, selectLevels } from "../domain/map/logic.js";
|
||||||
|
import type { MapLevelView, MapViewData } from "../domain/map/types.js";
|
||||||
|
|
||||||
|
export interface MapUrlFactory {
|
||||||
|
readonly createUrl: (blob: Blob) => string;
|
||||||
|
readonly revokeUrl: (url: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultUrls: MapUrlFactory = {
|
||||||
|
createUrl: (blob) => URL.createObjectURL(blob),
|
||||||
|
revokeUrl: (url) => URL.revokeObjectURL(url),
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface MapHandle {
|
||||||
|
readonly data: MapViewData | null;
|
||||||
|
readonly revoke: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadMapData(urls: MapUrlFactory = defaultUrls): Promise<MapHandle> {
|
||||||
|
const created: string[] = [];
|
||||||
|
const revoke = (): void => {
|
||||||
|
for (const url of created) {
|
||||||
|
try {
|
||||||
|
urls.revokeUrl(url);
|
||||||
|
} catch {
|
||||||
|
// Revocation is best-effort cleanup — never fail the app for it.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
created.length = 0;
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
const data = await load(urls, created);
|
||||||
|
return { data, revoke };
|
||||||
|
} catch {
|
||||||
|
revoke();
|
||||||
|
return { data: null, revoke: () => {} };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function levelView(
|
||||||
|
urls: MapUrlFactory,
|
||||||
|
created: string[],
|
||||||
|
open: (assetId: string) => Promise<Blob | null>,
|
||||||
|
assetId: string,
|
||||||
|
nightAssetId: string | undefined,
|
||||||
|
width: number,
|
||||||
|
height: number,
|
||||||
|
id: string,
|
||||||
|
): Promise<MapLevelView | null> {
|
||||||
|
const blob = await open(assetId);
|
||||||
|
if (!blob) return null;
|
||||||
|
const url = urls.createUrl(blob);
|
||||||
|
created.push(url);
|
||||||
|
let nightUrl: string | null = null;
|
||||||
|
if (nightAssetId) {
|
||||||
|
const night = await open(nightAssetId);
|
||||||
|
if (night) {
|
||||||
|
nightUrl = urls.createUrl(night);
|
||||||
|
created.push(nightUrl);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { id, url, nightUrl, width, height };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function load(urls: MapUrlFactory, created: string[]): Promise<MapViewData | null> {
|
||||||
|
const system = await openSystemDB();
|
||||||
|
let activeSlot;
|
||||||
|
try {
|
||||||
|
activeSlot = (await readSystemMeta(system)).activeSlot;
|
||||||
|
} finally {
|
||||||
|
system.close();
|
||||||
|
}
|
||||||
|
if (!activeSlot) return null;
|
||||||
|
|
||||||
|
const slot = await openSlotDB(activeSlot);
|
||||||
|
try {
|
||||||
|
const section = narrowMapSection(await readSlotFile(slot, "map"));
|
||||||
|
if (!section) return null;
|
||||||
|
const selection = selectLevels(section);
|
||||||
|
const open = async (assetId: string): Promise<Blob | null> => {
|
||||||
|
try {
|
||||||
|
return (await readSlotAsset(slot, assetId))?.blob ?? null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let overview: MapLevelView | null = null;
|
||||||
|
if (selection.overview) {
|
||||||
|
overview = await levelView(
|
||||||
|
urls,
|
||||||
|
created,
|
||||||
|
open,
|
||||||
|
selection.overview.assetId,
|
||||||
|
selection.overview.nightAssetId,
|
||||||
|
selection.overview.width,
|
||||||
|
selection.overview.height,
|
||||||
|
selection.overview.id,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let detail: MapLevelView | null = null;
|
||||||
|
if (overview && selection.detail) {
|
||||||
|
detail = await levelView(
|
||||||
|
urls,
|
||||||
|
created,
|
||||||
|
open,
|
||||||
|
selection.detail.assetId,
|
||||||
|
selection.detail.nightAssetId,
|
||||||
|
selection.detail.width,
|
||||||
|
selection.detail.height,
|
||||||
|
selection.detail.id,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
mapAvailable: overview !== null,
|
||||||
|
overview,
|
||||||
|
detail,
|
||||||
|
pois: section.pois,
|
||||||
|
categories: section.categories,
|
||||||
|
overviewOnly: overview !== null && detail === null,
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
slot.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
593
src/app/readiness.ts
Normal file
593
src/app/readiness.ts
Normal file
|
|
@ -0,0 +1,593 @@
|
||||||
|
/**
|
||||||
|
* Stage 9 boot evaluator — gathers C1–C8 evidence from local stores only and
|
||||||
|
* maps it through the pure predicate.
|
||||||
|
*
|
||||||
|
* Rules honored here, not just in docs:
|
||||||
|
* - No network, no fetch, no clocks for verdicts (time-independent READY).
|
||||||
|
* - `now` is used ONLY for staging-age expiry and display timestamps.
|
||||||
|
* - Never throws: every failure mode maps to a report state (no-blank-screen).
|
||||||
|
* - Domain logic stays pure; all I/O lives in this app-layer composer (B-2).
|
||||||
|
*
|
||||||
|
* Trace: SPIKE-05 §2–§5, IMPLEMENTATION-CONTRACT.md §20.1–§20.5.
|
||||||
|
*/
|
||||||
|
import { checkShellCache } from "../platform/cache/shell-cache.js";
|
||||||
|
import { getStorageEstimate } from "../platform/idb/storage-helpers.js";
|
||||||
|
import type { StorageEstimate } from "../platform/idb/storage-helpers.js";
|
||||||
|
import type { SlotId } from "../platform/idb/names.js";
|
||||||
|
import { openSystemDB, readSystemMeta } from "../data/system-meta/store.js";
|
||||||
|
import {
|
||||||
|
openSlotDB,
|
||||||
|
readSlotAsset,
|
||||||
|
readSlotFile,
|
||||||
|
readSlotFileMeta,
|
||||||
|
readSlotManifest,
|
||||||
|
readStagingProgress,
|
||||||
|
} from "../data/slot/store.js";
|
||||||
|
import type { FestivalManifest, SectionEntry } from "../data/festival-package/types.js";
|
||||||
|
import { PACKAGE_FORMAT } from "../data/festival-package/types.js";
|
||||||
|
import { isCompatible } from "../data/festival-package/validation.js";
|
||||||
|
import { loadEmergencyFloor } from "../emergency-baseline/loader.js";
|
||||||
|
import { STAGING_MAX_AGE_MS, recoverPendingActivation } from "../sync/activation.js";
|
||||||
|
import { APP_VERSION, SUPPORTED_SCHEMA_RANGE } from "../domain/readiness/config.js";
|
||||||
|
import { CANONICAL_SECTIONS } from "../domain/readiness/types.js";
|
||||||
|
import { evaluateReadiness } from "../domain/readiness/logic.js";
|
||||||
|
import type {
|
||||||
|
BootReadinessReport,
|
||||||
|
ReadinessEvidence,
|
||||||
|
SectionStatus,
|
||||||
|
} from "../domain/readiness/types.js";
|
||||||
|
|
||||||
|
export interface ShellEvidence {
|
||||||
|
readonly ok: boolean;
|
||||||
|
readonly cacheName: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FloorEvidence {
|
||||||
|
readonly ok: boolean;
|
||||||
|
readonly version: string | null;
|
||||||
|
readonly bytes: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BootReadinessDeps {
|
||||||
|
readonly openSystem: () => Promise<IDBDatabase>;
|
||||||
|
readonly openSlot: (slot: SlotId) => Promise<IDBDatabase>;
|
||||||
|
readonly checkShell: () => Promise<ShellEvidence>;
|
||||||
|
readonly loadFloor: () => FloorEvidence;
|
||||||
|
readonly estimate: () => Promise<StorageEstimate | null>;
|
||||||
|
readonly appVersion: string;
|
||||||
|
readonly supportedSchemaRange: readonly number[];
|
||||||
|
/** ONLY for staging-age expiry + display — never for verdicts. */
|
||||||
|
readonly now: () => number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const defaultBootDeps: BootReadinessDeps = {
|
||||||
|
openSystem: openSystemDB,
|
||||||
|
openSlot: openSlotDB,
|
||||||
|
checkShell: checkShellCache,
|
||||||
|
loadFloor: () => {
|
||||||
|
const loaded = loadEmergencyFloor();
|
||||||
|
return loaded.ok
|
||||||
|
? { ok: true, version: loaded.floor.floorVersion, bytes: loaded.bytes }
|
||||||
|
: { ok: false, version: null, bytes: 0 };
|
||||||
|
},
|
||||||
|
estimate: getStorageEstimate,
|
||||||
|
appVersion: APP_VERSION,
|
||||||
|
supportedSchemaRange: SUPPORTED_SCHEMA_RANGE,
|
||||||
|
now: () => Date.now(),
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Re-exported for app-layer consumers — canonical home is domain/readiness. */
|
||||||
|
export type { BootReadinessReport, SectionStatus } from "../domain/readiness/types.js";
|
||||||
|
|
||||||
|
function failedReport(
|
||||||
|
op: string,
|
||||||
|
floor: FloorEvidence,
|
||||||
|
storage: StorageEstimate | null,
|
||||||
|
): BootReadinessReport {
|
||||||
|
return {
|
||||||
|
...evaluateReadiness({
|
||||||
|
shellValid: false,
|
||||||
|
datasetPresent: false,
|
||||||
|
authenticity: "record-missing",
|
||||||
|
integrity: "files-missing",
|
||||||
|
schemaCompatible: false,
|
||||||
|
requiredSectionsOk: false,
|
||||||
|
requiredAssetsOk: false,
|
||||||
|
emergencyFloorOk: floor.ok,
|
||||||
|
missing: [],
|
||||||
|
hadVerifiedDataset: false,
|
||||||
|
manifestOk: false,
|
||||||
|
incompatible: false,
|
||||||
|
failure: { op },
|
||||||
|
presentSections: [],
|
||||||
|
stagedPending: null,
|
||||||
|
}),
|
||||||
|
edition: null,
|
||||||
|
packageVersion: null,
|
||||||
|
generatedAt: null,
|
||||||
|
fetchedAt: null,
|
||||||
|
sections: [],
|
||||||
|
assets: { total: 0, present: 0, missing: [] },
|
||||||
|
floorVersion: floor.version,
|
||||||
|
storage,
|
||||||
|
shellCache: null,
|
||||||
|
canRestore: false,
|
||||||
|
activationPending: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function baselineOnlyReport(
|
||||||
|
floor: FloorEvidence,
|
||||||
|
storage: StorageEstimate | null,
|
||||||
|
): BootReadinessReport {
|
||||||
|
return {
|
||||||
|
state: "BASELINE_ONLY",
|
||||||
|
reason: null,
|
||||||
|
missing: [],
|
||||||
|
failedOp: null,
|
||||||
|
level: "L0",
|
||||||
|
edition: null,
|
||||||
|
packageVersion: null,
|
||||||
|
generatedAt: null,
|
||||||
|
fetchedAt: null,
|
||||||
|
sections: [],
|
||||||
|
assets: { total: 0, present: 0, missing: [] },
|
||||||
|
floorVersion: floor.version,
|
||||||
|
storage,
|
||||||
|
shellCache: null,
|
||||||
|
canRestore: false,
|
||||||
|
activationPending: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Forward-tolerant asset-reference narrowing for the C7 check: the inventory
|
||||||
|
* was schema-verified at staging; boot only needs id+bytes without trusting
|
||||||
|
* its shape. Unknown entries are ignored, never crash (I-2/Ring-0 ethos).
|
||||||
|
*/
|
||||||
|
function narrowAssetRefs(value: unknown): readonly { id: string; bytes: number }[] {
|
||||||
|
if (typeof value !== "object" || value === null) return [];
|
||||||
|
const listed: unknown = (value as { assets?: unknown }).assets;
|
||||||
|
if (!Array.isArray(listed)) return [];
|
||||||
|
return listed
|
||||||
|
.map((item: unknown) => {
|
||||||
|
const entry = item as { id?: unknown; bytes?: unknown };
|
||||||
|
return typeof entry.id === "string" && typeof entry.bytes === "number"
|
||||||
|
? { id: entry.id, bytes: entry.bytes }
|
||||||
|
: null;
|
||||||
|
})
|
||||||
|
.filter((entry): entry is { id: string; bytes: number } => entry !== null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Structural manifest check: the staged manifest must be the one the journal
|
||||||
|
* records (same edition + package version, V1 format). Parameter is
|
||||||
|
* explicitly nullable so the optional chain below is type-honest.
|
||||||
|
*/
|
||||||
|
function isUsableManifest(
|
||||||
|
manifest: FestivalManifest | undefined,
|
||||||
|
journal: { edition: string; packageVersion: number },
|
||||||
|
): manifest is FestivalManifest {
|
||||||
|
return (
|
||||||
|
manifest?.format === PACKAGE_FORMAT &&
|
||||||
|
manifest.edition === journal.edition &&
|
||||||
|
manifest.packageVersion === journal.packageVersion
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function otherSlotComplete(
|
||||||
|
openSlot: BootReadinessDeps["openSlot"],
|
||||||
|
slot: SlotId,
|
||||||
|
): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const db = await openSlot(slot);
|
||||||
|
try {
|
||||||
|
const journal = await readStagingProgress(db);
|
||||||
|
return Boolean(
|
||||||
|
journal?.complete &&
|
||||||
|
CANONICAL_SECTIONS.every((id) => journal.stagedFiles.includes(id as never)),
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
db.close();
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function findRecentStaging(
|
||||||
|
openSlot: BootReadinessDeps["openSlot"],
|
||||||
|
now: number,
|
||||||
|
): Promise<{ slot: SlotId; staged: readonly string[]; complete: boolean } | null> {
|
||||||
|
for (const slot of ["A", "B"] as const) {
|
||||||
|
try {
|
||||||
|
const db = await openSlot(slot);
|
||||||
|
try {
|
||||||
|
const journal = await readStagingProgress(db);
|
||||||
|
if (journal && now - journal.lastProgressAt <= STAGING_MAX_AGE_MS) {
|
||||||
|
return { slot, staged: journal.stagedFiles, complete: journal.complete };
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
db.close();
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// unreadable slot — not resumable, try the next
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function evaluateBootReadiness(
|
||||||
|
deps: BootReadinessDeps = defaultBootDeps,
|
||||||
|
): Promise<BootReadinessReport> {
|
||||||
|
const floor = deps.loadFloor();
|
||||||
|
let storage: StorageEstimate | null = null;
|
||||||
|
try {
|
||||||
|
storage = await deps.estimate();
|
||||||
|
} catch {
|
||||||
|
storage = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await evaluate(deps, floor, storage);
|
||||||
|
} catch {
|
||||||
|
return failedReport("boot-check", floor, storage);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function evaluate(
|
||||||
|
deps: BootReadinessDeps,
|
||||||
|
floor: FloorEvidence,
|
||||||
|
storage: StorageEstimate | null,
|
||||||
|
): Promise<BootReadinessReport> {
|
||||||
|
const shell = await deps.checkShell().catch(() => ({ ok: false, cacheName: null }));
|
||||||
|
|
||||||
|
// Storage probe: an unopenable system DB means nothing can persist.
|
||||||
|
let system: IDBDatabase;
|
||||||
|
try {
|
||||||
|
system = await deps.openSystem();
|
||||||
|
} catch {
|
||||||
|
return baselineOnlyReport(floor, storage);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
let meta = await readSystemMeta(system);
|
||||||
|
|
||||||
|
if (meta.readbackPending) {
|
||||||
|
try {
|
||||||
|
await recoverPendingActivation();
|
||||||
|
const reopened = await deps.openSystem();
|
||||||
|
system.close();
|
||||||
|
system = reopened;
|
||||||
|
meta = await readSystemMeta(system);
|
||||||
|
} catch {
|
||||||
|
return { ...failedReport("boot-check", floor, storage), shellCache: shell.cacheName };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!meta.activeSlot) {
|
||||||
|
return await evaluateNoActivePointer(deps, floor, storage, shell, meta.verification !== null);
|
||||||
|
}
|
||||||
|
|
||||||
|
const activeSlot = meta.activeSlot;
|
||||||
|
let slot: IDBDatabase;
|
||||||
|
try {
|
||||||
|
slot = await deps.openSlot(activeSlot);
|
||||||
|
} catch {
|
||||||
|
return { ...failedReport("boot-check", floor, storage), shellCache: shell.cacheName };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await evaluateActiveSlot(deps, floor, storage, shell, meta, slot, activeSlot);
|
||||||
|
} finally {
|
||||||
|
slot.close();
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
system.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function evaluateNoActivePointer(
|
||||||
|
deps: BootReadinessDeps,
|
||||||
|
floor: FloorEvidence,
|
||||||
|
storage: StorageEstimate | null,
|
||||||
|
shell: ShellEvidence,
|
||||||
|
hadVerifiedDataset: boolean,
|
||||||
|
): Promise<BootReadinessReport> {
|
||||||
|
if (hadVerifiedDataset) {
|
||||||
|
// A verification record outlived its slots — previously verified, now gone.
|
||||||
|
const result = evaluateReadiness(
|
||||||
|
baseEvidence(floor, shell, {
|
||||||
|
datasetPresent: false,
|
||||||
|
hadVerifiedDataset: true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
...result,
|
||||||
|
edition: null,
|
||||||
|
packageVersion: null,
|
||||||
|
generatedAt: null,
|
||||||
|
fetchedAt: null,
|
||||||
|
sections: [],
|
||||||
|
assets: { total: 0, present: 0, missing: [] },
|
||||||
|
floorVersion: floor.version,
|
||||||
|
storage,
|
||||||
|
shellCache: shell.cacheName,
|
||||||
|
canRestore: false,
|
||||||
|
activationPending: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const staging = await findRecentStaging(deps.openSlot, deps.now());
|
||||||
|
if (staging) {
|
||||||
|
const missing = CANONICAL_SECTIONS.filter((id) => !staging.staged.includes(id));
|
||||||
|
// Fully staged but never activated (kill between staging and flip):
|
||||||
|
// still PARTIAL with empty missing; the activationPending flag tells the
|
||||||
|
// Status screen. Activation itself belongs to Stage 15 sync.
|
||||||
|
const result = evaluateReadiness(
|
||||||
|
baseEvidence(floor, shell, {
|
||||||
|
datasetPresent: false,
|
||||||
|
hadVerifiedDataset: false,
|
||||||
|
requiredSectionsOk: staging.complete && missing.length === 0,
|
||||||
|
requiredAssetsOk: staging.complete && missing.length === 0,
|
||||||
|
missing,
|
||||||
|
presentSections: [...staging.staged],
|
||||||
|
stagedPending: { missing },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
...result,
|
||||||
|
edition: null,
|
||||||
|
packageVersion: null,
|
||||||
|
generatedAt: null,
|
||||||
|
fetchedAt: null,
|
||||||
|
sections: [],
|
||||||
|
assets: { total: 0, present: 0, missing: [] },
|
||||||
|
floorVersion: floor.version,
|
||||||
|
storage,
|
||||||
|
shellCache: shell.cacheName,
|
||||||
|
canRestore: false,
|
||||||
|
activationPending: staging.complete && missing.length === 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = evaluateReadiness(
|
||||||
|
baseEvidence(floor, shell, {
|
||||||
|
datasetPresent: false,
|
||||||
|
hadVerifiedDataset: false,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
...result,
|
||||||
|
edition: null,
|
||||||
|
packageVersion: null,
|
||||||
|
generatedAt: null,
|
||||||
|
fetchedAt: null,
|
||||||
|
sections: [],
|
||||||
|
assets: { total: 0, present: 0, missing: [] },
|
||||||
|
floorVersion: floor.version,
|
||||||
|
storage,
|
||||||
|
shellCache: shell.cacheName,
|
||||||
|
canRestore: false,
|
||||||
|
activationPending: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function baseEvidence(
|
||||||
|
floor: FloorEvidence,
|
||||||
|
shell: ShellEvidence,
|
||||||
|
patch: Partial<ReadinessEvidence>,
|
||||||
|
): ReadinessEvidence {
|
||||||
|
return {
|
||||||
|
shellValid: shell.ok,
|
||||||
|
datasetPresent: false,
|
||||||
|
authenticity: "record-missing",
|
||||||
|
integrity: "files-missing",
|
||||||
|
schemaCompatible: false,
|
||||||
|
requiredSectionsOk: false,
|
||||||
|
requiredAssetsOk: false,
|
||||||
|
emergencyFloorOk: floor.ok,
|
||||||
|
missing: [],
|
||||||
|
hadVerifiedDataset: false,
|
||||||
|
manifestOk: true,
|
||||||
|
incompatible: false,
|
||||||
|
failure: null,
|
||||||
|
presentSections: [],
|
||||||
|
stagedPending: null,
|
||||||
|
...patch,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function evaluateActiveSlot(
|
||||||
|
deps: BootReadinessDeps,
|
||||||
|
floor: FloorEvidence,
|
||||||
|
storage: StorageEstimate | null,
|
||||||
|
shell: ShellEvidence,
|
||||||
|
meta: Awaited<ReturnType<typeof readSystemMeta>>,
|
||||||
|
slot: IDBDatabase,
|
||||||
|
activeSlot: SlotId,
|
||||||
|
): Promise<BootReadinessReport> {
|
||||||
|
const fail = (op: string): BootReadinessReport => ({
|
||||||
|
...failedReport(op, floor, storage),
|
||||||
|
shellCache: shell.cacheName,
|
||||||
|
});
|
||||||
|
|
||||||
|
let journal: Awaited<ReturnType<typeof readStagingProgress>>;
|
||||||
|
let manifest: FestivalManifest | undefined;
|
||||||
|
try {
|
||||||
|
journal = await readStagingProgress(slot);
|
||||||
|
manifest = await readSlotManifest(slot);
|
||||||
|
} catch {
|
||||||
|
return fail("boot-check");
|
||||||
|
}
|
||||||
|
|
||||||
|
const verification = meta.verification;
|
||||||
|
const missing: string[] = [];
|
||||||
|
const sections: SectionStatus[] = [];
|
||||||
|
const presentSections: string[] = [];
|
||||||
|
|
||||||
|
// Journal probe first: without staging evidence the pointer cannot be
|
||||||
|
// corroborated. After this block `journal` is defined (direct access below).
|
||||||
|
if (!journal) {
|
||||||
|
let anyFilePresent = false;
|
||||||
|
for (const id of CANONICAL_SECTIONS) {
|
||||||
|
try {
|
||||||
|
if (await readSlotFileMeta(slot, id as never)) {
|
||||||
|
anyFilePresent = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return fail("boot-check");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Pointer claims a dataset but the slot carries no staging evidence:
|
||||||
|
// files gone → loss; files present without a journal → inconsistency.
|
||||||
|
const result = evaluateReadiness(
|
||||||
|
baseEvidence(floor, shell, {
|
||||||
|
datasetPresent: true,
|
||||||
|
authenticity: "mismatch",
|
||||||
|
integrity: anyFilePresent ? "corrupt" : "files-missing",
|
||||||
|
hadVerifiedDataset: true,
|
||||||
|
presentSections: [],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
...result,
|
||||||
|
edition: meta.activeEdition,
|
||||||
|
packageVersion: meta.activePackageVersion,
|
||||||
|
generatedAt: null,
|
||||||
|
fetchedAt: null,
|
||||||
|
sections: [],
|
||||||
|
assets: { total: 0, present: 0, missing: [] },
|
||||||
|
floorVersion: floor.version,
|
||||||
|
storage,
|
||||||
|
shellCache: shell.cacheName,
|
||||||
|
canRestore: await otherSlotComplete(deps.openSlot, activeSlot === "A" ? "B" : "A"),
|
||||||
|
activationPending: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// C3 authenticity: recorded verification must match the staged journal.
|
||||||
|
// No crypto at boot — hashes were verified at staging and recorded (F-2).
|
||||||
|
let authenticity: ReadinessEvidence["authenticity"] = "verified";
|
||||||
|
if (!verification) {
|
||||||
|
authenticity = "record-missing";
|
||||||
|
} else if (
|
||||||
|
journal.edition !== meta.activeEdition ||
|
||||||
|
journal.packageVersion !== meta.activePackageVersion ||
|
||||||
|
journal.manifestSha256 !== verification.manifestSha256
|
||||||
|
) {
|
||||||
|
authenticity = "mismatch";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Manifest structural check — the evidence C4/C5/C6 read from.
|
||||||
|
const manifestOk = isUsableManifest(manifest, journal);
|
||||||
|
|
||||||
|
// C4 sizes + C6 presence/parseability over required sections.
|
||||||
|
let integrity: ReadinessEvidence["integrity"] = "ok";
|
||||||
|
let requiredSectionsOk = true;
|
||||||
|
if (manifestOk && manifest) {
|
||||||
|
// Defensive view: staged manifests passed pipeline gates, but boot must
|
||||||
|
// tolerate corrupt or future shapes the static type cannot see.
|
||||||
|
const entries = manifest.sections as Partial<Record<string, SectionEntry>>;
|
||||||
|
for (const id of CANONICAL_SECTIONS) {
|
||||||
|
const entry = entries[id];
|
||||||
|
const required = entry?.required !== false;
|
||||||
|
let present = false;
|
||||||
|
let parseable = false;
|
||||||
|
try {
|
||||||
|
const fileMeta = await readSlotFileMeta(slot, id as never);
|
||||||
|
present = Boolean(fileMeta && (!entry || fileMeta.bytes === entry.bytes));
|
||||||
|
if (fileMeta && entry && fileMeta.bytes !== entry.bytes) integrity = "corrupt";
|
||||||
|
if (!fileMeta) {
|
||||||
|
if (required) {
|
||||||
|
integrity = integrity === "ok" ? "files-missing" : integrity;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const json = await readSlotFile(slot, id as never);
|
||||||
|
parseable = typeof json === "object" && json !== null;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return fail("boot-check");
|
||||||
|
}
|
||||||
|
if (present) presentSections.push(id);
|
||||||
|
sections.push({ id, required, present, parseable });
|
||||||
|
if (required && (!present || !parseable)) {
|
||||||
|
requiredSectionsOk = false;
|
||||||
|
if (!missing.includes(id)) missing.push(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
requiredSectionsOk = false;
|
||||||
|
if (!missing.includes("manifest")) missing.push("manifest");
|
||||||
|
}
|
||||||
|
|
||||||
|
// C7: assets referenced by the inventory, present with matching sizes.
|
||||||
|
let requiredAssetsOk = true;
|
||||||
|
const assetMissing: string[] = [];
|
||||||
|
let assetTotal = 0;
|
||||||
|
let assetPresent = 0;
|
||||||
|
if (manifestOk) {
|
||||||
|
try {
|
||||||
|
const inventory = await readSlotFile(slot, "assets");
|
||||||
|
const assets = narrowAssetRefs(inventory);
|
||||||
|
assetTotal = assets.length;
|
||||||
|
for (const asset of assets) {
|
||||||
|
const record = await readSlotAsset(slot, asset.id);
|
||||||
|
if (record?.bytes === asset.bytes) {
|
||||||
|
assetPresent += 1;
|
||||||
|
} else {
|
||||||
|
requiredAssetsOk = false;
|
||||||
|
assetMissing.push(`asset:${asset.id}`);
|
||||||
|
if (record && record.bytes !== asset.bytes) integrity = "corrupt";
|
||||||
|
else if (integrity === "ok") integrity = "files-missing";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return fail("boot-check");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
missing.push(...assetMissing);
|
||||||
|
|
||||||
|
// C5 compatibility — dual check (§18.5). Decided from the staged manifest.
|
||||||
|
let schemaCompatible = false;
|
||||||
|
let incompatible = false;
|
||||||
|
if (manifestOk && manifest) {
|
||||||
|
schemaCompatible = isCompatible(manifest, deps.supportedSchemaRange, deps.appVersion);
|
||||||
|
incompatible = !schemaCompatible;
|
||||||
|
}
|
||||||
|
|
||||||
|
const evidence: ReadinessEvidence = {
|
||||||
|
shellValid: shell.ok,
|
||||||
|
datasetPresent: true,
|
||||||
|
authenticity,
|
||||||
|
integrity,
|
||||||
|
schemaCompatible,
|
||||||
|
requiredSectionsOk,
|
||||||
|
requiredAssetsOk,
|
||||||
|
emergencyFloorOk: floor.ok,
|
||||||
|
missing,
|
||||||
|
hadVerifiedDataset: true,
|
||||||
|
manifestOk,
|
||||||
|
incompatible,
|
||||||
|
failure: null,
|
||||||
|
presentSections,
|
||||||
|
stagedPending: null,
|
||||||
|
};
|
||||||
|
const result = evaluateReadiness(evidence);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...result,
|
||||||
|
edition: meta.activeEdition,
|
||||||
|
packageVersion: meta.activePackageVersion,
|
||||||
|
generatedAt: manifestOk && manifest ? manifest.generatedAt : null,
|
||||||
|
fetchedAt: journal.verifiedAt ?? journal.lastProgressAt,
|
||||||
|
sections,
|
||||||
|
assets: { total: assetTotal, present: assetPresent, missing: assetMissing },
|
||||||
|
floorVersion: floor.version,
|
||||||
|
storage,
|
||||||
|
shellCache: shell.cacheName,
|
||||||
|
canRestore: await otherSlotComplete(deps.openSlot, activeSlot === "A" ? "B" : "A"),
|
||||||
|
activationPending: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
89
src/app/schedule.ts
Normal file
89
src/app/schedule.ts
Normal file
|
|
@ -0,0 +1,89 @@
|
||||||
|
/**
|
||||||
|
* Stage 11 app-layer loader — schedule section, manifest window, favorites,
|
||||||
|
* prefs, and persisted clock skew for the schedule view. Local reads only,
|
||||||
|
* never network. Never throws: null means "no schedule on this device".
|
||||||
|
* Trace: IMPLEMENTATION-CONTRACT.md §21, §24.
|
||||||
|
*/
|
||||||
|
import { openSystemDB, readSystemMeta } from "../data/system-meta/store.js";
|
||||||
|
import { openSlotDB, readSlotFile, readSlotManifest } from "../data/slot/store.js";
|
||||||
|
import { openUserDB, listFavorites, getPrefs } from "../data/user/store.js";
|
||||||
|
import type { FavoriteEntry, UserPrefs } from "../data/user/types.js";
|
||||||
|
import type { ScheduleSection } from "../data/festival-package/types.js";
|
||||||
|
import type { ClockSkew } from "../domain/clock/types.js";
|
||||||
|
|
||||||
|
export interface ScheduleWindow {
|
||||||
|
readonly timezone: string;
|
||||||
|
readonly startUtc: number;
|
||||||
|
readonly endUtc: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ScheduleData {
|
||||||
|
readonly section: ScheduleSection;
|
||||||
|
readonly window: ScheduleWindow;
|
||||||
|
readonly favorites: readonly FavoriteEntry[];
|
||||||
|
readonly prefs: UserPrefs | undefined;
|
||||||
|
readonly skew: ClockSkew | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function record(value: unknown): Record<string, unknown> | null {
|
||||||
|
return typeof value === "object" && value !== null ? (value as Record<string, unknown>) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Structural schedule check — per-event tolerance lives in query helpers. */
|
||||||
|
export function narrowScheduleSection(value: unknown): ScheduleSection | null {
|
||||||
|
const section = record(value);
|
||||||
|
if (section?.section !== "schedule") return null;
|
||||||
|
if (!Array.isArray(section.stages) || !Array.isArray(section.artists)) return null;
|
||||||
|
if (!Array.isArray(section.events)) return null;
|
||||||
|
return value as ScheduleSection;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadScheduleData(): Promise<ScheduleData | null> {
|
||||||
|
try {
|
||||||
|
const system = await openSystemDB();
|
||||||
|
let activeSlot;
|
||||||
|
let skew: ClockSkew | null;
|
||||||
|
try {
|
||||||
|
const meta = await readSystemMeta(system);
|
||||||
|
activeSlot = meta.activeSlot;
|
||||||
|
skew = meta.clockSkew;
|
||||||
|
} finally {
|
||||||
|
system.close();
|
||||||
|
}
|
||||||
|
if (!activeSlot) return null;
|
||||||
|
|
||||||
|
const slot = await openSlotDB(activeSlot);
|
||||||
|
let section: ScheduleSection | null = null;
|
||||||
|
let window: ScheduleWindow | null = null;
|
||||||
|
try {
|
||||||
|
section = narrowScheduleSection(await readSlotFile(slot, "schedule"));
|
||||||
|
const manifest = await readSlotManifest(slot).catch(() => undefined);
|
||||||
|
if (manifest && typeof manifest.festival?.timezone === "string") {
|
||||||
|
window = {
|
||||||
|
timezone: manifest.festival.timezone,
|
||||||
|
startUtc: manifest.festival.startUtc,
|
||||||
|
endUtc: manifest.festival.endUtc,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
slot.close();
|
||||||
|
}
|
||||||
|
if (!section || !window) return null;
|
||||||
|
|
||||||
|
const user = await openUserDB();
|
||||||
|
try {
|
||||||
|
return {
|
||||||
|
section,
|
||||||
|
window,
|
||||||
|
favorites: await listFavorites(user),
|
||||||
|
prefs: await getPrefs(user),
|
||||||
|
skew,
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
user.close();
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Storage errors degrade to "no schedule" — never blank, never throw (I-8).
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -109,6 +109,42 @@ html {
|
||||||
color-scheme: light dark;
|
color-scheme: light dark;
|
||||||
scroll-behavior: smooth;
|
scroll-behavior: smooth;
|
||||||
}
|
}
|
||||||
|
html.dark {
|
||||||
|
--bg: #0b1411; --fg: #ede9de; --deck-bg: #0b1411; --deck-fg: #ede9de; --deck-surface: rgba(255,255,255,0.035); --deck-line: rgba(255,255,255,0.1); --accent: #75c7a9; --surface: rgba(255,255,255,0.035); --border: rgba(255,255,255,0.1);
|
||||||
|
}
|
||||||
|
html.light :root,
|
||||||
|
html.light body {
|
||||||
|
--deck-bg: #f6f9f7; --deck-fg: #172621; --deck-fg-muted: #576b63;
|
||||||
|
--deck-surface: rgba(20,30,25,0.04); --deck-surface-2: rgba(20,30,25,0.06); --deck-line: rgba(20,30,25,0.12); --deck-line-2: rgba(20,30,25,0.2);
|
||||||
|
--deck-pine: #1f7a5f; --deck-indigo: #683ecc; --deck-rose: #d3224b; --deck-clay: #bf4622; --deck-amber: #b8610a;
|
||||||
|
--bg: #f6f9f7; --fg: #172621; --muted: #576b63; --accent: #1f7a5f; --surface: rgba(20,30,25,0.04); --border: rgba(20,30,25,0.12);
|
||||||
|
--highlight-bg: rgba(184,97,10,0.07); --highlight-border: rgba(184,97,10,0.3);
|
||||||
|
--chip-pink-bg: rgba(211,34,75,0.08); --chip-pink-fg: #b5244a;
|
||||||
|
--chip-green-bg: rgba(31,122,95,0.09); --chip-green-fg: #1f7a5f;
|
||||||
|
--chip-indigo-bg: rgba(104,62,204,0.08); --chip-indigo-fg: #683ecc;
|
||||||
|
--chip-amber-bg: rgba(184,97,10,0.09); --chip-amber-fg: #96500a;
|
||||||
|
--focus: #1f7a5f; --emergency: #d3224b; --emergency-contrast: #fff;
|
||||||
|
}
|
||||||
|
html.dark :root,
|
||||||
|
html.dark body {
|
||||||
|
--deck-bg: #0b1411; --deck-fg: #ede9de; --deck-fg-muted: #96a69e;
|
||||||
|
--deck-surface: rgba(255,255,255,0.035); --deck-surface-2: rgba(255,255,255,0.055);
|
||||||
|
--deck-line: rgba(255,255,255,0.1); --deck-line-2: rgba(255,255,255,0.16);
|
||||||
|
--deck-pine: #75c7a9; --deck-indigo: #af97f7; --deck-rose: #fc88ab; --deck-clay: #e47958; --deck-amber: #fcc669;
|
||||||
|
--bg: var(--deck-bg); --fg: var(--deck-fg); --muted: var(--deck-fg-muted);
|
||||||
|
--accent: var(--deck-pine); --accent-contrast: var(--deck-on-accent);
|
||||||
|
--accent-soft: rgba(117,199,169,0.12);
|
||||||
|
--surface: var(--deck-surface); --border: var(--deck-line); --border-accent: rgba(117,199,169,0.32);
|
||||||
|
--highlight-bg: rgba(252,198,105,0.07); --highlight-border: rgba(252,198,105,0.3);
|
||||||
|
--chip-pink-bg: rgba(252,136,171,0.12); --chip-pink-fg: #fc88ab;
|
||||||
|
--chip-green-bg: rgba(117,199,169,0.12); --chip-green-fg: #75c7a9;
|
||||||
|
--chip-indigo-bg: rgba(175,151,247,0.12); --chip-indigo-fg: #af97f7;
|
||||||
|
--chip-amber-bg: rgba(252,198,105,0.12); --chip-amber-fg: #fcc669;
|
||||||
|
--focus: var(--deck-pine); --emergency: #fc88ab; --emergency-contrast: var(--deck-on-accent);
|
||||||
|
}
|
||||||
|
html.dark body {
|
||||||
|
background: radial-gradient(120% 80% at 80% -10%, rgba(14,57,44,0.55), transparent 60%), radial-gradient(90% 70% at -10% 110%, rgba(83,48,9,0.35), transparent 55%), var(--bg);
|
||||||
|
}
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
html {
|
html {
|
||||||
scroll-behavior: auto;
|
scroll-behavior: auto;
|
||||||
|
|
@ -322,6 +358,9 @@ h3 {
|
||||||
font-size: 1.05em;
|
font-size: 1.05em;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
}
|
}
|
||||||
|
.theme-btn {
|
||||||
|
min-height: 36px; padding: 0.25rem 0.5rem; font: inherit; background: var(--surface); border: 1px solid var(--border); border-radius: 999px; cursor: pointer; color: var(--fg);
|
||||||
|
}
|
||||||
.app-header__status {
|
.app-header__status {
|
||||||
font-size: 0.8125rem;
|
font-size: 0.8125rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
|
@ -331,6 +370,107 @@ h3 {
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
background: var(--surface);
|
background: var(--surface);
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
.app-header__status--ready {
|
||||||
|
background: var(--chip-green-bg);
|
||||||
|
color: var(--chip-green-fg);
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
.app-header__status--partial {
|
||||||
|
background: var(--chip-amber-bg);
|
||||||
|
color: var(--chip-amber-fg);
|
||||||
|
border-color: var(--highlight-border);
|
||||||
|
}
|
||||||
|
.app-header__status--recovery,
|
||||||
|
.app-header__status--failed {
|
||||||
|
background: var(--chip-pink-bg);
|
||||||
|
color: var(--chip-pink-fg);
|
||||||
|
border-color: var(--emergency);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Status screen — readiness detail dialog (§20.5) */
|
||||||
|
.status-dialog {
|
||||||
|
width: min(92vw, 36rem);
|
||||||
|
max-height: min(88dvh, 56rem);
|
||||||
|
overflow: auto;
|
||||||
|
padding: 1.25rem;
|
||||||
|
color: var(--fg);
|
||||||
|
background: var(--bg);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
}
|
||||||
|
.status-dialog::backdrop {
|
||||||
|
background: rgba(0, 0, 0, 0.55);
|
||||||
|
}
|
||||||
|
.status-view h1 {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
margin: 0 0 0.75rem;
|
||||||
|
}
|
||||||
|
.status-view h2 {
|
||||||
|
font-size: 1.125rem;
|
||||||
|
margin: 1rem 0 0.5rem;
|
||||||
|
}
|
||||||
|
.status-view p {
|
||||||
|
margin: 0.375rem 0;
|
||||||
|
}
|
||||||
|
.status-state {
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
}
|
||||||
|
.status-checklist {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0.5rem 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
.status-check {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.625rem;
|
||||||
|
align-items: baseline;
|
||||||
|
min-height: 48px;
|
||||||
|
padding: 0.625rem 0.75rem;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
margin-block: 0.375rem;
|
||||||
|
}
|
||||||
|
.status-check--ok {
|
||||||
|
border-color: var(--border-accent);
|
||||||
|
}
|
||||||
|
.status-check--missing {
|
||||||
|
border-color: var(--highlight-border);
|
||||||
|
}
|
||||||
|
.status-check__mark {
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
.status-check--ok .status-check__mark {
|
||||||
|
color: var(--chip-green-fg);
|
||||||
|
}
|
||||||
|
.status-check--missing .status-check__mark {
|
||||||
|
color: var(--chip-pink-fg);
|
||||||
|
}
|
||||||
|
.status-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.625rem;
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
.status-action {
|
||||||
|
flex: 1 1 12rem;
|
||||||
|
min-height: 48px;
|
||||||
|
padding: 0.625rem 1rem;
|
||||||
|
font: inherit;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--fg);
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.status-action--primary {
|
||||||
|
background: var(--accent);
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: var(--accent-contrast);
|
||||||
}
|
}
|
||||||
.app-main {
|
.app-main {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
|
@ -346,10 +486,8 @@ h3 {
|
||||||
filter: invert(1);
|
filter: invert(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
html.dark .app-main__icon {
|
||||||
.app-main__icon {
|
filter: none;
|
||||||
filter: none;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.app-main h1 {
|
.app-main h1 {
|
||||||
font-size: 2rem;
|
font-size: 2rem;
|
||||||
|
|
@ -359,6 +497,20 @@ h3 {
|
||||||
max-width: 36rem;
|
max-width: 36rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Primary navigation — fixed bottom bar on small screens, 240px sidebar on desktop */
|
||||||
|
.side-nav {
|
||||||
|
position: fixed;
|
||||||
|
inset: auto 0 0 0;
|
||||||
|
z-index: 10;
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.625rem max(1rem, env(safe-area-inset-right)) max(0.625rem, env(safe-area-inset-bottom))
|
||||||
|
max(1rem, env(safe-area-inset-left));
|
||||||
|
background: color-mix(in srgb, var(--bg) 88%, transparent);
|
||||||
|
-webkit-backdrop-filter: blur(12px);
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
}
|
||||||
.side-nav__link {
|
.side-nav__link {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
@ -381,6 +533,42 @@ h3 {
|
||||||
border-color 0.2s ease,
|
border-color 0.2s ease,
|
||||||
color 0.2s ease;
|
color 0.2s ease;
|
||||||
}
|
}
|
||||||
|
.side-nav__link[aria-current="page"] {
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
.side-nav__link--emergency {
|
||||||
|
background: var(--emergency);
|
||||||
|
border-color: var(--emergency);
|
||||||
|
color: var(--emergency-contrast);
|
||||||
|
}
|
||||||
|
.side-nav__link--emergency[aria-current="page"] {
|
||||||
|
color: var(--emergency-contrast);
|
||||||
|
}
|
||||||
|
@media (min-width: 701px) {
|
||||||
|
.side-nav {
|
||||||
|
inset: 0 auto 0 0;
|
||||||
|
width: 240px;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: space-evenly;
|
||||||
|
padding: 1rem;
|
||||||
|
border-top: none;
|
||||||
|
border-right: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.side-nav__link {
|
||||||
|
flex: none;
|
||||||
|
min-height: 64px;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
.app-main {
|
||||||
|
margin-left: 240px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@media (max-width: 700px) {
|
||||||
|
.app-main {
|
||||||
|
padding-bottom: calc(var(--nav-h) + 1.5rem);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 700px) {
|
@media (max-width: 700px) {
|
||||||
.app-main {
|
.app-main {
|
||||||
|
|
@ -514,6 +702,118 @@ h3 {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Schedule view */
|
||||||
|
.schedule-view h1 {
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
.schedule-warning {
|
||||||
|
background: var(--highlight-bg);
|
||||||
|
border: 1px solid var(--highlight-border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
padding: 0.625rem 0.875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.schedule-controls {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.625rem;
|
||||||
|
margin-block: 1rem;
|
||||||
|
}
|
||||||
|
.schedule-days,
|
||||||
|
.schedule-tags {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
.schedule-day,
|
||||||
|
.schedule-tag,
|
||||||
|
.schedule-mine,
|
||||||
|
.schedule-zone {
|
||||||
|
min-height: 48px;
|
||||||
|
padding: 0.625rem 0.875rem;
|
||||||
|
font: inherit;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--fg);
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 999px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.schedule-day[aria-pressed="true"],
|
||||||
|
.schedule-tag[aria-pressed="true"],
|
||||||
|
.schedule-mine[aria-pressed="true"] {
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
.schedule-controls select,
|
||||||
|
.schedule-controls input[type="search"] {
|
||||||
|
min-height: 48px;
|
||||||
|
padding: 0.625rem 0.875rem;
|
||||||
|
font: inherit;
|
||||||
|
color: var(--fg);
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
}
|
||||||
|
.schedule-item {
|
||||||
|
position: relative;
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 1rem 3.5rem 1rem 1.25rem;
|
||||||
|
margin-block: 0.75rem;
|
||||||
|
}
|
||||||
|
.schedule-item h2 {
|
||||||
|
margin: 0 0 0.25rem;
|
||||||
|
font-size: 1.125rem;
|
||||||
|
}
|
||||||
|
.schedule-item p {
|
||||||
|
margin: 0.25rem 0;
|
||||||
|
}
|
||||||
|
.schedule-badge {
|
||||||
|
display: inline-block;
|
||||||
|
margin-top: 0.375rem;
|
||||||
|
padding: 0.25rem 0.625rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
.schedule-badge--moved {
|
||||||
|
background: var(--chip-amber-bg);
|
||||||
|
color: var(--chip-amber-fg);
|
||||||
|
}
|
||||||
|
.schedule-badge--cancelled {
|
||||||
|
background: var(--chip-pink-bg);
|
||||||
|
color: var(--chip-pink-fg);
|
||||||
|
text-decoration: line-through;
|
||||||
|
}
|
||||||
|
.schedule-fav {
|
||||||
|
position: absolute;
|
||||||
|
top: 0.75rem;
|
||||||
|
right: 0.75rem;
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
line-height: 1;
|
||||||
|
color: var(--fg);
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 50%;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.schedule-fav[aria-pressed="true"] {
|
||||||
|
color: var(--chip-amber-fg);
|
||||||
|
border-color: var(--highlight-border);
|
||||||
|
}
|
||||||
|
.schedule-conflicts {
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--chip-amber-fg);
|
||||||
|
}
|
||||||
|
.schedule-conflicts:empty {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
/* Cards — translucent surface, hairline border, 1rem radius (SolLunar signature) */
|
/* Cards — translucent surface, hairline border, 1rem radius (SolLunar signature) */
|
||||||
.view-placeholder {
|
.view-placeholder {
|
||||||
background: var(--surface);
|
background: var(--surface);
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ import {
|
||||||
} from "../../platform/idb/wrapper.js";
|
} from "../../platform/idb/wrapper.js";
|
||||||
import { SLOT_FILES, SLOT_ASSETS, SLOT_STAGING, slotDbName } from "../../platform/idb/names.js";
|
import { SLOT_FILES, SLOT_ASSETS, SLOT_STAGING, slotDbName } from "../../platform/idb/names.js";
|
||||||
import type { SlotId } from "../../platform/idb/names.js";
|
import type { SlotId } from "../../platform/idb/names.js";
|
||||||
|
import type { FestivalManifest } from "../festival-package/types.js";
|
||||||
import { MAX_RECORD_BYTES, checkRecordSize } from "../../platform/idb/errors.js";
|
import { MAX_RECORD_BYTES, checkRecordSize } from "../../platform/idb/errors.js";
|
||||||
import type { SectionId, SlotStagingJournal } from "./types.js";
|
import type { SectionId, SlotStagingJournal } from "./types.js";
|
||||||
|
|
||||||
|
|
@ -171,6 +172,25 @@ export async function clearSlotFiles(db: IDBDatabase): Promise<void> {
|
||||||
await idbClear(db, SLOT_FILES);
|
await idbClear(db, SLOT_FILES);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ——— Manifest (evidence for boot C4/C5/C6 — one short txn, P1-style) ———
|
||||||
|
|
||||||
|
/** Key under which the staged manifest lives in the files store. */
|
||||||
|
export const SLOT_MANIFEST_KEY = "manifest" as const;
|
||||||
|
|
||||||
|
export async function writeSlotManifest(
|
||||||
|
db: IDBDatabase,
|
||||||
|
manifest: FestivalManifest,
|
||||||
|
): Promise<void> {
|
||||||
|
await withTx(db, SLOT_FILES, "readwrite", async (tx) => {
|
||||||
|
await requestDone(tx.objectStore(SLOT_FILES).put(manifest, SLOT_MANIFEST_KEY));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function readSlotManifest(db: IDBDatabase): Promise<FestivalManifest | undefined> {
|
||||||
|
const rec = await idbGet<FestivalManifest>(db, SLOT_FILES, SLOT_MANIFEST_KEY);
|
||||||
|
return rec ?? undefined;
|
||||||
|
}
|
||||||
|
|
||||||
// ——— Assets (Blobs) ———
|
// ——— Assets (Blobs) ———
|
||||||
|
|
||||||
export async function writeSlotAsset(
|
export async function writeSlotAsset(
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,20 @@ import type { ClockOptions, ClockSkew, FestivalWindow } from "./types.js";
|
||||||
export const DEFAULT_WINDOW_DAYS = 45;
|
export const DEFAULT_WINDOW_DAYS = 45;
|
||||||
const MS_PER_DAY = 24 * 3600 * 1000;
|
const MS_PER_DAY = 24 * 3600 * 1000;
|
||||||
|
|
||||||
|
/* ClockService — pure, no network, no persistence. Pure time-model logic per SPIKE-08. */
|
||||||
|
export interface ClockService {
|
||||||
|
correctedNow(deviceNow: number, skew: ClockSkew | null): number;
|
||||||
|
hasDrift(baseDevice: number, monoElapsed: number, observedDevice: number, thresholdMs?: number): boolean;
|
||||||
|
shouldWarnOutsideWindow(nowCorrected: number, win: FestivalWindow, skew: ClockSkew | null, opts?: ClockOptions): boolean;
|
||||||
|
renderInZone(epochMs: number, timeZone: string): string;
|
||||||
|
formatEventTime(epochMs: number, timeZone: string): string;
|
||||||
|
formatDayLabel(epochMs: number, timeZone: string): string;
|
||||||
|
formatClockTime(epochMs: number, timeZone: string): string;
|
||||||
|
deviceTimeZone(): string;
|
||||||
|
dayKeyFor(epochMs: number, timeZone: string): string;
|
||||||
|
classifyNowNext<T extends SchedEventRef>(events: readonly T[], now: number): { now: readonly T[]; next: readonly T[] };
|
||||||
|
}
|
||||||
|
|
||||||
/** Render epoch ms in festival IANA zone via Intl (offline, ECMA-402). */
|
/** Render epoch ms in festival IANA zone via Intl (offline, ECMA-402). */
|
||||||
export function renderInZone(epochMs: number, timeZone: string): string {
|
export function renderInZone(epochMs: number, timeZone: string): string {
|
||||||
return new Intl.DateTimeFormat("en-US", {
|
return new Intl.DateTimeFormat("en-US", {
|
||||||
|
|
@ -23,6 +37,47 @@ export function renderInZone(epochMs: number, timeZone: string): string {
|
||||||
}).format(new Date(epochMs));
|
}).format(new Date(epochMs));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* F-4 display formats — Intl.DateTimeFormat with an explicit timeZone is the
|
||||||
|
* ONLY time-rendering path. No Date.toLocaleString() without options, no
|
||||||
|
* manual offsets, no wall-clock strings.
|
||||||
|
*/
|
||||||
|
export function formatEventTime(epochMs: number, timeZone: string): string {
|
||||||
|
return new Intl.DateTimeFormat("en-US", {
|
||||||
|
timeZone,
|
||||||
|
weekday: "short",
|
||||||
|
hour: "numeric",
|
||||||
|
minute: "2-digit",
|
||||||
|
}).format(new Date(epochMs));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatDayLabel(epochMs: number, timeZone: string): string {
|
||||||
|
return new Intl.DateTimeFormat("en-US", {
|
||||||
|
timeZone,
|
||||||
|
weekday: "long",
|
||||||
|
month: "long",
|
||||||
|
day: "numeric",
|
||||||
|
}).format(new Date(epochMs));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Time-only end of a range ("2:30 PM") — same explicit-zone rule. */
|
||||||
|
export function formatClockTime(epochMs: number, timeZone: string): string {
|
||||||
|
return new Intl.DateTimeFormat("en-US", {
|
||||||
|
timeZone,
|
||||||
|
hour: "numeric",
|
||||||
|
minute: "2-digit",
|
||||||
|
}).format(new Date(epochMs));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Device IANA zone for the display toggle — UTC fallback when unresolvable. */
|
||||||
|
export function deviceTimeZone(): string {
|
||||||
|
try {
|
||||||
|
return new Intl.DateTimeFormat().resolvedOptions().timeZone ?? "UTC";
|
||||||
|
} catch {
|
||||||
|
return "UTC";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Precomputed dayKey at publish time: calendar date in festival zone. */
|
/** Precomputed dayKey at publish time: calendar date in festival zone. */
|
||||||
export function dayKeyFor(epochMs: number, timeZone: string): string {
|
export function dayKeyFor(epochMs: number, timeZone: string): string {
|
||||||
const parts = new Intl.DateTimeFormat("en-US", {
|
const parts = new Intl.DateTimeFormat("en-US", {
|
||||||
|
|
|
||||||
65
src/domain/clock/service.ts
Normal file
65
src/domain/clock/service.ts
Normal file
|
|
@ -0,0 +1,65 @@
|
||||||
|
/**
|
||||||
|
* ClockService session state — pure transitions over injected readings.
|
||||||
|
* Persisted skew lives in lumen-system (app layer); the monotonic session
|
||||||
|
* base lives in memory only (performance.now resets every page load).
|
||||||
|
* No timers, no background work (C-19); everything computes on demand.
|
||||||
|
* Trace: ADR-009, SPIKE-08 §3.5/§3.8, IMPLEMENTATION-CONTRACT.md §21.
|
||||||
|
*/
|
||||||
|
import { correctedNow, hasDrift } from "./logic.js";
|
||||||
|
import type { ClockSkew } from "./types.js";
|
||||||
|
|
||||||
|
export interface ClockReading {
|
||||||
|
/** Wall clock as the device reports it. */
|
||||||
|
readonly deviceNow: number;
|
||||||
|
/** Session monotonic clock (performance.now equivalent). */
|
||||||
|
readonly monoNow: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ClockSession {
|
||||||
|
/** Device reading at session start (or last re-derive). */
|
||||||
|
readonly baseDevice: number;
|
||||||
|
/** Monotonic reading at session start (or last re-derive). */
|
||||||
|
readonly baseMono: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResolvedClock {
|
||||||
|
/** Corrected now: device + persisted skew when present. */
|
||||||
|
readonly now: number;
|
||||||
|
/** Mid-session device-clock jump detected; base re-derived, skew kept. */
|
||||||
|
readonly driftWarn: boolean;
|
||||||
|
readonly session: ClockSession;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Capture a skew sample from a sync HTTP `Date` header:
|
||||||
|
* skew = serverNow − deviceNow. Pure; persistence is the app layer's job.
|
||||||
|
*/
|
||||||
|
export function captureSkew(serverNowMs: number, reading: ClockReading, source: string): ClockSkew {
|
||||||
|
return {
|
||||||
|
skewMs: serverNowMs - reading.deviceNow,
|
||||||
|
capturedAtDevice: reading.deviceNow,
|
||||||
|
capturedAtMono: reading.monoNow,
|
||||||
|
source,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve "now" for one render: apply persisted skew, then guard against
|
||||||
|
* mid-session device-clock jumps with the monotonic anchor. On drift the
|
||||||
|
* base re-derives (warn once); the server skew is kept when present.
|
||||||
|
*/
|
||||||
|
export function resolveClock(
|
||||||
|
reading: ClockReading,
|
||||||
|
skew: ClockSkew | null,
|
||||||
|
session: ClockSession | null,
|
||||||
|
): ResolvedClock {
|
||||||
|
const base: ClockSession = session ?? {
|
||||||
|
baseDevice: reading.deviceNow,
|
||||||
|
baseMono: reading.monoNow,
|
||||||
|
};
|
||||||
|
const drifted = hasDrift(base.baseDevice, reading.monoNow - base.baseMono, reading.deviceNow);
|
||||||
|
const settled: ClockSession = drifted
|
||||||
|
? { baseDevice: reading.deviceNow, baseMono: reading.monoNow }
|
||||||
|
: base;
|
||||||
|
return { now: correctedNow(reading.deviceNow, skew), driftWarn: drifted, session: settled };
|
||||||
|
}
|
||||||
|
|
@ -1,39 +1,138 @@
|
||||||
import { loadEmergencyFloor } from "../../emergency-baseline/loader.js";
|
import { loadEmergencyFloor } from "../../emergency-baseline/loader.js";
|
||||||
import type { EmergencyFloor } from "../../emergency-baseline/types.js";
|
import type { EmergencyFloor } from "../../emergency-baseline/types.js";
|
||||||
import type { EmergencySection } from "../../data/festival-package/types.js";
|
import type { EmergencySection } from "../../data/festival-package/types.js";
|
||||||
import type { EmergencyViewModel, VerifiedEmergencyData } from "./types.js";
|
import type {
|
||||||
|
EmergencyDemotionReason,
|
||||||
|
EmergencyViewModel,
|
||||||
|
VerifiedEmergencyData,
|
||||||
|
} from "./types.js";
|
||||||
|
|
||||||
const SUPPORTED_EMERGENCY_SCHEMAS = [1] as const;
|
const SUPPORTED_EMERGENCY_SCHEMAS = [1] as const;
|
||||||
|
|
||||||
|
function record(value: unknown): Record<string, unknown> | null {
|
||||||
|
return typeof value === "object" && value !== null ? (value as Record<string, unknown>) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function nonEmpty(value: unknown): value is string {
|
||||||
|
return typeof value === "string" && value.trim().length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Forward-tolerant narrowing of a stored emergency section. Unknown fields
|
||||||
|
* are preserved (never throw on future shapes). Only the section tag, a sane
|
||||||
|
* schema version, and well-formed procedures (when present) are required —
|
||||||
|
* sparse sections are usable because resolveEmergency defensively merges
|
||||||
|
* floor fields over omissions (SPIKE-06 merge rule).
|
||||||
|
*/
|
||||||
|
export function narrowEmergencySection(value: unknown): EmergencySection | null {
|
||||||
|
const section = record(value);
|
||||||
|
if (section?.section !== "emergency") return null;
|
||||||
|
const version: unknown = section.emergencySchemaVersion;
|
||||||
|
if (typeof version !== "number" || !Number.isInteger(version) || version < 1) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (section.services !== undefined && !record(section.services)) return null;
|
||||||
|
if (
|
||||||
|
section.procedures !== undefined &&
|
||||||
|
(!Array.isArray(section.procedures) ||
|
||||||
|
!section.procedures.every((p) => {
|
||||||
|
const item = record(p);
|
||||||
|
return Boolean(
|
||||||
|
item && nonEmpty(item.id) && nonEmpty(item.title) && Array.isArray(item.steps),
|
||||||
|
);
|
||||||
|
}))
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return value as EmergencySection;
|
||||||
|
}
|
||||||
|
|
||||||
function mergeFloorFields(section: EmergencySection, floor: EmergencyFloor): EmergencySection {
|
function mergeFloorFields(section: EmergencySection, floor: EmergencyFloor): EmergencySection {
|
||||||
const candidate = section as unknown as Partial<EmergencySection>;
|
const candidate = section as unknown as Partial<EmergencySection>;
|
||||||
|
const candidateServices = record(candidate.services);
|
||||||
|
const emergencyNumber = candidateServices?.emergencyNumber;
|
||||||
return {
|
return {
|
||||||
...section,
|
...section,
|
||||||
services: candidate.services ?? floor.services,
|
services: {
|
||||||
|
emergencyNumber: nonEmpty(emergencyNumber) ? emergencyNumber : floor.services.emergencyNumber,
|
||||||
|
security: candidate.services?.security ?? floor.services.security,
|
||||||
|
firstAid: candidate.services?.firstAid ?? floor.services.firstAid,
|
||||||
|
},
|
||||||
address: candidate.address ?? floor.address,
|
address: candidate.address ?? floor.address,
|
||||||
procedures: candidate.procedures?.length ? candidate.procedures : floor.procedures,
|
procedures: candidate.procedures?.length ? candidate.procedures : floor.procedures,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resolveEmergency(dataset?: VerifiedEmergencyData): EmergencyViewModel {
|
/**
|
||||||
const loaded = loadEmergencyFloor();
|
* Notices with a past expiresAtUtc are hidden — expiry hides the notice,
|
||||||
if (!loaded.ok) throw new Error(loaded.reason);
|
* never critical data (SPIKE-04 F-2). Null/undefined means unexpiring.
|
||||||
const floor = loaded.floor;
|
* This is the one sanctioned wall-clock read in emergency display;
|
||||||
const usable =
|
* readiness verdicts never consult clocks.
|
||||||
dataset !== undefined &&
|
*/
|
||||||
SUPPORTED_EMERGENCY_SCHEMAS.includes(dataset.emergencySchemaVersion as 1) &&
|
export function visibleNotices(
|
||||||
dataset.section.emergencySchemaVersion === dataset.emergencySchemaVersion;
|
section: EmergencySection,
|
||||||
if (!usable)
|
now: number = Date.now(),
|
||||||
return {
|
): EmergencySection {
|
||||||
source: "baseline",
|
if (!section.notices?.length) return section;
|
||||||
provenance: `BASELINE v${floor.floorVersion}`,
|
|
||||||
floor,
|
|
||||||
section: null,
|
|
||||||
};
|
|
||||||
return {
|
return {
|
||||||
source: "dataset",
|
...section,
|
||||||
provenance: `FESTIVAL DATA v${String(dataset.packageVersion)} · generated ${dataset.generatedAt}`,
|
// Null AND undefined both mean unexpiring (forward tolerance for staged
|
||||||
floor,
|
// JSON that omits the field); only a past timestamp hides the notice.
|
||||||
section: mergeFloorFields(dataset.section, floor),
|
notices: section.notices.filter((notice) => (notice.expiresAtUtc ?? Infinity) > now),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function resolveEmergency(
|
||||||
|
dataset?: VerifiedEmergencyData,
|
||||||
|
now: number = Date.now(),
|
||||||
|
): EmergencyViewModel {
|
||||||
|
const loaded = loadEmergencyFloor();
|
||||||
|
if (!loaded.ok) throw new Error(loaded.reason);
|
||||||
|
|
||||||
|
if (dataset === undefined) return demotedBaseline("no-dataset", loaded.floor);
|
||||||
|
if (!isSupportedEmergencySchema(dataset.emergencySchemaVersion)) {
|
||||||
|
return demotedBaseline("unsupported-schema", loaded.floor);
|
||||||
|
}
|
||||||
|
const narrowed = narrowEmergencySection(dataset.section);
|
||||||
|
if (narrowed?.emergencySchemaVersion !== dataset.emergencySchemaVersion) {
|
||||||
|
return demotedBaseline("malformed-section", loaded.floor);
|
||||||
|
}
|
||||||
|
const merged = visibleNotices(mergeFloorFields(narrowed, loaded.floor), now);
|
||||||
|
return {
|
||||||
|
source: "dataset",
|
||||||
|
provenance:
|
||||||
|
`FESTIVAL DATA v${String(dataset.packageVersion)} · generated ${dataset.generatedAt}` +
|
||||||
|
(typeof narrowed.updatedAt === "string" && narrowed.updatedAt !== dataset.generatedAt
|
||||||
|
? ` · updated ${narrowed.updatedAt}`
|
||||||
|
: ""),
|
||||||
|
floor: loaded.floor,
|
||||||
|
section: merged,
|
||||||
|
demotion: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Shell-side support gate for the independent emergency schema sub-version. */
|
||||||
|
export function isSupportedEmergencySchema(version: unknown): version is number {
|
||||||
|
return typeof version === "number" && SUPPORTED_EMERGENCY_SCHEMAS.includes(version as 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Floor viewmodel with an explicit demotion reason (loader-driven path). */
|
||||||
|
export function demotedBaseline(
|
||||||
|
demotion: EmergencyDemotionReason,
|
||||||
|
floor?: EmergencyFloor,
|
||||||
|
): EmergencyViewModel {
|
||||||
|
const resolved = floor ?? loadEmergencyFloorOrThrow();
|
||||||
|
return {
|
||||||
|
source: "baseline",
|
||||||
|
provenance: `BASELINE v${resolved.floorVersion}`,
|
||||||
|
floor: resolved,
|
||||||
|
section: null,
|
||||||
|
demotion,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadEmergencyFloorOrThrow(): EmergencyFloor {
|
||||||
|
const loaded = loadEmergencyFloor();
|
||||||
|
if (!loaded.ok) throw new Error(loaded.reason);
|
||||||
|
return loaded.floor;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,9 +8,14 @@ export interface VerifiedEmergencyData {
|
||||||
readonly emergencySchemaVersion: number;
|
readonly emergencySchemaVersion: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Why the view fell back to the floor despite a dataset being present. */
|
||||||
|
export type EmergencyDemotionReason = "no-dataset" | "unsupported-schema" | "malformed-section";
|
||||||
|
|
||||||
export interface EmergencyViewModel {
|
export interface EmergencyViewModel {
|
||||||
readonly source: "baseline" | "dataset";
|
readonly source: "baseline" | "dataset";
|
||||||
readonly provenance: string;
|
readonly provenance: string;
|
||||||
readonly floor: EmergencyFloor;
|
readonly floor: EmergencyFloor;
|
||||||
readonly section: EmergencySection | null;
|
readonly section: EmergencySection | null;
|
||||||
|
/** Null when nothing demoted the dataset tier (or no dataset exists quietly). */
|
||||||
|
readonly demotion: EmergencyDemotionReason | null;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,8 @@
|
||||||
# domain/favorites
|
# domain/favorites
|
||||||
|
|
||||||
FavoritesService over UserStore (B-6). Stable ids. DISCOVERY §15.8. Never dataset package.
|
My Schedule assembly over device-local favorites (stable event ids).
|
||||||
|
Updates/rollback/GC never touch favorites (B-6); orphans are kept, never
|
||||||
|
dropped. Write-through persistence lives in data/user + app callbacks.
|
||||||
|
|
||||||
Stage 1: directory exists with .gitkeep; no feature code yet per IMPLEMENTATION-CONTRACT.md Stage 1. See IMPLEMENTATION-CONTRACT.md §4 responsibilities and §6 B-1…B-7.
|
Stage 11: `types.ts` (FavoriteStatus, MyScheduleItem), `logic.ts`
|
||||||
|
(buildMySchedule, myScheduleConflicts). See IMPLEMENTATION-CONTRACT.md §24.
|
||||||
|
|
|
||||||
43
src/domain/favorites/logic.ts
Normal file
43
src/domain/favorites/logic.ts
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
/**
|
||||||
|
* Favorites domain — My Schedule assembly over device-local favorites.
|
||||||
|
* Favorites are keyed by stable event id (contractual across dataset
|
||||||
|
* versions); updates/rollback/GC never touch them (B-6, SPIKE-02 X3).
|
||||||
|
* Trace: IMPLEMENTATION-CONTRACT.md §24, DISCOVERY.md:545 §15.8.
|
||||||
|
*/
|
||||||
|
import type { ScheduleEvent, ScheduleSection } from "../../data/festival-package/types.js";
|
||||||
|
import type { FavoriteEntry } from "../../data/user/types.js";
|
||||||
|
import type { FavoriteStatus, MyScheduleItem } from "./types.js";
|
||||||
|
import { detectOverlaps } from "../schedule/logic.js";
|
||||||
|
|
||||||
|
export function favoriteStatus(event: ScheduleEvent | undefined): FavoriteStatus {
|
||||||
|
if (!event) return "orphan";
|
||||||
|
return event.status;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Join favorites against the active section, scheduled-first by startUtc,
|
||||||
|
* orphans last (kept, never dropped, never crash).
|
||||||
|
*/
|
||||||
|
export function buildMySchedule(
|
||||||
|
section: ScheduleSection,
|
||||||
|
favorites: readonly FavoriteEntry[],
|
||||||
|
): MyScheduleItem[] {
|
||||||
|
const byId = new Map(section.events.map((e) => [e.id, e]));
|
||||||
|
return [...favorites]
|
||||||
|
.map((fav) => {
|
||||||
|
const event = byId.get(fav.eventId);
|
||||||
|
return { fav, event, status: favoriteStatus(event) };
|
||||||
|
})
|
||||||
|
.sort(
|
||||||
|
(a, b) =>
|
||||||
|
(a.event?.startUtc ?? Number.MAX_SAFE_INTEGER) -
|
||||||
|
(b.event?.startUtc ?? Number.MAX_SAFE_INTEGER),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Overlapping pairs within My Schedule (cancelled excluded by detectOverlaps). */
|
||||||
|
export function myScheduleConflicts(
|
||||||
|
items: readonly MyScheduleItem[],
|
||||||
|
): [ScheduleEvent, ScheduleEvent][] {
|
||||||
|
return detectOverlaps(items.flatMap((item) => (item.event ? [item.event] : [])));
|
||||||
|
}
|
||||||
15
src/domain/favorites/types.ts
Normal file
15
src/domain/favorites/types.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
/**
|
||||||
|
* Favorites domain types. Pure data — see logic.ts for evaluation.
|
||||||
|
* Trace: IMPLEMENTATION-CONTRACT.md §24.
|
||||||
|
*/
|
||||||
|
import type { ScheduleEvent } from "../../data/festival-package/types.js";
|
||||||
|
import type { FavoriteEntry } from "../../data/user/types.js";
|
||||||
|
|
||||||
|
export type FavoriteStatus = "scheduled" | "moved" | "cancelled" | "orphan";
|
||||||
|
|
||||||
|
export interface MyScheduleItem {
|
||||||
|
readonly fav: FavoriteEntry;
|
||||||
|
/** Undefined when the event vanished without a cancellation marker. */
|
||||||
|
readonly event: ScheduleEvent | undefined;
|
||||||
|
readonly status: FavoriteStatus;
|
||||||
|
}
|
||||||
156
src/domain/map/logic.ts
Normal file
156
src/domain/map/logic.ts
Normal file
|
|
@ -0,0 +1,156 @@
|
||||||
|
/**
|
||||||
|
* Map domain — level selection under decode budgets, POI math, facilities
|
||||||
|
* queries. All geometry is normalized 0..1 (D-02); the view converts to %.
|
||||||
|
* No GPS, no network, no persistence here.
|
||||||
|
* Trace: IMPLEMENTATION-CONTRACT.md §22, SPIKE-03 F-1.
|
||||||
|
*/
|
||||||
|
import type { MapPoi, MapSection, PoiCategory } from "../../data/festival-package/types.js";
|
||||||
|
import type { FacilityGroup, LevelSelection } from "./types.js";
|
||||||
|
|
||||||
|
/** Longest-edge caps (px) — SPIKE-03 F-1 replaces the prior 4096 cap. */
|
||||||
|
export const OVERVIEW_MAX_EDGE = 1600;
|
||||||
|
export const DETAIL_MAX_EDGE = 3072;
|
||||||
|
|
||||||
|
/** Scale factor where the detail level takes over from the overview. */
|
||||||
|
export const DETAIL_ZOOM_THRESHOLD = 2;
|
||||||
|
|
||||||
|
function longestEdge(width: number, height: number): number {
|
||||||
|
return Math.max(width, height);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pick renderable levels. Overview over cap (or absent) means the base is
|
||||||
|
* unrenderable — the Facilities list still works from POI data. An oversized
|
||||||
|
* detail drops to the predefined overview-only fallback (never a blank map
|
||||||
|
* when the overview itself is fine).
|
||||||
|
*/
|
||||||
|
export function selectLevels(section: MapSection): LevelSelection {
|
||||||
|
const dropped: string[] = [];
|
||||||
|
const byId = new Map(section.base.levels.map((level) => [level.id, level]));
|
||||||
|
const overview = byId.get("overview") ?? section.base.levels[0] ?? null;
|
||||||
|
const detail = byId.get("detail") ?? null;
|
||||||
|
|
||||||
|
let usableOverview = overview;
|
||||||
|
if (overview && longestEdge(overview.width, overview.height) > OVERVIEW_MAX_EDGE) {
|
||||||
|
dropped.push(overview.assetId);
|
||||||
|
usableOverview = null;
|
||||||
|
}
|
||||||
|
let usableDetail = detail && detail.assetId !== usableOverview?.assetId ? detail : null;
|
||||||
|
if (usableDetail && longestEdge(usableDetail.width, usableDetail.height) > DETAIL_MAX_EDGE) {
|
||||||
|
dropped.push(usableDetail.assetId);
|
||||||
|
usableDetail = null;
|
||||||
|
}
|
||||||
|
return { overview: usableOverview, detail: usableDetail, dropped };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Normalized 0..1 → CSS % position, clamped (bad authoring never escapes). */
|
||||||
|
export function poiPosition(poi: MapPoi): { readonly left: string; readonly top: string } {
|
||||||
|
const x = Math.min(1, Math.max(0, poi.x));
|
||||||
|
const y = Math.min(1, Math.max(0, poi.y));
|
||||||
|
return { left: `${String(x * 100)}%`, top: `${String(y * 100)}%` };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PoiFilter {
|
||||||
|
readonly categories?: ReadonlySet<PoiCategory>;
|
||||||
|
readonly query?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function matchesQuery(poi: MapPoi, query: string): boolean {
|
||||||
|
const needle = query.trim().toLowerCase();
|
||||||
|
if (!needle) return true;
|
||||||
|
return `${poi.name} ${poi.category} ${poi.description ?? ""}`.toLowerCase().includes(needle);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function filterPois(pois: readonly MapPoi[], filter: PoiFilter): MapPoi[] {
|
||||||
|
const query = filter.query ?? "";
|
||||||
|
return pois.filter((poi) => {
|
||||||
|
if (filter.categories && filter.categories.size > 0 && !filter.categories.has(poi.category)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return matchesQuery(poi, query);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* First-class Facilities list: grouped by the section's category order,
|
||||||
|
* extras last, POIs alphabetical — the screen-reader and GPS-free path.
|
||||||
|
*/
|
||||||
|
export function groupFacilities(
|
||||||
|
pois: readonly MapPoi[],
|
||||||
|
categories: readonly PoiCategory[],
|
||||||
|
): FacilityGroup[] {
|
||||||
|
const order = new Map(categories.map((category, index) => [category, index]));
|
||||||
|
const groups = new Map<PoiCategory, MapPoi[]>();
|
||||||
|
for (const poi of pois) {
|
||||||
|
const list = groups.get(poi.category) ?? [];
|
||||||
|
list.push(poi);
|
||||||
|
groups.set(poi.category, list);
|
||||||
|
}
|
||||||
|
return [...groups.entries()]
|
||||||
|
.map(([category, items]) => ({
|
||||||
|
category,
|
||||||
|
pois: [...items].sort((a, b) => a.name.localeCompare(b.name)),
|
||||||
|
}))
|
||||||
|
.sort((a, b) => (order.get(a.category) ?? Number.MAX_SAFE_INTEGER) - (order.get(b.category) ?? Number.MAX_SAFE_INTEGER));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Distinct categories present, in section order — for filter chips. */
|
||||||
|
export function presentCategories(
|
||||||
|
pois: readonly MapPoi[],
|
||||||
|
categories: readonly PoiCategory[],
|
||||||
|
): PoiCategory[] {
|
||||||
|
const seen: PoiCategory[] = [];
|
||||||
|
for (const category of categories) {
|
||||||
|
if (pois.some((poi) => poi.category === category) && !seen.includes(category)) {
|
||||||
|
seen.push(category);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const poi of pois) {
|
||||||
|
if (!seen.includes(poi.category)) seen.push(poi.category);
|
||||||
|
}
|
||||||
|
return seen;
|
||||||
|
}
|
||||||
|
|
||||||
|
function record(value: unknown): Record<string, unknown> | null {
|
||||||
|
return typeof value === "object" && value !== null ? (value as Record<string, unknown>) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function validLevel(value: unknown): boolean {
|
||||||
|
const level = record(value);
|
||||||
|
return Boolean(
|
||||||
|
level &&
|
||||||
|
typeof level.id === "string" &&
|
||||||
|
typeof level.assetId === "string" &&
|
||||||
|
typeof level.width === "number" &&
|
||||||
|
Number.isFinite(level.width) &&
|
||||||
|
level.width > 0 &&
|
||||||
|
typeof level.height === "number" &&
|
||||||
|
Number.isFinite(level.height) &&
|
||||||
|
level.height > 0,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function validPoi(value: unknown): boolean {
|
||||||
|
const poi = record(value);
|
||||||
|
return Boolean(
|
||||||
|
poi &&
|
||||||
|
typeof poi.id === "string" &&
|
||||||
|
typeof poi.name === "string" &&
|
||||||
|
typeof poi.category === "string" &&
|
||||||
|
typeof poi.x === "number" &&
|
||||||
|
Number.isFinite(poi.x) &&
|
||||||
|
typeof poi.y === "number" &&
|
||||||
|
Number.isFinite(poi.y),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Structural map check — per-POI tolerance lives in the queries above. */
|
||||||
|
export function narrowMapSection(value: unknown): MapSection | null {
|
||||||
|
const section = record(value);
|
||||||
|
if (!section || section.section !== "map") return null;
|
||||||
|
const base = record(section.base);
|
||||||
|
if (!base || !Array.isArray(base.levels) || !base.levels.every(validLevel)) return null;
|
||||||
|
if (!Array.isArray(section.pois) || !section.pois.every(validPoi)) return null;
|
||||||
|
if (!Array.isArray(section.categories)) return null;
|
||||||
|
return value as MapSection;
|
||||||
|
}
|
||||||
52
src/domain/map/types.ts
Normal file
52
src/domain/map/types.ts
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
/**
|
||||||
|
* Map domain types. Pure data — see logic.ts for evaluation.
|
||||||
|
* Coordinate space is normalized 0..1 relative to the base image (D-02);
|
||||||
|
* optional lat/lng is a hook for future positioning only (no GPS in V1).
|
||||||
|
* Trace: IMPLEMENTATION-CONTRACT.md §22, SPIKE-03.
|
||||||
|
*/
|
||||||
|
import type { MapLevel, MapPoi, MapSection, PoiCategory } from "../../data/festival-package/types.js";
|
||||||
|
|
||||||
|
export type { MapPoi, PoiCategory };
|
||||||
|
|
||||||
|
/** A level cleared for rendering (within dimension caps). */
|
||||||
|
export interface UsableLevel {
|
||||||
|
readonly id: string;
|
||||||
|
readonly assetId: string;
|
||||||
|
readonly width: number;
|
||||||
|
readonly height: number;
|
||||||
|
readonly nightAssetId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LevelSelection {
|
||||||
|
/** Null when no overview clears the caps — base unrenderable (list still works). */
|
||||||
|
readonly overview: MapLevel | null;
|
||||||
|
readonly detail: MapLevel | null;
|
||||||
|
readonly dropped: readonly string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FacilityGroup {
|
||||||
|
readonly category: PoiCategory;
|
||||||
|
readonly pois: readonly MapPoi[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A level cleared for display, backed by object URLs (owned by the app layer). */
|
||||||
|
export interface MapLevelView {
|
||||||
|
readonly id: string;
|
||||||
|
readonly url: string;
|
||||||
|
readonly nightUrl: string | null;
|
||||||
|
readonly width: number;
|
||||||
|
readonly height: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Loader output — plain data, safe for ui to import (B-2). */
|
||||||
|
export interface MapViewData {
|
||||||
|
/** False when no overview clears the caps/blob check — list still renders. */
|
||||||
|
readonly mapAvailable: boolean;
|
||||||
|
readonly overview: MapLevelView | null;
|
||||||
|
readonly detail: MapLevelView | null;
|
||||||
|
readonly pois: readonly MapPoi[];
|
||||||
|
readonly categories: readonly PoiCategory[];
|
||||||
|
readonly overviewOnly: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type { MapSection };
|
||||||
|
|
@ -2,4 +2,7 @@
|
||||||
|
|
||||||
Predicate C1–C8 + six-state taxonomy, time-independent. Imports data/system. SPIKE-05.
|
Predicate C1–C8 + six-state taxonomy, time-independent. Imports data/system. SPIKE-05.
|
||||||
|
|
||||||
Stage 1: directory exists with .gitkeep; no feature code yet per IMPLEMENTATION-CONTRACT.md Stage 1. See IMPLEMENTATION-CONTRACT.md §4 responsibilities and §6 B-1…B-7.
|
Stage 9: `config.ts` (APP_VERSION, SUPPORTED_SCHEMA_RANGE), `types.ts`
|
||||||
|
(evidence, six states, chip, report), `logic.ts` (pure evaluation — no I/O,
|
||||||
|
no clocks). Boot evidence gathering lives in `src/app/readiness.ts` (app
|
||||||
|
layer, per B-2). See IMPLEMENTATION-CONTRACT.md §20.
|
||||||
|
|
|
||||||
16
src/domain/readiness/config.ts
Normal file
16
src/domain/readiness/config.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
/**
|
||||||
|
* Readiness build constants — the shell side of the compatibility envelope.
|
||||||
|
* Trace: IMPLEMENTATION-CONTRACT.md §20.1 C5, §15 C-23 ordering rule.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Current shell version (semver). Must match package.json — enforced by test.
|
||||||
|
* Published datasets declare appCompatibility against this.
|
||||||
|
*/
|
||||||
|
export const APP_VERSION = "0.1.0";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schema versions this shell can render. C-23: shells ship the range first
|
||||||
|
* (here {1,2}), datasets using a new schema publish only afterwards.
|
||||||
|
*/
|
||||||
|
export const SUPPORTED_SCHEMA_RANGE: readonly number[] = [1, 2];
|
||||||
177
src/domain/readiness/logic.ts
Normal file
177
src/domain/readiness/logic.ts
Normal file
|
|
@ -0,0 +1,177 @@
|
||||||
|
/**
|
||||||
|
* Offline Ready — pure predicate evaluation (no I/O, no clocks, no network).
|
||||||
|
* Time-independent per SPIKE-05 F-1: a wrong device clock can never demote
|
||||||
|
* READY. Clock problems surface as warnings elsewhere (Stage 11), never here.
|
||||||
|
* Trace: SPIKE-05 §2–§3§5, IMPLEMENTATION-CONTRACT.md §20.1–§20.2.
|
||||||
|
*/
|
||||||
|
import { CANONICAL_SECTIONS, L1_SECTIONS } from "./types.js";
|
||||||
|
import type {
|
||||||
|
PreparationLevel,
|
||||||
|
ReadinessEvidence,
|
||||||
|
ReadinessResult,
|
||||||
|
ReadinessState,
|
||||||
|
StatusChip,
|
||||||
|
} from "./types.js";
|
||||||
|
|
||||||
|
export function preparationLevel(present: readonly string[]): PreparationLevel {
|
||||||
|
if (CANONICAL_SECTIONS.every((id) => present.includes(id))) return "L2";
|
||||||
|
if (L1_SECTIONS.every((id) => present.includes(id))) return "L1";
|
||||||
|
return "L0";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Six-state taxonomy. Precedence is normative:
|
||||||
|
* FAILED (session error) > shell-missing handling > NOT_READY/RECOVERY(missing)
|
||||||
|
* > integrity-missing > integrity-corrupt > authenticity-mismatch >
|
||||||
|
* RECOVERY(incompatible) > PARTIAL > READY.
|
||||||
|
*
|
||||||
|
* Integrity is checked before authenticity so that a verification record
|
||||||
|
* that outlived its files reads as data loss (missing), not tampering:
|
||||||
|
* files-gone dominates a mismatched record (SPIKE-05 §5).
|
||||||
|
*/
|
||||||
|
export function evaluateReadiness(e: ReadinessEvidence): ReadinessResult {
|
||||||
|
const level = preparationLevel(e.presentSections);
|
||||||
|
|
||||||
|
if (e.failure) {
|
||||||
|
return {
|
||||||
|
state: "FAILED",
|
||||||
|
reason: null,
|
||||||
|
missing: [...e.missing],
|
||||||
|
failedOp: e.failure.op,
|
||||||
|
level,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!e.emergencyFloorOk) {
|
||||||
|
// The floor is compiled into the shell — absence means a broken build.
|
||||||
|
return {
|
||||||
|
state: "FAILED",
|
||||||
|
reason: null,
|
||||||
|
missing: [...e.missing],
|
||||||
|
failedOp: "emergency-floor",
|
||||||
|
level,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!e.shellValid) {
|
||||||
|
// The running shell proves code is present; a missing cache entry only
|
||||||
|
// endangers relaunch. With a dataset, enumerate it as PARTIAL; without,
|
||||||
|
// the user still needs the preparation flow.
|
||||||
|
if (e.datasetPresent) {
|
||||||
|
return {
|
||||||
|
state: "PARTIAL",
|
||||||
|
reason: null,
|
||||||
|
missing: ["shell-cache", ...e.missing],
|
||||||
|
failedOp: null,
|
||||||
|
level,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { state: "NOT_READY", reason: null, missing: [...e.missing], failedOp: null, level };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!e.datasetPresent) {
|
||||||
|
if (e.hadVerifiedDataset) {
|
||||||
|
return { state: "RECOVERY", reason: "missing", missing: ["dataset"], failedOp: null, level };
|
||||||
|
}
|
||||||
|
if (e.stagedPending) {
|
||||||
|
// Prep interrupted (or fully staged, awaiting activation) — resumable,
|
||||||
|
// so PARTIAL rather than NOT_READY. Level follows staged sections.
|
||||||
|
return {
|
||||||
|
state: "PARTIAL",
|
||||||
|
reason: null,
|
||||||
|
missing: [...e.stagedPending.missing],
|
||||||
|
failedOp: null,
|
||||||
|
level,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { state: "NOT_READY", reason: null, missing: [...e.missing], failedOp: null, level };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.integrity === "files-missing") {
|
||||||
|
return { state: "RECOVERY", reason: "missing", missing: [...e.missing], failedOp: null, level };
|
||||||
|
}
|
||||||
|
if (e.integrity === "corrupt") {
|
||||||
|
return { state: "RECOVERY", reason: "corrupt", missing: [...e.missing], failedOp: null, level };
|
||||||
|
}
|
||||||
|
if (e.authenticity === "mismatch") {
|
||||||
|
return { state: "RECOVERY", reason: "corrupt", missing: [...e.missing], failedOp: null, level };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.manifestOk && (!e.schemaCompatible || e.incompatible)) {
|
||||||
|
return {
|
||||||
|
state: "RECOVERY",
|
||||||
|
reason: "incompatible",
|
||||||
|
missing: [...e.missing],
|
||||||
|
failedOp: null,
|
||||||
|
level,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.authenticity === "record-missing") {
|
||||||
|
// Files present but no verification evidence — READY cannot be claimed
|
||||||
|
// without evidence (SPIKE-05 §5). Full re-verify path, not corruption.
|
||||||
|
return {
|
||||||
|
state: "PARTIAL",
|
||||||
|
reason: null,
|
||||||
|
missing: ["verification", ...e.missing],
|
||||||
|
failedOp: null,
|
||||||
|
level,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!e.manifestOk) {
|
||||||
|
// Slot files exist but the manifest they are evidenced from is gone —
|
||||||
|
// sizes/compat/required-flags cannot be proven. Re-verify path.
|
||||||
|
const missing = e.missing.includes("manifest") ? [...e.missing] : ["manifest", ...e.missing];
|
||||||
|
return { state: "PARTIAL", reason: null, missing, failedOp: null, level };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!e.requiredSectionsOk || !e.requiredAssetsOk) {
|
||||||
|
return { state: "PARTIAL", reason: null, missing: [...e.missing], failedOp: null, level };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { state: "READY", reason: null, missing: [], failedOp: null, level };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Header chip mapping (§20.2) — text, tone, and accessible label per state. */
|
||||||
|
export function chipForState(state: ReadinessState, detail?: string): StatusChip {
|
||||||
|
switch (state) {
|
||||||
|
case "READY":
|
||||||
|
return {
|
||||||
|
text: "OFFLINE READY ✓",
|
||||||
|
tone: "ready",
|
||||||
|
label: "Offline status: ready. All festival data is verified on this device.",
|
||||||
|
};
|
||||||
|
case "PARTIAL":
|
||||||
|
return {
|
||||||
|
text: "PARTIAL",
|
||||||
|
tone: "partial",
|
||||||
|
label: `Offline status: partial. Missing: ${detail ?? "some required data"}.`,
|
||||||
|
};
|
||||||
|
case "NOT_READY":
|
||||||
|
return {
|
||||||
|
text: "Get festival data",
|
||||||
|
tone: "neutral",
|
||||||
|
label: "Offline status: not ready. Open with internet to get festival data.",
|
||||||
|
};
|
||||||
|
case "RECOVERY":
|
||||||
|
return {
|
||||||
|
text: `RECOVERY — ${detail ?? "data missing"}`,
|
||||||
|
tone: "recovery",
|
||||||
|
label: `Offline status: recovery needed (${detail ?? "data missing"}). Emergency info still available.`,
|
||||||
|
};
|
||||||
|
case "BASELINE_ONLY":
|
||||||
|
return {
|
||||||
|
text: "SAVING UNAVAILABLE",
|
||||||
|
tone: "baseline",
|
||||||
|
label:
|
||||||
|
"Offline status: this browser cannot save offline data. Emergency info still available.",
|
||||||
|
};
|
||||||
|
case "FAILED":
|
||||||
|
return {
|
||||||
|
text: `FAILED — ${detail ?? "error"}`,
|
||||||
|
tone: "failed",
|
||||||
|
label: `Offline status: operation failed (${detail ?? "error"}). Emergency info still available.`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,40 +1,119 @@
|
||||||
/**
|
/**
|
||||||
* Offline Ready — inputs for predicate C1…C8.
|
* Offline Ready — predicate inputs, six-state taxonomy, chip mapping.
|
||||||
* Pure types, no I/O.
|
* Pure types only: no I/O, no clocks (time independence, SPIKE-05 F-1).
|
||||||
* Trace: SPIKE-05, IMPLEMENTATION-CONTRACT.md §20
|
* Trace: SPIKE-05 §2–§3, IMPLEMENTATION-CONTRACT.md §20.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export interface ReadinessInput {
|
|
||||||
readonly shellValid: boolean; // C1
|
|
||||||
readonly datasetPresent: boolean; // C2
|
|
||||||
readonly authenticityOk: boolean; // C3
|
|
||||||
readonly integrityOk: boolean; // C4
|
|
||||||
readonly schemaCompatible: boolean; // C5
|
|
||||||
readonly requiredSectionsOk: boolean; // C6
|
|
||||||
readonly requiredAssetsOk: boolean; // C7
|
|
||||||
readonly emergencyFloorOk: boolean; // C8
|
|
||||||
}
|
|
||||||
|
|
||||||
export type ReadinessState =
|
export type ReadinessState =
|
||||||
"READY" | "PARTIAL" | "NOT_READY" | "RECOVERY" | "BASELINE_ONLY" | "FAILED";
|
"READY" | "PARTIAL" | "NOT_READY" | "RECOVERY" | "BASELINE_ONLY" | "FAILED";
|
||||||
|
|
||||||
export function computeReadiness(i: ReadinessInput): ReadinessState {
|
export type RecoveryReason = "missing" | "corrupt" | "incompatible";
|
||||||
if (!i.shellValid) return "FAILED"; // shell itself invalid — not a normal state but keep
|
|
||||||
// All true => READY (time-independent per SPIKE-05 F-1)
|
/** Preparation levels L0–L2 (SPIKE-07): shell+floor / +emergency+schedule / full. */
|
||||||
if (
|
export type PreparationLevel = "L0" | "L1" | "L2";
|
||||||
i.datasetPresent &&
|
|
||||||
i.authenticityOk &&
|
/** The five canonical V1 sections. Order is the staging priority order. */
|
||||||
i.integrityOk &&
|
export const CANONICAL_SECTIONS: readonly string[] = [
|
||||||
i.schemaCompatible &&
|
"emergency",
|
||||||
i.requiredSectionsOk &&
|
"schedule",
|
||||||
i.requiredAssetsOk &&
|
"info",
|
||||||
i.emergencyFloorOk
|
"map",
|
||||||
) {
|
"assets",
|
||||||
return "READY";
|
];
|
||||||
}
|
|
||||||
// If any of C2…C7 false due to missing data but shell ok, PARTIAL/NOT_READY
|
/** Sections whose presence constitutes the L1 minimum-safe set. */
|
||||||
// For Stage 4 we distinguish only READY vs not-READY; detailed taxonomy is Stage 9.
|
export const L1_SECTIONS: readonly string[] = ["emergency", "schedule"];
|
||||||
// Return PARTIAL as representative for missing required data.
|
|
||||||
if (!i.datasetPresent) return "NOT_READY";
|
/**
|
||||||
return "PARTIAL";
|
* Evidence for the C1–C8 predicate. Assembled by the app-layer evaluator
|
||||||
|
* from local stores only — never from the network, never from a clock.
|
||||||
|
*/
|
||||||
|
export interface ReadinessEvidence {
|
||||||
|
/** C1: every precache entry present in the current shell cache. */
|
||||||
|
readonly shellValid: boolean;
|
||||||
|
/** C2: active slot exists for the active edition. */
|
||||||
|
readonly datasetPresent: boolean;
|
||||||
|
/** C3: recorded verification matches the active package (no crypto at boot). */
|
||||||
|
readonly authenticity: "verified" | "record-missing" | "mismatch";
|
||||||
|
/** C4: manifest-listed files present with matching sizes (hashes at staging). */
|
||||||
|
readonly integrity: "ok" | "files-missing" | "corrupt";
|
||||||
|
/** C5: schemaVersion in shell range and appCompatibility satisfied. */
|
||||||
|
readonly schemaCompatible: boolean;
|
||||||
|
/** C6: every required section present and parseable. */
|
||||||
|
readonly requiredSectionsOk: boolean;
|
||||||
|
/** C7: every asset referenced by required sections present. */
|
||||||
|
readonly requiredAssetsOk: boolean;
|
||||||
|
/** C8: embedded emergency floor present (asserted against broken builds). */
|
||||||
|
readonly emergencyFloorOk: boolean;
|
||||||
|
/** Required section/asset ids absent — the PARTIAL(list) enumeration. */
|
||||||
|
readonly missing: readonly string[];
|
||||||
|
/** A dataset was previously verified — distinguishes RECOVERY from NOT_READY. */
|
||||||
|
readonly hadVerifiedDataset: boolean;
|
||||||
|
/**
|
||||||
|
* A recent incomplete staging exists in a slot with no active pointer
|
||||||
|
* (prep interrupted). Non-null (even with empty missing = fully staged,
|
||||||
|
* awaiting activation) maps to PARTIAL instead of NOT_READY.
|
||||||
|
*/
|
||||||
|
readonly stagedPending: { readonly missing: readonly string[] } | null;
|
||||||
|
/** The staged manifest is present and structurally sound (evidence for C4–C6). */
|
||||||
|
readonly manifestOk: boolean;
|
||||||
|
/** Schema/app incompatibility (vs generic corruption) — RECOVERY(incompatible). */
|
||||||
|
readonly incompatible: boolean;
|
||||||
|
/** Session operation failure, e.g. { op: "activation" } — FAILED(op). */
|
||||||
|
readonly failure: { readonly op: string } | null;
|
||||||
|
/** Required sections present (for L0–L2), regardless of trust state. */
|
||||||
|
readonly presentSections: readonly string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReadinessResult {
|
||||||
|
readonly state: ReadinessState;
|
||||||
|
/** Set for RECOVERY; null otherwise. */
|
||||||
|
readonly reason: RecoveryReason | null;
|
||||||
|
/** Required ids absent (PARTIAL) or the failing part; empty for READY. */
|
||||||
|
readonly missing: readonly string[];
|
||||||
|
/** Set for FAILED; null otherwise. */
|
||||||
|
readonly failedOp: string | null;
|
||||||
|
readonly level: PreparationLevel;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ChipTone = "ready" | "partial" | "neutral" | "recovery" | "baseline" | "failed";
|
||||||
|
|
||||||
|
export interface StatusChip {
|
||||||
|
readonly text: string;
|
||||||
|
readonly tone: ChipTone;
|
||||||
|
/** Accessible label for the chip (status + what it means). */
|
||||||
|
readonly label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SectionStatus {
|
||||||
|
readonly id: string;
|
||||||
|
readonly required: boolean;
|
||||||
|
readonly present: boolean;
|
||||||
|
readonly parseable: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Full boot report: predicate verdict plus everything the Status screen
|
||||||
|
* shows (checklist, versions, usage). Plain data — safe for ui to import.
|
||||||
|
* Storage shape mirrors the platform estimate without importing platform.
|
||||||
|
*/
|
||||||
|
export interface BootReadinessReport extends ReadinessResult {
|
||||||
|
readonly edition: string | null;
|
||||||
|
readonly packageVersion: number | null;
|
||||||
|
readonly generatedAt: string | null;
|
||||||
|
/** Local progress timestamp for display — never a verdict input. */
|
||||||
|
readonly fetchedAt: number | null;
|
||||||
|
readonly sections: readonly SectionStatus[];
|
||||||
|
readonly assets: {
|
||||||
|
readonly total: number;
|
||||||
|
readonly present: number;
|
||||||
|
readonly missing: readonly string[];
|
||||||
|
};
|
||||||
|
readonly floorVersion: string | null;
|
||||||
|
readonly storage: { readonly quota?: number; readonly usage?: number } | null;
|
||||||
|
readonly shellCache: string | null;
|
||||||
|
/** The retained slot could serve a user-initiated restore. */
|
||||||
|
readonly canRestore: boolean;
|
||||||
|
/** A complete staging exists with no active pointer (activate in Stage 15). */
|
||||||
|
readonly activationPending: boolean;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,8 @@
|
||||||
# domain/schedule
|
# domain/schedule
|
||||||
|
|
||||||
Queries: Now/Next, filters/search, conflict. Imports data + clock. ADR-009, SPIKE-08.
|
Schedule queries over the active section: day groups (publish-time dayKey),
|
||||||
|
Now/Next (epoch predicates), stage/tag/status/favorites/search filters,
|
||||||
|
overlap detection, name lookups. Pure, offline, no clocks for verdicts.
|
||||||
|
|
||||||
Stage 1: directory exists with .gitkeep; no feature code yet per IMPLEMENTATION-CONTRACT.md Stage 1. See IMPLEMENTATION-CONTRACT.md §4 responsibilities and §6 B-1…B-7.
|
Stage 11: `types.ts` (ScheduleFilter, DayGroup), `logic.ts` (queries).
|
||||||
|
Rendering uses explicit-zone Intl only (F-4). See IMPLEMENTATION-CONTRACT.md §21.
|
||||||
|
|
|
||||||
111
src/domain/schedule/logic.ts
Normal file
111
src/domain/schedule/logic.ts
Normal file
|
|
@ -0,0 +1,111 @@
|
||||||
|
/**
|
||||||
|
* Schedule query model — pure views over a ScheduleSection. All predicates
|
||||||
|
* run on UTC epoch ms; grouping uses the publish-time dayKey (never client
|
||||||
|
* day math); rendering uses explicit-zone Intl (F-4).
|
||||||
|
* Trace: IMPLEMENTATION-CONTRACT.md §21, ARCHITECTURE-DESIGN.md:508.
|
||||||
|
*/
|
||||||
|
import type { ScheduleEvent, ScheduleSection } from "../../data/festival-package/types.js";
|
||||||
|
import type { DayGroup, ScheduleFilter } from "./types.js";
|
||||||
|
|
||||||
|
export function sortByStart(events: readonly ScheduleEvent[]): ScheduleEvent[] {
|
||||||
|
return [...events].sort((a, b) => a.startUtc - b.startUtc || a.title.localeCompare(b.title));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Group by opaque publish-time dayKey — no client day math, DST-safe. */
|
||||||
|
export function groupByDay(section: ScheduleSection): DayGroup[] {
|
||||||
|
const groups = new Map<string, ScheduleEvent[]>();
|
||||||
|
for (const event of section.events) {
|
||||||
|
const list = groups.get(event.dayKey) ?? [];
|
||||||
|
list.push(event);
|
||||||
|
groups.set(event.dayKey, list);
|
||||||
|
}
|
||||||
|
return [...groups.entries()]
|
||||||
|
.map(([dayKey, events]) => {
|
||||||
|
const sorted = sortByStart(events);
|
||||||
|
return { dayKey, anchorUtc: sorted[0]?.startUtc ?? 0, events: sorted };
|
||||||
|
})
|
||||||
|
.sort((a, b) => (a.dayKey < b.dayKey ? -1 : a.dayKey > b.dayKey ? 1 : 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Running now: startUtc ≤ now < endUtc. Overlaps all show (SPIKE-08 T7a). */
|
||||||
|
export function nowEvents(events: readonly ScheduleEvent[], now: number): ScheduleEvent[] {
|
||||||
|
return sortByStart(events.filter((e) => e.startUtc <= now && now < e.endUtc));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Up next: startUtc > now, start-time order, first N (SPIKE-08 T7b–d). */
|
||||||
|
export function upNext(events: readonly ScheduleEvent[], now: number, count = 3): ScheduleEvent[] {
|
||||||
|
return sortByStart(events.filter((e) => e.startUtc > now)).slice(0, Math.max(0, count));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function upNextByStage(
|
||||||
|
section: ScheduleSection,
|
||||||
|
now: number,
|
||||||
|
count = 1,
|
||||||
|
): ReadonlyMap<string, readonly ScheduleEvent[]> {
|
||||||
|
const byStage = new Map<string, ScheduleEvent[]>();
|
||||||
|
for (const event of section.events) {
|
||||||
|
const list = byStage.get(event.stageId) ?? [];
|
||||||
|
list.push(event);
|
||||||
|
byStage.set(event.stageId, list);
|
||||||
|
}
|
||||||
|
const out = new Map<string, readonly ScheduleEvent[]>();
|
||||||
|
for (const [stageId, events] of byStage) out.set(stageId, upNext(events, now, count));
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function matchesQuery(event: ScheduleEvent, section: ScheduleSection, query: string): boolean {
|
||||||
|
const needle = query.trim().toLowerCase();
|
||||||
|
if (!needle) return true;
|
||||||
|
const stage = section.stages.find((s) => s.id === event.stageId)?.name ?? "";
|
||||||
|
const artists = (event.artistIds ?? [])
|
||||||
|
.map((id) => section.artists.find((a) => a.id === id)?.name ?? "")
|
||||||
|
.join(" ");
|
||||||
|
const haystack = `${event.title} ${artists} ${event.description ?? ""} ${stage}`.toLowerCase();
|
||||||
|
return haystack.includes(needle);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function filterEvents(section: ScheduleSection, filter: ScheduleFilter): ScheduleEvent[] {
|
||||||
|
const query = filter.query ?? "";
|
||||||
|
return sortByStart(
|
||||||
|
section.events.filter((event) => {
|
||||||
|
if (filter.dayKey !== undefined && event.dayKey !== filter.dayKey) return false;
|
||||||
|
if (filter.stageId !== undefined && event.stageId !== filter.stageId) return false;
|
||||||
|
if (filter.tag !== undefined && !(event.tags ?? []).includes(filter.tag)) return false;
|
||||||
|
if (filter.statuses !== undefined && !filter.statuses.includes(event.status)) return false;
|
||||||
|
if (filter.favoritesOnly && !(filter.favoriteIds?.has(event.id) ?? false)) return false;
|
||||||
|
if (!matchesQuery(event, section, query)) return false;
|
||||||
|
return true;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Overlapping attendable pairs (cancelled excluded) for conflict surfacing. */
|
||||||
|
export function detectOverlaps(events: readonly ScheduleEvent[]): [ScheduleEvent, ScheduleEvent][] {
|
||||||
|
const live = sortByStart(events.filter((e) => e.status !== "cancelled"));
|
||||||
|
const pairs: [ScheduleEvent, ScheduleEvent][] = [];
|
||||||
|
for (let i = 0; i < live.length; i++) {
|
||||||
|
const a = live[i];
|
||||||
|
if (!a) continue;
|
||||||
|
for (let j = i + 1; j < live.length; j++) {
|
||||||
|
const b = live[j];
|
||||||
|
if (!b || b.startUtc >= a.endUtc) break;
|
||||||
|
pairs.push([a, b]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return pairs;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stageName(section: ScheduleSection, stageId: string): string {
|
||||||
|
return section.stages.find((s) => s.id === stageId)?.name ?? stageId;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function artistNames(section: ScheduleSection, artistIds: readonly string[]): string {
|
||||||
|
return artistIds.map((id) => section.artists.find((a) => a.id === id)?.name ?? id).join(", ");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Distinct tags across the section, sorted — for filter chips. */
|
||||||
|
export function allTags(section: ScheduleSection): string[] {
|
||||||
|
const tags = new Set<string>();
|
||||||
|
for (const event of section.events) for (const tag of event.tags ?? []) tags.add(tag);
|
||||||
|
return [...tags].sort((a, b) => a.localeCompare(b));
|
||||||
|
}
|
||||||
24
src/domain/schedule/types.ts
Normal file
24
src/domain/schedule/types.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
/**
|
||||||
|
* Schedule query types. Pure data — see logic.ts for evaluation.
|
||||||
|
* Trace: IMPLEMENTATION-CONTRACT.md §21.
|
||||||
|
*/
|
||||||
|
import type { EventStatus, ScheduleEvent } from "../../data/festival-package/types.js";
|
||||||
|
|
||||||
|
export interface ScheduleFilter {
|
||||||
|
readonly dayKey?: string;
|
||||||
|
readonly stageId?: string;
|
||||||
|
readonly tag?: string;
|
||||||
|
/** Defaults to all statuses (cancelled renders distinctly, never hidden). */
|
||||||
|
readonly statuses?: readonly EventStatus[];
|
||||||
|
readonly favoritesOnly?: boolean;
|
||||||
|
readonly favoriteIds?: ReadonlySet<string>;
|
||||||
|
/** Case-insensitive substring over title, artists, description, stage. */
|
||||||
|
readonly query?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DayGroup {
|
||||||
|
readonly dayKey: string;
|
||||||
|
/** Anchor instant for zone-correct day labels (min startUtc). */
|
||||||
|
readonly anchorUtc: number;
|
||||||
|
readonly events: readonly ScheduleEvent[];
|
||||||
|
}
|
||||||
57
src/platform/cache/shell-cache.ts
vendored
Normal file
57
src/platform/cache/shell-cache.ts
vendored
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
/**
|
||||||
|
* Cache Storage adapter for the app shell — the only non-test place allowed
|
||||||
|
* to touch the `caches` global (B-1). Owns shell caches only, no datasets.
|
||||||
|
* Trace: ADR-004, IMPLEMENTATION-CONTRACT.md §18.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Delete shell caches left by an earlier session (dev-mode scrub). */
|
||||||
|
export async function deleteShellCaches(prefix = "lumen-shell-"): Promise<void> {
|
||||||
|
if (typeof caches === "undefined") return;
|
||||||
|
const keys = await caches.keys();
|
||||||
|
await Promise.all(keys.filter((k) => k.startsWith(prefix)).map((k) => caches.delete(k)));
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ShellCacheCheck {
|
||||||
|
/** A current shell cache exists and holds the shell document entry. */
|
||||||
|
readonly ok: boolean;
|
||||||
|
readonly cacheName: string | null;
|
||||||
|
readonly entry: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* C1 light check: find the current shell cache and confirm it holds the
|
||||||
|
* shell document. Full per-entry verification happens atomically at SW
|
||||||
|
* install (cache.addAll fails the install); boot only needs the fast
|
||||||
|
* presence proof. Never throws — absence/unavailability is data, not error.
|
||||||
|
*/
|
||||||
|
export async function checkShellCache(): Promise<ShellCacheCheck> {
|
||||||
|
if (typeof caches === "undefined") {
|
||||||
|
return { ok: false, cacheName: null, entry: null };
|
||||||
|
}
|
||||||
|
let keys: readonly string[];
|
||||||
|
try {
|
||||||
|
keys = await caches.keys();
|
||||||
|
} catch {
|
||||||
|
return { ok: false, cacheName: null, entry: null };
|
||||||
|
}
|
||||||
|
const current =
|
||||||
|
keys
|
||||||
|
.filter((k) => k.startsWith("lumen-shell-"))
|
||||||
|
.sort()
|
||||||
|
.at(-1) ?? null;
|
||||||
|
if (!current) return { ok: false, cacheName: null, entry: null };
|
||||||
|
let cache: Cache;
|
||||||
|
try {
|
||||||
|
cache = await caches.open(current);
|
||||||
|
} catch {
|
||||||
|
return { ok: false, cacheName: current, entry: null };
|
||||||
|
}
|
||||||
|
for (const entry of ["/index.html", "/"]) {
|
||||||
|
try {
|
||||||
|
if (await cache.match(entry)) return { ok: true, cacheName: current, entry };
|
||||||
|
} catch {
|
||||||
|
// try the next entry form below
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { ok: false, cacheName: current, entry: null };
|
||||||
|
}
|
||||||
|
|
@ -31,3 +31,16 @@ export function skipWaitingForUpdate(registration?: ServiceWorkerRegistration):
|
||||||
const waiting = registration?.waiting;
|
const waiting = registration?.waiting;
|
||||||
if (waiting) waiting.postMessage({ type: "SKIP_WAITING" });
|
if (waiting) waiting.postMessage({ type: "SKIP_WAITING" });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unregister every service worker (dev-mode scrub only — production never
|
||||||
|
* calls this). Resolves true when a controlling SW was removed and the
|
||||||
|
* caller should reload to pick up the un-cached shell.
|
||||||
|
*/
|
||||||
|
export async function unregisterAllServiceWorkers(): Promise<boolean> {
|
||||||
|
if (typeof navigator === "undefined" || !("serviceWorker" in navigator)) return false;
|
||||||
|
const regs = await navigator.serviceWorker.getRegistrations();
|
||||||
|
const hadController = Boolean(navigator.serviceWorker.controller);
|
||||||
|
const unregistered = await Promise.all(regs.map((r) => r.unregister()));
|
||||||
|
return hadController && unregistered.some(Boolean);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ import {
|
||||||
lightCheckSlot,
|
lightCheckSlot,
|
||||||
writeSlotAssetWithProgress,
|
writeSlotAssetWithProgress,
|
||||||
writeSlotFileWithProgress,
|
writeSlotFileWithProgress,
|
||||||
|
writeSlotManifest,
|
||||||
} from "../data/slot/store.js";
|
} from "../data/slot/store.js";
|
||||||
import type { SlotStagingJournal } from "../data/slot/types.js";
|
import type { SlotStagingJournal } from "../data/slot/types.js";
|
||||||
import {
|
import {
|
||||||
|
|
@ -29,7 +30,8 @@ import {
|
||||||
} from "../data/system-meta/store.js";
|
} from "../data/system-meta/store.js";
|
||||||
import type { SystemMeta } from "../data/system-meta/types.js";
|
import type { SystemMeta } from "../data/system-meta/types.js";
|
||||||
|
|
||||||
const STAGING_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
|
/** Staging older than this is discarded (§18.2 — 7 days). Exported for boot evaluation. */
|
||||||
|
export const STAGING_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
|
||||||
const REQUIRED_SECTION_IDS = ["emergency", "schedule", "map", "info", "assets"] as const;
|
const REQUIRED_SECTION_IDS = ["emergency", "schedule", "map", "info", "assets"] as const;
|
||||||
|
|
||||||
export interface StagedPackage {
|
export interface StagedPackage {
|
||||||
|
|
@ -137,6 +139,10 @@ export async function stageVerifiedPackage(pkg: VerifiedPackage): Promise<Staged
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Persist the manifest itself: boot C4 (sizes), C5 (compat) and C6
|
||||||
|
// (required flags) are evidenced from it. One short txn, P1-style.
|
||||||
|
await writeSlotManifest(db, pkg.manifest);
|
||||||
|
|
||||||
if (!journal.complete) journal = await markStagingComplete(db);
|
if (!journal.complete) journal = await markStagingComplete(db);
|
||||||
system.close();
|
system.close();
|
||||||
db.close();
|
db.close();
|
||||||
|
|
|
||||||
|
|
@ -21,13 +21,7 @@ export const ROUTES: readonly Route[] = [
|
||||||
{ id: "emergency", path: "/emergency", label: "Emergency", title: "Emergency" },
|
{ id: "emergency", path: "/emergency", label: "Emergency", title: "Emergency" },
|
||||||
{ id: "schedule", path: "/schedule", label: "Schedule", title: "Schedule" },
|
{ id: "schedule", path: "/schedule", label: "Schedule", title: "Schedule" },
|
||||||
{ id: "map", path: "/map", label: "Map", title: "Map" },
|
{ id: "map", path: "/map", label: "Map", title: "Map" },
|
||||||
{
|
{ id: "festival", path: "/festival", label: "Information", title: "Information" },
|
||||||
id: "festival",
|
|
||||||
path: "http://10.144.0.220:8080/home.html",
|
|
||||||
label: "Food",
|
|
||||||
title: "Food",
|
|
||||||
external: true,
|
|
||||||
},
|
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
const PATH_TO_ID = new Map<string, RouteId>([HOME_ROUTE, ...ROUTES].map((r) => [r.path, r.id]));
|
const PATH_TO_ID = new Map<string, RouteId>([HOME_ROUTE, ...ROUTES].map((r) => [r.path, r.id]));
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,16 @@
|
||||||
/** Emergency view — Ring-0 safe DOM rendering, with optional verified Tier 2 data. */
|
/**
|
||||||
|
* Emergency view — Ring-0 safe DOM rendering over the resolved Tier 1/2 model.
|
||||||
|
* Tier 2 (dataset section) renders full locations, contacts, procedures and
|
||||||
|
* notices; any demotion renders the floor plus honest guidance. No network,
|
||||||
|
* no IDB, no raw HTML — all text via textContent (C-22/B-7).
|
||||||
|
* Trace: IMPLEMENTATION-CONTRACT.md §14, SPIKE-06.
|
||||||
|
*/
|
||||||
import { resolveEmergency } from "../../../domain/emergency/logic.js";
|
import { resolveEmergency } from "../../../domain/emergency/logic.js";
|
||||||
import type { VerifiedEmergencyData } from "../../../domain/emergency/types.js";
|
import type {
|
||||||
|
EmergencyDemotionReason,
|
||||||
|
EmergencyViewModel,
|
||||||
|
VerifiedEmergencyData,
|
||||||
|
} from "../../../domain/emergency/types.js";
|
||||||
|
|
||||||
function text(tag: keyof HTMLElementTagNameMap, value: string): HTMLElement {
|
function text(tag: keyof HTMLElementTagNameMap, value: string): HTMLElement {
|
||||||
const element = document.createElement(tag);
|
const element = document.createElement(tag);
|
||||||
|
|
@ -36,8 +46,64 @@ function addService(section: HTMLElement, label: string, value: string, dial = f
|
||||||
section.append(wrapper);
|
section.append(wrapper);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createEmergencyView(dataset?: VerifiedEmergencyData): HTMLElement {
|
function demotionGuidance(reason: EmergencyDemotionReason): string {
|
||||||
const resolved = resolveEmergency(dataset);
|
switch (reason) {
|
||||||
|
case "no-dataset":
|
||||||
|
return "Festival data isn't on this device yet — showing the built-in baseline. Open with internet to get festival data.";
|
||||||
|
case "unsupported-schema":
|
||||||
|
return "This festival data needs a newer app version — showing the built-in baseline. Update Lumen when online.";
|
||||||
|
case "malformed-section":
|
||||||
|
return "The festival data emergency section couldn't be read — showing the built-in baseline.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "Show on map" handoff into the map destination (offline, same shell). */
|
||||||
|
function mapLink(): HTMLAnchorElement {
|
||||||
|
const link = document.createElement("a");
|
||||||
|
link.className = "emergency-map-link";
|
||||||
|
link.href = "/map";
|
||||||
|
link.setAttribute("data-route", "/map");
|
||||||
|
link.textContent = "Show on map";
|
||||||
|
return link;
|
||||||
|
}
|
||||||
|
|
||||||
|
function addPoiRow(listEl: HTMLUListElement, name: string, extra?: string): void {
|
||||||
|
const item = document.createElement("li");
|
||||||
|
item.append(text("span", extra ? `${name} — ${extra}` : name), text("span", " · "), mapLink());
|
||||||
|
listEl.append(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isViewModel(value: unknown): value is EmergencyViewModel {
|
||||||
|
const record = value as { source?: unknown } | null;
|
||||||
|
return (
|
||||||
|
typeof record === "object" &&
|
||||||
|
record !== null &&
|
||||||
|
(record.source === "baseline" || record.source === "dataset")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param dataset tier-2 input, or a pre-resolved viewmodel (lets the app
|
||||||
|
* convey precise demotion reasons the dataset alone cannot express).
|
||||||
|
* With no argument the compiled floor renders with zero IDB access (Ring-0).
|
||||||
|
*/
|
||||||
|
export function createEmergencyView(
|
||||||
|
dataset?: VerifiedEmergencyData | EmergencyViewModel,
|
||||||
|
now?: number,
|
||||||
|
): HTMLElement {
|
||||||
|
let resolved;
|
||||||
|
try {
|
||||||
|
resolved = isViewModel(dataset) ? dataset : resolveEmergency(dataset, now);
|
||||||
|
} catch {
|
||||||
|
// Broken floor build must still render something honest, never blank.
|
||||||
|
const fallback = document.createElement("section");
|
||||||
|
fallback.className = "emergency-view";
|
||||||
|
const h1 = document.createElement("h1");
|
||||||
|
h1.textContent = "Emergency";
|
||||||
|
fallback.append(h1);
|
||||||
|
fallback.append(text("p", "Emergency data failed to load on this device."));
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
const section = document.createElement("section");
|
const section = document.createElement("section");
|
||||||
section.className = "emergency-view";
|
section.className = "emergency-view";
|
||||||
section.setAttribute("aria-labelledby", "emergency-heading");
|
section.setAttribute("aria-labelledby", "emergency-heading");
|
||||||
|
|
@ -52,56 +118,96 @@ export function createEmergencyView(dataset?: VerifiedEmergencyData): HTMLElemen
|
||||||
provenance.setAttribute("role", "status");
|
provenance.setAttribute("role", "status");
|
||||||
section.append(provenance);
|
section.append(provenance);
|
||||||
|
|
||||||
|
if (resolved.demotion) {
|
||||||
|
const guidance = text("p", demotionGuidance(resolved.demotion));
|
||||||
|
guidance.className = "emergency-guidance";
|
||||||
|
section.append(guidance);
|
||||||
|
}
|
||||||
|
|
||||||
|
const detail = resolved.section;
|
||||||
|
const services = detail?.services ?? resolved.floor.services;
|
||||||
|
const address = detail?.address ?? resolved.floor.address;
|
||||||
|
const procedures = detail?.procedures?.length ? detail.procedures : resolved.floor.procedures;
|
||||||
|
|
||||||
const urgent = document.createElement("div");
|
const urgent = document.createElement("div");
|
||||||
urgent.className = "emergency-alert";
|
urgent.className = "emergency-alert";
|
||||||
urgent.append(heading("emergency-call-heading", "Need immediate help?"));
|
urgent.append(heading("emergency-call-heading", "Need immediate help?"));
|
||||||
addService(urgent, "Emergency services", resolved.floor.services.emergencyNumber, true);
|
addService(urgent, "Emergency services", services.emergencyNumber, true);
|
||||||
urgent.append(text("p", "Tap to call. The number is also selectable for copying."));
|
urgent.append(text("p", "Tap to call. The number is also selectable for copying."));
|
||||||
section.append(urgent);
|
section.append(urgent);
|
||||||
|
|
||||||
const services = document.createElement("section");
|
const contacts = document.createElement("section");
|
||||||
services.setAttribute("aria-labelledby", "emergency-services-heading");
|
contacts.setAttribute("aria-labelledby", "emergency-services-heading");
|
||||||
services.append(heading("emergency-services-heading", "Contacts"));
|
contacts.append(heading("emergency-services-heading", "Contacts"));
|
||||||
addService(
|
const securityPhone = services.security.phone;
|
||||||
services,
|
addService(contacts, "Security", securityPhone || "Contact event staff or venue personnel");
|
||||||
"Security",
|
if (services.security.location) {
|
||||||
resolved.floor.services.security.phone ?? "Contact event staff or venue personnel",
|
contacts.append(text("p", `Security location: ${services.security.location}`));
|
||||||
);
|
}
|
||||||
addService(services, "First aid", resolved.floor.services.firstAid.location);
|
addService(contacts, "First aid", services.firstAid.location);
|
||||||
section.append(services);
|
if (services.firstAid.hours) {
|
||||||
|
contacts.append(text("p", `First-aid hours: ${services.firstAid.hours}`));
|
||||||
|
}
|
||||||
|
section.append(contacts);
|
||||||
|
|
||||||
const address = document.createElement("section");
|
// Locations: dataset tier carries POI-linked muster/exit/AED detail with a
|
||||||
address.setAttribute("aria-labelledby", "emergency-address-heading");
|
// map handoff; the floor carries name summaries (same-source, no drift).
|
||||||
address.append(heading("emergency-address-heading", "Venue and exits"));
|
const places = document.createElement("section");
|
||||||
address.append(text("p", resolved.floor.address.lines.join(", ")));
|
places.setAttribute("aria-labelledby", "emergency-address-heading");
|
||||||
address.append(
|
places.append(heading("emergency-address-heading", "Venue and exits"));
|
||||||
|
places.append(text("p", address.lines.join(", ")));
|
||||||
|
places.append(
|
||||||
text(
|
text(
|
||||||
"p",
|
"p",
|
||||||
`Coordinates: ${String(resolved.floor.address.coordinates.lat)}, ${String(resolved.floor.address.coordinates.lon)}`,
|
`Coordinates: ${String(address.coordinates.lat)}, ${String(address.coordinates.lon)}`,
|
||||||
),
|
),
|
||||||
text("h3", "Muster points"),
|
|
||||||
list(resolved.floor.musterPoints.map((point) => point.name)),
|
|
||||||
text("h3", "Exits"),
|
|
||||||
list(resolved.floor.exits.map((exit) => exit.name)),
|
|
||||||
text("h3", "AEDs"),
|
|
||||||
text("p", resolved.floor.aedSummary),
|
|
||||||
);
|
);
|
||||||
section.append(address);
|
if (detail?.locations) {
|
||||||
|
places.append(text("h3", "Muster points"));
|
||||||
const procedures = document.createElement("section");
|
const muster = document.createElement("ul");
|
||||||
procedures.setAttribute("aria-labelledby", "emergency-procedures-heading");
|
for (const point of detail.locations.musterPoints) {
|
||||||
procedures.append(heading("emergency-procedures-heading", "What to do"));
|
addPoiRow(muster, point.name, undefined);
|
||||||
for (const procedure of resolved.floor.procedures) {
|
}
|
||||||
procedures.append(text("h3", procedure.title), list(procedure.steps));
|
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);
|
||||||
|
}
|
||||||
|
places.append(exits);
|
||||||
|
if (detail.locations.aeds.length > 0) {
|
||||||
|
places.append(text("h3", "AEDs"));
|
||||||
|
const aeds = document.createElement("ul");
|
||||||
|
for (const aed of detail.locations.aeds) {
|
||||||
|
addPoiRow(aeds, `AED near ${aed.poi}`, undefined);
|
||||||
|
}
|
||||||
|
places.append(aeds);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
places.append(text("h3", "Muster points"));
|
||||||
|
places.append(list(resolved.floor.musterPoints.map((point) => point.name)));
|
||||||
|
places.append(text("h3", "Exits"));
|
||||||
|
places.append(list(resolved.floor.exits.map((exit) => exit.name)));
|
||||||
|
places.append(text("h3", "AEDs"));
|
||||||
|
places.append(text("p", resolved.floor.aedSummary));
|
||||||
}
|
}
|
||||||
section.append(procedures);
|
section.append(places);
|
||||||
|
|
||||||
if (resolved.section?.notices?.length) {
|
const proceduresSection = document.createElement("section");
|
||||||
|
proceduresSection.setAttribute("aria-labelledby", "emergency-procedures-heading");
|
||||||
|
proceduresSection.append(heading("emergency-procedures-heading", "What to do"));
|
||||||
|
for (const procedure of procedures) {
|
||||||
|
proceduresSection.append(text("h3", procedure.title), list(procedure.steps));
|
||||||
|
}
|
||||||
|
section.append(proceduresSection);
|
||||||
|
|
||||||
|
if (detail?.notices?.length) {
|
||||||
const notices = document.createElement("section");
|
const notices = document.createElement("section");
|
||||||
notices.setAttribute("aria-labelledby", "emergency-notices-heading");
|
notices.setAttribute("aria-labelledby", "emergency-notices-heading");
|
||||||
notices.append(heading("emergency-notices-heading", "Notices"));
|
notices.append(heading("emergency-notices-heading", "Notices"));
|
||||||
for (const notice of resolved.section.notices) {
|
for (const notice of detail.notices) {
|
||||||
notices.append(text("h3", notice.title), text("p", notice.body.map(String).join(" ")));
|
notices.append(text("h3", `[${notice.severity}] ${notice.title}`));
|
||||||
|
notices.append(text("p", notice.body.map(String).join(" ")));
|
||||||
}
|
}
|
||||||
section.append(notices);
|
section.append(notices);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,56 +1,417 @@
|
||||||
import { summit } from "../../../content/summit.js";
|
/**
|
||||||
|
* Map view — offline raster base + DOM POI overlay + Facilities list.
|
||||||
|
* Pan/zoom is a single CSS transform on the world layer; markers
|
||||||
|
* counter-scale via --map-scale so targets stay constant size. The Facilities
|
||||||
|
* list is first-class (screen-reader and GPS-free path), never a fallback.
|
||||||
|
* No network, no GPS, no raw HTML — all text via textContent (C-22/B-7).
|
||||||
|
* Trace: IMPLEMENTATION-CONTRACT.md §22, SPIKE-03.
|
||||||
|
*/
|
||||||
|
import {
|
||||||
|
DETAIL_ZOOM_THRESHOLD,
|
||||||
|
filterPois,
|
||||||
|
groupFacilities,
|
||||||
|
poiPosition,
|
||||||
|
presentCategories,
|
||||||
|
} from "../../../domain/map/logic.js";
|
||||||
|
import type {
|
||||||
|
MapLevelView,
|
||||||
|
MapPoi,
|
||||||
|
MapViewData,
|
||||||
|
PoiCategory,
|
||||||
|
} from "../../../domain/map/types.js";
|
||||||
|
|
||||||
export function createMapView(): HTMLElement {
|
const MIN_SCALE = 1;
|
||||||
|
const MAX_SCALE = 4;
|
||||||
|
const PAN_STEP = 40;
|
||||||
|
|
||||||
|
function text(tag: keyof HTMLElementTagNameMap, value: string): HTMLElement {
|
||||||
|
const element = document.createElement(tag);
|
||||||
|
element.textContent = value;
|
||||||
|
return element;
|
||||||
|
}
|
||||||
|
|
||||||
|
function levelImages(level: MapLevelView): HTMLElement[] {
|
||||||
|
const day = document.createElement("img");
|
||||||
|
day.className = "map-base";
|
||||||
|
day.src = level.url;
|
||||||
|
day.alt = `Festival grounds map (${level.id})`;
|
||||||
|
day.draggable = false;
|
||||||
|
if (!level.nightUrl) return [day];
|
||||||
|
const night = document.createElement("img");
|
||||||
|
night.className = "map-base map-base--night";
|
||||||
|
night.src = level.nightUrl;
|
||||||
|
night.alt = `Festival grounds map at night (${level.id})`;
|
||||||
|
night.draggable = false;
|
||||||
|
return [day, night];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render the map. Null input (nothing staged) shows the honest
|
||||||
|
* not-downloaded card — never blank. With POIs but no base image the
|
||||||
|
* Facilities list still renders (SPIKE-05 cross-check).
|
||||||
|
*/
|
||||||
|
export function createMapView(input: MapViewData | null): HTMLElement {
|
||||||
const section = document.createElement("section");
|
const section = document.createElement("section");
|
||||||
|
section.className = "map-view";
|
||||||
section.setAttribute("aria-labelledby", "map-heading");
|
section.setAttribute("aria-labelledby", "map-heading");
|
||||||
|
|
||||||
const h1 = document.createElement("h1");
|
const h1 = document.createElement("h1");
|
||||||
h1.id = "map-heading";
|
h1.id = "map-heading";
|
||||||
h1.textContent = "Map";
|
h1.textContent = "Map";
|
||||||
section.append(h1);
|
section.append(h1);
|
||||||
const p = document.createElement("p");
|
|
||||||
p.textContent = "Official grounds map and venue information for the 2026 summit.";
|
|
||||||
section.append(p);
|
|
||||||
const card = document.createElement("div");
|
|
||||||
card.className = "summit-card";
|
|
||||||
const map = document.createElement("img");
|
|
||||||
map.src = "https://solarpunksummit.com/wp-content/uploads/2024/10/map-2024-new-min.jpg";
|
|
||||||
map.alt = "Solarpunk Summit grounds map";
|
|
||||||
map.loading = "lazy";
|
|
||||||
map.tabIndex = 0;
|
|
||||||
map.setAttribute("role", "button");
|
|
||||||
map.setAttribute("aria-label", "Open grounds map full screen");
|
|
||||||
card.append(map);
|
|
||||||
const address = document.createElement("h2");
|
|
||||||
address.textContent = summit.venue;
|
|
||||||
const details = document.createElement("p");
|
|
||||||
details.textContent = `${summit.location}. The venue is along the San Marcos River.`;
|
|
||||||
const link = document.createElement("a");
|
|
||||||
link.href = "https://solarpunksummit.com/map/";
|
|
||||||
link.target = "_blank";
|
|
||||||
link.rel = "noreferrer";
|
|
||||||
link.textContent = "Open official grounds map";
|
|
||||||
card.append(address, details, link);
|
|
||||||
section.append(card);
|
|
||||||
|
|
||||||
const viewer = document.createElement("dialog");
|
if (!input) {
|
||||||
viewer.className = "map-viewer";
|
const card = document.createElement("div");
|
||||||
viewer.setAttribute("aria-label", "Full-screen grounds map");
|
card.className = "view-placeholder";
|
||||||
const close = document.createElement("button");
|
card.append(text("p", "The map isn't on this device yet."));
|
||||||
close.type = "button";
|
card.append(
|
||||||
close.className = "map-viewer__close";
|
text("p", "Open with internet to get festival data — the emergency floor always works meanwhile."),
|
||||||
close.textContent = "Close map";
|
);
|
||||||
close.addEventListener("click", () => viewer.close());
|
section.append(card);
|
||||||
const enlarged = document.createElement("img");
|
return section;
|
||||||
enlarged.src = map.src;
|
}
|
||||||
enlarged.alt = map.alt;
|
|
||||||
viewer.append(close, enlarged);
|
const state = {
|
||||||
map.addEventListener("click", () => viewer.showModal());
|
scale: 1,
|
||||||
map.addEventListener("keydown", (event) => {
|
x: 0,
|
||||||
if (event.key === "Enter" || event.key === " ") {
|
y: 0,
|
||||||
event.preventDefault();
|
level: "overview",
|
||||||
viewer.showModal();
|
categories: new Set<PoiCategory>(),
|
||||||
|
query: "",
|
||||||
|
};
|
||||||
|
|
||||||
|
// Detail panel (polite live region announces selections).
|
||||||
|
const detail = document.createElement("section");
|
||||||
|
detail.className = "map-detail";
|
||||||
|
detail.setAttribute("aria-live", "polite");
|
||||||
|
|
||||||
|
function selectPoi(poi: MapPoi | null): void {
|
||||||
|
detail.replaceChildren();
|
||||||
|
if (!poi) {
|
||||||
|
detail.append(text("p", "Select a facility on the map or list for details."));
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
const heading = document.createElement("h2");
|
||||||
|
heading.textContent = poi.name;
|
||||||
|
detail.append(heading);
|
||||||
|
detail.append(text("p", `Category: ${poi.category}`));
|
||||||
|
detail.append(text("p", poi.description ?? "No description."));
|
||||||
|
}
|
||||||
|
selectPoi(null);
|
||||||
|
|
||||||
|
if (input.mapAvailable && input.overview) {
|
||||||
|
const toolbar = document.createElement("div");
|
||||||
|
toolbar.className = "map-toolbar";
|
||||||
|
|
||||||
|
const zoomOut = document.createElement("button");
|
||||||
|
zoomOut.type = "button";
|
||||||
|
zoomOut.className = "map-zoom";
|
||||||
|
zoomOut.setAttribute("aria-label", "Zoom out");
|
||||||
|
zoomOut.textContent = "−";
|
||||||
|
const zoomIn = document.createElement("button");
|
||||||
|
zoomIn.type = "button";
|
||||||
|
zoomIn.className = "map-zoom";
|
||||||
|
zoomIn.setAttribute("aria-label", "Zoom in");
|
||||||
|
zoomIn.textContent = "+";
|
||||||
|
const reset = document.createElement("button");
|
||||||
|
reset.type = "button";
|
||||||
|
reset.className = "map-zoom";
|
||||||
|
reset.textContent = "Reset";
|
||||||
|
reset.setAttribute("aria-label", "Reset map view");
|
||||||
|
const levelLabel = text("span", "Overview");
|
||||||
|
levelLabel.className = "map-level-label";
|
||||||
|
levelLabel.setAttribute("role", "status");
|
||||||
|
toolbar.append(zoomOut, zoomIn, reset, levelLabel);
|
||||||
|
section.append(toolbar);
|
||||||
|
|
||||||
|
const stage = document.createElement("div");
|
||||||
|
stage.className = "map-stage";
|
||||||
|
stage.tabIndex = 0;
|
||||||
|
stage.setAttribute("aria-label", "Festival map. Drag to pan, use the zoom buttons, or arrow keys. Full facility list follows.");
|
||||||
|
stage.style.touchAction = "none";
|
||||||
|
|
||||||
|
const world = document.createElement("div");
|
||||||
|
world.className = "map-world";
|
||||||
|
const levelSlot = document.createElement("span");
|
||||||
|
levelSlot.className = "map-level";
|
||||||
|
world.append(levelSlot);
|
||||||
|
|
||||||
|
const markers = new Map<string, HTMLButtonElement>();
|
||||||
|
for (const poi of input.pois) {
|
||||||
|
const marker = document.createElement("button");
|
||||||
|
marker.type = "button";
|
||||||
|
marker.className = "map-poi";
|
||||||
|
marker.setAttribute("data-poi-id", poi.id);
|
||||||
|
marker.setAttribute("aria-label", `${poi.name}, ${poi.category}`);
|
||||||
|
marker.title = poi.name;
|
||||||
|
const pos = poiPosition(poi);
|
||||||
|
marker.style.left = pos.left;
|
||||||
|
marker.style.top = pos.top;
|
||||||
|
marker.append(text("span", poi.name));
|
||||||
|
marker.addEventListener("click", () => {
|
||||||
|
selectPoi(poi);
|
||||||
|
});
|
||||||
|
markers.set(poi.id, marker);
|
||||||
|
world.append(marker);
|
||||||
|
}
|
||||||
|
stage.append(world);
|
||||||
|
section.append(stage);
|
||||||
|
|
||||||
|
const renderLevel = (): void => {
|
||||||
|
const level = state.level === "detail" && input.detail ? input.detail : input.overview;
|
||||||
|
if (!level) return;
|
||||||
|
levelSlot.replaceChildren(...levelImages(level));
|
||||||
|
levelLabel.textContent = input.detail && state.level === "detail" ? "Detail" : "Overview";
|
||||||
|
};
|
||||||
|
|
||||||
|
const clampPan = (): void => {
|
||||||
|
const w = stage.clientWidth;
|
||||||
|
const h = stage.clientHeight;
|
||||||
|
if (!w || !h) return;
|
||||||
|
state.x = Math.min(0, Math.max(w - w * state.scale, state.x));
|
||||||
|
state.y = Math.min(0, Math.max(h - h * state.scale, state.y));
|
||||||
|
};
|
||||||
|
|
||||||
|
const apply = (): void => {
|
||||||
|
world.style.transform = `translate(${String(state.x)}px, ${String(state.y)}px) scale(${String(state.scale)})`;
|
||||||
|
world.style.setProperty("--map-scale", String(state.scale));
|
||||||
|
const want = state.scale >= DETAIL_ZOOM_THRESHOLD && input.detail ? "detail" : "overview";
|
||||||
|
if (want !== state.level) {
|
||||||
|
state.level = want;
|
||||||
|
renderLevel();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const zoomAt = (cx: number, cy: number, factor: number): void => {
|
||||||
|
const next = Math.min(MAX_SCALE, Math.max(MIN_SCALE, state.scale * factor));
|
||||||
|
const k = next / state.scale;
|
||||||
|
state.x = cx - (cx - state.x) * k;
|
||||||
|
state.y = cy - (cy - state.y) * k;
|
||||||
|
state.scale = next;
|
||||||
|
clampPan();
|
||||||
|
apply();
|
||||||
|
};
|
||||||
|
|
||||||
|
const center = (): { x: number; y: number } => {
|
||||||
|
const rect = stage.getBoundingClientRect();
|
||||||
|
return { x: rect.width / 2, y: rect.height / 2 };
|
||||||
|
};
|
||||||
|
|
||||||
|
zoomIn.addEventListener("click", () => {
|
||||||
|
const c = center();
|
||||||
|
zoomAt(c.x, c.y, 1.5);
|
||||||
|
});
|
||||||
|
zoomOut.addEventListener("click", () => {
|
||||||
|
const c = center();
|
||||||
|
zoomAt(c.x, c.y, 1 / 1.5);
|
||||||
|
});
|
||||||
|
reset.addEventListener("click", () => {
|
||||||
|
state.scale = 1;
|
||||||
|
state.x = 0;
|
||||||
|
state.y = 0;
|
||||||
|
apply();
|
||||||
|
});
|
||||||
|
stage.addEventListener(
|
||||||
|
"wheel",
|
||||||
|
(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
const rect = stage.getBoundingClientRect();
|
||||||
|
zoomAt(event.clientX - rect.left, event.clientY - rect.top, event.deltaY < 0 ? 1.25 : 1 / 1.25);
|
||||||
|
},
|
||||||
|
{ passive: false },
|
||||||
|
);
|
||||||
|
stage.addEventListener("keydown", (event) => {
|
||||||
|
const step = event.shiftKey ? PAN_STEP * 3 : PAN_STEP;
|
||||||
|
if (event.key === "ArrowLeft") {
|
||||||
|
state.x += step;
|
||||||
|
clampPan();
|
||||||
|
apply();
|
||||||
|
event.preventDefault();
|
||||||
|
} else if (event.key === "ArrowRight") {
|
||||||
|
state.x -= step;
|
||||||
|
clampPan();
|
||||||
|
apply();
|
||||||
|
event.preventDefault();
|
||||||
|
} else if (event.key === "ArrowUp") {
|
||||||
|
state.y += step;
|
||||||
|
clampPan();
|
||||||
|
apply();
|
||||||
|
event.preventDefault();
|
||||||
|
} else if (event.key === "ArrowDown") {
|
||||||
|
state.y -= step;
|
||||||
|
clampPan();
|
||||||
|
apply();
|
||||||
|
event.preventDefault();
|
||||||
|
} else if (event.key === "+" || event.key === "=") {
|
||||||
|
const c = center();
|
||||||
|
zoomAt(c.x, c.y, 1.25);
|
||||||
|
event.preventDefault();
|
||||||
|
} else if (event.key === "-") {
|
||||||
|
const c = center();
|
||||||
|
zoomAt(c.x, c.y, 1 / 1.25);
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Drag pan + two-pointer pinch, all pointer types.
|
||||||
|
const pointers = new Map<number, { x: number; y: number }>();
|
||||||
|
let pinchDistance = 0;
|
||||||
|
stage.addEventListener("pointerdown", (event) => {
|
||||||
|
pointers.set(event.pointerId, { x: event.clientX, y: event.clientY });
|
||||||
|
if (pointers.size === 2) {
|
||||||
|
const [a, b] = [...pointers.values()];
|
||||||
|
if (a && b) pinchDistance = Math.hypot(a.x - b.x, a.y - b.y);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
stage.setPointerCapture?.(event.pointerId);
|
||||||
|
} catch {
|
||||||
|
// jsdom and old browsers lack capture — tracking still works.
|
||||||
|
}
|
||||||
|
});
|
||||||
|
stage.addEventListener("pointermove", (event) => {
|
||||||
|
const prev = pointers.get(event.pointerId);
|
||||||
|
if (!prev) return;
|
||||||
|
const next = { x: event.clientX, y: event.clientY };
|
||||||
|
pointers.set(event.pointerId, next);
|
||||||
|
if (pointers.size === 1) {
|
||||||
|
state.x += next.x - prev.x;
|
||||||
|
state.y += next.y - prev.y;
|
||||||
|
clampPan();
|
||||||
|
apply();
|
||||||
|
} else if (pointers.size === 2) {
|
||||||
|
const [a, b] = [...pointers.values()];
|
||||||
|
if (!a || !b) return;
|
||||||
|
const distance = Math.hypot(a.x - b.x, a.y - b.y);
|
||||||
|
const mid = { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 };
|
||||||
|
const rect = stage.getBoundingClientRect();
|
||||||
|
if (pinchDistance > 0 && distance > 0) {
|
||||||
|
zoomAt(mid.x - rect.left, mid.y - rect.top, distance / pinchDistance);
|
||||||
|
}
|
||||||
|
pinchDistance = distance;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const release = (event: PointerEvent): void => {
|
||||||
|
pointers.delete(event.pointerId);
|
||||||
|
pinchDistance = 0;
|
||||||
|
};
|
||||||
|
stage.addEventListener("pointerup", release);
|
||||||
|
stage.addEventListener("pointercancel", release);
|
||||||
|
|
||||||
|
// Filter application shared by chips + search.
|
||||||
|
const applyMarkerVisibility = (): void => {
|
||||||
|
const visible = new Set(
|
||||||
|
filterPois(input.pois, { categories: state.categories, query: state.query }).map((p) => p.id),
|
||||||
|
);
|
||||||
|
for (const [id, marker] of markers) marker.hidden = !visible.has(id);
|
||||||
|
};
|
||||||
|
stage.addEventListener("mapfilter", () => {
|
||||||
|
applyMarkerVisibility();
|
||||||
|
});
|
||||||
|
|
||||||
|
renderLevel();
|
||||||
|
apply();
|
||||||
|
} else {
|
||||||
|
const card = document.createElement("div");
|
||||||
|
card.className = "view-placeholder";
|
||||||
|
card.append(text("p", "The map image isn't downloaded — the facilities list below still works."));
|
||||||
|
section.append(card);
|
||||||
|
}
|
||||||
|
|
||||||
|
section.append(detail);
|
||||||
|
|
||||||
|
// Category chips + search drive both the markers (via event) and the list.
|
||||||
|
const controls = document.createElement("div");
|
||||||
|
controls.className = "map-controls";
|
||||||
|
const chipBar = document.createElement("div");
|
||||||
|
chipBar.className = "map-chips";
|
||||||
|
chipBar.setAttribute("role", "group");
|
||||||
|
chipBar.setAttribute("aria-label", "Filter facilities by category");
|
||||||
|
const chipButtons: HTMLButtonElement[] = [];
|
||||||
|
const paintChips = (): void => {
|
||||||
|
for (const button of chipButtons) {
|
||||||
|
const category = button.getAttribute("data-category") ?? "";
|
||||||
|
button.setAttribute("aria-pressed", String(category === "" ? state.categories.size === 0 : state.categories.has(category as PoiCategory)));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const allButton = document.createElement("button");
|
||||||
|
allButton.type = "button";
|
||||||
|
allButton.className = "map-chip";
|
||||||
|
allButton.textContent = "All";
|
||||||
|
allButton.setAttribute("data-category", "");
|
||||||
|
allButton.addEventListener("click", () => {
|
||||||
|
state.categories.clear();
|
||||||
|
paintChips();
|
||||||
|
refreshFacilities();
|
||||||
|
section.dispatchEvent(new Event("mapfilter"));
|
||||||
});
|
});
|
||||||
section.append(viewer);
|
chipButtons.push(allButton);
|
||||||
|
chipBar.append(allButton);
|
||||||
|
for (const category of presentCategories(input.pois, input.categories)) {
|
||||||
|
const button = document.createElement("button");
|
||||||
|
button.type = "button";
|
||||||
|
button.className = "map-chip";
|
||||||
|
button.textContent = category;
|
||||||
|
button.setAttribute("data-category", category);
|
||||||
|
button.addEventListener("click", () => {
|
||||||
|
if (state.categories.has(category)) state.categories.delete(category);
|
||||||
|
else state.categories.add(category);
|
||||||
|
paintChips();
|
||||||
|
refreshFacilities();
|
||||||
|
section.dispatchEvent(new Event("mapfilter"));
|
||||||
|
});
|
||||||
|
chipButtons.push(button);
|
||||||
|
chipBar.append(button);
|
||||||
|
}
|
||||||
|
paintChips();
|
||||||
|
controls.append(chipBar);
|
||||||
|
|
||||||
|
const search = document.createElement("input");
|
||||||
|
search.type = "search";
|
||||||
|
search.setAttribute("aria-label", "Search facilities");
|
||||||
|
search.placeholder = "Search facilities…";
|
||||||
|
search.addEventListener("input", () => {
|
||||||
|
state.query = search.value;
|
||||||
|
refreshFacilities();
|
||||||
|
section.dispatchEvent(new Event("mapfilter"));
|
||||||
|
});
|
||||||
|
controls.append(search);
|
||||||
|
section.append(controls);
|
||||||
|
|
||||||
|
// First-class Facilities list.
|
||||||
|
const facilities = document.createElement("div");
|
||||||
|
facilities.className = "map-facilities";
|
||||||
|
section.append(facilities);
|
||||||
|
|
||||||
|
function refreshFacilities(): void {
|
||||||
|
facilities.replaceChildren();
|
||||||
|
const visible = filterPois(input!.pois, { categories: state.categories, query: state.query });
|
||||||
|
if (visible.length === 0) {
|
||||||
|
facilities.append(text("p", input!.pois.length === 0 ? "No facilities listed." : "No facilities match."));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const group of groupFacilities(visible, input!.categories)) {
|
||||||
|
const heading = document.createElement("h2");
|
||||||
|
heading.textContent = `${group.category} (${String(group.pois.length)})`;
|
||||||
|
facilities.append(heading);
|
||||||
|
const list = document.createElement("ul");
|
||||||
|
list.className = "map-facility-list";
|
||||||
|
for (const poi of group.pois) {
|
||||||
|
const item = document.createElement("li");
|
||||||
|
const button = document.createElement("button");
|
||||||
|
button.type = "button";
|
||||||
|
button.className = "map-facility";
|
||||||
|
button.setAttribute("data-poi-id", poi.id);
|
||||||
|
button.textContent = poi.name;
|
||||||
|
button.addEventListener("click", () => {
|
||||||
|
selectPoi(poi);
|
||||||
|
});
|
||||||
|
item.append(button);
|
||||||
|
list.append(item);
|
||||||
|
}
|
||||||
|
facilities.append(list);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
refreshFacilities();
|
||||||
|
|
||||||
return section;
|
return section;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,34 +1,415 @@
|
||||||
import { summit } from "../../../content/summit.js";
|
/**
|
||||||
|
* Schedule view — offline day browsing, Now/Next, filters, search, favorites.
|
||||||
|
* Pure rendering over loaded section data: filter/day/search state lives in
|
||||||
|
* the view (in-place list updates, no route churn); persistence and zone
|
||||||
|
* preference flow out through caller callbacks (B-1 — no IDB here).
|
||||||
|
* All text via textContent (C-22/B-7); times via explicit-zone Intl (F-4).
|
||||||
|
* Trace: IMPLEMENTATION-CONTRACT.md §21.
|
||||||
|
*/
|
||||||
|
import {
|
||||||
|
allTags,
|
||||||
|
artistNames,
|
||||||
|
filterEvents,
|
||||||
|
groupByDay,
|
||||||
|
nowEvents,
|
||||||
|
stageName,
|
||||||
|
upNext,
|
||||||
|
} from "../../../domain/schedule/logic.js";
|
||||||
|
import type { ScheduleFilter } from "../../../domain/schedule/types.js";
|
||||||
|
import { buildMySchedule, myScheduleConflicts } from "../../../domain/favorites/logic.js";
|
||||||
|
import { formatClockTime, formatDayLabel, formatEventTime } from "../../../domain/clock/logic.js";
|
||||||
|
import type { ScheduleEvent, ScheduleSection } from "../../../data/festival-package/types.js";
|
||||||
|
import type { FavoriteEntry } from "../../../data/user/types.js";
|
||||||
|
|
||||||
export function createScheduleView(): HTMLElement {
|
export interface ScheduleClock {
|
||||||
|
readonly now: number;
|
||||||
|
/** Display zone (festival zone or device zone per toggle). */
|
||||||
|
readonly zone: string;
|
||||||
|
readonly festivalZone: string;
|
||||||
|
readonly useDeviceZone: boolean;
|
||||||
|
/** F-3 sanity warning — absolute times stay readable regardless. */
|
||||||
|
readonly outsideWarn: boolean;
|
||||||
|
readonly driftWarn: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ScheduleViewInput {
|
||||||
|
readonly section: ScheduleSection;
|
||||||
|
readonly favorites: ReadonlySet<string>;
|
||||||
|
readonly clock: ScheduleClock;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ScheduleViewActions {
|
||||||
|
/**
|
||||||
|
* Persist a favorite toggle. Resolves true when write-through succeeded;
|
||||||
|
* the view rolls its optimistic flip back on false (no memory-only truth).
|
||||||
|
*/
|
||||||
|
readonly onToggleFavorite: (eventId: string, favored: boolean) => boolean | Promise<boolean>;
|
||||||
|
readonly onZoneToggle: (useDeviceZone: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function text(tag: keyof HTMLElementTagNameMap, value: string): HTMLElement {
|
||||||
|
const element = document.createElement(tag);
|
||||||
|
element.textContent = value;
|
||||||
|
return element;
|
||||||
|
}
|
||||||
|
|
||||||
|
function eventTime(event: ScheduleEvent, zone: string): string {
|
||||||
|
return `${formatEventTime(event.startUtc, zone)} – ${formatClockTime(event.endUtc, zone)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusBadge(event: ScheduleEvent, zone: string): HTMLSpanElement | null {
|
||||||
|
if (event.status === "scheduled") return null;
|
||||||
|
const badge = document.createElement("span");
|
||||||
|
badge.className = `schedule-badge schedule-badge--${event.status}`;
|
||||||
|
if (event.status === "moved" && event.originalStartUtc !== undefined) {
|
||||||
|
badge.textContent = `Moved (was ${formatEventTime(event.originalStartUtc, zone)})`;
|
||||||
|
} else {
|
||||||
|
badge.textContent = event.status === "moved" ? "Moved" : "Cancelled";
|
||||||
|
}
|
||||||
|
return badge;
|
||||||
|
}
|
||||||
|
|
||||||
|
function eventCard(
|
||||||
|
event: ScheduleEvent,
|
||||||
|
section: ScheduleSection,
|
||||||
|
zone: string,
|
||||||
|
favored: boolean,
|
||||||
|
onToggleFavorite: (eventId: string, favored: boolean) => void,
|
||||||
|
): HTMLElement {
|
||||||
|
const card = document.createElement("article");
|
||||||
|
card.className = "schedule-item";
|
||||||
|
card.setAttribute("data-event-id", event.id);
|
||||||
|
|
||||||
|
const title = document.createElement("h2");
|
||||||
|
title.textContent = event.title;
|
||||||
|
card.append(title);
|
||||||
|
|
||||||
|
card.append(text("p", eventTime(event, zone)));
|
||||||
|
const names = artistNames(section, event.artistIds);
|
||||||
|
card.append(
|
||||||
|
text(
|
||||||
|
"p",
|
||||||
|
names ? `${stageName(section, event.stageId)} · ${names}` : stageName(section, event.stageId),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (event.description) card.append(text("p", event.description));
|
||||||
|
|
||||||
|
const badge = statusBadge(event, zone);
|
||||||
|
if (badge) card.append(badge);
|
||||||
|
|
||||||
|
const fav = document.createElement("button");
|
||||||
|
fav.type = "button";
|
||||||
|
fav.className = "schedule-fav";
|
||||||
|
fav.setAttribute("aria-pressed", String(favored));
|
||||||
|
fav.setAttribute(
|
||||||
|
"aria-label",
|
||||||
|
favored ? `Remove ${event.title} from My Schedule` : `Add ${event.title} to My Schedule`,
|
||||||
|
);
|
||||||
|
fav.textContent = favored ? "★" : "☆";
|
||||||
|
const paintFav = (value: boolean): void => {
|
||||||
|
fav.setAttribute("aria-pressed", String(value));
|
||||||
|
fav.textContent = value ? "★" : "☆";
|
||||||
|
fav.setAttribute(
|
||||||
|
"aria-label",
|
||||||
|
value ? `Remove ${event.title} from My Schedule` : `Add ${event.title} to My Schedule`,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
fav.addEventListener("click", () => {
|
||||||
|
const next = fav.getAttribute("aria-pressed") !== "true";
|
||||||
|
paintFav(next);
|
||||||
|
onToggleFavorite(event.id, next);
|
||||||
|
});
|
||||||
|
card.append(fav);
|
||||||
|
return card;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render the schedule. With null input the view shows the honest
|
||||||
|
* not-downloaded card (PARTIAL/NOT_READY states) — never blank.
|
||||||
|
*/
|
||||||
|
export function createScheduleView(
|
||||||
|
input: ScheduleViewInput | null,
|
||||||
|
actions: ScheduleViewActions,
|
||||||
|
): HTMLElement {
|
||||||
const section = document.createElement("section");
|
const section = document.createElement("section");
|
||||||
|
section.className = "schedule-view";
|
||||||
section.setAttribute("aria-labelledby", "schedule-heading");
|
section.setAttribute("aria-labelledby", "schedule-heading");
|
||||||
|
|
||||||
const h1 = document.createElement("h1");
|
const h1 = document.createElement("h1");
|
||||||
h1.id = "schedule-heading";
|
h1.id = "schedule-heading";
|
||||||
h1.textContent = "Schedule";
|
h1.textContent = "Schedule";
|
||||||
section.append(h1);
|
section.append(h1);
|
||||||
const p = document.createElement("p");
|
|
||||||
p.textContent = `${summit.dates}: the public site organizes the summit around four themed days.`;
|
if (!input) {
|
||||||
section.append(p);
|
const card = document.createElement("div");
|
||||||
const card = document.createElement("div");
|
card.className = "view-placeholder";
|
||||||
card.className = "summit-card";
|
card.append(text("p", "The schedule isn't on this device yet."));
|
||||||
const themes: readonly [string, string, string][] = [
|
card.append(
|
||||||
["Thursday, October 8", "Air", "Communication and personal development"],
|
text(
|
||||||
["Friday, October 9", "Earth", "Regenerative culture and permaculture"],
|
"p",
|
||||||
["Saturday, October 10", "Fire", "Innovation, entrepreneurship, and technology"],
|
"Open with internet to get festival data — the emergency floor always works meanwhile.",
|
||||||
["Sunday, October 11", "Water", "Consciousness, spirituality, and integration"],
|
),
|
||||||
["Monday, October 12", "Closing", "Community reflection and carry-forward"],
|
);
|
||||||
];
|
section.append(card);
|
||||||
for (const [day, name, description] of themes) {
|
return section;
|
||||||
const item = document.createElement("div");
|
|
||||||
item.className = "schedule-item";
|
|
||||||
const title = document.createElement("h2");
|
|
||||||
title.textContent = `${day}: ${name}`;
|
|
||||||
const detail = document.createElement("p");
|
|
||||||
detail.textContent = description;
|
|
||||||
item.append(title, detail);
|
|
||||||
card.append(item);
|
|
||||||
}
|
}
|
||||||
section.append(card);
|
|
||||||
|
const { section: data, clock } = input;
|
||||||
|
const favs = new Set(input.favorites);
|
||||||
|
const state: {
|
||||||
|
day: string | null;
|
||||||
|
stage: string | null;
|
||||||
|
tag: string | null;
|
||||||
|
query: string;
|
||||||
|
mine: boolean;
|
||||||
|
} = {
|
||||||
|
day: null,
|
||||||
|
stage: null,
|
||||||
|
tag: null,
|
||||||
|
query: "",
|
||||||
|
mine: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (clock.outsideWarn || clock.driftWarn) {
|
||||||
|
const warn = text(
|
||||||
|
"p",
|
||||||
|
clock.driftWarn
|
||||||
|
? "The device clock changed mid-session — times re-anchored. Event times below are absolute."
|
||||||
|
: "The device clock looks off — Now/Next may be wrong, but the absolute event times below still read fine.",
|
||||||
|
);
|
||||||
|
warn.className = "schedule-warning";
|
||||||
|
warn.setAttribute("role", "status");
|
||||||
|
section.append(warn);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Now / Up Next (epoch predicates only — SPIKE-08 T7).
|
||||||
|
const live = document.createElement("section");
|
||||||
|
live.setAttribute("aria-labelledby", "schedule-now-heading");
|
||||||
|
const liveHeading = document.createElement("h2");
|
||||||
|
liveHeading.id = "schedule-now-heading";
|
||||||
|
liveHeading.textContent = "Now & Next";
|
||||||
|
live.append(liveHeading);
|
||||||
|
const liveList = document.createElement("div");
|
||||||
|
liveList.className = "schedule-live";
|
||||||
|
live.append(liveList);
|
||||||
|
section.append(live);
|
||||||
|
|
||||||
|
// Controls: day tabs, stage select, tag chips, favorites, search.
|
||||||
|
const controls = document.createElement("div");
|
||||||
|
controls.className = "schedule-controls";
|
||||||
|
|
||||||
|
const days = groupByDay(data);
|
||||||
|
state.day ??=
|
||||||
|
days.find((d) => d.events.some((e) => e.startUtc <= clock.now && clock.now < e.endUtc))
|
||||||
|
?.dayKey ??
|
||||||
|
days.find((d) => d.events.some((e) => e.startUtc > clock.now))?.dayKey ??
|
||||||
|
days[0]?.dayKey ??
|
||||||
|
null;
|
||||||
|
const dayBar = document.createElement("div");
|
||||||
|
dayBar.className = "schedule-days";
|
||||||
|
dayBar.setAttribute("role", "group");
|
||||||
|
dayBar.setAttribute("aria-label", "Festival days");
|
||||||
|
const dayButtons = new Map<string, HTMLButtonElement>();
|
||||||
|
for (const day of days) {
|
||||||
|
const button = document.createElement("button");
|
||||||
|
button.type = "button";
|
||||||
|
button.className = "schedule-day";
|
||||||
|
button.textContent = formatDayLabel(day.anchorUtc, clock.zone);
|
||||||
|
button.setAttribute("aria-pressed", String(state.day === day.dayKey));
|
||||||
|
button.addEventListener("click", () => {
|
||||||
|
state.day = day.dayKey;
|
||||||
|
for (const [key, other] of dayButtons)
|
||||||
|
other.setAttribute("aria-pressed", String(key === day.dayKey));
|
||||||
|
refresh();
|
||||||
|
});
|
||||||
|
dayButtons.set(day.dayKey, button);
|
||||||
|
dayBar.append(button);
|
||||||
|
}
|
||||||
|
controls.append(dayBar);
|
||||||
|
|
||||||
|
const stageSelect = document.createElement("select");
|
||||||
|
stageSelect.setAttribute("aria-label", "Filter by stage");
|
||||||
|
const allStages = document.createElement("option");
|
||||||
|
allStages.value = "";
|
||||||
|
allStages.textContent = "All stages";
|
||||||
|
stageSelect.append(allStages);
|
||||||
|
for (const stage of data.stages) {
|
||||||
|
const option = document.createElement("option");
|
||||||
|
option.value = stage.id;
|
||||||
|
option.textContent = stage.name;
|
||||||
|
stageSelect.append(option);
|
||||||
|
}
|
||||||
|
stageSelect.addEventListener("change", () => {
|
||||||
|
state.stage = stageSelect.value || null;
|
||||||
|
refresh();
|
||||||
|
});
|
||||||
|
controls.append(stageSelect);
|
||||||
|
|
||||||
|
const tagBar = document.createElement("div");
|
||||||
|
tagBar.className = "schedule-tags";
|
||||||
|
tagBar.setAttribute("role", "group");
|
||||||
|
tagBar.setAttribute("aria-label", "Filter by tag");
|
||||||
|
const tagButtons = new Map<string, HTMLButtonElement>();
|
||||||
|
const paintTags = (): void => {
|
||||||
|
for (const [key, other] of tagButtons)
|
||||||
|
other.setAttribute("aria-pressed", String(key === (state.tag ?? "")));
|
||||||
|
};
|
||||||
|
const allTagsButton = document.createElement("button");
|
||||||
|
allTagsButton.type = "button";
|
||||||
|
allTagsButton.className = "schedule-tag";
|
||||||
|
allTagsButton.textContent = "All";
|
||||||
|
allTagsButton.addEventListener("click", () => {
|
||||||
|
state.tag = null;
|
||||||
|
paintTags();
|
||||||
|
refresh();
|
||||||
|
});
|
||||||
|
tagButtons.set("", allTagsButton);
|
||||||
|
tagBar.append(allTagsButton);
|
||||||
|
for (const tag of allTags(data)) {
|
||||||
|
const button = document.createElement("button");
|
||||||
|
button.type = "button";
|
||||||
|
button.className = "schedule-tag";
|
||||||
|
button.textContent = tag;
|
||||||
|
button.addEventListener("click", () => {
|
||||||
|
state.tag = state.tag === tag ? null : tag;
|
||||||
|
paintTags();
|
||||||
|
refresh();
|
||||||
|
});
|
||||||
|
tagButtons.set(tag, button);
|
||||||
|
tagBar.append(button);
|
||||||
|
}
|
||||||
|
paintTags();
|
||||||
|
controls.append(tagBar);
|
||||||
|
|
||||||
|
const search = document.createElement("input");
|
||||||
|
search.type = "search";
|
||||||
|
search.setAttribute("aria-label", "Search schedule");
|
||||||
|
search.placeholder = "Search artists, titles, stages…";
|
||||||
|
search.addEventListener("input", () => {
|
||||||
|
state.query = search.value;
|
||||||
|
refresh();
|
||||||
|
});
|
||||||
|
controls.append(search);
|
||||||
|
|
||||||
|
const mineToggle = document.createElement("button");
|
||||||
|
mineToggle.type = "button";
|
||||||
|
mineToggle.className = "schedule-mine";
|
||||||
|
const paintMine = (): void => {
|
||||||
|
mineToggle.setAttribute("aria-pressed", String(state.mine));
|
||||||
|
mineToggle.textContent = `★ Mine (${String(favs.size)})`;
|
||||||
|
};
|
||||||
|
mineToggle.addEventListener("click", () => {
|
||||||
|
state.mine = !state.mine;
|
||||||
|
paintMine();
|
||||||
|
refresh();
|
||||||
|
});
|
||||||
|
paintMine();
|
||||||
|
controls.append(mineToggle);
|
||||||
|
|
||||||
|
const zoneToggle = document.createElement("button");
|
||||||
|
zoneToggle.type = "button";
|
||||||
|
zoneToggle.className = "schedule-zone";
|
||||||
|
zoneToggle.textContent = clock.useDeviceZone ? "Showing device time" : "Showing festival time";
|
||||||
|
zoneToggle.addEventListener("click", () => {
|
||||||
|
actions.onZoneToggle(!clock.useDeviceZone);
|
||||||
|
});
|
||||||
|
controls.append(zoneToggle);
|
||||||
|
|
||||||
|
section.append(controls);
|
||||||
|
|
||||||
|
const list = document.createElement("div");
|
||||||
|
list.className = "schedule-list";
|
||||||
|
section.append(list);
|
||||||
|
|
||||||
|
const toggleFavorite = (eventId: string, favored: boolean): void => {
|
||||||
|
if (favored) favs.add(eventId);
|
||||||
|
else favs.delete(eventId);
|
||||||
|
paintMine();
|
||||||
|
// Persistence may be async — failure only affects the next render, which
|
||||||
|
// re-reads the store (the view never owns truth).
|
||||||
|
void Promise.resolve(actions.onToggleFavorite(eventId, favored)).catch(() => {
|
||||||
|
// Persistence failure is silent here by design — see above.
|
||||||
|
});
|
||||||
|
refreshConflicts();
|
||||||
|
};
|
||||||
|
|
||||||
|
const conflictsNote = document.createElement("p");
|
||||||
|
conflictsNote.className = "schedule-conflicts";
|
||||||
|
conflictsNote.setAttribute("role", "status");
|
||||||
|
const refreshConflicts = (): void => {
|
||||||
|
const items = buildMySchedule(
|
||||||
|
data,
|
||||||
|
[...favs].map((eventId): FavoriteEntry => ({ eventId, addedAt: 0 })),
|
||||||
|
);
|
||||||
|
const conflicts = myScheduleConflicts(items);
|
||||||
|
const first = conflicts[0];
|
||||||
|
if (state.mine && first) {
|
||||||
|
const [a, b] = first;
|
||||||
|
conflictsNote.textContent = `Overlap in My Schedule: ${a.title} × ${b.title}${conflicts.length > 1 ? ` (+${String(conflicts.length - 1)} more)` : ""}`;
|
||||||
|
} else {
|
||||||
|
conflictsNote.textContent = "";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
section.append(conflictsNote);
|
||||||
|
|
||||||
|
function currentFilter(): ScheduleFilter {
|
||||||
|
// Conditional spreads satisfy exactOptionalPropertyTypes — absent means unfiltered.
|
||||||
|
return {
|
||||||
|
...(state.day ? { dayKey: state.day } : {}),
|
||||||
|
...(state.stage ? { stageId: state.stage } : {}),
|
||||||
|
...(state.tag ? { tag: state.tag } : {}),
|
||||||
|
...(state.query ? { query: state.query } : {}),
|
||||||
|
...(state.mine ? { favoritesOnly: true as const, favoriteIds: favs } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function refresh(): void {
|
||||||
|
const filter = currentFilter();
|
||||||
|
const events = filterEvents(data, filter);
|
||||||
|
// Up Next respects the stage filter (per-stage up-next); Now stays global.
|
||||||
|
const scoped = state.stage ? data.events.filter((e) => e.stageId === state.stage) : data.events;
|
||||||
|
const happening = nowEvents(data.events, clock.now);
|
||||||
|
const upcoming = upNext(scoped, clock.now, 3);
|
||||||
|
|
||||||
|
liveList.replaceChildren();
|
||||||
|
if (happening.length > 0) {
|
||||||
|
liveList.append(text("h3", "On now"));
|
||||||
|
for (const event of happening) {
|
||||||
|
liveList.append(eventCard(event, data, clock.zone, favs.has(event.id), toggleFavorite));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (upcoming.length > 0) {
|
||||||
|
liveList.append(text("h3", state.stage ? "Next on this stage" : "Up next"));
|
||||||
|
for (const event of upcoming) {
|
||||||
|
liveList.append(eventCard(event, data, clock.zone, favs.has(event.id), toggleFavorite));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (happening.length === 0 && upcoming.length === 0) {
|
||||||
|
liveList.append(text("p", "Nothing scheduled around this time."));
|
||||||
|
}
|
||||||
|
|
||||||
|
list.replaceChildren();
|
||||||
|
if (events.length === 0) {
|
||||||
|
list.append(
|
||||||
|
text(
|
||||||
|
"p",
|
||||||
|
state.mine ? "No favorites yet — tap ☆ on any event." : "No events match these filters.",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const activeDay = state.day ?? days[0]?.dayKey;
|
||||||
|
const dayLabel = days.find((d) => d.dayKey === activeDay);
|
||||||
|
if (dayLabel && !state.mine && !state.query) {
|
||||||
|
list.append(text("h2", formatDayLabel(dayLabel.anchorUtc, clock.zone)));
|
||||||
|
}
|
||||||
|
for (const event of events) {
|
||||||
|
list.append(eventCard(event, data, clock.zone, favs.has(event.id), toggleFavorite));
|
||||||
|
}
|
||||||
|
refreshConflicts();
|
||||||
|
}
|
||||||
|
|
||||||
|
refresh();
|
||||||
|
refreshConflicts();
|
||||||
return section;
|
return section;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,2 +1,7 @@
|
||||||
# ui/views/status
|
# ui/views/status
|
||||||
View for status — may import domain only. B-1, §7. Stage 1: no code.
|
|
||||||
|
Status screen (§20.5) — pure rendering over a BootReadinessReport plus
|
||||||
|
caller-provided action callbacks. May import domain only. B-1, §7.
|
||||||
|
|
||||||
|
Stage 9: `status.ts` (`createStatusView`, `createPrepGuidanceView`). All text
|
||||||
|
via textContent (C-22/B-7). Dialog lifecycle is owned by `src/app/main.ts`.
|
||||||
|
|
|
||||||
191
src/ui/views/status/status.ts
Normal file
191
src/ui/views/status/status.ts
Normal file
|
|
@ -0,0 +1,191 @@
|
||||||
|
/**
|
||||||
|
* Status screen — readiness detail for the header chip (§20.5).
|
||||||
|
* Pure rendering over a BootReadinessReport: per-section checklist, dataset
|
||||||
|
* version, storage usage, and state-appropriate actions. No persistence, no
|
||||||
|
* fetch, no clocks for verdicts — actions are caller-provided callbacks.
|
||||||
|
* All text via textContent (C-22/B-7).
|
||||||
|
* Trace: IMPLEMENTATION-CONTRACT.md §20.5, SPIKE-05 §3.
|
||||||
|
*/
|
||||||
|
import type { BootReadinessReport, SectionStatus } from "../../../domain/readiness/types.js";
|
||||||
|
|
||||||
|
export interface StatusActions {
|
||||||
|
/** Re-run the readiness evaluation ("Check my data" / "Retry"). */
|
||||||
|
readonly onCheckData: () => void;
|
||||||
|
/** Preparation guidance ("Get festival data" / "Continue setup" / "Restore"). */
|
||||||
|
readonly onGetData: () => void;
|
||||||
|
/** User-initiated restore of the retained slot; null when unavailable. */
|
||||||
|
readonly onRestore: (() => void) | null;
|
||||||
|
readonly onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function text(tag: keyof HTMLElementTagNameMap, value: string): HTMLElement {
|
||||||
|
const element = document.createElement(tag);
|
||||||
|
element.textContent = value;
|
||||||
|
return element;
|
||||||
|
}
|
||||||
|
|
||||||
|
function actionButton(label: string, handler: () => void, primary = false): HTMLButtonElement {
|
||||||
|
const button = document.createElement("button");
|
||||||
|
button.type = "button";
|
||||||
|
button.className = primary ? "status-action status-action--primary" : "status-action";
|
||||||
|
button.textContent = label;
|
||||||
|
button.addEventListener("click", handler);
|
||||||
|
return button;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatBytes(bytes: number | undefined): string {
|
||||||
|
if (bytes === undefined) return "unknown";
|
||||||
|
if (bytes < 1024) return `${String(bytes)} B`;
|
||||||
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||||
|
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sectionRow(section: SectionStatus): HTMLLIElement {
|
||||||
|
const item = document.createElement("li");
|
||||||
|
item.className = "status-check";
|
||||||
|
const mark = document.createElement("span");
|
||||||
|
mark.className = "status-check__mark";
|
||||||
|
mark.setAttribute("aria-hidden", "true");
|
||||||
|
const ok = section.present && section.parseable;
|
||||||
|
mark.textContent = ok ? "✓" : "✗";
|
||||||
|
item.classList.toggle("status-check--ok", ok);
|
||||||
|
item.classList.toggle("status-check--missing", !ok);
|
||||||
|
const label = document.createElement("span");
|
||||||
|
const req = section.required ? "required" : "optional";
|
||||||
|
label.textContent = `${section.id} — ${ok ? "present" : section.present ? "unreadable" : "missing"} (${req})`;
|
||||||
|
item.append(mark, label);
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render the status detail for a readiness report. The caller owns
|
||||||
|
* dialog/overlay lifecycle; this returns fresh content on every call so the
|
||||||
|
* caller can re-render after re-evaluation.
|
||||||
|
*/
|
||||||
|
export function createStatusView(report: BootReadinessReport, actions: StatusActions): HTMLElement {
|
||||||
|
const section = document.createElement("section");
|
||||||
|
section.className = "status-view";
|
||||||
|
section.setAttribute("aria-labelledby", "status-heading");
|
||||||
|
|
||||||
|
const h1 = document.createElement("h1");
|
||||||
|
h1.id = "status-heading";
|
||||||
|
h1.textContent = "Offline status";
|
||||||
|
section.append(h1);
|
||||||
|
|
||||||
|
const stateLine = text("p", `State: ${report.state}`);
|
||||||
|
stateLine.className = `status-state status-state--${report.state.toLowerCase().replace("_", "-")}`;
|
||||||
|
section.append(stateLine);
|
||||||
|
|
||||||
|
if (report.reason) section.append(text("p", `Reason: ${report.reason}`));
|
||||||
|
if (report.failedOp) section.append(text("p", `Failed operation: ${report.failedOp}`));
|
||||||
|
if (report.missing.length > 0) {
|
||||||
|
section.append(text("p", `Missing: ${report.missing.join(", ")}`));
|
||||||
|
}
|
||||||
|
section.append(text("p", `Preparation level: ${report.level}`));
|
||||||
|
|
||||||
|
if (report.sections.length > 0) {
|
||||||
|
section.append(text("h2", "Sections"));
|
||||||
|
const list = document.createElement("ul");
|
||||||
|
list.className = "status-checklist";
|
||||||
|
for (const item of report.sections) list.append(sectionRow(item));
|
||||||
|
section.append(list);
|
||||||
|
}
|
||||||
|
|
||||||
|
const dataset = text(
|
||||||
|
"p",
|
||||||
|
report.packageVersion === null
|
||||||
|
? "Dataset: none"
|
||||||
|
: `Dataset: ${report.edition ?? "unknown edition"} v${String(report.packageVersion)} · generated ${report.generatedAt ?? "unknown"}`,
|
||||||
|
);
|
||||||
|
dataset.className = "status-dataset";
|
||||||
|
section.append(dataset);
|
||||||
|
|
||||||
|
if (report.fetchedAt !== null) {
|
||||||
|
section.append(
|
||||||
|
text("p", `Verified on this device: ${new Date(report.fetchedAt).toISOString()}`),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
section.append(
|
||||||
|
text("p", `Assets: ${String(report.assets.present)}/${String(report.assets.total)} present`),
|
||||||
|
);
|
||||||
|
if (report.floorVersion) {
|
||||||
|
section.append(text("p", `Emergency floor: v${report.floorVersion} (always on this device)`));
|
||||||
|
}
|
||||||
|
const usage = report.storage;
|
||||||
|
const storageText =
|
||||||
|
usage === null || (usage.usage === undefined && usage.quota === undefined)
|
||||||
|
? "Storage: unavailable in this browser"
|
||||||
|
: `Storage: ${formatBytes(usage.usage)} used of ${formatBytes(usage.quota)}${report.shellCache ? ` · shell ${report.shellCache}` : ""}`;
|
||||||
|
section.append(text("p", storageText));
|
||||||
|
|
||||||
|
const buttons = document.createElement("div");
|
||||||
|
buttons.className = "status-actions";
|
||||||
|
|
||||||
|
switch (report.state) {
|
||||||
|
case "NOT_READY":
|
||||||
|
buttons.append(actionButton("Get festival data", actions.onGetData, true));
|
||||||
|
break;
|
||||||
|
case "PARTIAL":
|
||||||
|
buttons.append(
|
||||||
|
actionButton(
|
||||||
|
report.activationPending ? "Staged data found — continue setup" : "Continue setup",
|
||||||
|
actions.onGetData,
|
||||||
|
true,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case "RECOVERY":
|
||||||
|
buttons.append(actionButton("Restore festival data", actions.onGetData, true));
|
||||||
|
if (actions.onRestore) {
|
||||||
|
buttons.append(actionButton("Restore previous version", actions.onRestore));
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "FAILED":
|
||||||
|
buttons.append(actionButton("Retry", actions.onCheckData, true));
|
||||||
|
section.append(
|
||||||
|
text(
|
||||||
|
"p",
|
||||||
|
"If this persists, restore festival data when online. Diagnostics stay on this device.",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case "BASELINE_ONLY":
|
||||||
|
section.append(
|
||||||
|
text(
|
||||||
|
"p",
|
||||||
|
"This browser cannot save offline data (private mode, blocked website data, or no space). Install Lumen to the home screen, use a normal browsing window, or free storage — emergency info below still works offline.",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case "READY":
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
buttons.append(actionButton("Check my data", actions.onCheckData));
|
||||||
|
buttons.append(actionButton("Close", actions.onClose));
|
||||||
|
section.append(buttons);
|
||||||
|
|
||||||
|
return section;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Preparation guidance shown for Get/Continue/Restore — sync flow lands in Stage 15. */
|
||||||
|
export function createPrepGuidanceView(onBack: () => void, onClose: () => void): HTMLElement {
|
||||||
|
const section = document.createElement("section");
|
||||||
|
section.className = "status-view";
|
||||||
|
section.setAttribute("aria-labelledby", "prep-heading");
|
||||||
|
const h1 = document.createElement("h1");
|
||||||
|
h1.id = "prep-heading";
|
||||||
|
h1.textContent = "Get festival data";
|
||||||
|
section.append(h1);
|
||||||
|
section.append(
|
||||||
|
text(
|
||||||
|
"p",
|
||||||
|
"Festival data preparation needs an internet connection. Open Lumen online and return here — one-tap download with resume, verification, and OFFLINE READY confirmation arrives with the sync stage. Your emergency floor below always works, with or without it.",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const buttons = document.createElement("div");
|
||||||
|
buttons.className = "status-actions";
|
||||||
|
buttons.append(actionButton("Back to status", onBack, true));
|
||||||
|
buttons.append(actionButton("Close", onClose));
|
||||||
|
section.append(buttons);
|
||||||
|
return section;
|
||||||
|
}
|
||||||
|
|
@ -341,30 +341,37 @@ describe("dataset metadata", () => {
|
||||||
|
|
||||||
describe("readiness inputs (time-independent per SPIKE-05)", () => {
|
describe("readiness inputs (time-independent per SPIKE-05)", () => {
|
||||||
it("readiness is data-driven, not clock", async () => {
|
it("readiness is data-driven, not clock", async () => {
|
||||||
const { computeReadiness } = await import("../../src/domain/readiness/types.js");
|
const { evaluateReadiness } = await import("../../src/domain/readiness/logic.js");
|
||||||
|
const allOk = {
|
||||||
|
shellValid: true,
|
||||||
|
datasetPresent: true,
|
||||||
|
authenticity: "verified" as const,
|
||||||
|
integrity: "ok" as const,
|
||||||
|
schemaCompatible: true,
|
||||||
|
requiredSectionsOk: true,
|
||||||
|
requiredAssetsOk: true,
|
||||||
|
emergencyFloorOk: true,
|
||||||
|
missing: [] as readonly string[],
|
||||||
|
hadVerifiedDataset: true,
|
||||||
|
manifestOk: true,
|
||||||
|
incompatible: false,
|
||||||
|
failure: null,
|
||||||
|
presentSections: ["emergency", "schedule", "info", "map", "assets"] as readonly string[],
|
||||||
|
stagedPending: null,
|
||||||
|
};
|
||||||
|
expect(evaluateReadiness(allOk).state).toBe("READY");
|
||||||
expect(
|
expect(
|
||||||
computeReadiness({
|
evaluateReadiness({
|
||||||
shellValid: true,
|
...allOk,
|
||||||
datasetPresent: true,
|
|
||||||
authenticityOk: true,
|
|
||||||
integrityOk: true,
|
|
||||||
schemaCompatible: true,
|
|
||||||
requiredSectionsOk: true,
|
|
||||||
requiredAssetsOk: true,
|
|
||||||
emergencyFloorOk: true,
|
|
||||||
}),
|
|
||||||
).toBe("READY");
|
|
||||||
expect(
|
|
||||||
computeReadiness({
|
|
||||||
shellValid: true,
|
|
||||||
datasetPresent: false,
|
datasetPresent: false,
|
||||||
authenticityOk: false,
|
authenticity: "record-missing",
|
||||||
integrityOk: false,
|
integrity: "files-missing",
|
||||||
schemaCompatible: false,
|
schemaCompatible: false,
|
||||||
requiredSectionsOk: false,
|
requiredSectionsOk: false,
|
||||||
requiredAssetsOk: false,
|
requiredAssetsOk: false,
|
||||||
emergencyFloorOk: true,
|
hadVerifiedDataset: false,
|
||||||
}),
|
presentSections: [],
|
||||||
|
}).state,
|
||||||
).toBe("NOT_READY");
|
).toBe("NOT_READY");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
350
tests/unit/emergency.test.ts
Normal file
350
tests/unit/emergency.test.ts
Normal file
|
|
@ -0,0 +1,350 @@
|
||||||
|
/**
|
||||||
|
* @vitest-environment jsdom
|
||||||
|
* Stage 10 — Emergency three tiers: resolution matrix, Tier 2 rendering,
|
||||||
|
* demotion guidance, zero-IDB floor, loader, and boot wiring.
|
||||||
|
* Trace: IMPLEMENTATION-CONTRACT.md §14, SPIKE-06.
|
||||||
|
*/
|
||||||
|
/* eslint-disable @typescript-eslint/no-unsafe-call */
|
||||||
|
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||||
|
// @ts-expect-error fake-indexeddb types via exports fallback
|
||||||
|
import FDBFactory from "fake-indexeddb/lib/FDBFactory";
|
||||||
|
// @ts-expect-error fake-indexeddb types via exports fallback
|
||||||
|
import FDBKeyRange from "fake-indexeddb/lib/FDBKeyRange";
|
||||||
|
import { mount as boot } from "../../src/app/main.js";
|
||||||
|
import { loadDatasetEmergency } from "../../src/app/emergency.js";
|
||||||
|
import {
|
||||||
|
demotedBaseline,
|
||||||
|
isSupportedEmergencySchema,
|
||||||
|
narrowEmergencySection,
|
||||||
|
resolveEmergency,
|
||||||
|
visibleNotices,
|
||||||
|
} from "../../src/domain/emergency/logic.js";
|
||||||
|
import type { VerifiedEmergencyData } from "../../src/domain/emergency/types.js";
|
||||||
|
import type { EmergencySection } from "../../src/data/festival-package/types.js";
|
||||||
|
import { createEmergencyView } from "../../src/ui/views/emergency/emergency.js";
|
||||||
|
import { buildPackage } from "../../pipeline/package.js";
|
||||||
|
import { generateTestKeyPair } from "../../pipeline/sign.js";
|
||||||
|
import { makeValidInput } from "../../pipeline/fixtures.js";
|
||||||
|
import { canonicalJson } from "../../pipeline/canonical-json.js";
|
||||||
|
import { publicKeyFromDerBase64 } from "../../src/sync/verifier/ed25519.js";
|
||||||
|
import { verifyPackage } from "../../src/sync/verifier/package.js";
|
||||||
|
import { activateStagedPackage, stageVerifiedPackage } from "../../src/sync/activation.js";
|
||||||
|
import { openSystemDB } from "../../src/data/system-meta/store.js";
|
||||||
|
import { openSlotDB, writeSlotFile } from "../../src/data/slot/store.js";
|
||||||
|
import { DB } from "../../src/platform/idb/names.js";
|
||||||
|
|
||||||
|
const g = globalThis as unknown as Record<string, unknown>;
|
||||||
|
const NOW = 1_750_000_000_000;
|
||||||
|
|
||||||
|
function section(patch: Record<string, unknown> = {}): EmergencySection {
|
||||||
|
return {
|
||||||
|
section: "emergency",
|
||||||
|
emergencySchemaVersion: 1,
|
||||||
|
contentVersion: 2,
|
||||||
|
updatedAt: "2026-09-01T00:00:00.000Z",
|
||||||
|
services: {
|
||||||
|
emergencyNumber: "911",
|
||||||
|
security: { phone: "555-0100", location: "North gate" },
|
||||||
|
firstAid: { location: "Medical tent", hours: "10:00–22:00" },
|
||||||
|
},
|
||||||
|
locations: {
|
||||||
|
musterPoints: [{ id: "m1", name: "North field", poi: "poi-muster" }],
|
||||||
|
exits: [{ id: "e1", name: "Main gate", poi: "poi-exit" }],
|
||||||
|
aeds: [{ poi: "poi-aed" }],
|
||||||
|
},
|
||||||
|
address: { lines: ["1 Festival Way"], coordinates: { lat: 29.9, lon: -97.9 } },
|
||||||
|
procedures: [{ id: "medical", title: "Medical", steps: ["Call 911", "Find staff"] }],
|
||||||
|
notices: [
|
||||||
|
{
|
||||||
|
id: "n1",
|
||||||
|
severity: "info",
|
||||||
|
title: "Gates open",
|
||||||
|
body: ["Gates open at 9am"],
|
||||||
|
expiresAtUtc: null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
...patch,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function dataset(patch: Partial<VerifiedEmergencyData> = {}): VerifiedEmergencyData {
|
||||||
|
return {
|
||||||
|
section: section(),
|
||||||
|
packageVersion: 3,
|
||||||
|
generatedAt: "2026-08-20T00:00:00.000Z",
|
||||||
|
emergencySchemaVersion: 1,
|
||||||
|
...patch,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("resolveEmergency — tier matrix (SPIKE-06)", () => {
|
||||||
|
it("no dataset → baseline with no-dataset demotion", () => {
|
||||||
|
const model = resolveEmergency(undefined, NOW);
|
||||||
|
expect(model.source).toBe("baseline");
|
||||||
|
expect(model.demotion).toBe("no-dataset");
|
||||||
|
expect(model.provenance).toMatch(/^BASELINE v/);
|
||||||
|
expect(model.section).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("valid dataset → dataset tier with merged floor fields and provenance", () => {
|
||||||
|
const sparse = section();
|
||||||
|
const { services: _dropServices, ...withoutServices } = sparse;
|
||||||
|
void _dropServices;
|
||||||
|
const model = resolveEmergency(dataset({ section: withoutServices as EmergencySection }), NOW);
|
||||||
|
expect(model.source).toBe("dataset");
|
||||||
|
expect(model.demotion).toBeNull();
|
||||||
|
// Floor-only services backfill the sparse section (defensive merge).
|
||||||
|
expect(model.section?.services.emergencyNumber).toBeTruthy();
|
||||||
|
expect(model.provenance).toContain("FESTIVAL DATA v3");
|
||||||
|
expect(model.provenance).toContain("generated 2026-08-20");
|
||||||
|
expect(model.provenance).toContain("updated 2026-09-01");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sparse sections merge floor fields instead of demoting", () => {
|
||||||
|
const sparse = section({ services: {}, procedures: [] });
|
||||||
|
const model = resolveEmergency(dataset({ section: sparse }), NOW);
|
||||||
|
expect(model.source).toBe("dataset");
|
||||||
|
expect(model.section?.services.emergencyNumber).toBeTruthy();
|
||||||
|
expect(model.section?.procedures.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("unsupported schema → baseline with unsupported-schema demotion", () => {
|
||||||
|
const model = resolveEmergency(dataset({ emergencySchemaVersion: 99 }), NOW);
|
||||||
|
expect(model.source).toBe("baseline");
|
||||||
|
expect(model.demotion).toBe("unsupported-schema");
|
||||||
|
expect(isSupportedEmergencySchema(99)).toBe(false);
|
||||||
|
expect(isSupportedEmergencySchema(1)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("malformed section → baseline with malformed-section demotion", () => {
|
||||||
|
const badValues: unknown[] = [
|
||||||
|
"nope",
|
||||||
|
42,
|
||||||
|
null,
|
||||||
|
{},
|
||||||
|
{ section: "emergency" },
|
||||||
|
{ section: "emergency", emergencySchemaVersion: 0 },
|
||||||
|
{ section: "emergency", emergencySchemaVersion: "1" },
|
||||||
|
{ section: "emergency", emergencySchemaVersion: 1, services: "911" },
|
||||||
|
{
|
||||||
|
section: "emergency",
|
||||||
|
emergencySchemaVersion: 1,
|
||||||
|
procedures: [{ id: "x" }],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
for (const bad of badValues) {
|
||||||
|
const model = resolveEmergency(dataset({ section: bad as EmergencySection }), NOW);
|
||||||
|
expect(model.source).toBe("baseline");
|
||||||
|
expect(model.demotion).toBe("malformed-section");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("unknown future fields never throw (forward tolerance)", () => {
|
||||||
|
const future = {
|
||||||
|
...section(),
|
||||||
|
brandNewField: { deep: [1, 2, 3] },
|
||||||
|
services: { ...section().services, another: "x" },
|
||||||
|
};
|
||||||
|
const model = resolveEmergency(dataset({ section: future }), NOW);
|
||||||
|
expect(model.source).toBe("dataset");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("expired notices hide; unexpiring and future notices show", () => {
|
||||||
|
const withNotices = section({
|
||||||
|
notices: [
|
||||||
|
{ id: "old", severity: "info", title: "Old", body: ["x"], expiresAtUtc: NOW - 1000 },
|
||||||
|
{ id: "live", severity: "urgent", title: "Live", body: ["y"], expiresAtUtc: NOW + 1000 },
|
||||||
|
{ id: "forever", severity: "info", title: "Forever", body: ["z"], expiresAtUtc: null },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const filtered = visibleNotices(withNotices, NOW);
|
||||||
|
expect(filtered.notices?.map((n) => n.id)).toEqual(["live", "forever"]);
|
||||||
|
const model = resolveEmergency(dataset({ section: withNotices }), NOW);
|
||||||
|
expect(model.section?.notices?.map((n) => n.id)).toEqual(["live", "forever"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("demotedBaseline carries the reason with floor provenance", () => {
|
||||||
|
const model = demotedBaseline("unsupported-schema");
|
||||||
|
expect(model.source).toBe("baseline");
|
||||||
|
expect(model.demotion).toBe("unsupported-schema");
|
||||||
|
expect(model.provenance).toMatch(/^BASELINE v/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("narrowEmergencySection", () => {
|
||||||
|
it("accepts a valid section, rejects everything else without throwing", () => {
|
||||||
|
expect(narrowEmergencySection(section())).toBeTruthy();
|
||||||
|
for (const bad of [undefined, null, 7, "x", [], {}, { section: "schedule" }]) {
|
||||||
|
expect(narrowEmergencySection(bad)).toBeNull();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("emergency view", () => {
|
||||||
|
it("floor tier renders number, dial link, procedures, and guidance", () => {
|
||||||
|
const view = createEmergencyView(undefined, NOW);
|
||||||
|
expect(view.querySelector("h1")?.textContent).toBe("Emergency");
|
||||||
|
expect(view.querySelector('a[href^="tel:"]')).toBeTruthy();
|
||||||
|
expect(view.textContent).toContain("BASELINE v");
|
||||||
|
expect(view.textContent).toContain("isn't on this device yet");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("dataset tier renders locations with map handoffs and notices", () => {
|
||||||
|
const view = createEmergencyView(dataset(), NOW);
|
||||||
|
expect(view.textContent).toContain("FESTIVAL DATA v3");
|
||||||
|
expect(view.textContent).not.toContain("isn't on this device yet");
|
||||||
|
expect(view.textContent).toContain("North field");
|
||||||
|
expect(view.textContent).toContain("[info] Gates open");
|
||||||
|
const mapLinks = [...view.querySelectorAll('a[data-route="/map"]')];
|
||||||
|
expect(mapLinks.length).toBeGreaterThan(0);
|
||||||
|
expect(view.textContent).toContain("First-aid hours: 10:00–22:00");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("each demotion reason renders its own guidance", () => {
|
||||||
|
for (const [reason, hint] of [
|
||||||
|
["no-dataset", "isn't on this device yet"],
|
||||||
|
["unsupported-schema", "newer app version"],
|
||||||
|
["malformed-section", "couldn't be read"],
|
||||||
|
] as const) {
|
||||||
|
const view = createEmergencyView(demotedBaseline(reason), NOW);
|
||||||
|
expect(view.textContent).toContain("BASELINE v");
|
||||||
|
expect(view.textContent).toContain(hint);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders with zero storage access (Ring-0: no indexedDB, no caches)", () => {
|
||||||
|
const idx = g.indexedDB;
|
||||||
|
const cachesGlobal = (globalThis as Record<string, unknown>).caches;
|
||||||
|
// Ring-0 proof — storage must not exist here.
|
||||||
|
delete g.indexedDB;
|
||||||
|
delete (globalThis as Record<string, unknown>).caches;
|
||||||
|
try {
|
||||||
|
const view = createEmergencyView(undefined, NOW);
|
||||||
|
expect(view.querySelector("h1")?.textContent).toBe("Emergency");
|
||||||
|
expect(view.querySelector('a[href^="tel:"]')).toBeTruthy();
|
||||||
|
} finally {
|
||||||
|
g.indexedDB = idx;
|
||||||
|
if (cachesGlobal !== undefined) {
|
||||||
|
(globalThis as Record<string, unknown>).caches = cachesGlobal;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never renders raw HTML from content", () => {
|
||||||
|
const evil = section({ procedures: [{ id: "x", title: "<img src=x>", steps: ["<b>do</b>"] }] });
|
||||||
|
const view = createEmergencyView(dataset({ section: evil }), NOW);
|
||||||
|
expect(view.innerHTML).not.toContain("<img src=x>");
|
||||||
|
expect(view.innerHTML).not.toContain("<b>do</b>");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("dataset emergency loader", () => {
|
||||||
|
function deleteDb(name: string): Promise<void> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const request = (g.indexedDB as IDBFactory).deleteDatabase(name);
|
||||||
|
request.onsuccess = () => {
|
||||||
|
resolve();
|
||||||
|
};
|
||||||
|
request.onerror = () => {
|
||||||
|
reject(request.error ?? new Error("delete failed"));
|
||||||
|
};
|
||||||
|
request.onblocked = () => {
|
||||||
|
resolve();
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fixturePackage(version: number) {
|
||||||
|
const keyPair = generateTestKeyPair();
|
||||||
|
const built = buildPackage(makeValidInput({ packageVersion: version }), { signWith: keyPair });
|
||||||
|
if (!built.ok || !built.pkg.signature) throw new Error("fixture build failed");
|
||||||
|
const manifestBytes = new TextEncoder().encode(canonicalJson(built.pkg.manifest));
|
||||||
|
const files = new Map<string, Uint8Array>();
|
||||||
|
for (const [name, file] of built.pkg.files) files.set(name, file.canonicalBytes);
|
||||||
|
for (const asset of built.pkg.assets) files.set(asset.file, asset.bytesContent);
|
||||||
|
const result = await verifyPackage(
|
||||||
|
{
|
||||||
|
manifestBytes,
|
||||||
|
signature: built.pkg.signature,
|
||||||
|
files: { getFile: (name) => Promise.resolve(files.get(name)) },
|
||||||
|
emergencyFloor: built.pkg.emergencyFloor,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
trustedKeys: new Map([
|
||||||
|
[keyPair.fingerprint, publicKeyFromDerBase64(keyPair.publicKeyDerBase64)],
|
||||||
|
]),
|
||||||
|
appVersion: "1.0.0",
|
||||||
|
supportedSchemaRange: [1],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (!result.ok) throw new Error(`verify failed: ${result.reason}`);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
g.indexedDB = new FDBFactory() as unknown;
|
||||||
|
g.IDBKeyRange = FDBKeyRange as unknown;
|
||||||
|
await deleteDb(DB.SYSTEM);
|
||||||
|
await deleteDb(DB.SLOT_A);
|
||||||
|
await deleteDb(DB.SLOT_B);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
// Leave no polyfill behind for the zero-storage test above.
|
||||||
|
delete g.indexedDB;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fresh install → absent", async () => {
|
||||||
|
expect(await loadDatasetEmergency()).toEqual({ status: "absent" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("staged + activated fixture → ready with version detail", async () => {
|
||||||
|
const pkg = await fixturePackage(2);
|
||||||
|
const staged = await stageVerifiedPackage(pkg);
|
||||||
|
const activated = await activateStagedPackage(pkg, staged, "1.0.0");
|
||||||
|
expect(activated.ok).toBe(true);
|
||||||
|
const loaded = await loadDatasetEmergency();
|
||||||
|
expect(loaded.status).toBe("ready");
|
||||||
|
if (loaded.status !== "ready") throw new Error("unreachable");
|
||||||
|
expect(loaded.data.packageVersion).toBe(2);
|
||||||
|
expect(loaded.data.section.services.emergencyNumber).toBeTruthy();
|
||||||
|
expect(loaded.data.generatedAt).toBe(pkg.manifest.generatedAt);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("malformed stored section → unusable malformed-section", async () => {
|
||||||
|
const system = await openSystemDB();
|
||||||
|
const { writeSystemMeta } = await import("../../src/data/system-meta/store.js");
|
||||||
|
const { INITIAL_SYSTEM_META } = await import("../../src/data/system-meta/types.js");
|
||||||
|
await writeSystemMeta(system, {
|
||||||
|
...INITIAL_SYSTEM_META,
|
||||||
|
activeSlot: "A",
|
||||||
|
activeEdition: "test-edition",
|
||||||
|
activePackageVersion: 1,
|
||||||
|
});
|
||||||
|
system.close();
|
||||||
|
const slot = await openSlotDB("A");
|
||||||
|
await writeSlotFile(slot, "emergency", {
|
||||||
|
bytes: 32,
|
||||||
|
sha256: "0".repeat(64),
|
||||||
|
json: { section: "emergency", services: {} },
|
||||||
|
});
|
||||||
|
slot.close();
|
||||||
|
expect(await loadDatasetEmergency()).toEqual({
|
||||||
|
status: "unusable",
|
||||||
|
reason: "malformed-section",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("boot renders Tier 2 through the real path after staging", async () => {
|
||||||
|
const pkg = await fixturePackage(1);
|
||||||
|
const staged = await stageVerifiedPackage(pkg);
|
||||||
|
expect((await activateStagedPackage(pkg, staged, "1.0.0")).ok).toBe(true);
|
||||||
|
history.replaceState(null, "", "/emergency");
|
||||||
|
document.body.innerHTML = '<div id="app" aria-busy="true"></div>';
|
||||||
|
boot();
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(document.querySelector(".emergency-view")?.textContent).toContain("FESTIVAL DATA v1");
|
||||||
|
});
|
||||||
|
document.body.innerHTML = "";
|
||||||
|
});
|
||||||
|
});
|
||||||
627
tests/unit/readiness.test.ts
Normal file
627
tests/unit/readiness.test.ts
Normal file
|
|
@ -0,0 +1,627 @@
|
||||||
|
/* eslint-disable @typescript-eslint/no-unsafe-call */
|
||||||
|
/**
|
||||||
|
* Stage 9 — Offline Ready predicate C1–C8, six-state taxonomy, boot evaluation.
|
||||||
|
* Pure truth table (every state reachable) + boot integration against
|
||||||
|
* fake-indexeddb using the Stage 8 staging/activation path.
|
||||||
|
* Trace: SPIKE-05 §2–§5, IMPLEMENTATION-CONTRACT.md §20.
|
||||||
|
*/
|
||||||
|
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||||
|
// @ts-expect-error fake-indexeddb types via exports fallback
|
||||||
|
import FDBFactory from "fake-indexeddb/lib/FDBFactory";
|
||||||
|
// @ts-expect-error fake-indexeddb types via exports fallback
|
||||||
|
import FDBKeyRange from "fake-indexeddb/lib/FDBKeyRange";
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
|
||||||
|
const g = globalThis as unknown as Record<string, unknown>;
|
||||||
|
g.indexedDB = new FDBFactory() as unknown;
|
||||||
|
g.IDBKeyRange = FDBKeyRange as unknown;
|
||||||
|
|
||||||
|
import { buildPackage } from "../../pipeline/package.js";
|
||||||
|
import { generateTestKeyPair } from "../../pipeline/sign.js";
|
||||||
|
import { makeValidInput } from "../../pipeline/fixtures.js";
|
||||||
|
import { canonicalJson } from "../../pipeline/canonical-json.js";
|
||||||
|
import { publicKeyFromDerBase64 } from "../../src/sync/verifier/ed25519.js";
|
||||||
|
import { verifyPackage } from "../../src/sync/verifier/package.js";
|
||||||
|
import type { VerifiedPackage } from "../../src/sync/verifier/types.js";
|
||||||
|
import {
|
||||||
|
activateStagedPackage,
|
||||||
|
restorePreviousSlot,
|
||||||
|
stageVerifiedPackage,
|
||||||
|
} from "../../src/sync/activation.js";
|
||||||
|
import { openSystemDB, readSystemMeta, writeSystemMeta } from "../../src/data/system-meta/store.js";
|
||||||
|
import { INITIAL_SYSTEM_META } from "../../src/data/system-meta/types.js";
|
||||||
|
import {
|
||||||
|
initializeStaging,
|
||||||
|
markStagingComplete,
|
||||||
|
openSlotDB,
|
||||||
|
readSlotFileMeta,
|
||||||
|
writeSlotFile,
|
||||||
|
writeSlotFileWithProgress,
|
||||||
|
writeSlotManifest,
|
||||||
|
} from "../../src/data/slot/store.js";
|
||||||
|
import type { FestivalManifest } from "../../src/data/festival-package/types.js";
|
||||||
|
import { withTx } from "../../src/platform/idb/wrapper.js";
|
||||||
|
import { DB, SLOT_FILES } from "../../src/platform/idb/names.js";
|
||||||
|
import { APP_VERSION, SUPPORTED_SCHEMA_RANGE } from "../../src/domain/readiness/config.js";
|
||||||
|
import {
|
||||||
|
chipForState,
|
||||||
|
evaluateReadiness,
|
||||||
|
preparationLevel,
|
||||||
|
} from "../../src/domain/readiness/logic.js";
|
||||||
|
import type { ReadinessEvidence } from "../../src/domain/readiness/types.js";
|
||||||
|
import { checkShellCache } from "../../src/platform/cache/shell-cache.js";
|
||||||
|
import { defaultBootDeps, evaluateBootReadiness } from "../../src/app/readiness.js";
|
||||||
|
import type { BootReadinessDeps } from "../../src/app/readiness.js";
|
||||||
|
|
||||||
|
const NOW = 1_750_000_000_000;
|
||||||
|
|
||||||
|
function okEvidence(patch: Partial<ReadinessEvidence> = {}): ReadinessEvidence {
|
||||||
|
return {
|
||||||
|
shellValid: true,
|
||||||
|
datasetPresent: true,
|
||||||
|
authenticity: "verified",
|
||||||
|
integrity: "ok",
|
||||||
|
schemaCompatible: true,
|
||||||
|
requiredSectionsOk: true,
|
||||||
|
requiredAssetsOk: true,
|
||||||
|
emergencyFloorOk: true,
|
||||||
|
missing: [],
|
||||||
|
hadVerifiedDataset: true,
|
||||||
|
manifestOk: true,
|
||||||
|
incompatible: false,
|
||||||
|
failure: null,
|
||||||
|
presentSections: ["emergency", "schedule", "info", "map", "assets"] as readonly string[],
|
||||||
|
stagedPending: null,
|
||||||
|
...patch,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("readiness predicate — C1–C8 truth table (SPIKE-05 §5)", () => {
|
||||||
|
it("all true → READY, level L2, green chip", () => {
|
||||||
|
const result = evaluateReadiness(okEvidence());
|
||||||
|
expect(result.state).toBe("READY");
|
||||||
|
expect(result.missing).toEqual([]);
|
||||||
|
expect(result.level).toBe("L2");
|
||||||
|
const chip = chipForState("READY");
|
||||||
|
expect(chip.text).toBe("OFFLINE READY ✓");
|
||||||
|
expect(chip.tone).toBe("ready");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("no dataset + never verified → NOT_READY; + previously verified → RECOVERY(missing)", () => {
|
||||||
|
expect(
|
||||||
|
evaluateReadiness(okEvidence({ datasetPresent: false, hadVerifiedDataset: false })).state,
|
||||||
|
).toBe("NOT_READY");
|
||||||
|
const recovery = evaluateReadiness(
|
||||||
|
okEvidence({ datasetPresent: false, hadVerifiedDataset: true }),
|
||||||
|
);
|
||||||
|
expect(recovery.state).toBe("RECOVERY");
|
||||||
|
expect(recovery.reason).toBe("missing");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("record present but files gone → RECOVERY(missing), not corrupt", () => {
|
||||||
|
const result = evaluateReadiness(
|
||||||
|
okEvidence({ authenticity: "mismatch", integrity: "files-missing", missing: ["schedule"] }),
|
||||||
|
);
|
||||||
|
expect(result.state).toBe("RECOVERY");
|
||||||
|
expect(result.reason).toBe("missing");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("size tampering → RECOVERY(corrupt); record mismatch alone → RECOVERY(corrupt)", () => {
|
||||||
|
expect(evaluateReadiness(okEvidence({ integrity: "corrupt" }))).toMatchObject({
|
||||||
|
state: "RECOVERY",
|
||||||
|
reason: "corrupt",
|
||||||
|
});
|
||||||
|
expect(evaluateReadiness(okEvidence({ authenticity: "mismatch" }))).toMatchObject({
|
||||||
|
state: "RECOVERY",
|
||||||
|
reason: "corrupt",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("incompatible schema demotes to RECOVERY(incompatible), ahead of PARTIAL", () => {
|
||||||
|
const result = evaluateReadiness(
|
||||||
|
okEvidence({
|
||||||
|
schemaCompatible: false,
|
||||||
|
incompatible: true,
|
||||||
|
requiredSectionsOk: false,
|
||||||
|
missing: ["map"],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(result.state).toBe("RECOVERY");
|
||||||
|
expect(result.reason).toBe("incompatible");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("files present but record missing → PARTIAL(verification), full re-verify path", () => {
|
||||||
|
const result = evaluateReadiness(okEvidence({ authenticity: "record-missing" }));
|
||||||
|
expect(result.state).toBe("PARTIAL");
|
||||||
|
expect(result.missing).toContain("verification");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("staged manifest missing → PARTIAL(manifest)", () => {
|
||||||
|
const result = evaluateReadiness(
|
||||||
|
okEvidence({ manifestOk: false, requiredSectionsOk: false, missing: ["manifest"] }),
|
||||||
|
);
|
||||||
|
expect(result.state).toBe("PARTIAL");
|
||||||
|
expect(result.missing).toContain("manifest");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("missing required sections/assets → PARTIAL(list); optional never gates", () => {
|
||||||
|
const partial = evaluateReadiness(
|
||||||
|
okEvidence({ requiredAssetsOk: false, missing: ["asset:map-base-overview"] }),
|
||||||
|
);
|
||||||
|
expect(partial.state).toBe("PARTIAL");
|
||||||
|
expect(partial.missing).toEqual(["asset:map-base-overview"]);
|
||||||
|
// Optional sections are not evidence at all — absence changes nothing.
|
||||||
|
expect(evaluateReadiness(okEvidence()).state).toBe("READY");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("session failure → FAILED ahead of everything, even READY evidence", () => {
|
||||||
|
const result = evaluateReadiness(okEvidence({ failure: { op: "activation" } }));
|
||||||
|
expect(result.state).toBe("FAILED");
|
||||||
|
expect(result.failedOp).toBe("activation");
|
||||||
|
expect(chipForState("FAILED", "activation").tone).toBe("failed");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("broken floor build → FAILED(emergency-floor)", () => {
|
||||||
|
const result = evaluateReadiness(okEvidence({ emergencyFloorOk: false }));
|
||||||
|
expect(result.state).toBe("FAILED");
|
||||||
|
expect(result.failedOp).toBe("emergency-floor");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shell cache missing + dataset → PARTIAL(shell-cache); without dataset → NOT_READY", () => {
|
||||||
|
expect(evaluateReadiness(okEvidence({ shellValid: false })).state).toBe("PARTIAL");
|
||||||
|
expect(evaluateReadiness(okEvidence({ shellValid: false })).missing).toContain("shell-cache");
|
||||||
|
expect(
|
||||||
|
evaluateReadiness(
|
||||||
|
okEvidence({ shellValid: false, datasetPresent: false, hadVerifiedDataset: false }),
|
||||||
|
).state,
|
||||||
|
).toBe("NOT_READY");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("interrupted staging with no active pointer → PARTIAL (resumable)", () => {
|
||||||
|
const result = evaluateReadiness(
|
||||||
|
okEvidence({
|
||||||
|
datasetPresent: false,
|
||||||
|
hadVerifiedDataset: false,
|
||||||
|
requiredSectionsOk: false,
|
||||||
|
requiredAssetsOk: false,
|
||||||
|
missing: ["info", "map", "assets"],
|
||||||
|
presentSections: ["emergency", "schedule"],
|
||||||
|
stagedPending: { missing: ["info", "map", "assets"] },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(result.state).toBe("PARTIAL");
|
||||||
|
expect(result.level).toBe("L1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preparation levels L0/L1/L2 follow staged sections, not trust", () => {
|
||||||
|
expect(preparationLevel([])).toBe("L0");
|
||||||
|
expect(preparationLevel(["emergency"])).toBe("L0");
|
||||||
|
expect(preparationLevel(["emergency", "schedule"])).toBe("L1");
|
||||||
|
expect(preparationLevel(["emergency", "schedule", "info", "map", "assets"])).toBe("L2");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("chip mapping covers every state", () => {
|
||||||
|
expect(chipForState("PARTIAL", "map").tone).toBe("partial");
|
||||||
|
expect(chipForState("NOT_READY").text).toBe("Get festival data");
|
||||||
|
expect(chipForState("RECOVERY", "missing").text).toContain("missing");
|
||||||
|
expect(chipForState("BASELINE_ONLY").tone).toBe("baseline");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("verdicts do not consult clocks", () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
try {
|
||||||
|
vi.setSystemTime(new Date("2031-05-04T12:00:00Z"));
|
||||||
|
expect(evaluateReadiness(okEvidence()).state).toBe("READY");
|
||||||
|
vi.setSystemTime(new Date("1999-01-01T00:00:00Z"));
|
||||||
|
expect(evaluateReadiness(okEvidence()).state).toBe("READY");
|
||||||
|
} finally {
|
||||||
|
vi.useRealTimers();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("APP_VERSION matches package.json; schema range covers v1", () => {
|
||||||
|
const pkg = JSON.parse(readFileSync("package.json", "utf8")) as { version: string };
|
||||||
|
expect(APP_VERSION).toBe(pkg.version);
|
||||||
|
expect(SUPPORTED_SCHEMA_RANGE).toContain(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("shell cache check (C1)", () => {
|
||||||
|
it("reports unavailable where Cache Storage is absent (Node)", async () => {
|
||||||
|
expect(await checkShellCache()).toMatchObject({ ok: false, cacheName: null });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ——— Boot integration ———
|
||||||
|
|
||||||
|
function deleteDb(name: string): Promise<void> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const request = (g.indexedDB as IDBFactory).deleteDatabase(name);
|
||||||
|
request.onsuccess = () => {
|
||||||
|
resolve();
|
||||||
|
};
|
||||||
|
request.onerror = () => {
|
||||||
|
reject(request.error ?? new Error("delete database failed"));
|
||||||
|
};
|
||||||
|
request.onblocked = () => {
|
||||||
|
resolve();
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fixture(version = 1): Promise<VerifiedPackage> {
|
||||||
|
const keyPair = generateTestKeyPair();
|
||||||
|
const built = buildPackage(makeValidInput({ packageVersion: version }), { signWith: keyPair });
|
||||||
|
if (!built.ok || !built.pkg.signature) throw new Error("fixture build failed");
|
||||||
|
const manifestBytes = new TextEncoder().encode(canonicalJson(built.pkg.manifest));
|
||||||
|
const files = new Map<string, Uint8Array>();
|
||||||
|
for (const [name, file] of built.pkg.files) files.set(name, file.canonicalBytes);
|
||||||
|
for (const asset of built.pkg.assets) files.set(asset.file, asset.bytesContent);
|
||||||
|
const result = await verifyPackage(
|
||||||
|
{
|
||||||
|
manifestBytes,
|
||||||
|
signature: built.pkg.signature,
|
||||||
|
files: { getFile: (name) => Promise.resolve(files.get(name)) },
|
||||||
|
emergencyFloor: built.pkg.emergencyFloor,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
trustedKeys: new Map([
|
||||||
|
[keyPair.fingerprint, publicKeyFromDerBase64(keyPair.publicKeyDerBase64)],
|
||||||
|
]),
|
||||||
|
appVersion: "1.0.0",
|
||||||
|
supportedSchemaRange: [1],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (!result.ok) throw new Error(`fixture verify failed: ${result.reason}`);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function testDeps(patch: Partial<BootReadinessDeps> = {}): BootReadinessDeps {
|
||||||
|
return {
|
||||||
|
...defaultBootDeps,
|
||||||
|
checkShell: () => Promise.resolve({ ok: true, cacheName: "lumen-shell-test" }),
|
||||||
|
estimate: () => Promise.resolve(null),
|
||||||
|
// Pipeline fixtures declare minAppVersion 1.0.0 / schema 1 (activation.test
|
||||||
|
// does the same) — the dev shell version in package.json is younger.
|
||||||
|
appVersion: "1.0.0",
|
||||||
|
supportedSchemaRange: [1],
|
||||||
|
now: () => NOW,
|
||||||
|
...patch,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function activateFixture(version: number): Promise<VerifiedPackage> {
|
||||||
|
const pkg = await fixture(version);
|
||||||
|
const staged = await stageVerifiedPackage(pkg);
|
||||||
|
const activated = await activateStagedPackage(pkg, staged, APP_VERSION, () => NOW);
|
||||||
|
expect(activated.ok).toBe(true);
|
||||||
|
return pkg;
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await deleteDb(DB.SYSTEM);
|
||||||
|
await deleteDb(DB.SLOT_A);
|
||||||
|
await deleteDb(DB.SLOT_B);
|
||||||
|
await deleteDb(DB.USER);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("boot evaluation", () => {
|
||||||
|
it("fresh install → NOT_READY, L0, floor present", async () => {
|
||||||
|
const report = await evaluateBootReadiness(testDeps());
|
||||||
|
expect(report.state).toBe("NOT_READY");
|
||||||
|
expect(report.level).toBe("L0");
|
||||||
|
expect(report.floorVersion).toBeTruthy();
|
||||||
|
expect(report.canRestore).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("staged + activated fixture → READY with version detail, L2", async () => {
|
||||||
|
const pkg = await activateFixture(1);
|
||||||
|
const report = await evaluateBootReadiness(testDeps());
|
||||||
|
expect(report.state).toBe("READY");
|
||||||
|
expect(report.edition).toBe(pkg.manifest.edition);
|
||||||
|
expect(report.packageVersion).toBe(1);
|
||||||
|
expect(report.generatedAt).toBe(pkg.manifest.generatedAt);
|
||||||
|
// Journal progress timestamps come from the real clock at staging time —
|
||||||
|
// display only, never a verdict input.
|
||||||
|
expect(report.fetchedAt).toBeGreaterThan(0);
|
||||||
|
expect(report.sections).toHaveLength(5);
|
||||||
|
expect(report.sections.every((s) => s.present && s.parseable)).toBe(true);
|
||||||
|
expect(report.assets.total).toBeGreaterThan(0);
|
||||||
|
expect(report.assets.present).toBe(report.assets.total);
|
||||||
|
expect(report.level).toBe("L2");
|
||||||
|
expect(report.shellCache).toBe("lumen-shell-test");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("second update retains rollback slot → canRestore; restore returns to v1 READY", async () => {
|
||||||
|
await activateFixture(1);
|
||||||
|
await activateFixture(2);
|
||||||
|
const updated = await evaluateBootReadiness(testDeps());
|
||||||
|
expect(updated.state).toBe("READY");
|
||||||
|
expect(updated.packageVersion).toBe(2);
|
||||||
|
expect(updated.canRestore).toBe(true);
|
||||||
|
expect(await restorePreviousSlot()).toBe(true);
|
||||||
|
const restored = await evaluateBootReadiness(testDeps());
|
||||||
|
expect(restored.state).toBe("READY");
|
||||||
|
expect(restored.packageVersion).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("slot loss with surviving record → RECOVERY(missing)", async () => {
|
||||||
|
await activateFixture(1);
|
||||||
|
await deleteDb(DB.SLOT_A);
|
||||||
|
await deleteDb(DB.SLOT_B);
|
||||||
|
const report = await evaluateBootReadiness(testDeps());
|
||||||
|
expect(report.state).toBe("RECOVERY");
|
||||||
|
expect(report.reason).toBe("missing");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("size tampering post-activation → RECOVERY(corrupt)", async () => {
|
||||||
|
await activateFixture(1);
|
||||||
|
const system = await openSystemDB();
|
||||||
|
const meta = await readSystemMeta(system);
|
||||||
|
system.close();
|
||||||
|
const slot = await openSlotDB(meta.activeSlot ?? "A");
|
||||||
|
const current = await readSlotFileMeta(slot, "schedule");
|
||||||
|
await writeSlotFile(slot, "schedule", {
|
||||||
|
bytes: (current?.bytes ?? 100) + 7,
|
||||||
|
sha256: current?.sha256 ?? "0".repeat(64),
|
||||||
|
json: { section: "schedule" },
|
||||||
|
});
|
||||||
|
slot.close();
|
||||||
|
const report = await evaluateBootReadiness(testDeps());
|
||||||
|
expect(report.state).toBe("RECOVERY");
|
||||||
|
expect(report.reason).toBe("corrupt");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("staged manifest deleted → PARTIAL(manifest), re-verify path", async () => {
|
||||||
|
await activateFixture(1);
|
||||||
|
const system = await openSystemDB();
|
||||||
|
const meta = await readSystemMeta(system);
|
||||||
|
system.close();
|
||||||
|
const slot = await openSlotDB(meta.activeSlot ?? "A");
|
||||||
|
await withTx(slot, SLOT_FILES, "readwrite", async (tx) => {
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
const req = tx.objectStore(SLOT_FILES).delete("manifest");
|
||||||
|
req.onsuccess = () => {
|
||||||
|
resolve();
|
||||||
|
};
|
||||||
|
req.onerror = () => {
|
||||||
|
reject(req.error ?? new Error("delete failed"));
|
||||||
|
};
|
||||||
|
});
|
||||||
|
});
|
||||||
|
slot.close();
|
||||||
|
const report = await evaluateBootReadiness(testDeps());
|
||||||
|
expect(report.state).toBe("PARTIAL");
|
||||||
|
expect(report.missing).toContain("manifest");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("verification record wiped but files present → PARTIAL(verification)", async () => {
|
||||||
|
await activateFixture(1);
|
||||||
|
const system = await openSystemDB();
|
||||||
|
const meta = await readSystemMeta(system);
|
||||||
|
await writeSystemMeta(system, { ...meta, verification: null });
|
||||||
|
system.close();
|
||||||
|
const report = await evaluateBootReadiness(testDeps());
|
||||||
|
expect(report.state).toBe("PARTIAL");
|
||||||
|
expect(report.missing).toContain("verification");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("interrupted staging (emergency+schedule) with no active → PARTIAL, L1 core-ready", async () => {
|
||||||
|
const pkg = await fixture(1);
|
||||||
|
const slot = await openSlotDB("A");
|
||||||
|
let journal = await initializeStaging(slot, {
|
||||||
|
edition: pkg.manifest.edition,
|
||||||
|
packageVersion: 1,
|
||||||
|
manifestSha256: pkg.manifestSha256,
|
||||||
|
startedAt: NOW,
|
||||||
|
lastProgressAt: NOW,
|
||||||
|
});
|
||||||
|
for (const id of ["emergency", "schedule"] as const) {
|
||||||
|
const entry = pkg.manifest.sections[id];
|
||||||
|
const bytes = pkg.files.get(entry.file);
|
||||||
|
if (!bytes) throw new Error("fixture file missing");
|
||||||
|
journal = await writeSlotFileWithProgress(
|
||||||
|
slot,
|
||||||
|
id,
|
||||||
|
{
|
||||||
|
bytes: entry.bytes,
|
||||||
|
sha256: entry.sha256,
|
||||||
|
json: JSON.parse(new TextDecoder().decode(bytes)) as unknown,
|
||||||
|
},
|
||||||
|
journal,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
expect(journal.complete).toBe(false);
|
||||||
|
slot.close();
|
||||||
|
const report = await evaluateBootReadiness(testDeps());
|
||||||
|
expect(report.state).toBe("PARTIAL");
|
||||||
|
expect(report.level).toBe("L1");
|
||||||
|
expect(report.missing).toEqual(expect.arrayContaining(["info", "map", "assets"]));
|
||||||
|
expect(report.activationPending).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hand-built incompatible package (schema 99) → RECOVERY(incompatible)", async () => {
|
||||||
|
const manifest = {
|
||||||
|
format: "lumen.package/1",
|
||||||
|
edition: "test-edition",
|
||||||
|
packageVersion: 9,
|
||||||
|
schemaVersion: 99,
|
||||||
|
generatedAt: "2026-09-01T00:00:00.000Z",
|
||||||
|
festival: { name: "Test", timezone: "America/Chicago", startUtc: 1, endUtc: 2 },
|
||||||
|
appCompatibility: { minAppVersion: "0.1.0", maxAppVersion: null },
|
||||||
|
sections: {
|
||||||
|
emergency: { file: "emergency.json", sha256: "a".repeat(64), bytes: 10 },
|
||||||
|
schedule: { file: "schedule.json", sha256: "b".repeat(64), bytes: 10 },
|
||||||
|
map: { file: "map.json", sha256: "c".repeat(64), bytes: 10 },
|
||||||
|
info: { file: "info.json", sha256: "d".repeat(64), bytes: 10 },
|
||||||
|
assets: { file: "assets.json", sha256: "e".repeat(64), bytes: 2 },
|
||||||
|
},
|
||||||
|
counts: { events: 0, pois: 0, assets: 0 },
|
||||||
|
limits: { totalBytes: 52 },
|
||||||
|
} as unknown as FestivalManifest;
|
||||||
|
const slot = await openSlotDB("A");
|
||||||
|
let journal = await initializeStaging(slot, {
|
||||||
|
edition: "test-edition",
|
||||||
|
packageVersion: 9,
|
||||||
|
manifestSha256: "f".repeat(64),
|
||||||
|
startedAt: NOW,
|
||||||
|
lastProgressAt: NOW,
|
||||||
|
});
|
||||||
|
for (const id of ["emergency", "schedule", "map", "info"] as const) {
|
||||||
|
journal = await writeSlotFileWithProgress(
|
||||||
|
slot,
|
||||||
|
id,
|
||||||
|
{ bytes: 10, sha256: `${id}-sha`, json: { section: id } },
|
||||||
|
journal,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
journal = await writeSlotFileWithProgress(
|
||||||
|
slot,
|
||||||
|
"assets",
|
||||||
|
{ bytes: 2, sha256: "e".repeat(64), json: { assets: [] } },
|
||||||
|
journal,
|
||||||
|
);
|
||||||
|
await writeSlotManifest(slot, manifest);
|
||||||
|
journal = await markStagingComplete(slot);
|
||||||
|
expect(journal.complete).toBe(true);
|
||||||
|
slot.close();
|
||||||
|
const system = await openSystemDB();
|
||||||
|
await writeSystemMeta(system, {
|
||||||
|
...INITIAL_SYSTEM_META,
|
||||||
|
activeSlot: "A",
|
||||||
|
activeEdition: "test-edition",
|
||||||
|
activePackageVersion: 9,
|
||||||
|
verification: {
|
||||||
|
packageVersion: 9,
|
||||||
|
edition: "test-edition",
|
||||||
|
manifestSha256: "f".repeat(64),
|
||||||
|
publicKeyFingerprint: "test-key",
|
||||||
|
verifiedAt: NOW,
|
||||||
|
appVersionAtActivation: APP_VERSION,
|
||||||
|
},
|
||||||
|
appVersionAtActivation: APP_VERSION,
|
||||||
|
});
|
||||||
|
system.close();
|
||||||
|
const report = await evaluateBootReadiness(testDeps());
|
||||||
|
expect(report.state).toBe("RECOVERY");
|
||||||
|
expect(report.reason).toBe("incompatible");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("optional section (required:false) absent → still READY", async () => {
|
||||||
|
const manifest = {
|
||||||
|
format: "lumen.package/1",
|
||||||
|
edition: "test-edition",
|
||||||
|
packageVersion: 3,
|
||||||
|
schemaVersion: 1,
|
||||||
|
generatedAt: "2026-09-01T00:00:00.000Z",
|
||||||
|
festival: { name: "Test", timezone: "America/Chicago", startUtc: 1, endUtc: 2 },
|
||||||
|
appCompatibility: { minAppVersion: "0.1.0", maxAppVersion: null },
|
||||||
|
sections: {
|
||||||
|
emergency: { file: "emergency.json", sha256: "a".repeat(64), bytes: 10 },
|
||||||
|
schedule: { file: "schedule.json", sha256: "b".repeat(64), bytes: 10 },
|
||||||
|
map: { file: "map.json", sha256: "c".repeat(64), bytes: 10 },
|
||||||
|
info: { file: "info.json", sha256: "d".repeat(64), bytes: 10, required: false },
|
||||||
|
assets: { file: "assets.json", sha256: "e".repeat(64), bytes: 2 },
|
||||||
|
},
|
||||||
|
counts: { events: 0, pois: 0, assets: 0 },
|
||||||
|
limits: { totalBytes: 42 },
|
||||||
|
} as unknown as FestivalManifest;
|
||||||
|
const slot = await openSlotDB("A");
|
||||||
|
let journal = await initializeStaging(slot, {
|
||||||
|
edition: "test-edition",
|
||||||
|
packageVersion: 3,
|
||||||
|
manifestSha256: "f".repeat(64),
|
||||||
|
startedAt: NOW,
|
||||||
|
lastProgressAt: NOW,
|
||||||
|
});
|
||||||
|
for (const id of ["emergency", "schedule", "map"] as const) {
|
||||||
|
journal = await writeSlotFileWithProgress(
|
||||||
|
slot,
|
||||||
|
id,
|
||||||
|
{ bytes: 10, sha256: `${id}-sha`, json: { section: id } },
|
||||||
|
journal,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
journal = await writeSlotFileWithProgress(
|
||||||
|
slot,
|
||||||
|
"assets",
|
||||||
|
{ bytes: 2, sha256: "e".repeat(64), json: { assets: [] } },
|
||||||
|
journal,
|
||||||
|
);
|
||||||
|
await writeSlotManifest(slot, manifest);
|
||||||
|
await markStagingComplete(slot);
|
||||||
|
slot.close();
|
||||||
|
const system = await openSystemDB();
|
||||||
|
await writeSystemMeta(system, {
|
||||||
|
...INITIAL_SYSTEM_META,
|
||||||
|
activeSlot: "A",
|
||||||
|
activeEdition: "test-edition",
|
||||||
|
activePackageVersion: 3,
|
||||||
|
verification: {
|
||||||
|
packageVersion: 3,
|
||||||
|
edition: "test-edition",
|
||||||
|
manifestSha256: "f".repeat(64),
|
||||||
|
publicKeyFingerprint: "test-key",
|
||||||
|
verifiedAt: NOW,
|
||||||
|
appVersionAtActivation: APP_VERSION,
|
||||||
|
},
|
||||||
|
appVersionAtActivation: APP_VERSION,
|
||||||
|
});
|
||||||
|
system.close();
|
||||||
|
const report = await evaluateBootReadiness(testDeps());
|
||||||
|
expect(report.state).toBe("READY");
|
||||||
|
expect(report.sections.find((s) => s.id === "info")).toMatchObject({
|
||||||
|
required: false,
|
||||||
|
present: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("readbackPending left by a kill resolves at next boot → READY", async () => {
|
||||||
|
await activateFixture(1);
|
||||||
|
const system = await openSystemDB();
|
||||||
|
const meta = await readSystemMeta(system);
|
||||||
|
expect(meta.readbackPending).toBe(false);
|
||||||
|
await writeSystemMeta(system, { ...meta, readbackPending: true });
|
||||||
|
system.close();
|
||||||
|
const report = await evaluateBootReadiness(testDeps());
|
||||||
|
expect(report.state).toBe("READY");
|
||||||
|
const reopened = await openSystemDB();
|
||||||
|
expect((await readSystemMeta(reopened)).readbackPending).toBe(false);
|
||||||
|
reopened.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("unavailable storage → BASELINE_ONLY; slot IDB error → FAILED", async () => {
|
||||||
|
const baseline = await evaluateBootReadiness(
|
||||||
|
testDeps({
|
||||||
|
openSystem: () => Promise.reject(new Error("denied")),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(baseline.state).toBe("BASELINE_ONLY");
|
||||||
|
expect(baseline.floorVersion).toBeTruthy();
|
||||||
|
|
||||||
|
await activateFixture(1);
|
||||||
|
const failed = await evaluateBootReadiness(
|
||||||
|
testDeps({
|
||||||
|
openSlot: () => Promise.reject(new Error("IDB exploded")),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(failed.state).toBe("FAILED");
|
||||||
|
expect(failed.failedOp).toBe("boot-check");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stale staging (>7d) is not resumable → NOT_READY", async () => {
|
||||||
|
const pkg = await fixture(1);
|
||||||
|
const slot = await openSlotDB("A");
|
||||||
|
const journal = await initializeStaging(slot, {
|
||||||
|
edition: pkg.manifest.edition,
|
||||||
|
packageVersion: 1,
|
||||||
|
manifestSha256: pkg.manifestSha256,
|
||||||
|
startedAt: NOW - 8 * 24 * 60 * 60 * 1000,
|
||||||
|
lastProgressAt: NOW - 8 * 24 * 60 * 60 * 1000,
|
||||||
|
});
|
||||||
|
expect(journal.complete).toBe(false);
|
||||||
|
slot.close();
|
||||||
|
const report = await evaluateBootReadiness(testDeps());
|
||||||
|
expect(report.state).toBe("NOT_READY");
|
||||||
|
});
|
||||||
|
});
|
||||||
528
tests/unit/schedule.test.ts
Normal file
528
tests/unit/schedule.test.ts
Normal file
|
|
@ -0,0 +1,528 @@
|
||||||
|
/**
|
||||||
|
* @vitest-environment jsdom
|
||||||
|
* Stage 11 — Schedule queries, favorites, ClockService state, view, wiring.
|
||||||
|
* Trace: IMPLEMENTATION-CONTRACT.md §21/§24, SPIKE-08, ADR-009.
|
||||||
|
*/
|
||||||
|
/* eslint-disable @typescript-eslint/no-unsafe-call */
|
||||||
|
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||||
|
// @ts-expect-error fake-indexeddb types via exports fallback
|
||||||
|
import FDBFactory from "fake-indexeddb/lib/FDBFactory";
|
||||||
|
// @ts-expect-error fake-indexeddb types via exports fallback
|
||||||
|
import FDBKeyRange from "fake-indexeddb/lib/FDBKeyRange";
|
||||||
|
import { mount as boot } from "../../src/app/main.js";
|
||||||
|
import {
|
||||||
|
allTags,
|
||||||
|
artistNames,
|
||||||
|
detectOverlaps,
|
||||||
|
filterEvents,
|
||||||
|
groupByDay,
|
||||||
|
nowEvents,
|
||||||
|
stageName,
|
||||||
|
upNext,
|
||||||
|
upNextByStage,
|
||||||
|
} from "../../src/domain/schedule/logic.js";
|
||||||
|
import { buildMySchedule, myScheduleConflicts } from "../../src/domain/favorites/logic.js";
|
||||||
|
import {
|
||||||
|
dayKeyFor,
|
||||||
|
deviceTimeZone,
|
||||||
|
formatClockTime,
|
||||||
|
formatDayLabel,
|
||||||
|
formatEventTime,
|
||||||
|
} from "../../src/domain/clock/logic.js";
|
||||||
|
import { captureSkew, resolveClock } from "../../src/domain/clock/service.js";
|
||||||
|
import { shouldWarnOutsideWindow } from "../../src/domain/clock/logic.js";
|
||||||
|
import { narrowScheduleSection } from "../../src/app/schedule.js";
|
||||||
|
import { loadScheduleData } from "../../src/app/schedule.js";
|
||||||
|
import { loadPersistedSkew, persistSkew, resetClockSession } from "../../src/app/clock.js";
|
||||||
|
import { createScheduleView } from "../../src/ui/views/schedule/schedule.js";
|
||||||
|
import type { ScheduleViewInput } from "../../src/ui/views/schedule/schedule.js";
|
||||||
|
import type { ScheduleSection, ScheduleEvent } from "../../src/data/festival-package/types.js";
|
||||||
|
import { buildPackage } from "../../pipeline/package.js";
|
||||||
|
import { generateTestKeyPair } from "../../pipeline/sign.js";
|
||||||
|
import { makeValidInput } from "../../pipeline/fixtures.js";
|
||||||
|
import { canonicalJson } from "../../pipeline/canonical-json.js";
|
||||||
|
import { publicKeyFromDerBase64 } from "../../src/sync/verifier/ed25519.js";
|
||||||
|
import { verifyPackage } from "../../src/sync/verifier/package.js";
|
||||||
|
import { activateStagedPackage, stageVerifiedPackage } from "../../src/sync/activation.js";
|
||||||
|
import { openUserDB, listFavorites } from "../../src/data/user/store.js";
|
||||||
|
import { DB } from "../../src/platform/idb/names.js";
|
||||||
|
|
||||||
|
const g = globalThis as unknown as Record<string, unknown>;
|
||||||
|
const CHI = "America/Chicago";
|
||||||
|
// 2026-09-10 12:00 UTC = 07:00 CDT.
|
||||||
|
const T0 = Date.UTC(2026, 8, 10, 12, 0, 0);
|
||||||
|
const HOUR = 3600_000;
|
||||||
|
|
||||||
|
function event(patch: Partial<ScheduleEvent> & { id: string }): ScheduleEvent {
|
||||||
|
return {
|
||||||
|
title: `Title ${patch.id}`,
|
||||||
|
stageId: "stage-a",
|
||||||
|
artistIds: ["art-1"],
|
||||||
|
startUtc: T0,
|
||||||
|
endUtc: T0 + HOUR,
|
||||||
|
dayKey: dayKeyFor(T0, CHI),
|
||||||
|
tags: [],
|
||||||
|
status: "scheduled",
|
||||||
|
...patch,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function section(): ScheduleSection {
|
||||||
|
return {
|
||||||
|
section: "schedule",
|
||||||
|
stages: [
|
||||||
|
{ id: "stage-a", name: "Main Stage" },
|
||||||
|
{ id: "stage-b", name: "Second Stage" },
|
||||||
|
],
|
||||||
|
artists: [
|
||||||
|
{ id: "art-1", name: "The Luminants" },
|
||||||
|
{ id: "art-2", name: "Solar Echo" },
|
||||||
|
],
|
||||||
|
events: [
|
||||||
|
event({ id: "e1", startUtc: T0, endUtc: T0 + HOUR, tags: ["music"] }),
|
||||||
|
event({ id: "e2", startUtc: T0 + 30 * 60_000, endUtc: T0 + 90 * 60_000, tags: ["music"] }),
|
||||||
|
event({
|
||||||
|
id: "e3",
|
||||||
|
stageId: "stage-b",
|
||||||
|
artistIds: ["art-2"],
|
||||||
|
startUtc: T0 + 3 * HOUR,
|
||||||
|
endUtc: T0 + 4 * HOUR,
|
||||||
|
tags: ["talk"],
|
||||||
|
status: "moved",
|
||||||
|
originalStartUtc: T0 + 2 * HOUR,
|
||||||
|
}),
|
||||||
|
event({
|
||||||
|
id: "e4",
|
||||||
|
stageId: "stage-b",
|
||||||
|
startUtc: T0 + 6 * HOUR,
|
||||||
|
endUtc: T0 + 7 * HOUR,
|
||||||
|
tags: ["talk"],
|
||||||
|
status: "cancelled",
|
||||||
|
}),
|
||||||
|
event({
|
||||||
|
id: "e5",
|
||||||
|
startUtc: Date.UTC(2026, 8, 11, 12, 0, 0),
|
||||||
|
endUtc: Date.UTC(2026, 8, 11, 13, 0, 0),
|
||||||
|
dayKey: "2026-09-11",
|
||||||
|
tags: ["music"],
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function viewInput(patch: Partial<ScheduleViewInput> = {}): ScheduleViewInput {
|
||||||
|
return {
|
||||||
|
section: section(),
|
||||||
|
favorites: new Set<string>(),
|
||||||
|
clock: {
|
||||||
|
now: T0 + 15 * 60_000,
|
||||||
|
zone: CHI,
|
||||||
|
festivalZone: CHI,
|
||||||
|
useDeviceZone: false,
|
||||||
|
outsideWarn: false,
|
||||||
|
driftWarn: false,
|
||||||
|
},
|
||||||
|
...patch,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function viewActions() {
|
||||||
|
return {
|
||||||
|
onToggleFavorite: vi.fn(() => true),
|
||||||
|
onZoneToggle: vi.fn(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("schedule queries", () => {
|
||||||
|
it("groups by publish-time dayKey in order", () => {
|
||||||
|
const groups = groupByDay(section());
|
||||||
|
expect(groups.map((d) => d.dayKey)).toEqual(["2026-09-10", "2026-09-11"]);
|
||||||
|
expect(groups[0]?.events.map((e) => e.id)).toEqual(["e1", "e2", "e3", "e4"]);
|
||||||
|
expect(groups[0]?.anchorUtc).toBe(T0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("now/next: overlaps all show; upcoming sorted", () => {
|
||||||
|
const data = section();
|
||||||
|
expect(nowEvents(data.events, T0 + 45 * 60_000).map((e) => e.id)).toEqual(["e1", "e2"]);
|
||||||
|
expect(nowEvents(data.events, T0 - 1).map((e) => e.id)).toEqual([]);
|
||||||
|
expect(upNext(data.events, T0 + 45 * 60_000, 2).map((e) => e.id)).toEqual(["e3", "e4"]);
|
||||||
|
expect(
|
||||||
|
upNextByStage(data, T0 + 15 * 60_000, 1)
|
||||||
|
.get("stage-b")
|
||||||
|
?.map((e) => e.id),
|
||||||
|
).toEqual(["e3"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("filters combine: day, stage, tag, status, favorites, query", () => {
|
||||||
|
const data = section();
|
||||||
|
expect(filterEvents(data, { dayKey: "2026-09-11" }).map((e) => e.id)).toEqual(["e5"]);
|
||||||
|
expect(filterEvents(data, { stageId: "stage-b" }).map((e) => e.id)).toEqual(["e3", "e4"]);
|
||||||
|
expect(filterEvents(data, { tag: "talk" }).map((e) => e.id)).toEqual(["e3", "e4"]);
|
||||||
|
expect(filterEvents(data, { statuses: ["scheduled"] }).map((e) => e.id)).toEqual([
|
||||||
|
"e1",
|
||||||
|
"e2",
|
||||||
|
"e5",
|
||||||
|
]);
|
||||||
|
expect(
|
||||||
|
filterEvents(data, { favoritesOnly: true, favoriteIds: new Set(["e3"]) }).map((e) => e.id),
|
||||||
|
).toEqual(["e3"]);
|
||||||
|
expect(filterEvents(data, { query: "luminants" }).map((e) => e.id)).toEqual([
|
||||||
|
"e1",
|
||||||
|
"e2",
|
||||||
|
"e4",
|
||||||
|
"e5",
|
||||||
|
]);
|
||||||
|
expect(filterEvents(data, { query: "SECOND" }).map((e) => e.id)).toEqual(["e3", "e4"]);
|
||||||
|
expect(filterEvents(data, { dayKey: "2026-09-11", tag: "music" }).map((e) => e.id)).toEqual([
|
||||||
|
"e5",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("conflicts: overlaps pair up, adjacency and cancelled excluded", () => {
|
||||||
|
const data = section();
|
||||||
|
const pairs = detectOverlaps(
|
||||||
|
[data.events[0], data.events[1], data.events[3]].filter((e): e is ScheduleEvent =>
|
||||||
|
Boolean(e),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(pairs).toHaveLength(1);
|
||||||
|
expect(pairs[0]?.map((e) => e.id)).toEqual(["e1", "e2"]);
|
||||||
|
const adjacent = [
|
||||||
|
event({ id: "a", startUtc: T0, endUtc: T0 + HOUR }),
|
||||||
|
event({ id: "b", startUtc: T0 + HOUR, endUtc: T0 + 2 * HOUR }),
|
||||||
|
];
|
||||||
|
expect(detectOverlaps(adjacent)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("name lookups fall back to ids; tags sorted", () => {
|
||||||
|
const data = section();
|
||||||
|
expect(stageName(data, "stage-a")).toBe("Main Stage");
|
||||||
|
expect(stageName(data, "nope")).toBe("nope");
|
||||||
|
expect(artistNames(data, ["art-2", "ghost"])).toBe("Solar Echo, ghost");
|
||||||
|
expect(allTags(data)).toEqual(["music", "talk"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("tolerates missing per-event arrays", () => {
|
||||||
|
const sparse = section();
|
||||||
|
const target = {
|
||||||
|
...sparse.events[0],
|
||||||
|
tags: undefined,
|
||||||
|
artistIds: undefined,
|
||||||
|
} as unknown as ScheduleEvent;
|
||||||
|
expect(filterEvents({ ...sparse, events: [target] }, { tag: "music" })).toEqual([]);
|
||||||
|
expect(
|
||||||
|
filterEvents({ ...sparse, events: [target] }, { query: "title" }).map((e) => e.id),
|
||||||
|
).toEqual([target.id]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("favorites domain", () => {
|
||||||
|
it("builds My Schedule sorted, orphans last and kept", () => {
|
||||||
|
const items = buildMySchedule(section(), [
|
||||||
|
{ eventId: "ghost", addedAt: 2 },
|
||||||
|
{ eventId: "e3", addedAt: 1 },
|
||||||
|
]);
|
||||||
|
expect(items.map((i) => i.status)).toEqual(["moved", "orphan"]);
|
||||||
|
expect(items[1]?.event).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("moved/cancelled mark; conflicts surface overlaps", () => {
|
||||||
|
const items = buildMySchedule(section(), [
|
||||||
|
{ eventId: "e1", addedAt: 1 },
|
||||||
|
{ eventId: "e2", addedAt: 2 },
|
||||||
|
{ eventId: "e4", addedAt: 3 },
|
||||||
|
]);
|
||||||
|
expect(items.map((i) => i.status)).toEqual(["scheduled", "scheduled", "cancelled"]);
|
||||||
|
const conflicts = myScheduleConflicts(items);
|
||||||
|
expect(conflicts).toHaveLength(1);
|
||||||
|
expect(conflicts[0]?.map((e) => e.id)).toEqual(["e1", "e2"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("ClockService state", () => {
|
||||||
|
it("captures skew as server minus device", () => {
|
||||||
|
const skew = captureSkew(1000, { deviceNow: 700, monoNow: 50 }, "Date header");
|
||||||
|
expect(skew).toMatchObject({ skewMs: 300, capturedAtDevice: 700, capturedAtMono: 50 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resolves now with skew; drift re-derives base and warns once", () => {
|
||||||
|
// Threshold is 120s — jumps below it are clock noise, not drift.
|
||||||
|
const first = resolveClock({ deviceNow: 1_000_000, monoNow: 0 }, null, null);
|
||||||
|
expect(first.now).toBe(1_000_000);
|
||||||
|
expect(first.driftWarn).toBe(false);
|
||||||
|
const steady = resolveClock({ deviceNow: 1_000_010, monoNow: 10 }, null, first.session);
|
||||||
|
expect(steady.driftWarn).toBe(false);
|
||||||
|
const jumped = resolveClock({ deviceNow: 1_300_000, monoNow: 20 }, null, first.session);
|
||||||
|
expect(jumped.driftWarn).toBe(true);
|
||||||
|
const settled = resolveClock({ deviceNow: 1_300_010, monoNow: 30 }, null, jumped.session);
|
||||||
|
expect(settled.driftWarn).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps server skew across a mid-session jump", () => {
|
||||||
|
const skew = { skewMs: 500, capturedAtDevice: 0, capturedAtMono: 0, source: "t" };
|
||||||
|
const base = resolveClock({ deviceNow: 1_000_000, monoNow: 0 }, skew, null);
|
||||||
|
const jumped = resolveClock({ deviceNow: 1_300_000, monoNow: 10 }, skew, base.session);
|
||||||
|
expect(jumped.now).toBe(1_300_500);
|
||||||
|
expect(jumped.driftWarn).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("F-3: early prep quiet; near-festival or captured skew warns", () => {
|
||||||
|
const win = { startUtc: T0, endUtc: T0 + 4 * 24 * HOUR, timezone: CHI };
|
||||||
|
const july = Date.UTC(2026, 6, 1);
|
||||||
|
expect(shouldWarnOutsideWindow(july, win, null)).toBe(false);
|
||||||
|
expect(
|
||||||
|
shouldWarnOutsideWindow(july, win, {
|
||||||
|
skewMs: 1,
|
||||||
|
capturedAtDevice: 0,
|
||||||
|
capturedAtMono: 0,
|
||||||
|
source: "t",
|
||||||
|
}),
|
||||||
|
).toBe(true);
|
||||||
|
expect(shouldWarnOutsideWindow(T0 + 60 * 24 * HOUR, win, null)).toBe(true);
|
||||||
|
expect(shouldWarnOutsideWindow(T0 + HOUR, win, null)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("formats in explicit zones; device zone resolves", () => {
|
||||||
|
expect(formatEventTime(Date.UTC(2026, 6, 15, 18, 0, 0), CHI)).toContain("1:00 PM");
|
||||||
|
expect(formatDayLabel(Date.UTC(2026, 8, 10, 12, 0, 0), CHI)).toContain("Thursday");
|
||||||
|
expect(formatClockTime(Date.UTC(2026, 8, 10, 12, 0, 0), CHI)).toContain("7:00 AM");
|
||||||
|
expect(deviceTimeZone()).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("schedule narrowing", () => {
|
||||||
|
it("accepts valid sections, rejects the rest", () => {
|
||||||
|
expect(narrowScheduleSection(section())).toBeTruthy();
|
||||||
|
for (const bad of [
|
||||||
|
null,
|
||||||
|
{},
|
||||||
|
{ section: "schedule" },
|
||||||
|
{ section: "map", stages: [], artists: [], events: [] },
|
||||||
|
]) {
|
||||||
|
expect(narrowScheduleSection(bad)).toBeNull();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("schedule view", () => {
|
||||||
|
it("null input renders the honest not-downloaded card", () => {
|
||||||
|
const view = createScheduleView(null, viewActions());
|
||||||
|
expect(view.querySelector("h1")?.textContent).toBe("Schedule");
|
||||||
|
expect(view.textContent).toContain("isn't on this device yet");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders days, now/next, and events with statuses", () => {
|
||||||
|
const view = createScheduleView(viewInput(), viewActions());
|
||||||
|
expect(view.querySelectorAll(".schedule-day")).toHaveLength(2);
|
||||||
|
expect(view.textContent).toContain("On now");
|
||||||
|
expect(view.textContent).toContain("Up next");
|
||||||
|
expect(view.textContent).toContain("Title e1");
|
||||||
|
expect(view.textContent).toContain("Moved (was");
|
||||||
|
expect(view.textContent).toContain("Cancelled");
|
||||||
|
expect(view.querySelector(".schedule-badge--cancelled")).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("day tabs and stage filter narrow the list", () => {
|
||||||
|
const view = createScheduleView(viewInput(), viewActions());
|
||||||
|
const tabs = [...view.querySelectorAll<HTMLButtonElement>(".schedule-day")];
|
||||||
|
tabs[1]?.click();
|
||||||
|
expect(view.querySelector(".schedule-list")?.textContent).toContain("Title e5");
|
||||||
|
expect(view.querySelector(".schedule-list")?.textContent).not.toContain("Title e1");
|
||||||
|
const select = view.querySelector<HTMLSelectElement>("select");
|
||||||
|
if (!select) throw new Error("stage select missing");
|
||||||
|
select.value = "stage-b";
|
||||||
|
select.dispatchEvent(new Event("change", { bubbles: true }));
|
||||||
|
tabs[0]?.click();
|
||||||
|
expect(view.querySelector(".schedule-list")?.textContent).toContain("Title e3");
|
||||||
|
expect(view.querySelector(".schedule-list")?.textContent).not.toContain("Title e1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("search narrows; empty result explains itself", () => {
|
||||||
|
const view = createScheduleView(viewInput(), viewActions());
|
||||||
|
const search = view.querySelector<HTMLInputElement>('input[type="search"]');
|
||||||
|
if (!search) throw new Error("search missing");
|
||||||
|
search.value = "Solar Echo";
|
||||||
|
search.dispatchEvent(new Event("input", { bubbles: true }));
|
||||||
|
expect(view.querySelector(".schedule-list")?.textContent).toContain("Title e3");
|
||||||
|
expect(view.querySelector(".schedule-list")?.textContent).not.toContain("Title e1");
|
||||||
|
search.value = "zzz-no-match";
|
||||||
|
search.dispatchEvent(new Event("input", { bubbles: true }));
|
||||||
|
expect(view.querySelector(".schedule-list")?.textContent).toContain("No events match");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("favorite toggle flips, persists via callback, and Mine filters", () => {
|
||||||
|
const actions = viewActions();
|
||||||
|
const view = createScheduleView(viewInput(), actions);
|
||||||
|
const card = view.querySelector('[data-event-id="e1"]');
|
||||||
|
const star = card?.querySelector<HTMLButtonElement>(".schedule-fav");
|
||||||
|
if (!star) throw new Error("fav button missing");
|
||||||
|
expect(star.getAttribute("aria-pressed")).toBe("false");
|
||||||
|
star.click();
|
||||||
|
expect(star.getAttribute("aria-pressed")).toBe("true");
|
||||||
|
expect(actions.onToggleFavorite).toHaveBeenCalledWith("e1", true);
|
||||||
|
const mine = view.querySelector<HTMLButtonElement>(".schedule-mine");
|
||||||
|
mine?.click();
|
||||||
|
expect(view.querySelector(".schedule-list")?.textContent).toContain("Title e1");
|
||||||
|
expect(view.querySelector(".schedule-list")?.textContent).not.toContain("Title e3");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("overlap among favorites surfaces a conflict note", () => {
|
||||||
|
const view = createScheduleView(viewInput({ favorites: new Set(["e1", "e2"]) }), viewActions());
|
||||||
|
const mine = view.querySelector<HTMLButtonElement>(".schedule-mine");
|
||||||
|
mine?.click();
|
||||||
|
expect(view.querySelector(".schedule-conflicts")?.textContent).toContain(
|
||||||
|
"Overlap in My Schedule",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clock warning shows; zone toggle reports the flip", () => {
|
||||||
|
const actions = viewActions();
|
||||||
|
const warned = createScheduleView(
|
||||||
|
viewInput({ clock: { ...viewInput().clock, outsideWarn: true } }),
|
||||||
|
actions,
|
||||||
|
);
|
||||||
|
expect(warned.querySelector(".schedule-warning")?.textContent).toContain("clock looks off");
|
||||||
|
const zone = warned.querySelector<HTMLButtonElement>(".schedule-zone");
|
||||||
|
expect(zone?.textContent).toBe("Showing festival time");
|
||||||
|
zone?.click();
|
||||||
|
expect(actions.onZoneToggle).toHaveBeenCalledWith(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never creates elements from content (inert text only)", () => {
|
||||||
|
const evil = section();
|
||||||
|
const target = evil.events[0] as unknown as Record<string, unknown>;
|
||||||
|
target.title = "<img src=x onerror=alert(1)>";
|
||||||
|
const view = createScheduleView(viewInput({ section: evil }), viewActions());
|
||||||
|
// No element is ever parsed out of content — textContent only (B-7).
|
||||||
|
expect(view.querySelector("img")).toBeNull();
|
||||||
|
expect(view.querySelector('[data-event-id="e1"] h2')?.textContent).toContain("<img src=x");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("schedule loader + clock persistence + wiring", () => {
|
||||||
|
function deleteDb(name: string): Promise<void> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const request = (g.indexedDB as IDBFactory).deleteDatabase(name);
|
||||||
|
request.onsuccess = () => {
|
||||||
|
resolve();
|
||||||
|
};
|
||||||
|
request.onerror = () => {
|
||||||
|
reject(request.error ?? new Error("delete failed"));
|
||||||
|
};
|
||||||
|
request.onblocked = () => {
|
||||||
|
resolve();
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fixturePackage(version: number) {
|
||||||
|
const keyPair = generateTestKeyPair();
|
||||||
|
const built = buildPackage(makeValidInput({ packageVersion: version }), { signWith: keyPair });
|
||||||
|
if (!built.ok || !built.pkg.signature) throw new Error("fixture build failed");
|
||||||
|
const manifestBytes = new TextEncoder().encode(canonicalJson(built.pkg.manifest));
|
||||||
|
const files = new Map<string, Uint8Array>();
|
||||||
|
for (const [name, file] of built.pkg.files) files.set(name, file.canonicalBytes);
|
||||||
|
for (const asset of built.pkg.assets) files.set(asset.file, asset.bytesContent);
|
||||||
|
const result = await verifyPackage(
|
||||||
|
{
|
||||||
|
manifestBytes,
|
||||||
|
signature: built.pkg.signature,
|
||||||
|
files: { getFile: (name) => Promise.resolve(files.get(name)) },
|
||||||
|
emergencyFloor: built.pkg.emergencyFloor,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
trustedKeys: new Map([
|
||||||
|
[keyPair.fingerprint, publicKeyFromDerBase64(keyPair.publicKeyDerBase64)],
|
||||||
|
]),
|
||||||
|
appVersion: "1.0.0",
|
||||||
|
supportedSchemaRange: [1],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (!result.ok) throw new Error(`verify failed: ${result.reason}`);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
g.indexedDB = new FDBFactory() as unknown;
|
||||||
|
g.IDBKeyRange = FDBKeyRange as unknown;
|
||||||
|
resetClockSession();
|
||||||
|
await deleteDb(DB.SYSTEM);
|
||||||
|
await deleteDb(DB.SLOT_A);
|
||||||
|
await deleteDb(DB.SLOT_B);
|
||||||
|
await deleteDb(DB.USER);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
document.body.innerHTML = "";
|
||||||
|
delete g.indexedDB;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fresh install loads nothing", async () => {
|
||||||
|
expect(await loadScheduleData()).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("staged fixture loads section, window, and empty favorites", async () => {
|
||||||
|
const pkg = await fixturePackage(1);
|
||||||
|
const staged = await stageVerifiedPackage(pkg);
|
||||||
|
expect((await activateStagedPackage(pkg, staged, "1.0.0")).ok).toBe(true);
|
||||||
|
const loaded = await loadScheduleData();
|
||||||
|
expect(loaded?.section.events.length).toBeGreaterThan(0);
|
||||||
|
expect(loaded?.window.timezone).toBe("America/Chicago");
|
||||||
|
expect(loaded?.favorites).toEqual([]);
|
||||||
|
expect(loaded?.skew).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skew persists across loads", async () => {
|
||||||
|
await persistSkew({ skewMs: 500, capturedAtDevice: 1000, capturedAtMono: 10, source: "test" });
|
||||||
|
expect(await loadPersistedSkew()).toMatchObject({ skewMs: 500 });
|
||||||
|
const loaded = await loadScheduleData();
|
||||||
|
// No dataset staged — loader is null, but skew survives independently.
|
||||||
|
expect(loaded).toBeNull();
|
||||||
|
expect(await loadPersistedSkew()).toMatchObject({ skewMs: 500 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("favorite survives a dataset update and still joins (X3 at schedule level)", async () => {
|
||||||
|
const first = await fixturePackage(1);
|
||||||
|
const stagedFirst = await stageVerifiedPackage(first);
|
||||||
|
expect((await activateStagedPackage(first, stagedFirst, "1.0.0")).ok).toBe(true);
|
||||||
|
const user = await openUserDB();
|
||||||
|
const { addFavorite } = await import("../../src/data/user/store.js");
|
||||||
|
await addFavorite(user, { eventId: "evt-0001", addedAt: 7 });
|
||||||
|
user.close();
|
||||||
|
const second = await fixturePackage(2);
|
||||||
|
const stagedSecond = await stageVerifiedPackage(second);
|
||||||
|
expect((await activateStagedPackage(second, stagedSecond, "1.0.0")).ok).toBe(true);
|
||||||
|
const loaded = await loadScheduleData();
|
||||||
|
expect(loaded?.favorites).toEqual([{ eventId: "evt-0001", addedAt: 7 }]);
|
||||||
|
expect(loaded?.section.events.some((e) => e.id === "evt-0001")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("boot at /schedule renders dataset events; star toggle persists", async () => {
|
||||||
|
const pkg = await fixturePackage(1);
|
||||||
|
const staged = await stageVerifiedPackage(pkg);
|
||||||
|
expect((await activateStagedPackage(pkg, staged, "1.0.0")).ok).toBe(true);
|
||||||
|
history.replaceState(null, "", "/schedule");
|
||||||
|
document.body.innerHTML = '<div id="app" aria-busy="true"></div>';
|
||||||
|
boot();
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(document.querySelector(".schedule-view")?.textContent).toContain("Opening Ceremony");
|
||||||
|
});
|
||||||
|
const star = document.querySelector<HTMLButtonElement>(
|
||||||
|
'[data-event-id="evt-0001"] .schedule-fav',
|
||||||
|
);
|
||||||
|
if (!star) throw new Error("fav button missing in wired view");
|
||||||
|
star.click();
|
||||||
|
await vi.waitFor(async () => {
|
||||||
|
const db = await openUserDB();
|
||||||
|
try {
|
||||||
|
const favs = await listFavorites(db);
|
||||||
|
expect(favs).toHaveLength(1);
|
||||||
|
expect(favs[0]?.eventId).toBe("evt-0001");
|
||||||
|
expect(typeof favs[0]?.addedAt).toBe("number");
|
||||||
|
} finally {
|
||||||
|
db.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
document.body.innerHTML = "";
|
||||||
|
});
|
||||||
|
});
|
||||||
177
tests/unit/status.test.ts
Normal file
177
tests/unit/status.test.ts
Normal file
|
|
@ -0,0 +1,177 @@
|
||||||
|
/* eslint-disable @typescript-eslint/no-unsafe-call */
|
||||||
|
/**
|
||||||
|
* @vitest-environment jsdom
|
||||||
|
* Stage 9 — Status screen rendering and header-chip boot wiring.
|
||||||
|
* Trace: IMPLEMENTATION-CONTRACT.md §20.5, SPIKE-05 §3.
|
||||||
|
*/
|
||||||
|
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||||
|
// @ts-expect-error fake-indexeddb types via exports fallback
|
||||||
|
import FDBFactory from "fake-indexeddb/lib/FDBFactory";
|
||||||
|
// @ts-expect-error fake-indexeddb types via exports fallback
|
||||||
|
import FDBKeyRange from "fake-indexeddb/lib/FDBKeyRange";
|
||||||
|
import { mount as boot } from "../../src/app/main.js";
|
||||||
|
import { createPrepGuidanceView, createStatusView } from "../../src/ui/views/status/status.js";
|
||||||
|
import type { BootReadinessReport } from "../../src/domain/readiness/types.js";
|
||||||
|
|
||||||
|
// jsdom implements <dialog> but not showModal — stub it like a real browser.
|
||||||
|
function stubDialog(): void {
|
||||||
|
const proto = window.HTMLDialogElement.prototype as unknown as Record<string, unknown>;
|
||||||
|
if (
|
||||||
|
typeof proto.showModal !== "function" ||
|
||||||
|
proto.showModal.toString().includes("not implemented")
|
||||||
|
) {
|
||||||
|
proto.showModal = function (this: Element): void {
|
||||||
|
this.setAttribute("open", "");
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function report(patch: Partial<BootReadinessReport> = {}): BootReadinessReport {
|
||||||
|
return {
|
||||||
|
state: "READY",
|
||||||
|
reason: null,
|
||||||
|
missing: [],
|
||||||
|
failedOp: null,
|
||||||
|
level: "L2",
|
||||||
|
edition: "lumen-2026",
|
||||||
|
packageVersion: 1,
|
||||||
|
generatedAt: "2026-09-01T00:00:00.000Z",
|
||||||
|
fetchedAt: 1_750_000_000_000,
|
||||||
|
sections: [
|
||||||
|
{ id: "emergency", required: true, present: true, parseable: true },
|
||||||
|
{ id: "schedule", required: true, present: true, parseable: true },
|
||||||
|
],
|
||||||
|
assets: { total: 2, present: 2, missing: [] },
|
||||||
|
floorVersion: "1.0.0+1",
|
||||||
|
storage: { usage: 1024, quota: 2048 },
|
||||||
|
shellCache: "lumen-shell-test",
|
||||||
|
canRestore: false,
|
||||||
|
activationPending: false,
|
||||||
|
...patch,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function actions() {
|
||||||
|
return {
|
||||||
|
onCheckData: vi.fn(),
|
||||||
|
onGetData: vi.fn(),
|
||||||
|
onRestore: null as (() => void) | null,
|
||||||
|
onClose: vi.fn(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("Status view", () => {
|
||||||
|
it("READY shows checklist, versions, storage, floor — no recovery actions", () => {
|
||||||
|
const view = createStatusView(report(), actions());
|
||||||
|
expect(view.querySelector("h1")?.textContent).toBe("Offline status");
|
||||||
|
expect(view.textContent).toContain("State: READY");
|
||||||
|
expect(view.textContent).toContain("v1");
|
||||||
|
expect(view.textContent).toContain("1.0 KB used of 2.0 KB");
|
||||||
|
expect(view.textContent).toContain("Emergency floor");
|
||||||
|
expect(view.textContent).not.toContain("Restore previous version");
|
||||||
|
expect(view.querySelectorAll(".status-check--ok")).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("PARTIAL enumerates missing and offers Continue setup", () => {
|
||||||
|
const a = actions();
|
||||||
|
const view = createStatusView(
|
||||||
|
report({ state: "PARTIAL", level: "L1", missing: ["map", "assets"] }),
|
||||||
|
a,
|
||||||
|
);
|
||||||
|
expect(view.textContent).toContain("Missing: map, assets");
|
||||||
|
const buttons = [...view.querySelectorAll("button")].map((b) => b.textContent);
|
||||||
|
expect(buttons).toContain("Continue setup");
|
||||||
|
view.querySelectorAll("button")[0]?.click();
|
||||||
|
expect(a.onGetData).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("RECOVERY with retained slot offers both restore paths", () => {
|
||||||
|
const onRestore = vi.fn();
|
||||||
|
const view = createStatusView(report({ state: "RECOVERY", reason: "missing", level: "L2" }), {
|
||||||
|
...actions(),
|
||||||
|
onRestore,
|
||||||
|
});
|
||||||
|
const labels = [...view.querySelectorAll("button")].map((b) => b.textContent);
|
||||||
|
expect(labels).toContain("Restore festival data");
|
||||||
|
expect(labels).toContain("Restore previous version");
|
||||||
|
[...view.querySelectorAll("button")]
|
||||||
|
.find((b) => b.textContent === "Restore previous version")
|
||||||
|
?.click();
|
||||||
|
expect(onRestore).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("FAILED offers Retry and keeps diagnostics on-device", () => {
|
||||||
|
const a = actions();
|
||||||
|
const view = createStatusView(report({ state: "FAILED", failedOp: "activation" }), a);
|
||||||
|
expect(view.textContent).toContain("Failed operation: activation");
|
||||||
|
[...view.querySelectorAll("button").values()].find((b) => b.textContent === "Retry")?.click();
|
||||||
|
expect(a.onCheckData).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("BASELINE_ONLY explains itself with install guidance, no dead actions", () => {
|
||||||
|
const view = createStatusView(report({ state: "BASELINE_ONLY", level: "L0" }), actions());
|
||||||
|
expect(view.textContent).toContain("cannot save offline data");
|
||||||
|
expect(view.querySelector("button")?.textContent).toBe("Check my data");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders no raw HTML — all content is text", () => {
|
||||||
|
const view = createStatusView(
|
||||||
|
report({ edition: "<img src=x>", missing: ["<b>map</b>"] }),
|
||||||
|
actions(),
|
||||||
|
);
|
||||||
|
expect(view.innerHTML).not.toContain("<img src=x>");
|
||||||
|
expect(view.innerHTML).not.toContain("<b>map</b>");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prep guidance has Back and Close", () => {
|
||||||
|
const onBack = vi.fn();
|
||||||
|
const onClose = vi.fn();
|
||||||
|
const view = createPrepGuidanceView(onBack, onClose);
|
||||||
|
expect(view.querySelector("h1")?.textContent).toBe("Get festival data");
|
||||||
|
[...view.querySelectorAll("button").values()]
|
||||||
|
.find((b) => b.textContent === "Back to status")
|
||||||
|
?.click();
|
||||||
|
[...view.querySelectorAll("button").values()].find((b) => b.textContent === "Close")?.click();
|
||||||
|
expect(onBack).toHaveBeenCalledTimes(1);
|
||||||
|
expect(onClose).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("header chip boot wiring", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
stubDialog();
|
||||||
|
history.replaceState(null, "", "/");
|
||||||
|
document.body.innerHTML = '<div id="app" aria-busy="true"></div>';
|
||||||
|
});
|
||||||
|
afterEach(() => {
|
||||||
|
document.body.innerHTML = "";
|
||||||
|
// Remove the polyfill for the no-IDB case.
|
||||||
|
delete (globalThis as Record<string, unknown>).indexedDB;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("without storage the chip reports BASELINE_ONLY, never blank", async () => {
|
||||||
|
boot();
|
||||||
|
const chip = document.querySelector(".app-header__status");
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(chip?.textContent).toBe("SAVING UNAVAILABLE");
|
||||||
|
});
|
||||||
|
expect(chip?.getAttribute("aria-label")).toContain("cannot save offline data");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clicking the chip re-evaluates and opens the Status dialog, not a stale report", async () => {
|
||||||
|
const g = globalThis as unknown as Record<string, unknown>;
|
||||||
|
g.indexedDB = new FDBFactory() as unknown;
|
||||||
|
g.IDBKeyRange = FDBKeyRange as unknown;
|
||||||
|
boot();
|
||||||
|
const chip = document.querySelector<HTMLElement>(".app-header__status");
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(chip?.textContent).toBe("Get festival data");
|
||||||
|
});
|
||||||
|
// Fresh boot: NOT_READY. Clicking must re-evaluate (not reuse the
|
||||||
|
// boot-time report) — the dialog proves which report was used.
|
||||||
|
chip?.click();
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(document.querySelector("dialog[open]")?.textContent).toContain("State: NOT_READY");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Add table
Add a link
Reference in a new issue