Files
biblical-guide.com/docs/plans/2025-01-12-phase-2-1b-sync-integration.md
2025-11-12 08:00:39 +00:00

21 KiB

Phase 2.1B: Backend Sync Integration - Implementation Plan

For Claude: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development to implement this plan task-by-task with code reviews.

Goal: Implement end-to-end highlight synchronization between client and backend with conflict resolution, cross-device sync, and UI status indicators.

Architecture: Client-side sync queue → POST /api/highlights/bulk → Backend upsert with timestamps → Pull sync on login → Merge highlights with timestamp-based conflict resolution

Tech Stack: TypeScript, React, IndexedDB, Prisma (backend), TDD


Task 1: Implement Backend Sync Logic with Timestamp Merging

Files:

  • Modify: /root/biblical-guide/app/api/highlights/bulk/route.ts - enhance with conflict resolution
  • Create: /root/biblical-guide/lib/sync-conflict-resolver.ts - timestamp-based merge
  • Test: /root/biblical-guide/__tests__/lib/sync-conflict-resolver.test.ts

Step 1: Write failing test

Create /root/biblical-guide/__tests__/lib/sync-conflict-resolver.test.ts:

import { resolveConflict } from '@/lib/sync-conflict-resolver'
import { BibleHighlight } from '@/types'

describe('SyncConflictResolver', () => {
  it('should prefer server version if newer', () => {
    const clientVersion: BibleHighlight = {
      id: 'h-1',
      verseId: 'v-1',
      color: 'yellow',
      createdAt: 1000,
      updatedAt: 1000,
      syncStatus: 'pending'
    }

    const serverVersion: BibleHighlight = {
      id: 'h-1',
      verseId: 'v-1',
      color: 'blue',
      createdAt: 1000,
      updatedAt: 2000, // newer
      syncStatus: 'synced'
    }

    const result = resolveConflict(clientVersion, serverVersion)
    expect(result.color).toBe('blue')
    expect(result.updatedAt).toBe(2000)
  })

  it('should prefer client version if newer', () => {
    const clientVersion: BibleHighlight = {
      id: 'h-1',
      verseId: 'v-1',
      color: 'blue',
      createdAt: 1000,
      updatedAt: 3000, // newer
      syncStatus: 'pending'
    }

    const serverVersion: BibleHighlight = {
      id: 'h-1',
      verseId: 'v-1',
      color: 'yellow',
      createdAt: 1000,
      updatedAt: 2000,
      syncStatus: 'synced'
    }

    const result = resolveConflict(clientVersion, serverVersion)
    expect(result.color).toBe('blue')
    expect(result.updatedAt).toBe(3000)
  })

  it('should mark as synced after resolution', () => {
    const clientVersion: BibleHighlight = {
      id: 'h-1',
      verseId: 'v-1',
      color: 'yellow',
      createdAt: 1000,
      updatedAt: 2000,
      syncStatus: 'pending'
    }

    const serverVersion: BibleHighlight = {
      id: 'h-1',
      verseId: 'v-1',
      color: 'yellow',
      createdAt: 1000,
      updatedAt: 2000,
      syncStatus: 'synced'
    }

    const result = resolveConflict(clientVersion, serverVersion)
    expect(result.syncStatus).toBe('synced')
  })
})

Step 2: Run test to verify it fails

npm test -- __tests__/lib/sync-conflict-resolver.test.ts

Expected output: FAIL - "sync-conflict-resolver module not found"

Step 3: Create sync-conflict-resolver.ts

Create /root/biblical-guide/lib/sync-conflict-resolver.ts:

import { BibleHighlight } from '@/types'

/**
 * Resolves conflicts between client and server versions of a highlight.
 * Uses timestamp-based "last write wins" strategy.
 */
export function resolveConflict(
  clientVersion: BibleHighlight,
  serverVersion: BibleHighlight
): BibleHighlight {
  // Use timestamp to determine which version is newer
  const isServerNewer = serverVersion.updatedAt > clientVersion.updatedAt

  // Take the newer version and mark as synced
  const resolvedVersion = isServerNewer ? serverVersion : clientVersion

  return {
    ...resolvedVersion,
    syncStatus: 'synced' as const
  }
}

/**
 * Merges server highlights with client highlights.
 * - Adds new highlights from server
 * - Updates existing highlights if server version is newer
 * - Keeps client highlights if client version is newer
 */
export function mergeHighlights(
  clientHighlights: BibleHighlight[],
  serverHighlights: BibleHighlight[]
): BibleHighlight[] {
  const clientMap = new Map(clientHighlights.map(h => [h.id, h]))
  const serverMap = new Map(serverHighlights.map(h => [h.id, h]))

  const merged = new Map<string, BibleHighlight>()

  // Add all client highlights, resolving conflicts with server
  for (const [id, clientH] of clientMap) {
    const serverH = serverMap.get(id)
    if (serverH) {
      // Conflict: both have this highlight
      merged.set(id, resolveConflict(clientH, serverH))
    } else {
      // No conflict: only client has it
      merged.set(id, clientH)
    }
  }

  // Add any server highlights not in client
  for (const [id, serverH] of serverMap) {
    if (!clientMap.has(id)) {
      merged.set(id, { ...serverH, syncStatus: 'synced' as const })
    }
  }

  return Array.from(merged.values())
}

Step 4: Run test to verify it passes

npm test -- __tests__/lib/sync-conflict-resolver.test.ts

Expected output: PASS - all 3 tests pass

Step 5: Commit

git add lib/sync-conflict-resolver.ts __tests__/lib/sync-conflict-resolver.test.ts
git commit -m "feat: implement sync conflict resolver with timestamp-based merging"

Task 2: Implement Client-Side Sync with Bulk API

Files:

  • Modify: /root/biblical-guide/lib/highlight-sync-manager.ts - add actual API sync
  • Test: Add to existing sync-manager tests

Step 1: Update HighlightSyncManager performSync

Update /root/biblical-guide/lib/highlight-sync-manager.ts to add the actual sync logic:

async performSync(): Promise<{ synced: number; errors: number }> {
  if (!this.db) await this.init()

  try {
    const pending = await this.getPendingSyncItems()
    if (pending.length === 0) return { synced: 0, errors: 0 }

    // Mark as syncing
    await this.markSyncing(pending.map(h => h.id))

    // POST to backend
    const response = await fetch('/api/highlights/bulk', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ highlights: pending })
    })

    if (!response.ok) {
      // Mark all as error
      const errorIds = pending.map(h => h.id)
      await this.markError(errorIds, `HTTP ${response.status}`)
      return { synced: 0, errors: pending.length }
    }

    const result = await response.json()

    // Mark successfully synced items
    if (result.synced > 0) {
      const syncedIds = pending
        .filter(h => !result.errors.some((e: any) => e.verseId === h.verseId))
        .map(h => h.id)
      await this.markSynced(syncedIds)
    }

    // Mark errored items
    if (result.errors && result.errors.length > 0) {
      for (const error of result.errors) {
        const h = pending.find(item => item.verseId === error.verseId)
        if (h) {
          await this.markError([h.id], error.error)
        }
      }
    }

    return { synced: result.synced, errors: result.errors?.length || 0 }
  } catch (error) {
    console.error('Sync failed:', error)
    const pending = await this.getPendingSyncItems()
    if (pending.length > 0) {
      await this.markError(
        pending.map(h => h.id),
        'Network error'
      )
    }
    return { synced: 0, errors: pending.length }
  }
}

Step 2: Add test for performSync

Add to existing highlight-sync-manager.test.ts:

it('should perform sync and mark items as synced', async () => {
  const highlight: BibleHighlight = {
    id: 'h-1',
    verseId: 'v-1',
    color: 'yellow',
    createdAt: Date.now(),
    updatedAt: Date.now(),
    syncStatus: 'pending'
  }

  await manager.queueHighlight(highlight)

  // Mock fetch
  global.fetch = jest.fn(() =>
    Promise.resolve({
      ok: true,
      json: () => Promise.resolve({ synced: 1, errors: [] })
    })
  ) as jest.Mock

  const result = await manager.performSync()

  expect(result.synced).toBe(1)
  expect(result.errors).toBe(0)
})

Step 3: Run tests

npm test -- __tests__/lib/highlight-sync-manager.test.ts

Step 4: Commit

git add lib/highlight-sync-manager.ts __tests__/lib/highlight-sync-manager.test.ts
git commit -m "feat: implement client-side sync with bulk API"

Task 3: Add Pull Sync on Login

Files:

  • Modify: /root/biblical-guide/components/bible/bible-reader-app.tsx - add pull sync on mount
  • Create: /root/biblical-guide/lib/highlight-pull-sync.ts - pull and merge logic

Step 1: Create highlight-pull-sync.ts

Create /root/biblical-guide/lib/highlight-pull-sync.ts:

import { BibleHighlight } from '@/types'
import { getAllHighlights, addHighlight, updateHighlight } from './highlight-manager'
import { mergeHighlights } from './sync-conflict-resolver'

export async function pullAndMergeHighlights(): Promise<BibleHighlight[]> {
  try {
    // Fetch all highlights from server
    const response = await fetch('/api/highlights/all')

    if (!response.ok) {
      console.error('Failed to pull highlights:', response.status)
      return []
    }

    const { highlights: serverHighlights } = await response.json()

    // Get local highlights
    const clientHighlights = await getAllHighlights()

    // Merge with conflict resolution
    const merged = mergeHighlights(clientHighlights, serverHighlights)

    // Update local storage with merged version
    for (const highlight of merged) {
      const existing = clientHighlights.find(h => h.id === highlight.id)
      if (existing) {
        // Update if different
        if (JSON.stringify(existing) !== JSON.stringify(highlight)) {
          await updateHighlight(highlight)
        }
      } else {
        // Add new highlights from server
        await addHighlight(highlight)
      }
    }

    return merged
  } catch (error) {
    console.error('Error pulling highlights:', error)
    return []
  }
}

Step 2: Integrate into BibleReaderApp

Update /root/biblical-guide/components/bible/bible-reader-app.tsx:

Add import:

import { pullAndMergeHighlights } from '@/lib/highlight-pull-sync'

Add useEffect for pull sync on auth change:

useEffect(() => {
  // Pull highlights from server when component mounts (user logged in)
  const pullHighlights = async () => {
    try {
      const merged = await pullAndMergeHighlights()
      const map = new Map(merged.map(h => [h.verseId, h]))
      setHighlights(map)
    } catch (error) {
      console.error('Failed to pull highlights:', error)
    }
  }

  pullHighlights()
}, [])

Step 3: Commit

git add lib/highlight-pull-sync.ts components/bible/bible-reader-app.tsx
git commit -m "feat: add pull sync on login with conflict resolution"

Task 4: Create Sync Status Indicator Component

Files:

  • Create: /root/biblical-guide/components/bible/sync-status-indicator.tsx
  • Test: /root/biblical-guide/__tests__/components/sync-status-indicator.test.tsx

Step 1: Write failing test

Create /root/biblical-guide/__tests__/components/sync-status-indicator.test.tsx:

import { render, screen } from '@testing-library/react'
import { SyncStatusIndicator } from '@/components/bible/sync-status-indicator'

describe('SyncStatusIndicator', () => {
  it('should show synced state', () => {
    render(<SyncStatusIndicator status="synced" />)
    expect(screen.getByTestId('sync-status-synced')).toBeInTheDocument()
  })

  it('should show syncing state with spinner', () => {
    render(<SyncStatusIndicator status="syncing" />)
    expect(screen.getByTestId('sync-status-syncing')).toBeInTheDocument()
  })

  it('should show error state', () => {
    render(<SyncStatusIndicator status="error" errorMessage="Network error" />)
    expect(screen.getByTestId('sync-status-error')).toBeInTheDocument()
    expect(screen.getByText('Network error')).toBeInTheDocument()
  })

  it('should show pending count', () => {
    render(<SyncStatusIndicator status="pending" pendingCount={3} />)
    expect(screen.getByText('3 pending')).toBeInTheDocument()
  })
})

Step 2: Run test to verify it fails

npm test -- __tests__/components/sync-status-indicator.test.tsx

Step 3: Create SyncStatusIndicator component

Create /root/biblical-guide/components/bible/sync-status-indicator.tsx:

'use client'
import { Box, Chip, CircularProgress, Tooltip, Typography } from '@mui/material'
import CloudSyncIcon from '@mui/icons-material/CloudSync'
import CheckCircleIcon from '@mui/icons-material/CheckCircle'
import ErrorIcon from '@mui/icons-material/Error'
import ScheduleIcon from '@mui/icons-material/Schedule'

interface SyncStatusIndicatorProps {
  status: 'synced' | 'syncing' | 'pending' | 'error'
  pendingCount?: number
  errorMessage?: string
}

export function SyncStatusIndicator({
  status,
  pendingCount = 0,
  errorMessage
}: SyncStatusIndicatorProps) {
  if (status === 'synced') {
    return (
      <Tooltip title="All changes synced">
        <Chip
          data-testid="sync-status-synced"
          icon={<CheckCircleIcon sx={{ color: 'success.main' }} />}
          label="Synced"
          variant="outlined"
          color="success"
          size="small"
          sx={{ fontWeight: 500 }}
        />
      </Tooltip>
    )
  }

  if (status === 'syncing') {
    return (
      <Tooltip title="Syncing with server">
        <Chip
          data-testid="sync-status-syncing"
          icon={<CircularProgress size={16} />}
          label="Syncing..."
          variant="filled"
          color="primary"
          size="small"
          sx={{ fontWeight: 500 }}
        />
      </Tooltip>
    )
  }

  if (status === 'pending') {
    return (
      <Tooltip title={`${pendingCount} highlights waiting to sync`}>
        <Chip
          data-testid="sync-status-pending"
          icon={<ScheduleIcon sx={{ color: 'warning.main' }} />}
          label={`${pendingCount} pending`}
          variant="outlined"
          color="warning"
          size="small"
          sx={{ fontWeight: 500 }}
        />
      </Tooltip>
    )
  }

  // error
  return (
    <Tooltip title={errorMessage || 'Sync failed'}>
      <Box data-testid="sync-status-error" sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
        <ErrorIcon sx={{ color: 'error.main', fontSize: 20 }} />
        <Box>
          <Typography variant="caption" color="error" sx={{ fontWeight: 600 }}>
            Sync Error
          </Typography>
          {errorMessage && (
            <Typography variant="caption" color="error" sx={{ display: 'block' }}>
              {errorMessage}
            </Typography>
          )}
        </Box>
      </Box>
    </Tooltip>
  )
}

Step 4: Run test to verify it passes

npm test -- __tests__/components/sync-status-indicator.test.tsx

Step 5: Commit

git add components/bible/sync-status-indicator.tsx __tests__/components/sync-status-indicator.test.tsx
git commit -m "feat: create sync status indicator component"

Task 5: Integrate Sync Status into HighlightsTab

Files:

  • Modify: /root/biblical-guide/components/bible/highlights-tab.tsx - add sync status display
  • Modify: /root/biblical-guide/components/bible/bible-reader-app.tsx - pass sync status

Step 1: Update HighlightsTab to accept sync status

Modify /root/biblical-guide/components/bible/highlights-tab.tsx:

Add to props interface:

interface HighlightsTabProps {
  // ... existing props
  syncStatus?: 'synced' | 'syncing' | 'pending' | 'error'
  syncErrorMessage?: string
}

Add sync status display in JSX (after color picker):

import { SyncStatusIndicator } from './sync-status-indicator'

// In the highlighted section, after color picker and divider:
{syncStatus && (
  <Box sx={{ mt: 2 }}>
    <Typography variant="subtitle2" sx={{ mb: 1 }}>
      Sync Status
    </Typography>
    <SyncStatusIndicator
      status={syncStatus}
      errorMessage={syncErrorMessage}
    />
  </Box>
)}

Step 2: Add sync status tracking to BibleReaderApp

Update /root/biblical-guide/components/bible/bible-reader-app.tsx:

Add state:

const [syncStatus, setSyncStatus] = useState<'synced' | 'syncing' | 'pending' | 'error'>('synced')
const [syncError, setSyncError] = useState<string | null>(null)

Update performSync function:

async function performSync() {
  if (!syncManager.current) return

  try {
    setSyncStatus('syncing')
    const result = await syncManager.current.performSync()

    if (result.errors > 0) {
      setSyncStatus('error')
      setSyncError(`Failed to sync ${result.errors} highlights`)
    } else {
      setSyncStatus('synced')
      setSyncError(null)
    }
  } catch (error) {
    setSyncStatus('error')
    setSyncError(error instanceof Error ? error.message : 'Unknown error')
  }
}

Update when rendering VersDetailsPanel:

<VersDetailsPanel
  // ... existing props
  syncStatus={syncStatus}
  syncErrorMessage={syncError || undefined}
/>

Step 3: Commit

git add components/bible/highlights-tab.tsx components/bible/bible-reader-app.tsx
git commit -m "feat: integrate sync status indicator into highlights panel"

Task 6: Add E2E Tests for Sync Flow

Files:

  • Create: /root/biblical-guide/__tests__/e2e/highlights-sync.test.ts

Step 1: Create E2E test

Create /root/biblical-guide/__tests__/e2e/highlights-sync.test.ts:

import { HighlightSyncManager } from '@/lib/highlight-sync-manager'
import { addHighlight, getAllHighlights } from '@/lib/highlight-manager'
import { resolveConflict } from '@/lib/sync-conflict-resolver'
import { BibleHighlight } from '@/types'

describe('E2E: Highlights Sync Flow', () => {
  let manager: HighlightSyncManager

  beforeEach(() => {
    manager = new HighlightSyncManager()
  })

  it('should complete full sync workflow', async () => {
    // 1. User creates highlight locally
    const highlight: BibleHighlight = {
      id: 'h-1',
      verseId: 'v-1',
      color: 'yellow',
      createdAt: Date.now(),
      updatedAt: Date.now(),
      syncStatus: 'pending'
    }

    await addHighlight(highlight)

    // 2. Queue it for sync
    await manager.init()
    await manager.queueHighlight(highlight)

    // 3. Check pending items
    const pending = await manager.getPendingSyncItems()
    expect(pending.length).toBe(1)
    expect(pending[0].color).toBe('yellow')

    // 4. Mark as syncing
    await manager.markSyncing(['h-1'])
    const syncing = await manager.getSyncingItems()
    expect(syncing.length).toBe(1)

    // 5. Simulate server response and mark synced
    await manager.markSynced(['h-1'])
    const allHighlights = await getAllHighlights()
    const synced = allHighlights.find(h => h.id === 'h-1')
    expect(synced?.syncStatus).toBe('synced')
  })

  it('should handle conflict resolution', () => {
    const clientVersion: BibleHighlight = {
      id: 'h-1',
      verseId: 'v-1',
      color: 'blue',
      createdAt: 1000,
      updatedAt: 3000,
      syncStatus: 'pending'
    }

    const serverVersion: BibleHighlight = {
      id: 'h-1',
      verseId: 'v-1',
      color: 'yellow',
      createdAt: 1000,
      updatedAt: 2000,
      syncStatus: 'synced'
    }

    // Client version is newer, should win
    const resolved = resolveConflict(clientVersion, serverVersion)
    expect(resolved.color).toBe('blue')
    expect(resolved.syncStatus).toBe('synced')
  })

  it('should handle sync errors gracefully', async () => {
    const highlight: BibleHighlight = {
      id: 'h-1',
      verseId: 'v-1',
      color: 'yellow',
      createdAt: Date.now(),
      updatedAt: Date.now(),
      syncStatus: 'pending'
    }

    await addHighlight(highlight)
    await manager.init()
    await manager.queueHighlight(highlight)

    // Mark as error
    await manager.markError(['h-1'], 'Network timeout')

    const synced = await manager.getSyncingItems()
    expect(synced.length).toBe(0) // Not syncing anymore

    const all = await getAllHighlights()
    const errored = all.find(h => h.id === 'h-1')
    expect(errored?.syncStatus).toBe('error')
    expect(errored?.syncErrorMsg).toBe('Network timeout')
  })
})

Step 2: Run tests

npm test -- __tests__/e2e/highlights-sync.test.ts

Step 3: Commit

git add __tests__/e2e/highlights-sync.test.ts
git commit -m "test: add E2E tests for highlights sync flow"

Task 7: Build Verification and Final Integration

Files:

  • Run full build and verify no errors

Step 1: Run build

npm run build 2>&1 | tail -50

Expected output: Build completed successfully

Step 2: Run all tests

npm test 2>&1 | tail -100

Expected output: All tests pass

Step 3: Commit

git add -A
git commit -m "build: complete Phase 2.1B backend sync integration"

Summary

Phase 2.1B implements:

Conflict Resolution - Timestamp-based "last write wins" merge strategy Client Sync - Push pending highlights to /api/highlights/bulk Pull Sync - Fetch all highlights from server on login Merge Logic - Smart merge that combines client and server versions Sync Status UI - Visual indicator for synced/syncing/pending/error states Error Handling - Graceful retry and error messaging E2E Testing - Full workflow tests from local to server and back

Next Phase (2.1C) - Future:

  • Real-time sync using WebSockets
  • Analytics for sync performance
  • Batch sync optimization
  • Offline queue persistence across sessions