From 693b44a4e301da7934d16c11866a4807cacae091 Mon Sep 17 00:00:00 2001 From: CodeDotJS Date: Thu, 15 Oct 2026 02:57:28 +0530 Subject: [PATCH 1/5] feat(pwa): add png install icons for home screen --- index.html | 2 ++ public/favicon.svg | 2 +- public/icons/apple-touch-icon.png | Bin 0 -> 714 bytes public/icons/icon-192-maskable.png | Bin 0 -> 730 bytes public/icons/icon-192.png | Bin 0 -> 780 bytes public/icons/icon-512-maskable.png | Bin 0 -> 2263 bytes public/icons/icon-512.png | Bin 0 -> 2344 bytes public/manifest.webmanifest | 24 ++++++++++++++++++++++++ 8 files changed, 27 insertions(+), 1 deletion(-) create mode 100644 public/icons/apple-touch-icon.png create mode 100644 public/icons/icon-192-maskable.png create mode 100644 public/icons/icon-192.png create mode 100644 public/icons/icon-512-maskable.png create mode 100644 public/icons/icon-512.png 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 0000000000000000000000000000000000000000..87ab6799ecd1c80797468e00b3c12ac5229f0c8f GIT binary patch literal 714 zcmeAS@N?(olHy`uVBq!ia0vp^TR@nD2}o{QKQWbofoYdbsQD~bF78#GzdDC?yb5y$C_7B@%<9(`Rk(AT$}Cq zYxPBM1q+t;2Ldup&IKHN2Ni)FH12~5D6&9~5RzhuoCi!VOerVCC>ZykBG_aEmt<=$ z)I>e2)S54Ues`Xg-OgDzU;nG9-1U0d?CH6a)OSX#HP7@tc*4$ME@-4aVKdD8YGr!uzBL@0WLPTmS6h-{>33tT7dDPAFPT z5q3W_bLme@)lX-HHqS`46aPKut{hL~I+o%mCrUneY-L;b?Y318Z_J(azN*jOJFGU{ z`=b=@-d3lu*6`?-*J;^4h81^nzHFbGnLe||_3M_+J@J8eAFO^8BK$UK?cWgL+)dZ^ zoqaF!wD@g?c-yC)+itZkyxE_idiiZ$ecz;)W_RWvK4;fFxAxPc4eB#&51RaF4FSg> ldMv3BN*jp8u(0GW(~Yc2Spg>&%mJoR22WQ%mvv4FO#oh}Fk}D# literal 0 HcmV?d00001 diff --git a/public/icons/icon-192-maskable.png b/public/icons/icon-192-maskable.png new file mode 100644 index 0000000000000000000000000000000000000000..681d4cf3d4694563cceec3e3b58fc333bb61c023 GIT binary patch literal 730 zcmeAS@N?(olHy`uVBq!ia0vp^2SAvE4M+yv$zf+;U|Q(u;uunK>+Kz1KPE?khKp$_ zkMAjT9{eF}+bI4)PwupGB~$OL89NjT-wXLS1YWW=j{SOXnv2qv<2=k)lD6J^!=++c z!6CrF!N9`6#K73V;J~23Ky5By}i?h^_vbHof%4=A$2OUr6s&%wcHfsk-D`CyU9-&75Lmq2?pI#8WN2}gG{_LlX zKdN@f{I#pzZ}W=7;bW|VK*Qzv`>poX7XK1{a(06Ax54PRf0Mu5ef6F3 z^I4jK0j}xS{Op(O4j`rU$FJh0>4SQ`cm1dK)bl^E^eX2vxfZyfT~{!9ua^5wA;D+1 z4zC}+$#nI4{jg_?lIoS&55k;pJ+502<}8(KAFyM!!`Ak#&!0wYZ8v>Xv}VF|$86r* zma_$0+st02S?qRv%eGZ|i^%PTx$WCzrMk{D(ju}U(E?3E4JLIA-~ZPyW=^q50;X98 MPgg&ebxsLQ0IChu_y7O^ literal 0 HcmV?d00001 diff --git a/public/icons/icon-192.png b/public/icons/icon-192.png new file mode 100644 index 0000000000000000000000000000000000000000..91a58a36d38be0e780d03d6c216bae21b014ca60 GIT binary patch literal 780 zcmeAS@N?(olHy`uVBq!ia0vp^2SAvE4M+yv$zf+;V7lz-;uunK>+M}%FK0uEwu@gL z9od=TsI}q+SInf-Mf!OML^b{?SP0Cqp0tZe+w1agHOurUf$aUuPhPFsY!S2W8Y`RW zPeBC+0R|2R76v8;#s&rl2C8#uV=9t+q09t{>b*7-zi)5v?U$F0uh{zk!Mi^xj~|Ek zuaC7kfAnF|)_0{Zj&8WS@V#N%Go~N!FLWpFcE0b;bNR&K_}AHeR)4ncTgITp|Efdq zip-U1N*(u^-d0&u-xmUsm$$o2ekh-AI^*$N%UaV9Kd)U%vnU_}1$iW{N5lp59ph^U8-0^KVVM z$<)Z8VBPTK{*Em>c)jlYQ^{*y!w|uU6#5#6|7$;b{x`B%|INX(#c#e(`;tE?jA0Eh zdTuYs+h%|A_JS?DT_PAaFdbN-l;1P;)5!~Qd6TAodU^WD>=f5^44lo^FUvj>V$=W1 zSGX~HfhGHf7-(%h01yhHPPcsoIYj;P&M)V bc_h8Sa86!u$8!&0DrfL?^>bP0l+XkK?DhXZ literal 0 HcmV?d00001 diff --git a/public/icons/icon-512-maskable.png b/public/icons/icon-512-maskable.png new file mode 100644 index 0000000000000000000000000000000000000000..13c91a7ab168c78d7cfbcafc5aa0ecc743744093 GIT binary patch literal 2263 zcmeAS@N?(olHy`uVBq!ia0y~yU;;9k7&zE~)R&4YzZe)e=6Sj}hE&{od)Lv+DN&^D z;rXIVHX#B_MWP#}GxT*=tL|hv%5<>t!JUO~6*iq&{oUrg%?tax5fn~_Vu zPSN3yEQ8Mfs{Q8E>Z9xOSs146zc23{!XmJqVS~lvsSIN2dxQ=MGw9SmKlG_w4=BCl zbE0P#FkYrDE>5xpGZuYL(gZUW76;c#f>{ebNB=v=501Ybe`ht{UT+Ds`ilX-_jwzj zW6GW-=}nuvQ-J}Pq8g&t{d_Pj+YT6%3jd@P^0wy|NBHqP?0S2D97cd;a&oM=8`gzW@Iy3lvu0$Ls($ z4kW7pWZ^3>4!C}4H+HsYK3tTKx_9@CD$pS@$18|GJ5?KN+a-Y{bp zV+B*ga)xJ*%esGpEdhqi&K*x@aWgUKSbyH|<_t4q!_suVX9+K7GJ{Rs@z=^eZ~wV| zh66$+c{+~I-53%it@NiY{H(;VL1eD5_eD!VhKRm%?N49S08@hJ^FxzL^z_z)s)S<) zR+inKder~c$Mem{#O&uWI81$-`SbELAfe2#!9};eOE>WJ{0dfwJwh9Pcuoyx5(D}} zG2vsd28&J|3xm^vN6P~^BK~kNv@{&`UnsEQhadw76Zd>3$ApK<3<4}-cC1YY4!HwE zV!Z literal 0 HcmV?d00001 diff --git a/public/icons/icon-512.png b/public/icons/icon-512.png new file mode 100644 index 0000000000000000000000000000000000000000..233cf461c32d59206f7f95184b1128a0934f4537 GIT binary patch literal 2344 zcmeAS@N?(olHy`uVBq!ia0y~yU;;9k7&zE~)R&4YzZe)e-g~+@hE&{od)LwHaDYhL zMf*obb}nh0)TCR=8awH9k!P%X&;qtYmH6Sk|#PG*I)FhS$K}y1x{_Z6=0TLA& z*fNCxgB$iRC(1+;qXX!G1O9To4f|ux9iRO#Z{r;)xjUKrBkz7G-~BG7;?Js8v$vVE zy^H;}h~Zc?kYGIUL~Nfz!$%l76jHvDL15ZUvi zwSnQlBXOpH4?u#6p*x<#;UJJWV$XEw)AyW!Z=dG>-n^6XKv9V_!?eY>8O#|X{>_ur zsXwp7uz|zYI-+HsZ5(65&!gImI`%^AS?(|-{G81>ZU0Q5@h{G>b)U--V2CJ4Yhg`y z16pmi`B0LvHVcE;teJ@mXG#KXJM(74i8FlQB1t{sjDUKCu?s^)%nVhr>5)ndIyz@W zxKH~Cg7SGmkZCjsvox@#9!qeXxe=V8{g$QmPS#A*1*-VD;?=UZy$3tDxLiMcOW?%y z+ue#i3D@WH9OMP1i#h)!Ph5W8u4wUB+Np+H0jQa=;qv}ypm-sK2THDwe%Z${3tazy zBpbOjQMmVC_&%e+`~Q!v(X-GI_J$+v4M6gvq5?@p;F+UIJ55_UcgbCAGbld)etw%l zATSm=(DLddXNO134v(A<{9q6O2N8nbet-Hez(J)U|$KkvlC`lOulayr~qriF=ow|;2Cb9j}3_2d?*;MsCPT%Kc z$Ya*2(|BP9Ot1_bjNS5FT#ViE3<^M{8X7=?u|Z(NkA(|>1S><*flKX9Kth~>so`ML zfkW*dfrLAQfrm)MH-k7J0SpU;e}V$Q!~qIcpyx@gCxN;efEr29#~@v>h$GozvfK&s s9eM7A=yCwMm@JDyUd3 Date: Thu, 15 Oct 2026 03:05:43 +0530 Subject: [PATCH 2/5] feat(offline): prioritize shell urls and skip brief warm --- src/lib/offline/precache.ts | 25 +++++++++++++++++++------ 1 file changed, 19 insertions(+), 6 deletions(-) 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[] } { From a9883849a23ee0aaa549e3c1d49eaf06a753c0fd Mon Sep 17 00:00:00 2001 From: CodeDotJS Date: Thu, 15 Oct 2026 03:05:43 +0530 Subject: [PATCH 3/5] feat(offline): cache shell before idle journey warm --- src/lib/offline/warm.ts | 96 +++++++++++++++++++++++++++-------------- 1 file changed, 63 insertions(+), 33 deletions(-) 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 { From 93e59ef7039bbc74b54a967493601f2cdbbf799e Mon Sep 17 00:00:00 2001 From: CodeDotJS Date: Thu, 15 Oct 2026 03:05:43 +0530 Subject: [PATCH 4/5] test(offline): cover shell-first precache order --- src/lib/offline/precache.test.ts | 36 +++++++++++++++++++++++++------- 1 file changed, 28 insertions(+), 8 deletions(-) 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", () => { From d0636428130259988ea5ec032988e0ba1214ead8 Mon Sep 17 00:00:00 2001 From: CodeDotJS Date: Thu, 15 Oct 2026 03:05:43 +0530 Subject: [PATCH 5/5] test(offline): cover first-load warm phases --- src/lib/offline/warm.test.ts | 131 +++++++++++++++++++++++++++++++++++ 1 file changed, 131 insertions(+) create mode 100644 src/lib/offline/warm.test.ts 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") + }) +})