nexus/.planning/phases/26-pwa-performance/26-03-PLAN.md

240 lines
12 KiB
Markdown

---
phase: 26-pwa-performance
plan: 03
type: execute
wave: 3
depends_on:
- 26-00
- 26-02
files_modified:
- ui/src/components/InstallPromptBanner.tsx
- ui/src/components/OfflineBanner.tsx
- ui/src/hooks/useInstallPrompt.ts
- ui/src/hooks/useOfflineQueue.ts
- ui/src/hooks/useOnlineStatus.ts
- ui/src/components/ChatPanel.tsx
autonomous: true
requirements:
- PWA-01
- PWA-02
- PWA-08
must_haves:
truths:
- "Install prompt banner appears after beforeinstallprompt fires and user has visited a conversation"
- "Install prompt is dismissable and respects 7-day localStorage cooldown"
- "On iOS (no beforeinstallprompt), banner shows Share menu instructions"
- "Offline banner appears when navigator.onLine is false, showing queued message count"
- "Offline banner auto-dismisses 3 seconds after reconnection when queue is empty"
- "Unsent messages are stored in IndexedDB and flushed when online event fires"
artifacts:
- path: "ui/src/components/InstallPromptBanner.tsx"
provides: "PWA install prompt UI"
min_lines: 40
- path: "ui/src/components/OfflineBanner.tsx"
provides: "Offline status banner with queue count"
min_lines: 20
- path: "ui/src/hooks/useInstallPrompt.ts"
provides: "Captures beforeinstallprompt event"
min_lines: 20
- path: "ui/src/hooks/useOfflineQueue.ts"
provides: "IndexedDB message queue with flush on reconnect"
min_lines: 40
- path: "ui/src/hooks/useOnlineStatus.ts"
provides: "navigator.onLine reactive state"
min_lines: 10
key_links:
- from: "ui/src/hooks/useOfflineQueue.ts"
to: "idb"
via: "openDB for IndexedDB access"
pattern: "openDB"
- from: "ui/src/components/InstallPromptBanner.tsx"
to: "ui/src/hooks/useInstallPrompt.ts"
via: "canInstall + promptInstall from hook"
pattern: "useInstallPrompt"
- from: "ui/src/components/OfflineBanner.tsx"
to: "ui/src/hooks/useOnlineStatus.ts"
via: "isOnline state for show/hide"
pattern: "useOnlineStatus"
---
<objective>
Create the PWA install prompt banner, offline status banner, and offline message queue. Users see an install prompt after engagement, an amber banner when offline with queued message count, and messages auto-send when back online.
Purpose: Delivers PWA-01 (offline capability with message queuing), PWA-02 (installable manifest — already done, this plan adds the install UI), and PWA-08 (Add to Home Screen prompt).
Output: 2 new components, 3 new hooks, ChatPanel integration for offline queue.
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/26-pwa-performance/26-RESEARCH.md
@.planning/phases/26-pwa-performance/26-UI-SPEC.md
@.planning/phases/26-pwa-performance/26-02-SUMMARY.md
@ui/src/components/ChatPanel.tsx
@ui/src/api/chat.ts
<interfaces>
From ui/src/api/chat.ts (chatApi used by offline queue):
```typescript
// chatApi.postMessage or equivalent POST method for sending messages
// The offline queue needs to know the POST shape to replay messages
```
From ui/src/types/pwa.d.ts (created in Plan 00):
```typescript
interface BeforeInstallPromptEvent extends Event {
prompt(): Promise<void>;
userChoice: Promise<{ outcome: "accepted" | "dismissed" }>;
}
```
</interfaces>
</context>
<tasks>
<task type="auto">
<name>Task 1: Create useInstallPrompt, useOnlineStatus, useOfflineQueue hooks</name>
<files>ui/src/hooks/useInstallPrompt.ts, ui/src/hooks/useOnlineStatus.ts, ui/src/hooks/useOfflineQueue.ts</files>
<read_first>
- ui/src/types/pwa.d.ts
- ui/src/api/chat.ts
- .planning/phases/26-pwa-performance/26-RESEARCH.md
</read_first>
<action>
1. Create `ui/src/hooks/useOnlineStatus.ts`:
- Export `function useOnlineStatus(): boolean`
- Use `useState(navigator.onLine)` for initial state
- `useEffect` adding `online` and `offline` event listeners on `window`
- Return `isOnline`
2. Create `ui/src/hooks/useInstallPrompt.ts`:
- Export `function useInstallPrompt(): { canInstall: boolean; promptInstall: () => Promise<void>; isIOS: boolean }`
- Use `useState<BeforeInstallPromptEvent | null>(null)` for deferred prompt
- `useEffect` listening for `beforeinstallprompt` on `window`: call `e.preventDefault()`, store event
- `isInstalled`: check `window.matchMedia("(display-mode: standalone)").matches`
- `isIOS`: detect via `navigator.userAgent` containing "iPhone" or "iPad" and not "CriOS" (Chrome on iOS)
- `promptInstall`: call `deferredPrompt.prompt()`, await `deferredPrompt.userChoice`, set deferred to null
- `canInstall`: `!!deferredPrompt && !isInstalled`
- Return `{ canInstall, promptInstall, isIOS }`
3. Create `ui/src/hooks/useOfflineQueue.ts`:
- Import `{ openDB }` from `"idb"`
- Constants: `DB_NAME = "nexus-offline"`, `STORE = "message_queue"`
- Export `function useOfflineQueue(): { enqueue: (conversationId: string, content: string) => Promise<void>; flush: () => Promise<void>; queuedCount: number }`
- Use `useState(0)` for `queuedCount`
- `getDb` helper: `openDB(DB_NAME, 1, { upgrade(db) { db.createObjectStore(STORE, { autoIncrement: true }); } })`
- `enqueue` callback: opens db, adds `{ conversationId, content, queuedAt: Date.now() }` to store, increments `queuedCount`
- `flush` callback: opens db, gets all entries and keys, iterates sequentially:
a. For each entry, call `chatApi.sendMessage(entry.conversationId, { content: entry.content })` (read chatApi to find the correct method name)
b. On success, delete the key from the store, decrement `queuedCount`
c. On failure, `break` — stop flushing, retry next time
- `useEffect` listening for `online` event on `window` — calls `flush()`
- `useEffect` on mount — reads current queue count from IndexedDB and sets `queuedCount`
- Return `{ enqueue, flush, queuedCount }`
</action>
<verify>
<automated>grep -q "openDB" ui/src/hooks/useOfflineQueue.ts && grep -q "beforeinstallprompt" ui/src/hooks/useInstallPrompt.ts && grep -q "navigator.onLine" ui/src/hooks/useOnlineStatus.ts && echo "PASS"</automated>
</verify>
<acceptance_criteria>
- `useOnlineStatus.ts` returns boolean based on `navigator.onLine`
- `useInstallPrompt.ts` captures `beforeinstallprompt` event and returns `canInstall`, `promptInstall`, `isIOS`
- `useOfflineQueue.ts` uses `idb` library's `openDB`, stores to `nexus-offline` DB, `message_queue` store
- `useOfflineQueue.ts` flushes on `online` event, stops on first failure
- `pnpm --filter @paperclipai/ui build` succeeds
</acceptance_criteria>
<done>Three hooks created: useOnlineStatus (reactive online/offline state), useInstallPrompt (beforeinstallprompt capture with iOS detection), useOfflineQueue (IndexedDB queue with auto-flush).</done>
</task>
<task type="auto">
<name>Task 2: Create InstallPromptBanner, OfflineBanner, and wire into ChatPanel</name>
<files>ui/src/components/InstallPromptBanner.tsx, ui/src/components/OfflineBanner.tsx, ui/src/components/ChatPanel.tsx</files>
<read_first>
- ui/src/hooks/useInstallPrompt.ts
- ui/src/hooks/useOnlineStatus.ts
- ui/src/hooks/useOfflineQueue.ts
- ui/src/components/ChatPanel.tsx
- .planning/phases/26-pwa-performance/26-UI-SPEC.md
</read_first>
<action>
1. Create `ui/src/components/InstallPromptBanner.tsx`:
- Import `useInstallPrompt` hook
- Props: none (self-contained)
- Internal state: `dismissed` from `localStorage.getItem("nexus.installPromptDismissed")`
- Show conditions (ALL must be true):
a. `canInstall === true` OR `isIOS === true` (iOS gets instruction text)
b. Not already installed (`display-mode: standalone` check is in the hook)
c. Not dismissed within last 7 days (check `nexus.installPromptDismissed` timestamp in localStorage)
d. User has visited at least one conversation (pass `hasEngaged` as prop or check localStorage)
- Layout (per UI-SPEC):
a. Fixed position: `fixed bottom-16 left-4 right-4 md:bottom-auto md:top-4 md:left-auto md:right-4 md:max-w-sm` (bottom on mobile above MobileBottomNav, top-right on desktop)
b. Background: `bg-card border border-border rounded-lg shadow-lg p-4`
c. Heading: "Add Nexus to your home screen" in `text-sm font-semibold`
d. Body: "Get the full experience — launch instantly, works offline." in `text-xs text-muted-foreground`
e. For iOS: body text changes to "Open the Share menu and tap 'Add to Home Screen'"
f. CTA button: `<Button size="sm">Add to Home Screen</Button>` — calls `promptInstall()` (or no-op on iOS)
g. Dismiss button: "Not now" as `<Button variant="ghost" size="sm">` — stores `Date.now()` in `localStorage.setItem("nexus.installPromptDismissed", ...)`
- Use `z-50` to layer above other content
2. Create `ui/src/components/OfflineBanner.tsx`:
- Import `useOnlineStatus` hook
- Props: `{ queuedCount?: number }`
- Show when `!isOnline`
- Auto-dismiss: when `isOnline` transitions to true, wait 3 seconds then hide (only if `queuedCount === 0`)
- Layout:
a. Position: `fixed top-0 left-0 right-0 z-50`
b. Dark themes: `bg-amber-900/40 text-amber-200 border-b border-amber-800`
c. Light theme: detect via `prefers-color-scheme` or use Tailwind `dark:` prefix — `bg-amber-50 text-amber-800 border-b border-amber-200 dark:bg-amber-900/40 dark:text-amber-200 dark:border-amber-800`
d. Content: `WifiOff` icon from lucide-react + text
e. Text (per UI-SPEC):
- No queue: "You're offline — messages will send when you reconnect"
- With queue: "You're offline — {n} message{n === 1 ? '' : 's'} queued"
f. Padding: `px-4 py-2 text-sm flex items-center gap-2`
3. Update `ui/src/components/ChatPanel.tsx`:
- Import `InstallPromptBanner`, `OfflineBanner`, `useOfflineQueue`, `useOnlineStatus`
- Add `useOfflineQueue()` hook call — destructure `{ enqueue, queuedCount }`
- Add `useOnlineStatus()` hook call — destructure `isOnline`
- In `handleSend`: if `!isOnline`, call `enqueue(activeConversationId, content)` instead of the normal chatApi send flow. Show a toast: "Message queued — will send when you're back online" using `pushToast` from `useToast()`
- Render `<OfflineBanner queuedCount={queuedCount} />` at the top of the ChatPanel return JSX
- Render `<InstallPromptBanner />` — it handles its own show/hide logic internally
NOTE: ChatPanel.tsx was also modified by plan 26-02 (MobileChatView wiring). This plan depends on 26-02 completing first. Read the current state of ChatPanel.tsx (post-26-02) before making changes.
</action>
<verify>
<automated>grep -q "nexus.installPromptDismissed" ui/src/components/InstallPromptBanner.tsx && grep -q "amber" ui/src/components/OfflineBanner.tsx && grep -q "enqueue" ui/src/components/ChatPanel.tsx && echo "PASS"</automated>
</verify>
<acceptance_criteria>
- `InstallPromptBanner.tsx` shows "Add Nexus to your home screen" heading, "Add to Home Screen" CTA, "Not now" dismiss
- `InstallPromptBanner.tsx` checks `nexus.installPromptDismissed` localStorage with 7-day expiry
- `InstallPromptBanner.tsx` handles iOS with Share menu instruction text
- `OfflineBanner.tsx` uses amber styling matching UI-SPEC (dark: `bg-amber-900/40`, light: `bg-amber-50`)
- `OfflineBanner.tsx` displays queued count when `queuedCount > 0`
- `ChatPanel.tsx` calls `enqueue` when offline instead of sending
- `pnpm --filter @paperclipai/ui build` succeeds
</acceptance_criteria>
<done>InstallPromptBanner shows install CTA with iOS fallback and 7-day dismiss cooldown. OfflineBanner shows amber notification with queue count. ChatPanel queues messages when offline via useOfflineQueue.</done>
</task>
</tasks>
<verification>
- `pnpm --filter @paperclipai/ui build` succeeds
- InstallPromptBanner respects 7-day localStorage dismiss cooldown
- OfflineBanner uses correct amber styling for dark/light themes
- ChatPanel enqueues messages to IndexedDB when offline
- useOfflineQueue auto-flushes on `online` event
</verification>
<success_criteria>
PWA install prompt, offline banner, and offline message queue all functional. Users see install prompt after engagement, see offline status with queue count, and messages auto-send on reconnection.
</success_criteria>
<output>
After completion, create `.planning/phases/26-pwa-performance/26-03-SUMMARY.md`
</output>