diff --git a/index.html b/index.html
index 352e24d..aa35be0 100644
--- a/index.html
+++ b/index.html
@@ -28,6 +28,8 @@
+
+
-
+
diff --git a/public/icons/apple-touch-icon.png b/public/icons/apple-touch-icon.png
new file mode 100644
index 0000000..87ab679
Binary files /dev/null and b/public/icons/apple-touch-icon.png differ
diff --git a/public/icons/icon-192-maskable.png b/public/icons/icon-192-maskable.png
new file mode 100644
index 0000000..681d4cf
Binary files /dev/null and b/public/icons/icon-192-maskable.png differ
diff --git a/public/icons/icon-192.png b/public/icons/icon-192.png
new file mode 100644
index 0000000..91a58a3
Binary files /dev/null and b/public/icons/icon-192.png differ
diff --git a/public/icons/icon-512-maskable.png b/public/icons/icon-512-maskable.png
new file mode 100644
index 0000000..13c91a7
Binary files /dev/null and b/public/icons/icon-512-maskable.png differ
diff --git a/public/icons/icon-512.png b/public/icons/icon-512.png
new file mode 100644
index 0000000..233cf46
Binary files /dev/null and b/public/icons/icon-512.png differ
diff --git a/public/manifest.webmanifest b/public/manifest.webmanifest
index 3a062fa..dbdf3ea 100644
--- a/public/manifest.webmanifest
+++ b/public/manifest.webmanifest
@@ -9,6 +9,30 @@
"theme_color": "#16181d",
"lang": "en",
"icons": [
+ {
+ "src": "/icons/icon-192.png",
+ "sizes": "192x192",
+ "type": "image/png",
+ "purpose": "any"
+ },
+ {
+ "src": "/icons/icon-512.png",
+ "sizes": "512x512",
+ "type": "image/png",
+ "purpose": "any"
+ },
+ {
+ "src": "/icons/icon-192-maskable.png",
+ "sizes": "192x192",
+ "type": "image/png",
+ "purpose": "maskable"
+ },
+ {
+ "src": "/icons/icon-512-maskable.png",
+ "sizes": "512x512",
+ "type": "image/png",
+ "purpose": "maskable"
+ },
{
"src": "/favicon.svg",
"sizes": "any",
diff --git a/src/lib/offline/precache.test.ts b/src/lib/offline/precache.test.ts
index 1eeb826..de949fa 100644
--- a/src/lib/offline/precache.test.ts
+++ b/src/lib/offline/precache.test.ts
@@ -20,18 +20,38 @@ describe("offline precache list", () => {
])
})
- it("saves the app, then journeys, then street tiles", () => {
+ it("saves pages and assets first, then journeys, then street tiles", () => {
expect(
backgroundOrder({
- shell: ["/", "/sw.js", "/assets/app.js", "/map-tiles/9/1/1.png", "/attractions/metro-people.svg"],
+ shell: [
+ "/attractions/metro-people.svg",
+ "/sw.js",
+ "/assets/app.js",
+ "/",
+ "/map-tiles/9/1/1.png",
+ "/icons/icon-192.png",
+ "/index.html",
+ ],
snapshot: ["/snapshot/en/station_brief/RI.json", "/snapshot/en/journeys/RI.json"],
}),
- ).toEqual([
- ["/", "/assets/app.js", "/attractions/metro-people.svg"],
- ["/snapshot/en/journeys/RI.json"],
- ["/snapshot/en/station_brief/RI.json"],
- ["/map-tiles/9/1/1.png"],
- ])
+ ).toEqual({
+ shell: ["/", "/index.html", "/assets/app.js", "/attractions/metro-people.svg", "/icons/icon-192.png"],
+ journeys: ["/snapshot/en/journeys/RI.json"],
+ tiles: ["/map-tiles/9/1/1.png"],
+ })
+ })
+
+ it("does not background-warm station briefs already in the bundle", () => {
+ const ordered = backgroundOrder({
+ shell: ["/"],
+ snapshot: [
+ "/snapshot/en/station_brief/RI.json",
+ "/snapshot/en/station_brief/KG.json",
+ "/snapshot/en/journeys/KG.json",
+ ],
+ })
+ expect(ordered.journeys).toEqual(["/snapshot/en/journeys/KG.json"])
+ expect(JSON.stringify(ordered)).not.toContain("station_brief")
})
it("uses the same cache names as the service worker", () => {
diff --git a/src/lib/offline/precache.ts b/src/lib/offline/precache.ts
index a93c44c..63baec4 100644
--- a/src/lib/offline/precache.ts
+++ b/src/lib/offline/precache.ts
@@ -1,18 +1,31 @@
-export function backgroundOrder(list: { shell: string[]; snapshot: string[] }): string[][] {
- const shell: string[] = []
+export type WarmGroups = {
+ shell: string[]
+ journeys: string[]
+ tiles: string[]
+}
+
+/** Split the build list into warm phases. Station briefs stay out — they ship in the JS bundle. */
+export function backgroundOrder(list: { shell: string[]; snapshot: string[] }): WarmGroups {
+ const pages: string[] = []
+ const assets: string[] = []
+ const other: string[] = []
const tiles: string[] = []
for (const url of list.shell) {
if (url === "/sw.js") continue
if (url.startsWith("/map-tiles/")) tiles.push(url)
- else shell.push(url)
+ else if (url === "/" || url === "/index.html") pages.push(url)
+ else if (url.startsWith("/assets/")) assets.push(url)
+ else other.push(url)
}
const journeys: string[] = []
- const briefs: string[] = []
for (const url of list.snapshot) {
if (url.startsWith("/snapshot/en/journeys/")) journeys.push(url)
- else briefs.push(url)
}
- return [shell, journeys, briefs, tiles]
+ return {
+ shell: [...pages, ...assets, ...other],
+ journeys,
+ tiles,
+ }
}
export function classifyPrecache(urls: string[]): { shell: string[]; snapshot: string[] } {
diff --git a/src/lib/offline/warm.test.ts b/src/lib/offline/warm.test.ts
new file mode 100644
index 0000000..d908bfe
--- /dev/null
+++ b/src/lib/offline/warm.test.ts
@@ -0,0 +1,131 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
+
+type Stored = Map
+
+function memoryCaches() {
+ const bags = new Map()
+
+ class MemoryCache {
+ constructor(private readonly bag: Stored) {}
+ async put(request: RequestInfo, response: Response) {
+ const url = typeof request === "string" ? request : request.url
+ this.bag.set(new URL(url, "http://localhost").pathname, response)
+ }
+ async match(request: RequestInfo) {
+ const url = typeof request === "string" ? request : request.url
+ return this.bag.get(new URL(url, "http://localhost").pathname)
+ }
+ async keys() {
+ return [...this.bag.keys()].map((path) => new Request(`http://localhost${path}`))
+ }
+ async delete(request: RequestInfo) {
+ const url = typeof request === "string" ? request : request.url
+ return this.bag.delete(new URL(url, "http://localhost").pathname)
+ }
+ }
+
+ return {
+ async open(name: string) {
+ let bag = bags.get(name)
+ if (!bag) {
+ bag = new Map()
+ bags.set(name, bag)
+ }
+ return new MemoryCache(bag)
+ },
+ bags,
+ }
+}
+
+describe("warmOffline first-load phases", () => {
+ let idleCallbacks: Array<() => void>
+ let fetchMock: ReturnType
+ let cachesApi: ReturnType
+
+ beforeEach(() => {
+ idleCallbacks = []
+ cachesApi = memoryCaches()
+ fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
+ const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url
+ const path = new URL(url, "http://localhost").pathname
+ if (path === "/precache.json") {
+ return new Response(
+ JSON.stringify({
+ shell: ["/", "/index.html", "/assets/app.js", "/icons/icon-192.png", "/map-tiles/9/1/1.png"],
+ snapshot: ["/snapshot/en/journeys/RI.json", "/snapshot/en/station_brief/RI.json"],
+ }),
+ { status: 200, headers: { "Content-Type": "application/json" } },
+ )
+ }
+ return new Response(`body:${path}`, {
+ status: 200,
+ headers: { "Content-Type": "text/plain", "X-Priority": String(init?.priority ?? "auto") },
+ })
+ })
+ vi.stubGlobal("fetch", fetchMock)
+ vi.stubGlobal("caches", cachesApi)
+ vi.stubGlobal("navigator", { onLine: true, serviceWorker: undefined })
+ vi.stubGlobal("window", {
+ ...globalThis,
+ requestIdleCallback: (cb: IdleRequestCallback) => {
+ idleCallbacks.push(() => cb({ didTimeout: false, timeRemaining: () => 50 }))
+ return 1
+ },
+ addEventListener: vi.fn(),
+ })
+ })
+
+ afterEach(() => {
+ vi.unstubAllGlobals()
+ vi.resetModules()
+ })
+
+ it("caches the shell before idle, and journeys only after idle", async () => {
+ const { warmOffline } = await import("./warm")
+ warmOffline()
+
+ await vi.waitFor(() => {
+ const paths = fetchMock.mock.calls.map((call) => new URL(String(call[0]), "http://localhost").pathname)
+ expect(paths).toContain("/assets/app.js")
+ expect(paths).toContain("/icons/icon-192.png")
+ })
+
+ await vi.waitFor(() => expect(idleCallbacks.length).toBeGreaterThan(0))
+
+ const beforeIdle = fetchMock.mock.calls.map((call) => new URL(String(call[0]), "http://localhost").pathname)
+ expect(beforeIdle).not.toContain("/snapshot/en/journeys/RI.json")
+ expect(beforeIdle).not.toContain("/map-tiles/9/1/1.png")
+ expect(beforeIdle).not.toContain("/snapshot/en/station_brief/RI.json")
+
+ for (const run of idleCallbacks.splice(0)) run()
+
+ await vi.waitFor(() => {
+ const paths = fetchMock.mock.calls.map((call) => new URL(String(call[0]), "http://localhost").pathname)
+ expect(paths).toContain("/snapshot/en/journeys/RI.json")
+ expect(paths).toContain("/map-tiles/9/1/1.png")
+ })
+
+ const all = fetchMock.mock.calls.map((call) => new URL(String(call[0]), "http://localhost").pathname)
+ expect(all).not.toContain("/snapshot/en/station_brief/RI.json")
+
+ const journeyCall = fetchMock.mock.calls.find(
+ (call) => new URL(String(call[0]), "http://localhost").pathname === "/snapshot/en/journeys/RI.json",
+ )
+ expect(journeyCall?.[1]).toMatchObject({ priority: "low" })
+ })
+
+ it("does not wait on idle before saving the shell", async () => {
+ const { warmOffline } = await import("./warm")
+ warmOffline()
+ expect(idleCallbacks).toHaveLength(0)
+
+ await vi.waitFor(() => {
+ const paths = fetchMock.mock.calls.map((call) => new URL(String(call[0]), "http://localhost").pathname)
+ expect(paths).toContain("/precache.json")
+ expect(paths).toContain("/assets/app.js")
+ })
+
+ const paths = fetchMock.mock.calls.map((call) => new URL(String(call[0]), "http://localhost").pathname)
+ expect(paths).not.toContain("/snapshot/en/journeys/RI.json")
+ })
+})
diff --git a/src/lib/offline/warm.ts b/src/lib/offline/warm.ts
index 926ddd4..e83d87e 100644
--- a/src/lib/offline/warm.ts
+++ b/src/lib/offline/warm.ts
@@ -1,52 +1,78 @@
import { backgroundOrder } from "./precache"
import { SHELL_CACHE, SNAPSHOT_CACHE } from "./caches"
-const WIDTH = 2
+const SHELL_WIDTH = 4
+const HEAVY_WIDTH = 2
let started = false
let running = false
+let again = false
export function warmOffline(): void {
- if (started || !("caches" in window)) return
+ if (started || !("caches" in globalThis)) return
started = true
- const run = () => {
- if (running) return
- running = true
- void fill().finally(() => {
- running = false
- })
+ void warm()
+ window.addEventListener("online", () => {
+ void warm()
+ })
+}
+
+async function warm(): Promise {
+ if (running) {
+ again = true
+ return
+ }
+ running = true
+ try {
+ do {
+ again = false
+ await fill()
+ } while (again && navigator.onLine)
+ } finally {
+ running = false
}
- if ("requestIdleCallback" in window) window.requestIdleCallback(run, { timeout: 2000 })
- else globalThis.setTimeout(run, 1500)
- window.addEventListener("online", run)
}
async function fill(): Promise {
if (!navigator.onLine) return
- let list: { shell: string[]; snapshot: string[] }
+ const list = await loadPrecache()
+ if (!list) return
+
+ const { shell, journeys, tiles } = backgroundOrder(list)
+ await refreshShell(list.shell)
+ if (!navigator.onLine) return
+ await storeMissing(SHELL_CACHE, shell, { width: SHELL_WIDTH })
+
+ if (!navigator.onLine) return
+ await whenIdle()
+ if (!navigator.onLine) return
+
+ await storeMissing(SNAPSHOT_CACHE, journeys, { width: HEAVY_WIDTH, priority: "low" })
+ if (!navigator.onLine) return
+ await storeMissing(SHELL_CACHE, tiles, { width: HEAVY_WIDTH, priority: "low" })
+}
+
+async function loadPrecache(): Promise<{ shell: string[]; snapshot: string[] } | null> {
try {
const response = await fetch("/precache.json")
- if (!response.ok) return
+ if (!response.ok) return null
const payload: unknown = await response.json()
- if (!isList(payload)) return
- list = payload
+ return isList(payload) ? payload : null
} catch {
- return
- }
- const [shell, journeys, briefs, tiles] = backgroundOrder(list)
- const groups: Array<[string, string[]]> = [
- [SHELL_CACHE, shell],
- [SNAPSHOT_CACHE, journeys],
- [SNAPSHOT_CACHE, briefs],
- [SHELL_CACHE, tiles],
- ]
- await refreshShell(list.shell)
- for (const [name, urls] of groups) {
- if (!navigator.onLine) return
- await storeMissing(name, urls)
+ return null
}
}
+function whenIdle(): Promise {
+ return new Promise((resolve) => {
+ if ("requestIdleCallback" in window) {
+ window.requestIdleCallback(() => resolve(), { timeout: 2000 })
+ return
+ }
+ globalThis.setTimeout(resolve, 1500)
+ })
+}
+
const PAGES = ["/", "/index.html"]
async function refreshShell(current: string[]): Promise {
@@ -59,7 +85,11 @@ async function refreshShell(current: string[]): Promise {
}
}
-async function storeMissing(name: string, urls: string[]): Promise {
+async function storeMissing(
+ name: string,
+ urls: string[],
+ options: { width: number; priority?: RequestPriority },
+): Promise {
const cache = await caches.open(name)
const saved = new Set((await cache.keys()).map((request) => new URL(request.url).pathname))
const pending = urls.filter((url) => !saved.has(url))
@@ -68,16 +98,16 @@ async function storeMissing(name: string, urls: string[]): Promise {
while (next < pending.length && navigator.onLine) {
const url = pending[next]
next += 1
- await storeOne(cache, url)
+ await storeOne(cache, url, options.priority)
}
}
- const workers = Math.min(WIDTH, pending.length)
+ const workers = Math.min(options.width, pending.length)
await Promise.all(Array.from({ length: workers }, () => worker()))
}
-async function storeOne(cache: Cache, url: string): Promise {
+async function storeOne(cache: Cache, url: string, priority?: RequestPriority): Promise {
try {
- const response = await fetch(url, { priority: "low" })
+ const response = await fetch(url, priority ? { priority } : undefined)
if (!response.ok) return
await cache.put(url, response)
} catch {