PWA & Offline Support
Pathfinder works even when internet access is unreliable, ensuring users can always access benefit information.
Why Offline Matters
Many people who need benefits most have unreliable internet access:- Rural areas with spotty connectivity
- Using mobile data with limited plans
- Public wifi in libraries or community centers
- Shelters or transitional housing
PWA Manifest
The web app manifest enables installation on home screens.Copy
// public/manifest.json
{
"name": "Pathfinder Benefits",
"short_name": "Pathfinder",
"description": "Discover benefits you qualify for",
"start_url": "/",
"display": "standalone",
"background_color": "#f5f5f4",
"theme_color": "#1c1917",
"orientation": "portrait-primary",
"icons": [
{
"src": "/icons/icon-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "/icons/icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any maskable"
}
],
"screenshots": [
{
"src": "/screenshots/home.png",
"sizes": "1080x1920",
"type": "image/png",
"form_factor": "narrow"
}
],
"shortcuts": [
{
"name": "Take Screener",
"short_name": "Screener",
"url": "/screener",
"icons": [{ "src": "/icons/screener.png", "sizes": "96x96" }]
},
{
"name": "Browse Programs",
"short_name": "Browse",
"url": "/browse",
"icons": [{ "src": "/icons/browse.png", "sizes": "96x96" }]
},
{
"name": "My Dashboard",
"short_name": "Dashboard",
"url": "/dashboard",
"icons": [{ "src": "/icons/dashboard.png", "sizes": "96x96" }]
}
],
"categories": ["government", "finance", "utilities"]
}
Service Worker Configuration
Usingnext-pwa for automatic service worker generation.
Copy
// next.config.ts
import withPWA from "next-pwa";
const config = withPWA({
dest: "public",
disable: process.env.NODE_ENV === "development",
register: true,
skipWaiting: true,
runtimeCaching: [
// Static assets - cache first
{
urlPattern: /\.(?:js|css|woff2?)$/i,
handler: "CacheFirst",
options: {
cacheName: "static-assets",
expiration: {
maxEntries: 100,
maxAgeSeconds: 60 * 60 * 24 * 30, // 30 days
},
},
},
// Images - cache first
{
urlPattern: /\.(?:png|jpg|jpeg|svg|gif|webp)$/i,
handler: "CacheFirst",
options: {
cacheName: "images",
expiration: {
maxEntries: 50,
maxAgeSeconds: 60 * 60 * 24 * 7, // 7 days
},
},
},
// API routes - stale while revalidate
{
urlPattern: /^\/api\//i,
handler: "StaleWhileRevalidate",
options: {
cacheName: "api-cache",
expiration: {
maxEntries: 50,
maxAgeSeconds: 60 * 60, // 1 hour
},
},
},
// Programs data - stale while revalidate
{
urlPattern: /\/api\/programs/i,
handler: "StaleWhileRevalidate",
options: {
cacheName: "programs-cache",
expiration: {
maxEntries: 100,
maxAgeSeconds: 60 * 60 * 24, // 24 hours
},
},
},
],
})({
// Next.js config
});
export default config;
IndexedDB Programs Cache
For reliable offline access to programs, we use IndexedDB.Copy
// lib/offline/programs-cache.ts
const DB_NAME = "pathfinder-offline";
const DB_VERSION = 1;
const PROGRAMS_STORE = "programs";
interface CachedProgram {
id: string;
data: Program;
cachedAt: number;
}
export async function openDatabase(): Promise<IDBDatabase> {
return new Promise((resolve, reject) => {
const request = indexedDB.open(DB_NAME, DB_VERSION);
request.onerror = () => reject(request.error);
request.onsuccess = () => resolve(request.result);
request.onupgradeneeded = (event) => {
const db = (event.target as IDBOpenDBRequest).result;
// Programs store
if (!db.objectStoreNames.contains(PROGRAMS_STORE)) {
const store = db.createObjectStore(PROGRAMS_STORE, { keyPath: "id" });
store.createIndex("category", "data.category", { unique: false });
store.createIndex("cachedAt", "cachedAt", { unique: false });
}
};
});
}
export async function cachePrograms(programs: Program[]): Promise<void> {
const db = await openDatabase();
const transaction = db.transaction(PROGRAMS_STORE, "readwrite");
const store = transaction.objectStore(PROGRAMS_STORE);
const now = Date.now();
for (const program of programs) {
store.put({
id: program.id,
data: program,
cachedAt: now,
});
}
return new Promise((resolve, reject) => {
transaction.oncomplete = () => resolve();
transaction.onerror = () => reject(transaction.error);
});
}
export async function getCachedPrograms(
options: { category?: string; limit?: number } = {}
): Promise<Program[]> {
const db = await openDatabase();
const transaction = db.transaction(PROGRAMS_STORE, "readonly");
const store = transaction.objectStore(PROGRAMS_STORE);
return new Promise((resolve, reject) => {
const programs: Program[] = [];
let request: IDBRequest;
if (options.category) {
const index = store.index("category");
request = index.openCursor(IDBKeyRange.only(options.category));
} else {
request = store.openCursor();
}
request.onsuccess = () => {
const cursor = request.result;
if (cursor && (!options.limit || programs.length < options.limit)) {
programs.push((cursor.value as CachedProgram).data);
cursor.continue();
} else {
resolve(programs);
}
};
request.onerror = () => reject(request.error);
});
}
export async function getCacheStatus(): Promise<{
programCount: number;
lastUpdated: Date | null;
sizeBytes: number;
}> {
const db = await openDatabase();
const transaction = db.transaction(PROGRAMS_STORE, "readonly");
const store = transaction.objectStore(PROGRAMS_STORE);
return new Promise((resolve, reject) => {
const countRequest = store.count();
let latestTime = 0;
countRequest.onsuccess = () => {
const count = countRequest.result;
// Find most recent cache time
const cursorRequest = store.index("cachedAt").openCursor(null, "prev");
cursorRequest.onsuccess = () => {
const cursor = cursorRequest.result;
if (cursor) {
latestTime = (cursor.value as CachedProgram).cachedAt;
}
resolve({
programCount: count,
lastUpdated: latestTime ? new Date(latestTime) : null,
sizeBytes: 0, // Estimated
});
};
};
countRequest.onerror = () => reject(countRequest.error);
});
}
Online Status Hook
Detect and react to connectivity changes.Copy
// hooks/use-online-status.ts
export function useOnlineStatus() {
const [isOnline, setIsOnline] = useState(
typeof navigator !== "undefined" ? navigator.onLine : true
);
const [wasOffline, setWasOffline] = useState(false);
useEffect(() => {
const handleOnline = () => {
setIsOnline(true);
if (wasOffline) {
// Trigger sync when coming back online
syncOfflineData();
}
};
const handleOffline = () => {
setIsOnline(false);
setWasOffline(true);
};
window.addEventListener("online", handleOnline);
window.addEventListener("offline", handleOffline);
return () => {
window.removeEventListener("online", handleOnline);
window.removeEventListener("offline", handleOffline);
};
}, [wasOffline]);
return { isOnline, wasOffline };
}
Offline Banner
Show users when they’re offline.Copy
// components/offline/offline-banner.tsx
export function OfflineBanner() {
const { isOnline, wasOffline } = useOnlineStatus();
if (isOnline && !wasOffline) return null;
return (
<AnimatePresence>
{!isOnline && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
className="bg-amber-500 text-white px-4 py-2 text-center text-sm"
>
<WifiOff className="h-4 w-4 inline mr-2" />
You're offline. Some features may be limited.
</motion.div>
)}
{isOnline && wasOffline && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
className="bg-emerald-500 text-white px-4 py-2 text-center text-sm"
>
<Wifi className="h-4 w-4 inline mr-2" />
You're back online!
</motion.div>
)}
</AnimatePresence>
);
}
// Compact indicator for header
export function OfflineIndicator() {
const { isOnline } = useOnlineStatus();
if (isOnline) return null;
return (
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center gap-1 text-amber-600">
<WifiOff className="h-4 w-4" />
<span className="text-xs">Offline</span>
</div>
</TooltipTrigger>
<TooltipContent>
You're currently offline. Some features may not work.
</TooltipContent>
</Tooltip>
);
}
Install Prompt
Custom install prompt for better UX than browser default.Copy
// components/pwa/install-prompt.tsx
interface BeforeInstallPromptEvent extends Event {
prompt(): Promise<void>;
userChoice: Promise<{ outcome: "accepted" | "dismissed" }>;
}
export function useInstallPrompt() {
const [deferredPrompt, setDeferredPrompt] =
useState<BeforeInstallPromptEvent | null>(null);
const [isInstalled, setIsInstalled] = useState(false);
const [isIOS, setIsIOS] = useState(false);
useEffect(() => {
// Check if already installed
if (window.matchMedia("(display-mode: standalone)").matches) {
setIsInstalled(true);
return;
}
// Check for iOS
const iOS = /iPad|iPhone|iPod/.test(navigator.userAgent);
setIsIOS(iOS);
// Listen for install prompt
const handler = (e: Event) => {
e.preventDefault();
setDeferredPrompt(e as BeforeInstallPromptEvent);
};
window.addEventListener("beforeinstallprompt", handler);
return () => {
window.removeEventListener("beforeinstallprompt", handler);
};
}, []);
const install = async () => {
if (!deferredPrompt) return false;
deferredPrompt.prompt();
const { outcome } = await deferredPrompt.userChoice;
if (outcome === "accepted") {
setIsInstalled(true);
setDeferredPrompt(null);
return true;
}
return false;
};
return {
canInstall: !!deferredPrompt,
isInstalled,
isIOS,
install,
};
}
export function InstallPrompt() {
const { canInstall, isInstalled, isIOS, install } = useInstallPrompt();
const [dismissed, setDismissed] = useState(false);
if (isInstalled || dismissed) return null;
// iOS instructions (can't trigger install programmatically)
if (isIOS) {
return (
<Card className="fixed bottom-4 left-4 right-4 z-50 md:left-auto md:right-4 md:w-80">
<CardContent className="p-4">
<div className="flex justify-between items-start mb-2">
<h3 className="font-semibold">Install Pathfinder</h3>
<Button
variant="ghost"
size="sm"
onClick={() => setDismissed(true)}
>
<X className="h-4 w-4" />
</Button>
</div>
<p className="text-sm text-stone-600 mb-3">
Install Pathfinder for quick access and offline use:
</p>
<ol className="text-sm text-stone-600 space-y-1">
<li>1. Tap the Share button <Share className="h-4 w-4 inline" /></li>
<li>2. Scroll down and tap "Add to Home Screen"</li>
<li>3. Tap "Add"</li>
</ol>
</CardContent>
</Card>
);
}
// Android/Desktop install button
if (!canInstall) return null;
return (
<Card className="fixed bottom-4 left-4 right-4 z-50 md:left-auto md:right-4 md:w-80">
<CardContent className="p-4">
<div className="flex justify-between items-start mb-2">
<h3 className="font-semibold">Install Pathfinder</h3>
<Button
variant="ghost"
size="sm"
onClick={() => setDismissed(true)}
>
<X className="h-4 w-4" />
</Button>
</div>
<p className="text-sm text-stone-600 mb-3">
Add Pathfinder to your home screen for quick access, even offline.
</p>
<Button onClick={install} className="w-full">
<Download className="h-4 w-4 mr-2" />
Install App
</Button>
</CardContent>
</Card>
);
}
Caching Strategies
| Resource Type | Strategy | TTL | Rationale |
|---|---|---|---|
| Static assets (JS/CSS) | Cache First | 30 days | Rarely change, versioned |
| Images | Cache First | 7 days | Visual consistency |
| Programs data | Stale While Revalidate | 24 hours | Show cached, update in background |
| User data | Network Only | N/A | Must be fresh |
| Search results | Network First | 1 hour | Freshness important |
Offline Capabilities
| Feature | Offline Support | Notes |
|---|---|---|
| Browse programs | Full | Cached in IndexedDB |
| Search programs | Partial | Limited to cached data |
| View program details | Full | If previously viewed |
| Take screener | Full | Runs locally |
| Save to dashboard | Queued | Syncs when online |
| Upload documents | No | Requires network |
| Notifications | No | Requires network |
Testing Offline Mode
Copy
// In Chrome DevTools:
// 1. Open Application tab
// 2. Check "Offline" in Service Workers section
// 3. Or use Network tab → "Offline" throttling
// Programmatic testing:
async function testOfflineMode() {
// Ensure cache is populated
await cachePrograms(await fetchAllPrograms());
// Verify cached data
const cached = await getCachedPrograms();
console.log(`Cached ${cached.length} programs`);
// Simulate offline
// Test that getCachedPrograms returns data
}