# Mobile & PWA

**Audience:** AI coding agents first.
**Date:** 2026-05-31  
**Status:** Draft  
**Depends on:** `foundation-monorepo`, `app-shell`, `foundation-design-system`, `notification-center`, `real-time-infrastructure`  
**Referenced by:** `app-shell`, `notification-center`, `expenses-module`

---

## Overview

Zync is a web application that must work well on mobile browsers (iOS Safari, Chrome Android). This spec covers PWA installation, service worker caching, offline behavior, Web Push notifications, and mobile-specific UX patterns. A native app is not planned for v1.

---

## PWA Manifest

File: `apps/zync-app/public/manifest.webmanifest`

```json
{
  "name": "Zync.is",
  "short_name": "Zync",
  "description": "Business management for Israeli freelancers and agencies",
  "start_url": "/",
  "display": "standalone",
  "orientation": "portrait-primary",
  "theme_color": "#007070",
  "background_color": "#0A0A0A",
  "lang": "he",
  "dir": "rtl",
  "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" },
    { "src": "/icons/icon-180.png", "sizes": "180x180", "type": "image/png" }
  ],
  "screenshots": [
    { "src": "/screenshots/mobile-dashboard.png", "sizes": "390x844", "type": "image/png" }
  ],
  "shortcuts": [
    { "name": "New Invoice", "url": "/invoices/new", "icons": [{ "src": "/icons/shortcut-invoice.png", "sizes": "96x96" }] },
    { "name": "Start Timer", "url": "/time?action=start", "icons": [{ "src": "/icons/shortcut-timer.png", "sizes": "96x96" }] }
  ]
}
```

**theme_color:** `#007070` (teal — matches design system accent). Shown in browser chrome on Android.

### Asset Response Headers

`apps/zync-app/src/worker.ts` MUST set these headers on static-asset responses:

| Path | Required response headers |
|------|---------------------------|
| `/sw.js` | `Service-Worker-Allowed: /`; `Cache-Control: no-cache, no-store, must-revalidate` |
| `/manifest.webmanifest` | `Content-Type: application/manifest+json`; `Cache-Control: no-cache` |

NEVER configure these headers through top-level `[[headers]]` in `apps/zync-app/wrangler.toml`; Wrangler Workers ignore that unsupported configuration.

Rationale: Worker-owned response mutation is the deployed static-asset response boundary and prevents stale service-worker or manifest metadata.

---

## Breakpoints

These extend the `app-shell` spec and apply across all modules:

| Name | Range | Layout |
|------|-------|--------|
| mobile | < 768px | Single column, overlay sidebar, bottom-safe-area insets |
| tablet | 768px – 1023px | Two-column (collapsed sidebar + content) |
| desktop | ≥ 1024px | Full sidebar + content (current default design) |

CSS custom properties:
```css
--breakpoint-mobile: 768px;
--breakpoint-tablet: 1024px;
```

---

## Mobile UX Patterns

### Sidebar Navigation
- Desktop: persistent fixed sidebar (240px)
- Mobile: overlay sidebar triggered by hamburger button in top bar
  - Slides in from right (RTL layout) or left (LTR)
  - Full-screen overlay with close button and tap-outside-to-close
  - Backdrop: semi-transparent dark overlay
  - Animation: 200ms ease-in-out slide

### Touch Targets
- All interactive elements: minimum 44×44px (WCAG 2.1 AA SC 2.5.5)
- Primary action buttons: minimum 48px height on mobile
- Table row actions: collapsed into a "..." menu on mobile (not inline buttons)

### Typography and Density
- Minimum font size: 16px for form inputs (prevents iOS auto-zoom on focus)
- Increased line-height on mobile (1.6 vs 1.5 desktop)
- Card-based layout replaces dense tables for key lists on mobile

### Forms
- `type="email"` — brings up email keyboard on iOS/Android
- `type="tel"` — brings up numeric phone keyboard
- `type="number"` — numeric keyboard for amounts
- `inputmode="decimal"` — for currency/float inputs
- `autocomplete` attributes set on all common fields (name, email, address, etc.)

### File Upload / Camera
- Expense receipt upload: `<input accept="image/*,application/pdf" capture="environment">` on mobile — offers camera capture directly
- Gallery picker fallback (capture="environment" opens camera; user can still browse gallery)
- Max file size enforced client-side with clear error (10MB)

### Timer Widget (mobile)
- The persistent timer widget at the bottom of the screen uses a larger touch target (full width tap area)
- On mobile, the widget shows simplified: project name (truncated) + elapsed time + stop button
- Timer continues client-side via `setInterval` even if the user navigates between pages

### Swipe Gestures (v1.1, deferred)
- Swipe right on task card → mark complete
- Swipe left on task card → open context menu (edit, delete)
- Uses `touch-action: pan-y` to avoid interfering with page scroll
- Deferred to v1.1; not required in v1

### Bottom Navigation Bar (v1.1, deferred)
- 4 tabs: Home | Tasks | Time | Notifications
- Fixed at bottom, above safe-area inset
- Badge count on Notifications tab
- Deferred to v1.1; mobile uses hamburger nav in v1

### Safe Area Insets
All full-height mobile views use:
```css
padding-bottom: env(safe-area-inset-bottom);
padding-top: env(safe-area-inset-top);
```
Bottom navigation (v1.1) will sit above the safe area.

---

## Service Worker

File: `apps/zync-app/public/sw.js` (generated by Workbox via vite-plugin-pwa).

### Cache Strategy

| Resource Type | Strategy | Cache Name | TTL |
|---------------|----------|------------|-----|
| App shell (HTML, CSS, JS bundles) | Cache-first | `app-shell-v{buildHash}` | Until new deploy |
| Static assets (icons, fonts) | Cache-first | `static-assets-v1` | 30 days |
| Private API responses (`/api/*`) | Network-only | — | — |
| Invoice PDFs / R2 files | Cache-first on request | `files-cache` | 24 hours |

**Cache invalidation:** on new deploy, the Service Worker version changes (build hash in cache name), triggering cache eviction and re-download of all shell assets.

Do not store authenticated `/api/*` responses in Cache Storage. Rationale: URL-only runtime cache keys are not partitioned by tenant, user, or session and can replay stale private data after an account switch.

### Offline Page

For uncached routes when network is unavailable, show `offline.html`:
- "You're offline" heading
- Current time
- "The page you're looking for requires an internet connection"
- "Retry" button (checks connectivity and reloads)
- Do not show cached authenticated dashboard data. Keep private data unavailable while offline.

### Offline Timer Behavior
- Timer state persisted to `localStorage`: `{ entryId, projectId, taskId, startedAt }`
- If user starts a timer then goes offline: timer continues client-side via `setInterval`
- On reconnect: service worker `sync` event (Background Sync API) triggers `POST /api/time/:id/update` to persist elapsed time
- Fallback (if Background Sync not supported): sync on next page focus via `document.addEventListener('visibilitychange')`

---

## PWA Install Prompt

The browser's native `beforeinstallprompt` event is used.

**Logic:**
```typescript
const INSTALL_DISMISSED_KEY = 'pwa_install_dismissed'
const LOGIN_COUNT_KEY = 'pwa_login_count'

// On each successful login: increment login count
// Show install prompt when: loginCount >= 2 AND not previously dismissed AND not already installed

window.addEventListener('beforeinstallprompt', (e) => {
  e.preventDefault()
  deferredPrompt = e
  checkAndShowInstallBanner()
})

function checkAndShowInstallBanner() {
  const dismissed = localStorage.getItem(INSTALL_DISMISSED_KEY)
  const count = parseInt(localStorage.getItem(LOGIN_COUNT_KEY) ?? '0')
  if (!dismissed && count >= 2) {
    showInstallBanner() // renders banner in app shell bottom bar
  }
}
```

**Install banner UI:** minimal banner at bottom of screen:
- "Add Zync to your home screen for quick access"
- "Install" button → triggers `deferredPrompt.prompt()`
- "×" dismiss button → sets `pwa_install_dismissed = true` in localStorage (permanent)

---

## Web Push Notifications

### Architecture
- Service Worker subscribes to Web Push using VAPID keys
- Subscription endpoint + keys stored server-side per user/device
- When a notification is created for a user:
  1. If user has an active SSE connection (tab open): deliver via SSE (real-time-infrastructure spec)
  2. If no SSE connection: send Web Push to all subscribed endpoints for that user
- Push payload is minimal (notification ID only); full content fetched on click

### VAPID Keys
- Generated once: `npx web-push generate-vapid-keys`
- Stored as Worker secrets: `VAPID_PUBLIC_KEY`, `VAPID_PRIVATE_KEY` (canonical names; shared with spec 11 `system-communications-notifications` — no `PUSH_` prefix)
- Public key exposed via `GET /api/push/vapid-public-key` (unauthenticated, needed for client subscription)

### Client Subscription Flow

```typescript
async function subscribeToPush() {
  const reg = await navigator.serviceWorker.ready
  const publicKey = await fetchVapidPublicKey()

  const subscription = await reg.pushManager.subscribe({
    userVisibleOnly: true,
    applicationServerKey: urlBase64ToUint8Array(publicKey)
  })

  await fetch('/api/push/subscribe', {
    method: 'POST',
    body: JSON.stringify(subscription.toJSON()),
    headers: { 'Content-Type': 'application/json' }
  })
}
```

Subscription is initiated:
1. When user first enables push notifications in `/profile` → Notifications tab
2. On PWA install (after install prompt accepted)

**Permission prompt:** never shown without explicit user action (OS requirement). User must click "Enable push notifications" toggle first.

### Push Message Payload (Web Push body)

```json
{
  "notification_id": "notif_abc123",
  "title": "Invoice Paid",
  "body": "Acme Ltd paid INV-0042 — ₪4,500",
  "icon": "/icons/icon-192.png",
  "badge": "/icons/badge-72.png",
  "data": { "url": "/invoices/inv_xyz" }
}
```

Service Worker `push` event handler:
```javascript
self.addEventListener('push', (event) => {
  const data = event.data.json()
  event.waitUntil(
    self.registration.showNotification(data.title, {
      body: data.body,
      icon: data.icon,
      badge: data.badge,
      data: data.data
    })
  )
})

self.addEventListener('notificationclick', (event) => {
  event.notification.close()
  event.waitUntil(
    clients.openWindow(event.notification.data.url)
  )
})
```

---

## Data Model

The `push_subscriptions` table is **owned by `system-communications-notifications`** (the Web Push schema) — see that spec for the canonical DDL (UUID ids, `gen_random_uuid()`, `TIMESTAMPTZ`). This spec does **not** redefine it; it only reads/writes rows for the PWA install + push-permission flow.

**Stale subscription cleanup:** if a push delivery returns HTTP 410 (Gone) from the push service, the endpoint is invalid. Delete the `push_subscriptions` row immediately.

---

## Permissions

| Action | All authenticated users |
|--------|------------------------|
| Subscribe to push | Yes |
| Unsubscribe | Yes |
| Manage other users' subscriptions | No |

---

## API Endpoints

### `GET /api/push/vapid-public-key`
Unauthenticated. Returns: `{ "publicKey": "BDxxxxxx..." }`

### `POST /api/push/subscribe`
Body: Web Push subscription object (as returned by `pushManager.subscribe().toJSON()`):
```json
{
  "endpoint": "https://fcm.googleapis.com/...",
  "keys": {
    "p256dh": "...",
    "auth": "..."
  }
}
```
Creates or updates (upsert by endpoint) a `push_subscriptions` row. Returns 201.

### `DELETE /api/push/subscribe`
Body: `{ "endpoint": "https://..." }` — unsubscribes and deletes the row.

---

## Architecture Decisions

### vite-plugin-pwa for Service Worker generation
The service worker is generated by `vite-plugin-pwa` (Workbox under the hood) in the `apps/web` package. Precache manifest is auto-generated from the Vite build output. Custom runtime caching rules defined in `vite.config.ts`.

### iOS Safari limitations
- Web Push: supported on iOS 16.4+ in standalone PWA mode only
- Background Sync: not supported on iOS Safari — fallback to `visibilitychange` sync for offline timer
- `beforeinstallprompt`: not supported on iOS — iOS users see "Add to Home Screen" manual instruction instead
- Install banner on iOS: detect `navigator.standalone === false && /iPhone|iPad/.test(navigator.userAgent)` and show instructional modal instead

### Push delivery vs. SSE priority
The notification dispatch function in `real-time-infrastructure` first checks for an active SSE connection (via Durable Object presence map). If present: SSE. If not: Web Push. This avoids duplicate notifications when the user has the app open in a tab.

### Multi-device push
A user may subscribe on multiple devices (phone + tablet). All subscriptions are stored. Push is sent to all active subscriptions for the user. Stale subscriptions (410 responses) are pruned on delivery.

### RTL in PWA manifest
`"dir": "rtl"` and `"lang": "he"` set in the manifest. This affects how the browser renders the PWA name on the home screen on Android.
