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 {