> ## Documentation Index
> Fetch the complete documentation index at: https://docs-terra.withunify.org/llms.txt
> Use this file to discover all available pages before exploring further.

# PWA & Offline Support

> Progressive Web App features and offline functionality for users with limited connectivity

# 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.

```mermaid theme={null}
flowchart LR
    subgraph Online
        API[API Server]
        DB[(Database)]
    end

    subgraph Browser
        SW[Service Worker]
        Cache[Cache Storage]
        IDB[(IndexedDB)]
    end

    subgraph User
        App[Pathfinder App]
    end

    API --> SW
    SW --> Cache
    SW --> IDB
    Cache --> App
    IDB --> App
    App --> SW
    SW --> API
```

***

## PWA Manifest

The web app manifest enables installation on home screens.

```json theme={null}
// 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.

```typescript theme={null}
// 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.

```typescript theme={null}
// 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.

```typescript theme={null}
// 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.

```tsx theme={null}
// 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.

```tsx theme={null}
// 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 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

```typescript theme={null}
// 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

<CardGroup cols={2}>
  <Card title="Success Stories" icon="sparkles" href="/pathfinder/features/success-stories">
    Community testimonials
  </Card>

  <Card title="System Map" icon="diagram-project" href="/pathfinder/foundation/system-map">
    Architecture overview
  </Card>
</CardGroup>
