Skip to main content

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
Pathfinder addresses this with Progressive Web App (PWA) features.

PWA Manifest

The web app manifest enables installation on home screens.
// 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

Using next-pwa for automatic service worker generation.
// 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.
// 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.
// 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.
// 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&apos;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&apos;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&apos;re currently offline. Some features may not work.
      </TooltipContent>
    </Tooltip>
  );
}

Install Prompt

Custom install prompt for better UX than browser default.
// 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 &quot;Add to Home Screen&quot;</li>
            <li>3. Tap &quot;Add&quot;</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 TypeStrategyTTLRationale
Static assets (JS/CSS)Cache First30 daysRarely change, versioned
ImagesCache First7 daysVisual consistency
Programs dataStale While Revalidate24 hoursShow cached, update in background
User dataNetwork OnlyN/AMust be fresh
Search resultsNetwork First1 hourFreshness important

Offline Capabilities

FeatureOffline SupportNotes
Browse programsFullCached in IndexedDB
Search programsPartialLimited to cached data
View program detailsFullIf previously viewed
Take screenerFullRuns locally
Save to dashboardQueuedSyncs when online
Upload documentsNoRequires network
NotificationsNoRequires network

Testing Offline Mode

// 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
}

Next Steps