Skip to content

Commit cb7040e

Browse files
vscarpenterclaude
andcommitted
feat: improve PWA offline experience with dashboard caching and path handling
Enhancements to service worker offline functionality: **Dashboard Caching** - Add /dashboard/ route to OFFLINE_ASSETS for offline access - Ensures analytics dashboard works when offline **Improved URL Matching** - Add trailing slash handling in cache matching strategy - Try both with and without trailing slashes for better cache hits - Fallback to home page (/) if no match found **Benefits** - More robust offline experience across all app routes - Handles URL variations (with/without trailing slashes) gracefully - Ensures users can access dashboard even when disconnected 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 92f05d3 commit cb7040e

File tree

1 file changed

+162
-124
lines changed

1 file changed

+162
-124
lines changed

public/sw.js

Lines changed: 162 additions & 124 deletions
Original file line numberDiff line numberDiff line change
@@ -1,159 +1,197 @@
11
// Use timestamp for cache versioning to ensure iOS devices get updates
22
const CACHE_NAME = `gsd-cache-${Date.now()}`;
3-
const OFFLINE_ASSETS = ["/", "/manifest.json", "/icons/icon-192.png", "/icons/icon-512.png", "/icons/icon.svg"];
3+
const OFFLINE_ASSETS = [
4+
"/",
5+
"/dashboard/",
6+
"/manifest.json",
7+
"/icons/icon-192.png",
8+
"/icons/icon-512.png",
9+
"/icons/icon.svg",
10+
];
411

512
self.addEventListener("install", (event) => {
6-
event.waitUntil(
7-
caches.open(CACHE_NAME).then((cache) => cache.addAll(OFFLINE_ASSETS)).then(() => self.skipWaiting())
8-
);
13+
event.waitUntil(
14+
caches
15+
.open(CACHE_NAME)
16+
.then((cache) => cache.addAll(OFFLINE_ASSETS))
17+
.then(() => self.skipWaiting()),
18+
);
919
});
1020

1121
self.addEventListener("activate", (event) => {
12-
event.waitUntil(
13-
caches.keys().then((keys) =>
14-
Promise.all(
15-
keys.map((key) => {
16-
if (key !== CACHE_NAME) {
17-
return caches.delete(key);
18-
}
19-
return undefined;
20-
})
21-
)
22-
).then(() => self.clients.claim())
23-
);
22+
event.waitUntil(
23+
caches
24+
.keys()
25+
.then((keys) =>
26+
Promise.all(
27+
keys.map((key) => {
28+
if (key !== CACHE_NAME) {
29+
return caches.delete(key);
30+
}
31+
return undefined;
32+
}),
33+
),
34+
)
35+
.then(() => self.clients.claim()),
36+
);
2437
});
2538

2639
// Handle skip waiting message from update toast
2740
self.addEventListener("message", (event) => {
28-
if (event.data && event.data.type === "SKIP_WAITING") {
29-
self.skipWaiting();
30-
}
41+
if (event.data && event.data.type === "SKIP_WAITING") {
42+
self.skipWaiting();
43+
}
3144
});
3245

3346
self.addEventListener("fetch", (event) => {
34-
const { request } = event;
35-
36-
// Only handle http/https requests
37-
if (!request.url.startsWith('http')) {
38-
return;
39-
}
40-
41-
if (request.method !== "GET") {
42-
return;
43-
}
44-
45-
// Network-first strategy for HTML to ensure fresh content on iOS
46-
const isHTMLRequest = request.headers.get('accept')?.includes('text/html') ||
47-
request.url.endsWith('/') ||
48-
request.url.endsWith('.html');
49-
50-
if (isHTMLRequest) {
51-
event.respondWith(
52-
fetch(request)
53-
.then((response) => {
54-
// Cache the fresh HTML response
55-
if (response && response.status === 200) {
56-
const clone = response.clone();
57-
caches.open(CACHE_NAME).then((cache) => {
58-
cache.put(request, clone).catch(() => {});
59-
});
60-
}
61-
return response;
62-
})
63-
.catch(() => caches.match(request).then(cached => cached || caches.match("/")))
64-
);
65-
} else {
66-
// Cache-first for static assets
67-
event.respondWith(
68-
caches.match(request).then((cachedResponse) => {
69-
if (cachedResponse) {
70-
return cachedResponse;
71-
}
72-
73-
return fetch(request)
74-
.then((response) => {
75-
// Only cache successful responses
76-
if (response && response.status === 200 && response.type === 'basic') {
77-
const clone = response.clone();
78-
caches.open(CACHE_NAME).then((cache) => {
79-
cache.put(request, clone).catch(() => {
80-
// Silently fail if caching fails
81-
});
82-
});
83-
}
84-
return response;
85-
})
86-
.catch(() => caches.match("/"));
87-
})
88-
);
89-
}
47+
const { request } = event;
48+
49+
// Only handle http/https requests
50+
if (!request.url.startsWith("http")) {
51+
return;
52+
}
53+
54+
if (request.method !== "GET") {
55+
return;
56+
}
57+
58+
// Network-first strategy for HTML to ensure fresh content on iOS
59+
const isHTMLRequest =
60+
request.headers.get("accept")?.includes("text/html") ||
61+
request.url.endsWith("/") ||
62+
request.url.endsWith(".html");
63+
64+
if (isHTMLRequest) {
65+
event.respondWith(
66+
fetch(request)
67+
.then((response) => {
68+
// Cache the fresh HTML response
69+
if (response && response.status === 200) {
70+
const clone = response.clone();
71+
caches.open(CACHE_NAME).then((cache) => {
72+
cache.put(request, clone).catch(() => {});
73+
});
74+
}
75+
return response;
76+
})
77+
.catch(() => {
78+
// Try to match the request with or without trailing slash
79+
return caches.match(request).then((cached) => {
80+
if (cached) return cached;
81+
82+
// If request ends with /, try without it, and vice versa
83+
const url = new URL(request.url);
84+
const altPath = url.pathname.endsWith("/")
85+
? url.pathname.slice(0, -1)
86+
: url.pathname + "/";
87+
88+
return caches
89+
.match(altPath)
90+
.then((altCached) => altCached || caches.match("/"));
91+
});
92+
}),
93+
);
94+
} else {
95+
// Cache-first for static assets
96+
event.respondWith(
97+
caches.match(request).then((cachedResponse) => {
98+
if (cachedResponse) {
99+
return cachedResponse;
100+
}
101+
102+
return fetch(request)
103+
.then((response) => {
104+
// Only cache successful responses
105+
if (
106+
response &&
107+
response.status === 200 &&
108+
response.type === "basic"
109+
) {
110+
const clone = response.clone();
111+
caches.open(CACHE_NAME).then((cache) => {
112+
cache.put(request, clone).catch(() => {
113+
// Silently fail if caching fails
114+
});
115+
});
116+
}
117+
return response;
118+
})
119+
.catch(() => caches.match("/"));
120+
}),
121+
);
122+
}
90123
});
91124

92125
// Handle notification clicks
93126
self.addEventListener("notificationclick", (event) => {
94-
event.notification.close();
95-
96-
event.waitUntil(
97-
clients.matchAll({ type: "window", includeUncontrolled: true }).then((clientList) => {
98-
// If app is already open, focus it
99-
for (const client of clientList) {
100-
if (client.url.includes(self.registration.scope) && "focus" in client) {
101-
return client.focus();
102-
}
103-
}
104-
// Otherwise open a new window
105-
if (clients.openWindow) {
106-
return clients.openWindow("/");
107-
}
108-
})
109-
);
127+
event.notification.close();
128+
129+
event.waitUntil(
130+
clients
131+
.matchAll({ type: "window", includeUncontrolled: true })
132+
.then((clientList) => {
133+
// If app is already open, focus it
134+
for (const client of clientList) {
135+
if (
136+
client.url.includes(self.registration.scope) &&
137+
"focus" in client
138+
) {
139+
return client.focus();
140+
}
141+
}
142+
// Otherwise open a new window
143+
if (clients.openWindow) {
144+
return clients.openWindow("/");
145+
}
146+
}),
147+
);
110148
});
111149

112150
// Handle periodic background sync for notifications
113151
// Supported on Chrome/Edge (desktop/mobile), not yet on Safari/iOS
114152
self.addEventListener("periodicsync", (event) => {
115-
if (event.tag === "check-notifications") {
116-
event.waitUntil(checkAndNotifyTasks());
117-
}
153+
if (event.tag === "check-notifications") {
154+
event.waitUntil(checkAndNotifyTasks());
155+
}
118156
});
119157

120158
// Handle push notifications (for future enhancement)
121159
self.addEventListener("push", (event) => {
122-
if (!event.data) {
123-
return;
124-
}
125-
126-
const data = event.data.json();
127-
const title = data.title || "GSD Task Manager";
128-
const options = {
129-
body: data.body || "You have a task reminder",
130-
icon: "/icons/icon-192.png",
131-
badge: "/icons/icon-192.png",
132-
tag: data.tag || "task-reminder",
133-
data: data.data || {}
134-
};
135-
136-
event.waitUntil(self.registration.showNotification(title, options));
160+
if (!event.data) {
161+
return;
162+
}
163+
164+
const data = event.data.json();
165+
const title = data.title || "GSD Task Manager";
166+
const options = {
167+
body: data.body || "You have a task reminder",
168+
icon: "/icons/icon-192.png",
169+
badge: "/icons/icon-192.png",
170+
tag: data.tag || "task-reminder",
171+
data: data.data || {},
172+
};
173+
174+
event.waitUntil(self.registration.showNotification(title, options));
137175
});
138176

139177
/**
140178
* Check tasks and send notifications (background sync version)
141179
* This is a simplified version that works in service worker context
142180
*/
143181
async function checkAndNotifyTasks() {
144-
try {
145-
// Open IndexedDB and check for due tasks
146-
// Note: This would require importing Dexie or using native IndexedDB API
147-
// For now, we'll rely on the active polling when app is open
148-
// This is a placeholder for future enhancement if needed
149-
150-
// Update badge count
151-
if ("setAppBadge" in self.navigator) {
152-
// In a real implementation, query IndexedDB for task count
153-
// For now, this is a placeholder
154-
await self.navigator.setAppBadge(0);
155-
}
156-
} catch (error) {
157-
console.error("Error in background notification check:", error);
158-
}
182+
try {
183+
// Open IndexedDB and check for due tasks
184+
// Note: This would require importing Dexie or using native IndexedDB API
185+
// For now, we'll rely on the active polling when app is open
186+
// This is a placeholder for future enhancement if needed
187+
188+
// Update badge count
189+
if ("setAppBadge" in self.navigator) {
190+
// In a real implementation, query IndexedDB for task count
191+
// For now, this is a placeholder
192+
await self.navigator.setAppBadge(0);
193+
}
194+
} catch (error) {
195+
console.error("Error in background notification check:", error);
196+
}
159197
}

0 commit comments

Comments
 (0)