diff --git a/web/index.html b/web/index.html
index f10630f..766063b 100644
--- a/web/index.html
+++ b/web/index.html
@@ -4,6 +4,9 @@
+
+
+
record-shop
diff --git a/web/public/icon.svg b/web/public/icon.svg
new file mode 100644
index 0000000..8cf081a
--- /dev/null
+++ b/web/public/icon.svg
@@ -0,0 +1,7 @@
+
diff --git a/web/public/manifest.webmanifest b/web/public/manifest.webmanifest
new file mode 100644
index 0000000..c1c676e
--- /dev/null
+++ b/web/public/manifest.webmanifest
@@ -0,0 +1,13 @@
+{
+ "name": "record-shop",
+ "short_name": "record-shop",
+ "description": "Track your physical music collection",
+ "start_url": "/",
+ "display": "standalone",
+ "background_color": "#0a0a0a",
+ "theme_color": "#0a0a0a",
+ "icons": [
+ { "src": "/icon.svg", "sizes": "any", "type": "image/svg+xml", "purpose": "any" },
+ { "src": "/icon.svg", "sizes": "any", "type": "image/svg+xml", "purpose": "maskable" }
+ ]
+}
diff --git a/web/public/sw.js b/web/public/sw.js
new file mode 100644
index 0000000..01e8b5f
--- /dev/null
+++ b/web/public/sw.js
@@ -0,0 +1,11 @@
+self.addEventListener('install', () => {
+ self.skipWaiting()
+})
+
+self.addEventListener('activate', (event) => {
+ event.waitUntil(self.clients.claim())
+})
+
+self.addEventListener('fetch', () => {
+ // no-op: presence of a fetch handler enables PWA install
+})
diff --git a/web/src/main.tsx b/web/src/main.tsx
index 4043b76..eff728d 100644
--- a/web/src/main.tsx
+++ b/web/src/main.tsx
@@ -8,3 +8,7 @@ ReactDOM.createRoot(document.getElementById('root')!).render(
)
+
+if ('serviceWorker' in navigator && import.meta.env.PROD) {
+ void navigator.serviceWorker.register('/sw.js')
+}
diff --git a/web/test/pwa.test.ts b/web/test/pwa.test.ts
new file mode 100644
index 0000000..d6b6582
--- /dev/null
+++ b/web/test/pwa.test.ts
@@ -0,0 +1,26 @@
+import { describe, it, expect } from 'vitest'
+import { readFileSync } from 'node:fs'
+import path from 'node:path'
+
+const pub = path.resolve(__dirname, '../public')
+
+describe('PWA assets', () => {
+ it('manifest has required fields and the icon exists', () => {
+ const manifest = JSON.parse(readFileSync(path.join(pub, 'manifest.webmanifest'), 'utf8')) as {
+ name: string
+ display: string
+ start_url: string
+ icons: { src: string; sizes: string; type: string }[]
+ }
+ expect(manifest.name).toContain('record-shop')
+ expect(manifest.display).toBe('standalone')
+ expect(manifest.start_url).toBe('/')
+ expect(manifest.icons.some((i) => i.src === '/icon.svg')).toBe(true)
+ expect(() => readFileSync(path.join(pub, 'icon.svg'))).not.toThrow()
+ })
+
+ it('service worker registers a fetch handler', () => {
+ const sw = readFileSync(path.join(pub, 'sw.js'), 'utf8')
+ expect(sw).toContain("addEventListener('fetch'")
+ })
+})