Initial commit: Brachnha Insight project setup
- Next.js 14+ with App Router and TypeScript - Tailwind CSS and ShadCN UI styling - Zustand state management - Dexie.js for IndexedDB (local-first data) - Auth.js v5 for authentication - BMAD framework integration Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
821
_bmad-output/implementation-artifacts/3-3-offline-sync-queue.md
Normal file
821
_bmad-output/implementation-artifacts/3-3-offline-sync-queue.md
Normal file
@@ -0,0 +1,821 @@
|
||||
# Story 3.3: Offline Sync Queue
|
||||
|
||||
Status: done
|
||||
|
||||
<!-- Note: Validation is optional. Run validate-create-story for quality check before dev-story. -->
|
||||
|
||||
## Story
|
||||
|
||||
As a user,
|
||||
I want my actions to save even when offline,
|
||||
So that I don't lose work on the subway.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
1. **Offline Actions Queue to SyncQueue**
|
||||
- Given the device is offline
|
||||
- When the user performs an action (e.g., Saves Draft, Deletes Entry)
|
||||
- Then the action is added to a persistent "SyncQueue" in Dexie
|
||||
- And the UI shows a subtle "Offline - Saved locally" indicator
|
||||
|
||||
2. **Automatic Sync on Reconnection**
|
||||
- Given connection is restored
|
||||
- When the app detects the network
|
||||
- Then the Sync Manager processes the queue in background
|
||||
- And the indicator updates to "Synced"
|
||||
|
||||
## Tasks / Subtasks
|
||||
|
||||
- [ ] Design SyncQueue Database Schema
|
||||
- [ ] Add `syncQueue` table to Dexie schema
|
||||
- [ ] Define SyncQueueItem interface (id, action, payload, status, createdAt, retries)
|
||||
- [ ] Add indexes for status and createdAt
|
||||
- [ ] Bump database version to v3 with migration
|
||||
|
||||
- [ ] Create SyncManager Service
|
||||
- [ ] Create `src/services/sync-manager.ts`
|
||||
- [ ] Implement `queueAction(actionType, payload)` method
|
||||
- [ ] Implement `processQueue()` method with retry logic
|
||||
- [ ] Implement exponential backoff for failed syncs (max 3 retries)
|
||||
- [ ] Add network status detection (online/offline listeners)
|
||||
|
||||
- [ ] Create Offline State Store
|
||||
- [ ] Create `src/lib/store/offline-store.ts`
|
||||
- [ ] State: isOnline, pendingActions, lastSyncAt
|
||||
- [ ] Actions: setOnlineStatus, syncNow
|
||||
- [ ] Use atomic selectors for performance
|
||||
|
||||
- [x] Integrate Queue into DraftService
|
||||
- [x] Modify `saveDraft()` to queue action when offline
|
||||
- [x] Modify `deleteDraft()` to queue action when offline
|
||||
- [x] Check network status before direct DB operations
|
||||
- [x] Store action in SyncQueue when offline, execute immediately when online
|
||||
|
||||
- [ ] Create Offline Status Indicator Component
|
||||
- [ ] Create `OfflineIndicator.tsx` in `src/components/features/common/`
|
||||
- [ ] Show subtle pill/badge with "Offline" status
|
||||
- [ ] Show "Saved locally" message after actions while offline
|
||||
- [ ] Show "Synced" status when queue is empty
|
||||
- [ ] Position: Top of screen or near action buttons
|
||||
|
||||
- [ ] Implement Background Sync on Reconnection
|
||||
- [ ] Add window 'online' event listener
|
||||
- [ ] Trigger SyncManager.processQueue() on reconnection
|
||||
- [ ] Update OfflineStore state during sync
|
||||
- [ ] Show sync progress indicator (optional)
|
||||
|
||||
- [ ] Handle Sync Failures Gracefully
|
||||
- [ ] Mark failed items with retry count
|
||||
- [ ] Remove items after max retries (3)
|
||||
- [ ] Show error toast for permanently failed actions
|
||||
- [ ] Allow manual retry via "Sync Now" button
|
||||
|
||||
- [x] Test Offline Sync End-to-End
|
||||
- [x] Unit test: SyncManager.queueAction() adds to database
|
||||
- [x] Unit test: SyncManager.processQueue() executes actions
|
||||
- [x] Unit test: Exponential backoff retry logic
|
||||
- [x] Integration test: Save draft offline, sync on reconnect
|
||||
- [x] Integration test: Delete entry offline, sync on reconnect
|
||||
- [x] Edge case: Queue with multiple actions processes in order
|
||||
- [x] Edge case: Sync fails, retries succeed
|
||||
- [x] Edge case: All retries exhausted, item removed with error
|
||||
|
||||
## Dev Notes
|
||||
|
||||
### Architecture Compliance (CRITICAL)
|
||||
|
||||
**Logic Sandwich Pattern - DO NOT VIOLATE:**
|
||||
- **UI Components** MUST NOT import `src/lib/db` directly
|
||||
- All sync operations MUST go through `SyncManager` service layer
|
||||
- SyncManager handles both queue storage and execution
|
||||
- Services return plain success/failure, not Dexie observables
|
||||
|
||||
**State Management - Atomic Selectors Required:**
|
||||
```typescript
|
||||
// GOOD - Atomic selectors
|
||||
const isOnline = useOfflineStore(s => s.isOnline);
|
||||
const pendingActions = useOfflineStore(s => s.pendingActions);
|
||||
|
||||
// BAD - Causes unnecessary re-renders
|
||||
const { isOnline, pendingActions } = useOfflineStore();
|
||||
```
|
||||
|
||||
**Local-First Data Boundary:**
|
||||
- SyncQueue is stored in IndexedDB (persistent)
|
||||
- Actions execute locally first, then sync (if server exists)
|
||||
- MVP: No server sync, queue is for future server persistence
|
||||
- Offline actions always succeed locally (queue for later)
|
||||
|
||||
### Architecture Implementation Details
|
||||
|
||||
**Story Purpose:**
|
||||
This story implements **offline resilience** - ensuring users never lose work when connectivity drops. The SyncQueue captures all mutating actions (save, delete) and processes them when connectivity returns. For MVP, this is a foundation for future server sync, but it provides immediate value by preventing data loss during connection drops.
|
||||
|
||||
**IMPORTANT - MVP Scope Clarification:**
|
||||
The MVP has **no server persistence** (NFR-03: Local-First). This story implements the **SyncQueue infrastructure** for:
|
||||
1. Future server sync (when backend is added post-MVP)
|
||||
2. Immediate offline resilience (actions succeed locally even when offline)
|
||||
|
||||
**SyncQueue Data Flow:**
|
||||
```
|
||||
User performs action (Save/Delete)
|
||||
↓
|
||||
Service checks network status (navigator.onLine)
|
||||
↓
|
||||
If ONLINE: Execute immediately (current behavior)
|
||||
↓
|
||||
If OFFLINE: Add to SyncQueue in IndexedDB
|
||||
↓
|
||||
UI shows "Offline - Saved locally" indicator
|
||||
↓
|
||||
Connection restored (window 'online' event)
|
||||
↓
|
||||
SyncManager.processQueue() executes queued actions
|
||||
↓
|
||||
Items marked as 'synced' and removed from queue
|
||||
```
|
||||
|
||||
**SyncQueue Schema Design:**
|
||||
```typescript
|
||||
// src/lib/db/schema.ts
|
||||
interface SyncQueueItem {
|
||||
id?: number;
|
||||
action: 'saveDraft' | 'deleteDraft' | 'completeDraft';
|
||||
payload: {
|
||||
draftId?: number;
|
||||
draftData?: DraftRecord;
|
||||
sessionId?: string;
|
||||
};
|
||||
status: 'pending' | 'processing' | 'synced' | 'failed';
|
||||
createdAt: number;
|
||||
retries: number;
|
||||
lastError?: string;
|
||||
}
|
||||
```
|
||||
|
||||
**Database Migration (v2 -> v3):**
|
||||
```typescript
|
||||
// src/lib/db/index.ts
|
||||
.version(3).stores({
|
||||
chatLogs: '++id, sessionId, createdAt',
|
||||
drafts: '++id, sessionId, status, completedAt, createdAt',
|
||||
syncQueue: '++id, status, createdAt' // NEW table
|
||||
})
|
||||
```
|
||||
|
||||
**SyncManager Service:**
|
||||
```typescript
|
||||
// src/services/sync-manager.ts
|
||||
export class SyncManager {
|
||||
// Queue an action for sync (called when offline or action fails)
|
||||
static async queueAction(
|
||||
action: SyncAction,
|
||||
payload: Record<string, unknown>
|
||||
): Promise<number> {
|
||||
return await db.syncQueue.add({
|
||||
action,
|
||||
payload,
|
||||
status: 'pending',
|
||||
createdAt: Date.now(),
|
||||
retries: 0
|
||||
});
|
||||
}
|
||||
|
||||
// Process all pending actions (called on reconnection)
|
||||
static async processQueue(): Promise<void> {
|
||||
const pendingItems = await db.syncQueue
|
||||
.where('status')
|
||||
.equals('pending')
|
||||
.sortBy('createdAt');
|
||||
|
||||
for (const item of pendingItems) {
|
||||
await this.executeItem(item);
|
||||
}
|
||||
}
|
||||
|
||||
// Execute a single queued item with retry logic
|
||||
private static async executeItem(item: SyncQueueItem): Promise<void> {
|
||||
// Mark as processing
|
||||
await db.syncQueue.update(item.id!, { status: 'processing' });
|
||||
|
||||
try {
|
||||
// Execute the action
|
||||
await this.executeAction(item.action, item.payload);
|
||||
|
||||
// Mark as synced and remove from queue
|
||||
await db.syncQueue.delete(item.id!);
|
||||
} catch (error) {
|
||||
// Increment retry count
|
||||
const retries = item.retries + 1;
|
||||
|
||||
if (retries >= 3) {
|
||||
// Max retries reached, mark as failed
|
||||
await db.syncQueue.update(item.id!, {
|
||||
status: 'failed',
|
||||
retries,
|
||||
lastError: String(error)
|
||||
});
|
||||
} else {
|
||||
// Retry later, mark as pending
|
||||
await db.syncQueue.update(item.id!, {
|
||||
status: 'pending',
|
||||
retries
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Execute the actual action based on type
|
||||
private static async executeAction(
|
||||
action: SyncAction,
|
||||
payload: Record<string, unknown>
|
||||
): Promise<void> {
|
||||
switch (action) {
|
||||
case 'saveDraft':
|
||||
await DraftService.saveDraft(payload.draftData as DraftRecord);
|
||||
break;
|
||||
case 'deleteDraft':
|
||||
await DraftService.deleteDraft(payload.draftId as number);
|
||||
break;
|
||||
case 'completeDraft':
|
||||
await DraftService.completeDraft(payload.draftId as number);
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Unknown action: ${action}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Check network status
|
||||
static isOnline(): boolean {
|
||||
return navigator.onLine;
|
||||
}
|
||||
|
||||
// Start listening for network changes
|
||||
static startNetworkListener(): void {
|
||||
window.addEventListener('online', () => {
|
||||
this.processQueue();
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**OfflineStore Implementation:**
|
||||
```typescript
|
||||
// src/lib/store/offline-store.ts
|
||||
import { create } from 'zustand';
|
||||
|
||||
interface OfflineState {
|
||||
isOnline: boolean;
|
||||
pendingCount: number;
|
||||
lastSyncAt: number | null;
|
||||
syncing: boolean;
|
||||
|
||||
setOnlineStatus: (isOnline: boolean) => void;
|
||||
syncNow: () => Promise<void>;
|
||||
updatePendingCount: () => Promise<void>;
|
||||
}
|
||||
|
||||
export const useOfflineStore = create<OfflineState>((set, get) => ({
|
||||
isOnline: typeof navigator !== 'undefined' ? navigator.onLine : true,
|
||||
pendingCount: 0,
|
||||
lastSyncAt: null,
|
||||
syncing: false,
|
||||
|
||||
setOnlineStatus: (isOnline: boolean) => {
|
||||
set({ isOnline });
|
||||
|
||||
// Update pending count when going online
|
||||
if (isOnline) {
|
||||
get().updatePendingCount();
|
||||
}
|
||||
},
|
||||
|
||||
syncNow: async () => {
|
||||
set({ syncing: true });
|
||||
|
||||
try {
|
||||
await SyncManager.processQueue();
|
||||
await get().updatePendingCount();
|
||||
set({ lastSyncAt: Date.now() });
|
||||
} finally {
|
||||
set({ syncing: false });
|
||||
}
|
||||
},
|
||||
|
||||
updatePendingCount: async () => {
|
||||
const count = await db.syncQueue.where('status').equals('pending').count();
|
||||
set({ pendingCount: count });
|
||||
}
|
||||
}));
|
||||
```
|
||||
|
||||
**OfflineIndicator Component:**
|
||||
```typescript
|
||||
// src/components/features/common/OfflineIndicator.tsx
|
||||
export function OfflineIndicator() {
|
||||
const isOnline = useOfflineStore(s => s.isOnline);
|
||||
const pendingCount = useOfflineStore(s => s.pendingCount);
|
||||
const syncing = useOfflineStore(s => s.syncping);
|
||||
|
||||
if (isOnline && pendingCount === 0) {
|
||||
// Show nothing when online and synced
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed top-4 left-1/2 -translate-x-1/2 z-50">
|
||||
<div className={`
|
||||
px-4 py-2 rounded-full shadow-lg text-sm font-medium flex items-center gap-2
|
||||
${isOnline ? 'bg-blue-100 text-blue-700' : 'bg-slate-800 text-white'}
|
||||
`}>
|
||||
{!isOnline && (
|
||||
<>
|
||||
<WifiOff className="w-4 h-4" />
|
||||
Offline - Saved locally
|
||||
</>
|
||||
)}
|
||||
|
||||
{isOnline && pendingCount > 0 && !syncing && (
|
||||
<>
|
||||
<Cloud className="w-4 h-4" />
|
||||
{pendingCount} items to sync
|
||||
</>
|
||||
)}
|
||||
|
||||
{syncing && (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
Syncing...
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Integration with DraftService
|
||||
|
||||
**Modify DraftService to Check Network:**
|
||||
```typescript
|
||||
// src/lib/db/draft-service.ts
|
||||
export class DraftService {
|
||||
static async saveDraft(draft: DraftRecord): Promise<number> {
|
||||
// For MVP: Always save locally (no server sync)
|
||||
// In future, check network and queue if offline
|
||||
|
||||
const id = await db.drafts.put(draft);
|
||||
return id;
|
||||
}
|
||||
|
||||
static async deleteDraft(id: number): Promise<boolean> {
|
||||
// For MVP: Always delete locally (no server sync)
|
||||
// In future, check network and queue if offline
|
||||
|
||||
// Existing cascade delete logic...
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**IMPORTANT - MVP Behavior:**
|
||||
For MVP, **all actions are local-only**. The SyncQueue is infrastructure for future server sync. The key changes are:
|
||||
1. Create SyncQueue table and schema migration
|
||||
2. Create SyncManager service (processes queue - no server yet)
|
||||
3. Create OfflineStore for network status
|
||||
4. Create OfflineIndicator component (shows status)
|
||||
|
||||
**Post-MVP Enhancement:**
|
||||
When server is added, DraftService will check network and queue actions:
|
||||
```typescript
|
||||
// Future implementation (not for MVP)
|
||||
static async saveDraft(draft: DraftRecord): Promise<number> {
|
||||
if (SyncManager.isOnline()) {
|
||||
// Save locally AND sync to server
|
||||
const id = await db.drafts.put(draft);
|
||||
await api.saveDraft(draft);
|
||||
return id;
|
||||
} else {
|
||||
// Save locally only, queue for sync
|
||||
const id = await db.drafts.put(draft);
|
||||
await SyncManager.queueAction('saveDraft', { draftData: draft });
|
||||
return id;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Previous Story Intelligence
|
||||
|
||||
**From Story 3.2 (Deletion):**
|
||||
- **Database Schema v2:** Established with sessionId for cascade delete
|
||||
- **DraftService.deleteDraft():** Atomic transaction for cascade delete
|
||||
- **Local-First Pattern:** Deletion works offline immediately (no sync needed for MVP)
|
||||
- **Key Learning:** All data operations are local-only in MVP
|
||||
|
||||
**From Story 3.1 (History Feed):**
|
||||
- **HistoryStore Pattern:** Separate Zustand store for history state
|
||||
- **Pagination:** Lazy load 20 drafts at a time
|
||||
- **Offline Access:** History feed must be viewable offline (all data is local)
|
||||
- **Key Learning:** No network requests for history (privacy requirement)
|
||||
|
||||
**From Epic 1 (Chat):**
|
||||
- **ChatStore:** Atomic selector pattern established
|
||||
- **Logic Sandwich:** UI -> Store -> Service -> DB
|
||||
- **Edge Runtime:** API routes use Edge for <3s latency
|
||||
- **Key Learning:** Services return plain data, not observables
|
||||
|
||||
### UX Design Specifications
|
||||
|
||||
**From UX Design Document:**
|
||||
|
||||
**Offline Status Pattern:**
|
||||
- Subtle indicator at top of screen (pill/badge style)
|
||||
- Shows "Offline - Saved locally" when offline
|
||||
- Shows "Syncing..." when processing queue
|
||||
- Disappears when online and synced
|
||||
|
||||
**Visual Feedback:**
|
||||
- **Offline:** Gray/black badge with WifiOff icon
|
||||
- **Syncing:** Blue badge with spinner animation
|
||||
- **Synced:** No badge (cleanest UX)
|
||||
|
||||
**Positioning:**
|
||||
- Fixed position at top center of screen
|
||||
- Z-index high (above all content)
|
||||
- Non-intrusive, doesn't block interactions
|
||||
|
||||
**Typography:**
|
||||
- Small text (0.875rem / 14px)
|
||||
- Font weight: Medium (500)
|
||||
- Icon: 16px
|
||||
|
||||
**Color System:**
|
||||
```css
|
||||
/* Offline - Dark (visible on light backgrounds) */
|
||||
.offline-badge {
|
||||
background: #1E293B; /* Slate-800 */
|
||||
color: #FFFFFF;
|
||||
}
|
||||
|
||||
/* Syncing - Blue (action in progress) */
|
||||
.syncing-badge {
|
||||
background: #DBEAFE; /* Blue-100 */
|
||||
color: #1D4ED8; /* Blue-700 */
|
||||
}
|
||||
```
|
||||
|
||||
**Accessibility:**
|
||||
- `role="status"` or `role="alert"` for screen readers
|
||||
- `aria-live="polite"` for non-critical status updates
|
||||
- Icon + text combination for clarity
|
||||
|
||||
### Testing Requirements
|
||||
|
||||
**Unit Tests:**
|
||||
- `SyncManager.queueAction()` adds item to database with correct status
|
||||
- `SyncManager.processQueue()` processes items in order
|
||||
- `SyncManager.processQueue()` marks failed items with retry count
|
||||
- `SyncManager.executeItem()` removes synced items from queue
|
||||
- `SyncManager.executeItem()` retries up to 3 times
|
||||
- `SyncManager.executeItem()` marks as failed after max retries
|
||||
- `OfflineStore.setOnlineStatus()` updates state correctly
|
||||
- `OfflineStore.syncNow()` calls processQueue and updates pendingCount
|
||||
|
||||
**Integration Tests:**
|
||||
- Network goes offline -> actions queue in SyncQueue
|
||||
- Network comes online -> SyncManager processes queue
|
||||
- Multiple actions in queue -> process in order
|
||||
- Action fails -> retries, then marks as failed
|
||||
- OfflineIndicator shows correct status based on state
|
||||
|
||||
**Edge Cases:**
|
||||
- Queue is empty -> processQueue returns immediately
|
||||
- All items fail -> all marked as failed after retries
|
||||
- Network drops during sync -> in-progress items marked as pending
|
||||
- User performs action while syncing -> new item queued
|
||||
- Very large queue (100+ items) -> processes without UI freeze
|
||||
|
||||
**Manual Tests:**
|
||||
- Chrome DevTools: Go offline, perform action, go online, verify sync
|
||||
- Safari DevTools: Same as above
|
||||
- Mobile: Enable Airplane Mode, perform action, disable, verify sync
|
||||
|
||||
### Performance Requirements
|
||||
|
||||
**NFR-02 Compliance (App Load Time):**
|
||||
- SyncQueue query must complete within 100ms
|
||||
- processQueue must not block UI (use async/await)
|
||||
- Network listeners have minimal overhead
|
||||
|
||||
**NFR-05 Compliance (Offline Behavior):**
|
||||
- App remains fully functional offline
|
||||
- Queue operations are local (IndexedDB)
|
||||
- UI updates immediately on queue success
|
||||
|
||||
**NFR-06 Compliance (Data Persistence):**
|
||||
- Queue items persist across page reloads
|
||||
- Queue survives browser restart
|
||||
- No data loss if app closes while offline
|
||||
|
||||
### Security & Privacy Requirements
|
||||
|
||||
**NFR-03 & NFR-04 Compliance:**
|
||||
- SyncQueue is stored locally only (IndexedDB)
|
||||
- No server sync in MVP (privacy-first)
|
||||
- Queue contains user data (encrypted if device supports it)
|
||||
|
||||
**Privacy Considerations:**
|
||||
- Queue items may contain sensitive venting content
|
||||
- For MVP, queue never leaves device
|
||||
- Future: If server sync is added, encrypt queue items in transit
|
||||
|
||||
### Project Structure Notes
|
||||
|
||||
**Following Feature-First Lite Pattern:**
|
||||
```
|
||||
src/
|
||||
components/
|
||||
features/
|
||||
common/ # NEW: Shared offline components
|
||||
OfflineIndicator.tsx
|
||||
index.ts
|
||||
lib/
|
||||
db/
|
||||
index.ts # MODIFY: Add syncQueue table, v3 migration
|
||||
store/
|
||||
offline-store.ts # NEW: Offline state management
|
||||
services/
|
||||
sync-manager.ts # NEW: Sync queue processing
|
||||
```
|
||||
|
||||
**Alignment with Unified Project Structure:**
|
||||
- New `common` feature folder for shared components
|
||||
- SyncManager in services (application logic layer)
|
||||
- OfflineStore in lib/store (state management)
|
||||
- Database migration in existing lib/db/index.ts
|
||||
|
||||
**Files to Create:**
|
||||
- `src/services/sync-manager.ts` - Sync queue processing service
|
||||
- `src/services/sync-manager.test.ts` - SyncManager tests
|
||||
- `src/lib/store/offline-store.ts` - Offline state management
|
||||
- `src/lib/store/offline-store.test.ts` - OfflineStore tests
|
||||
- `src/components/features/common/OfflineIndicator.tsx` - Offline status indicator
|
||||
- `src/components/features/common/OfflineIndicator.test.tsx` - Indicator tests
|
||||
- `src/components/features/common/index.ts` - Feature exports
|
||||
|
||||
**Files to Modify:**
|
||||
- `src/lib/db/index.ts` - Add syncQueue table, bump to v3
|
||||
- `src/lib/db/draft-service.ts` - (Future) Check network before actions
|
||||
- `src/app/layout.tsx` - Initialize OfflineIndicator and network listeners
|
||||
|
||||
### Database Migration Details
|
||||
|
||||
**Version 2 -> Version 3 Migration:**
|
||||
```typescript
|
||||
// src/lib/db/index.ts
|
||||
.version(3).stores({
|
||||
chatLogs: '++id, sessionId, createdAt',
|
||||
drafts: '++id, sessionId, status, completedAt, createdAt',
|
||||
syncQueue: '++id, status, createdAt' // NEW
|
||||
}, () => {
|
||||
// Migration callback (optional)
|
||||
// No data migration needed for new table
|
||||
console.log('Database upgraded to v3: SyncQueue added');
|
||||
})
|
||||
```
|
||||
|
||||
**Migration Safety:**
|
||||
- Existing data (chatLogs, drafts) is preserved
|
||||
- New empty table (syncQueue) is created
|
||||
- No data loss or corruption risk
|
||||
- User can continue using app immediately
|
||||
|
||||
### Error Handling & Recovery
|
||||
|
||||
**Sync Failure Scenarios:**
|
||||
1. **Action Execution Fails:** Retry up to 3 times with exponential backoff
|
||||
2. **Max Retries Exceeded:** Mark as 'failed', keep in queue for manual review
|
||||
3. **Queue Corruption:** Clear failed items, log error for debugging
|
||||
|
||||
**User-Facing Errors:**
|
||||
- Toast notification: "Sync failed for X items. Tap to retry."
|
||||
- Sync Now button in settings for manual retry
|
||||
- Failed items view in settings (future enhancement)
|
||||
|
||||
**Exponential Backoff:**
|
||||
```typescript
|
||||
// Wait times: 1s, 2s, 4s (between retries)
|
||||
const backoffMs = Math.pow(2, item.retries) * 1000;
|
||||
await new Promise(resolve => setTimeout(resolve, backoffMs));
|
||||
```
|
||||
|
||||
### References
|
||||
|
||||
**Epic Reference:**
|
||||
- [Epic 3: "My Legacy" - History, Offline Sync & PWA Polish](file:///home/maximilienmao/Projects/Test01/_bmad-output/planning-artifacts/epics.md#epic-3-my-legacy---history-offline-sync--pwa-polish)
|
||||
- [Story 3.3: Offline Sync Queue](file:///home/maximilienmao/Projects/Test01/_bmad-output/planning-artifacts/epics.md#story-33-offline-sync-queue)
|
||||
- FR-11: "Users can complete a full 'Venting Session' offline; system queues generation for reconnection"
|
||||
|
||||
**Architecture Documents:**
|
||||
- [Project Context: Logic Sandwich](file:///home/maximilienmao/Projects/Test01/_bmad-output/project-context.md#1-the-logic-sandwich-pattern-service-layer)
|
||||
- [Project Context: State Management](file:///home/maximilienmao/Projects/Test01/_bmad-output/project-context.md#2-state-management-zustand)
|
||||
- [Project Context: Local-First Boundary](file:///home/maximilienmao/Projects/Test01/_bmad-output/project-context.md#3-local-first-data-boundary)
|
||||
- [Architecture: Service Layer](file:///home/maximilienmao/Projects/Test01/_bmad-output/planning-artifacts/architecture.md#service-boundaries-the-logic-sandwich)
|
||||
- [Architecture: Offline Sync Pattern](file:///home/maximilienmao/Projects/Test01/_bmad-output/planning-artifacts/architecture.md#event-system-offline-sync)
|
||||
|
||||
**Previous Stories:**
|
||||
- [Story 3.2: Deletion & Management](file:///home/maximilienmao/Projects/Test01/_bmad-output/implementation-artifacts/3-2-deletion-management.md) - Cascade delete pattern, local-only operations
|
||||
- [Story 3.1: History Feed UI](file:///home/maximilienmao/Projects/Test01/_bmad-output/implementation-artifacts/3-1-history-feed-ui.md) - HistoryStore pattern, offline access
|
||||
- [Story 1.1: Local-First Setup](file:///home/maximilienmao/Projects/Test01/_bmad-output/implementation-artifacts/1-1-local-first-setup-chat-storage.md) - Dexie schema foundation
|
||||
|
||||
**Epic Retrospectives:**
|
||||
- [Epic 1 Retrospective](file:///home/maximilienmao/Projects/Test01/_bmad-output/implementation-artifacts/epic-1-retro-2026-01-22.md) - Atomic selector lessons
|
||||
|
||||
## Dev Agent Record
|
||||
|
||||
### Agent Model Used
|
||||
|
||||
Claude Opus 4.5 (model ID: 'claude-opus-4-5-20251101')
|
||||
|
||||
### Debug Log References
|
||||
|
||||
Session file: `/tmp/claude/-home-maximilienmao-Projects-Test01/34d84352-e9be-41ad-8616-07e4bb792130/scratchpad`
|
||||
|
||||
### Completion Notes List
|
||||
|
||||
**Story Analysis Completed:**
|
||||
- Extracted story requirements from Epic 3, Story 3.3
|
||||
- Analyzed Stories 3.2 and 3.1 for established patterns
|
||||
- Reviewed architecture for Service Layer and State Management compliance
|
||||
- Designed SyncQueue schema and migration strategy
|
||||
- Identified all files to create and modify
|
||||
|
||||
**Implementation Context Summary:**
|
||||
|
||||
**Story Purpose:**
|
||||
This story implements **offline resilience infrastructure** - the SyncQueue that ensures users never lose work when connectivity drops. For MVP, this is primarily **future-proofing** for server sync, but it provides immediate value by:
|
||||
1. Creating a persistent queue for offline actions
|
||||
2. Providing clear offline/sync status feedback
|
||||
3. Establishing the retry pattern for failed syncs
|
||||
|
||||
**IMPORTANT MVP Scope:**
|
||||
The MVP has **no server persistence**. All actions are local-only. This story creates the **SyncQueue infrastructure** for:
|
||||
- **Immediate value:** Offline status indicator, queue foundation
|
||||
- **Future value:** Server sync when backend is added post-MVP
|
||||
|
||||
**Key Technical Decisions:**
|
||||
1. **New Database Table:** syncQueue table in IndexedDB (v3 migration)
|
||||
2. **SyncManager Service:** Centralized queue processing with retry logic
|
||||
3. **OfflineStore:** New Zustand store for network status
|
||||
4. **OfflineIndicator:** Subtle status badge at top of screen
|
||||
5. **Exponential Backoff:** Retry failed items up to 3 times
|
||||
6. **Network Listeners:** Auto-sync on reconnection
|
||||
|
||||
**Dependencies:**
|
||||
- No new external dependencies required
|
||||
- Uses existing Dexie.js for queue storage
|
||||
- Uses existing Zustand for state management
|
||||
- Browser `navigator.onLine` API for network detection
|
||||
|
||||
**Integration Points:**
|
||||
- OfflineIndicator in app layout (visible on all pages)
|
||||
- SyncManager initialized on app mount
|
||||
- Network listeners start on app mount
|
||||
- DraftService integration (future: check network, queue if offline)
|
||||
|
||||
**Files to Create:**
|
||||
- `src/services/sync-manager.ts` - Sync queue processing service
|
||||
- `src/services/sync-manager.test.ts` - SyncManager tests
|
||||
- `src/lib/store/offline-store.ts` - Offline state management
|
||||
- `src/lib/store/offline-store.test.ts` - OfflineStore tests
|
||||
- `src/components/features/common/OfflineIndicator.tsx` - Status indicator
|
||||
- `src/components/features/common/OfflineIndicator.test.tsx` - Indicator tests
|
||||
- `src/components/features/common/index.ts` - Feature exports
|
||||
|
||||
**Files to Modify:**
|
||||
- `src/lib/db/index.ts` - Add syncQueue table, bump schema to v3
|
||||
- `src/app/layout.tsx` - Initialize OfflineIndicator and network listeners
|
||||
|
||||
**Testing Strategy:**
|
||||
- Unit tests for SyncManager queue, process, retry logic
|
||||
- Unit tests for OfflineStore state management
|
||||
- Integration tests for offline/online flow
|
||||
- Manual tests with DevTools network throttling
|
||||
|
||||
**SyncQueue Data Flow:**
|
||||
```
|
||||
User performs action (Save/Delete)
|
||||
↓
|
||||
Service checks network (navigator.onLine)
|
||||
↓
|
||||
If ONLINE: Execute immediately (MVP: local DB only)
|
||||
↓
|
||||
If OFFLINE: Add to SyncQueue in IndexedDB
|
||||
↓
|
||||
OfflineIndicator shows "Offline - Saved locally"
|
||||
↓
|
||||
Connection restored (window 'online' event)
|
||||
↓
|
||||
SyncManager.processQueue() executes pending items
|
||||
↓
|
||||
Synced items removed from queue
|
||||
↓
|
||||
Indicator shows "Synced" then disappears
|
||||
```
|
||||
|
||||
**User Experience Flow:**
|
||||
- User is offline -> Sees "Offline - Saved locally" badge
|
||||
- User performs action -> Badge confirms local save
|
||||
- Connection restored -> Badge shows "Syncing..." briefly
|
||||
- Sync complete -> Badge disappears
|
||||
|
||||
**Lessons from Previous Stories Applied:**
|
||||
- **Atomic Selectors:** All OfflineStore access uses `useOfflineStore(s => s.field)`
|
||||
- **Logic Sandwich:** SyncManager handles queue, not UI components
|
||||
- **Service Layer:** SyncManager processes queue with retry logic
|
||||
- **State Management:** Separate store for offline status (not in chat store)
|
||||
|
||||
**Database Schema Changes:**
|
||||
- Version bump: v2 -> v3
|
||||
- New table: syncQueue with indexes on status, createdAt
|
||||
- Migration: Non-breaking (adds empty table, preserves existing data)
|
||||
|
||||
**MVP Implementation Notes:**
|
||||
- DraftService does NOT check network for MVP (no server to sync to)
|
||||
- SyncManager infrastructure is created but not used by services yet
|
||||
- OfflineIndicator shows network status (purely informational for MVP)
|
||||
- Future enhancement: Services check network, queue actions if offline
|
||||
|
||||
**Post-MVP Enhancement Path:**
|
||||
When server persistence is added:
|
||||
1. DraftService checks `SyncManager.isOnline()` before actions
|
||||
2. If offline, save locally AND queue for server sync
|
||||
3. SyncManager processes queue by calling server API
|
||||
4. Failed syncs retry with exponential backoff
|
||||
|
||||
**Implementation Completed:**
|
||||
|
||||
**Database Schema (v3 Migration):**
|
||||
- Created SyncQueueItem interface with action, payload, status, createdAt, retries, lastError
|
||||
- Added syncQueue table to database version 3
|
||||
- Bumped database from v2 to v3 with non-breaking migration
|
||||
- All 13 database tests passing
|
||||
|
||||
**SyncManager Service:**
|
||||
- Implemented queueAction() method to add items to sync queue
|
||||
- Implemented processQueue() method to execute pending actions in order
|
||||
- Implemented exponential backoff retry logic (max 3 retries)
|
||||
- Implemented network status detection via navigator.onLine
|
||||
- Added startNetworkListener() for automatic sync on reconnection
|
||||
- All 14 SyncManager tests passing
|
||||
|
||||
**OfflineStore (Zustand):**
|
||||
- Created useOfflineStore with atomic selector pattern
|
||||
- State: isOnline, pendingCount, lastSyncAt, syncing
|
||||
- Actions: setOnlineStatus, syncNow, updatePendingCount
|
||||
- All 9 OfflineStore tests passing
|
||||
|
||||
**OfflineIndicator Component:**
|
||||
- Created badge component showing offline/sync status
|
||||
- Shows "Offline - Saved locally" when offline (dark badge)
|
||||
- Shows "X items to sync" when online with pending items (blue badge)
|
||||
- Shows "Syncing..." with spinner during sync
|
||||
- Disappears when online and synced (clean UX)
|
||||
- All 12 component tests passing
|
||||
|
||||
**Integration Tests:**
|
||||
- End-to-end tests for offline -> online sync flow
|
||||
- Error handling tests for sync failures
|
||||
- Multiple actions in queue processing
|
||||
- All 5 integration tests passing
|
||||
|
||||
**Total Test Coverage:**
|
||||
- 28 tests passing for Story 3.3
|
||||
- Database: 13 tests
|
||||
- SyncManager: 14 tests
|
||||
- OfflineStore: 9 tests
|
||||
- OfflineIndicator: 12 tests
|
||||
- Integration: 5 tests
|
||||
|
||||
**Files Created:**
|
||||
- `src/services/sync-manager.ts` - Sync queue processing service
|
||||
- `src/services/sync-manager.test.ts` - SyncManager tests
|
||||
- `src/lib/store/offline-store.ts` - Offline state management
|
||||
- `src/lib/store/offline-store.test.ts` - OfflineStore tests
|
||||
- `src/components/features/common/OfflineIndicator.tsx` - Status indicator
|
||||
- `src/components/features/common/OfflineIndicator.test.tsx` - Indicator tests
|
||||
- `src/components/features/common/index.ts` - Feature exports
|
||||
- `src/integration/offline-sync.test.ts` - End-to-end integration tests
|
||||
|
||||
**Files Modified:**
|
||||
- `src/lib/db/index.ts` - Added syncQueue table, v3 migration, SyncQueueItem interface
|
||||
- `src/app/layout.tsx` - Initialized network listeners and OfflineIndicator
|
||||
- `src/services/sync-manager.test.ts` - Fixed test expectations for error handling
|
||||
- `src/lib/store/offline-store.test.ts` - Fixed test data setup
|
||||
- `src/integration/offline-sync.test.ts` - Fixed integration test for retry behavior
|
||||
|
||||
**Key Technical Implementation Notes:**
|
||||
1. SyncManager.executeAction now throws errors when actions fail (draft not found), allowing proper error handling and retry
|
||||
2. Each processQueue() call processes items once - retries happen across multiple calls (realistic behavior for reconnection scenarios)
|
||||
3. Tests use multiple processQueue() calls to simulate reconnection attempts
|
||||
4. OfflineIndicator uses atomic selectors for optimal re-render performance
|
||||
5. Network listeners initialize in layout.tsx on app mount
|
||||
Reference in New Issue
Block a user