Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 1 addition & 35 deletions public/sw.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ const SHELL = "dms-shell-2026-10-04h"
const SNAPSHOT = "dms-snapshot-2026-09-30"

self.addEventListener("install", (event) => {
event.waitUntil(precacheApp().then(() => self.skipWaiting()))
event.waitUntil(self.skipWaiting())
})

self.addEventListener("activate", (event) => {
Expand Down Expand Up @@ -36,40 +36,6 @@ self.addEventListener("fetch", (event) => {
event.respondWith(networkThenCache(SHELL, request))
})

async function precacheApp() {
const list = await precacheList()
if (!list) return
await addAll(SNAPSHOT, list.snapshot)
await addAll(SHELL, list.shell)
}

async function precacheList() {
const response = await fetch("/precache.json")
if (!response.ok) return null
const list = await response.json()
return {
shell: Array.isArray(list.shell) ? list.shell : [],
snapshot: Array.isArray(list.snapshot) ? list.snapshot : [],
}
}

async function addAll(name, urls) {
const cache = await caches.open(name)
for (let index = 0; index < urls.length; index += 24) {
const batch = urls.slice(index, index + 24)
await Promise.all(
batch.map(async (url) => {
try {
const response = await fetch(url)
if (response.ok) await cache.put(url, response)
} catch {
// One missing file does not block the rest of the shell.
}
}),
)
}
}

async function navigate(request) {
const cache = await caches.open(SHELL)
try {
Expand Down
2 changes: 2 additions & 0 deletions src/lib/offline/caches.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export const SHELL_CACHE = "dms-shell-2026-10-04h"
export const SNAPSHOT_CACHE = "dms-snapshot-2026-09-30"
24 changes: 23 additions & 1 deletion src/lib/offline/precache.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { readFileSync } from "node:fs"
import { describe, expect, it } from "vitest"
import { classifyPrecache } from "./precache"
import { SHELL_CACHE, SNAPSHOT_CACHE } from "./caches"
import { backgroundOrder, classifyPrecache } from "./precache"

describe("offline precache list", () => {
it("keeps the app shell separate from station briefs", () => {
Expand All @@ -17,4 +19,24 @@ describe("offline precache list", () => {
"/snapshot/en/routes/least-distance/RI/KG.json",
])
})

it("saves the app, then journeys, then street tiles", () => {
expect(
backgroundOrder({
shell: ["/", "/sw.js", "/assets/app.js", "/map-tiles/9/1/1.png", "/attractions/metro-people.svg"],
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"],
])
})

it("uses the same cache names as the service worker", () => {
const source = readFileSync(new URL("../../../public/sw.js", import.meta.url), "utf8")
expect(source).toContain(SHELL_CACHE)
expect(source).toContain(SNAPSHOT_CACHE)
})
})
17 changes: 17 additions & 0 deletions src/lib/offline/precache.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,20 @@
export function backgroundOrder(list: { shell: string[]; snapshot: string[] }): string[][] {
const shell: 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)
}
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]
}

export function classifyPrecache(urls: string[]): { shell: string[]; snapshot: string[] } {
const shell = ["/"]
const snapshot: string[] = []
Expand Down
2 changes: 1 addition & 1 deletion src/lib/offline/saved.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import { journeyFromSaved } from "../transit/route"
import type { Journey } from "../transit/types"
import { SNAPSHOT_CACHE } from "./caches"
import { journeyFromPack, type PackRide } from "./pack"

type PackFile = Record<string, { d?: PackRide; c?: PackRide }>

const packs = new Map<string, PackFile>()
const SNAPSHOT_CACHE = "dms-snapshot-2026-09-30"

export async function savedJourneys(
from: string,
Expand Down
77 changes: 77 additions & 0 deletions src/lib/offline/warm.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import { backgroundOrder } from "./precache"
import { SHELL_CACHE, SNAPSHOT_CACHE } from "./caches"

const WIDTH = 2

let started = false

export function warmOffline(): void {
if (started || !("caches" in window)) return
started = true
const run = () => {
void fill()
}
if ("requestIdleCallback" in window) window.requestIdleCallback(run, { timeout: 2000 })
else globalThis.setTimeout(run, 1500)
}

async function fill(): Promise<void> {
if (!navigator.onLine) return
let list: { shell: string[]; snapshot: string[] }
try {
const response = await fetch("/precache.json")
if (!response.ok) return
const payload: unknown = await response.json()
if (!isList(payload)) return
list = payload
} 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],
]
for (const [name, urls] of groups) {
if (!navigator.onLine) return
await storeMissing(name, urls)
}
}

async function storeMissing(name: string, urls: string[]): Promise<void> {
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))
let next = 0
async function worker(): Promise<void> {
while (next < pending.length && navigator.onLine) {
const url = pending[next]
next += 1
await storeOne(cache, url)
}
}
const workers = Math.min(WIDTH, pending.length)
await Promise.all(Array.from({ length: workers }, () => worker()))
}

async function storeOne(cache: Cache, url: string): Promise<void> {
try {
const response = await fetch(url, { priority: "low" })
if (!response.ok) return
await cache.put(url, response)
} catch {
// The next visit continues from the files already saved.
}
}

function isList(value: unknown): value is { shell: string[]; snapshot: string[] } {
if (!value || typeof value !== "object") return false
const list = value as { shell?: unknown; snapshot?: unknown }
return Array.isArray(list.shell) && Array.isArray(list.snapshot) && list.shell.every(isPath) && list.snapshot.every(isPath)
}

function isPath(value: unknown): value is string {
return typeof value === "string"
}
37 changes: 8 additions & 29 deletions src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,38 +3,17 @@ import { createRoot } from "react-dom/client"
import "@fontsource/ibm-plex-sans/400.css"
import "@fontsource/ibm-plex-sans/600.css"
import { App } from "./App"
import { warmOffline } from "./lib/offline/warm"
import "./index.css"

const root = document.getElementById("root")
if (!root) throw new Error("Missing root element")

void openWhenReady().then(() => {
createRoot(root).render(
<StrictMode>
<App />
</StrictMode>,
)
})
createRoot(root).render(
<StrictMode>
<App />
</StrictMode>,
)

async function openWhenReady() {
if (!("serviceWorker" in navigator)) return
let registration: ServiceWorkerRegistration
try {
registration = await navigator.serviceWorker.register("/sw.js")
} catch {
return
}
if (navigator.serviceWorker.controller) return
const worker = registration.installing || registration.waiting
if (!worker) return
await new Promise<void>((resolve) => {
const finish = () => resolve()
const timer = window.setTimeout(finish, 120000)
worker.addEventListener("statechange", () => {
if (worker.state === "activated" || worker.state === "redundant") {
window.clearTimeout(timer)
finish()
}
})
})
}
if ("serviceWorker" in navigator) void navigator.serviceWorker.register("/sw.js")
warmOffline()
Loading