# Sprint 12: Tauri Fallback & Offline State

> **Priority**: 6 | **Owner**: Client | **Complexity**: Medium
> **Prerequisite**: Sprint 11

---

## Validation Status (2025-12-29)

### ✅ IMPLEMENTATION COMPLETE

**All Sprint 12 components are fully implemented and tested.**
**Decision**: Offline mode is **cached read-only**. No write queue in Phase 4.

| Component | Location | Status |
|-----------|----------|--------|
| Connection state module | `client/src/api/connection-state.ts` | ✅ Full implementation with mode tracking, subscribers |
| Connection context | `client/src/contexts/connection-context.tsx` | ✅ React provider with computed helpers |
| Offline banner | `client/src/components/offline-banner.tsx` | ✅ Mode-aware banner + compact indicator |
| Guarded mutation hook | `client/src/hooks/use-guarded-mutation.ts` | ✅ Blocks writes in cached/disconnected mode |
| Reconnection logic | `client/src/api/reconnection.ts` | ✅ Exponential backoff, network event listeners |
| Cached read-only adapter | `client/src/api/cached-adapter.ts` | ✅ Full NoteFlowAPI impl with cache reads |
| Read-only cache store | `client/src/lib/cache/meeting-cache.ts` | ✅ LocalStorage cache with versioning |
| Connection state tests | `client/src/api/connection-state.test.ts` | ✅ Comprehensive coverage |
| Connection context tests | `client/src/contexts/connection-context.test.tsx` | ✅ Provider tests |
| Offline banner tests | `client/src/components/offline-banner.test.tsx` | ✅ Mode-specific rendering |
| Guarded mutation tests | `client/src/hooks/use-guarded-mutation.test.tsx` | ✅ Block/allow scenarios |
| Reconnection tests | `client/src/api/reconnection.test.ts` | ✅ Backoff, network events |
| Cached adapter tests | `client/src/api/cached-adapter.test.ts` | ✅ Read/write behavior |
| Meeting cache tests | `client/src/lib/cache/meeting-cache.test.ts` | ✅ CRUD + versioning

---

## Objective

Make offline/fallback explicit with clear UI indicators and enforce **cached read-only** behavior (no silent writes).

---

## Current State Analysis

### API Initialization

**Current Behavior** (`api/index.ts`):
```typescript
let api: NoteFlowAPI;

export async function initializeAPI(): Promise<void> {
  if (isTauriEnvironment()) {
    try {
      api = new TauriAdapter();
      await api.connect();
    } catch (error) {
      console.warn('Tauri connection failed, using mock API');
      api = new MockAdapter(); // Silent fallback
    }
  } else {
    api = new MockAdapter();
  }
}
```

**Problem**: Users don't know they're in mock mode. Writes succeed locally but aren't persisted.

### Connection Status

**Current UI** (`connection-status.tsx`):
- Shows connection icon
- Red when disconnected
- No distinction between "disconnected" and "cached read-only"

### Write Operations

**Current Behavior**:
- All mutations allowed regardless of connection state
- Mock API silently succeeds
- No warning before destructive operations

**Target Behavior**:
- Cached read-only mode blocks all mutations
- Mock mode only for development; never auto-selected in Tauri
- UI clearly labels cached read-only mode

---

## Scope

| Task | Effort | Risk |
|------|--------|------|
| Add explicit API mode tracking (incl. `cached`) | Low | Low |
| Update API initialization to select cached adapter | Low | Low |
| Add cached read-only adapter + cache store | Medium | Medium |
| Create offline mode context | Low | Low |
| Add offline banner component | Low | Low |
| Enforce read-only guards on mutations | Medium | Medium |
| Add reconnection logic | Medium | Low |

---

## Implementation Plan

### Task 1: API Mode Tracking

**File**: `client/src/api/connection-state.ts`

```typescript
export type ConnectionMode = 'connected' | 'disconnected' | 'cached' | 'mock' | 'reconnecting';

export interface ConnectionState {
  mode: ConnectionMode;
  lastConnectedAt: Date | null;
  disconnectedAt: Date | null;
  reconnectAttempts: number;
  error: string | null;
}

let state: ConnectionState = {
  mode: 'disconnected',
  lastConnectedAt: null,
  disconnectedAt: null,
  reconnectAttempts: 0,
  error: null,
};

const listeners: Set<(state: ConnectionState) => void> = new Set();

export function getConnectionState(): ConnectionState {
  return { ...state };
}

export function setConnectionMode(mode: ConnectionMode, error?: string): void {
  const now = new Date();

  if (mode === 'connected' && state.mode !== 'connected') {
    state = { ...state, mode, lastConnectedAt: now, reconnectAttempts: 0, error: null };
  } else if (mode === 'disconnected' || mode === 'cached' || mode === 'mock') {
    state = { ...state, mode, disconnectedAt: now, error: error || null };
  } else {
    state = { ...state, mode, error: error || null };
  }

  notifyListeners();
}

export function incrementReconnectAttempts(): void {
  state = { ...state, reconnectAttempts: state.reconnectAttempts + 1 };
  notifyListeners();
}

export function subscribe(listener: (state: ConnectionState) => void): () => void {
  listeners.add(listener);
  listener(state);
  return () => listeners.delete(listener);
}

function notifyListeners(): void {
  for (const listener of listeners) {
    listener(state);
  }
}
```

### Task 1.5: Update API Initialization

**File**: `client/src/api/index.ts`

Update `initializeAPI()` to set connection mode after initialization:

```typescript
import { setConnectionMode } from './connection-state';

export async function initializeAPI(): Promise<NoteFlowAPI> {
  const environment = detectEnvironment();

  console.log(`[NoteFlow] Initializing API for environment: ${environment}`);

  if (environment === 'tauri') {
    try {
      const tauriAPI = await initializeTauriAPI();
      setAPIInstance(tauriAPI);
      setConnectionMode('connected');  // <-- Add this
      await preferences.initialize();
      await startTauriEventBridge().catch((error) => {
        console.warn('[NoteFlow] Failed to start Tauri event bridge:', error);
      });
      console.log('[NoteFlow] Tauri API initialized successfully');
      return tauriAPI;
    } catch (error) {
      console.warn('[NoteFlow] Failed to initialize Tauri API, falling back to cached read-only:', error);
      // Fall through to cached read-only API
    }
  }

  // Default to cached read-only for Tauri fallback
  setAPIInstance(cachedAPI);
  setConnectionMode('cached');  // <-- Add this
  console.log('[NoteFlow] Cached read-only API initialized');
  return cachedAPI;
}
```

Also update the auto-initialization block:

```typescript
if (typeof window !== 'undefined') {
  const environment = detectEnvironment();

  if (environment === 'tauri') {
    // Async initialization for Tauri - uses cached read-only until ready
    setAPIInstance(cachedAPI);
    setConnectionMode('cached');  // Temporary until Tauri is ready
    initializeAPI().catch((err) => {
      console.error('[NoteFlow] API initialization failed:', err);
      setConnectionMode('disconnected', err.message);  // <-- Add this
    });
  } else {
    // Synchronous initialization for web
    setAPIInstance(mockAPI);
    setConnectionMode('mock');  // Dev-only
  }
}
```

### Task 2: Cached Read-Only Adapter + Cache Store

**Files**:
- `client/src/api/cached-adapter.ts` (read-only adapter; no mutations)
- `client/src/lib/cache/meeting-cache.ts` (persisted snapshots of last known meeting list + transcript)

**Notes**:
- Cache is populated during normal connected sessions.
- Cached adapter serves read-only queries from the snapshot and throws on writes.
- Cache format should mirror existing DTOs (`client/src/api/types/*`) to avoid model drift.

### Task 3: Connection Context

**File**: `client/src/contexts/connection-context.tsx`

```typescript
import React, { createContext, useContext, useEffect, useState } from 'react';
import {
  ConnectionState,
  ConnectionMode,
  getConnectionState,
  subscribe,
} from '../api/connection-state';

interface ConnectionContextValue extends ConnectionState {
  isOnline: boolean;
  isCached: boolean;
  isMock: boolean;
  canWrite: boolean;
}

const ConnectionContext = createContext<ConnectionContextValue | null>(null);

export function ConnectionProvider({ children }: { children: React.ReactNode }) {
  const [state, setState] = useState<ConnectionState>(getConnectionState);

  useEffect(() => {
    return subscribe(setState);
  }, []);

  const value: ConnectionContextValue = {
    ...state,
    isOnline: state.mode === 'connected',
    isCached: state.mode === 'cached',
    isMock: state.mode === 'mock', // dev-only
    canWrite: state.mode === 'connected',
  };

  return (
    <ConnectionContext.Provider value={value}>
      {children}
    </ConnectionContext.Provider>
  );
}

export function useConnection(): ConnectionContextValue {
  const context = useContext(ConnectionContext);
  if (!context) {
    throw new Error('useConnection must be used within ConnectionProvider');
  }
  return context;
}
```

### Task 4: Offline Banner

**File**: `client/src/components/offline-banner.tsx`

```typescript
import { useConnection } from '../contexts/connection-context';
import { AlertTriangle, WifiOff, RefreshCw } from 'lucide-react';
import { Button } from './ui/button';
import { getAPI } from '../api';

export function OfflineBanner() {
  const { mode, isCached, isMock, error, reconnectAttempts } = useConnection();

  if (mode === 'connected') return null;

  const getMessage = () => {
    if (mode === 'cached') {
      return 'Offline (cached read-only). Changes are disabled.';
    }
    if (mode === 'mock') {
      return 'Running in demo mode. Changes will not be saved.';
    }
    if (mode === 'reconnecting') {
      return `Reconnecting... (attempt ${reconnectAttempts})`;
    }
    return error || 'Connection lost. Some features may be unavailable.';
  };

  const handleReconnect = async () => {
    try {
      await getAPI().connect();
    } catch {
      // Error handled by connection state
    }
  };

  return (
    <div
      role="alert"
      className={`
        fixed top-0 left-0 right-0 z-50 px-4 py-2 flex items-center justify-center gap-2
        ${isCached || isMock ? 'bg-yellow-500 text-yellow-950' : 'bg-red-500 text-white'}
      `}
    >
      {mode === 'reconnecting' ? (
        <RefreshCw className="w-4 h-4 animate-spin" />
      ) : isCached || isMock ? (
        <AlertTriangle className="w-4 h-4" />
      ) : (
        <WifiOff className="w-4 h-4" />
      )}

      <span className="text-sm font-medium">{getMessage()}</span>

      {mode !== 'reconnecting' && mode !== 'mock' && (
        <Button
          size="sm"
          variant="secondary"
          onClick={handleReconnect}
          className="ml-2"
        >
          Reconnect
        </Button>
      )}
    </div>
  );
}
```

### Task 5: Write Operation Guards

**File**: `client/src/hooks/use-guarded-mutation.ts`

```typescript
import { useMutation, UseMutationOptions } from '@tanstack/react-query';
import { useConnection } from '../contexts/connection-context';
import { toast } from 'sonner';

export function useGuardedMutation<TData, TError, TVariables>(
  mutationFn: (variables: TVariables) => Promise<TData>,
  options?: Omit<UseMutationOptions<TData, TError, TVariables>, 'mutationFn'>,
) {
  const { canWrite, mode } = useConnection();

  return useMutation({
    ...options,
    mutationFn: async (variables: TVariables) => {
      if (!canWrite) {
        if (mode === 'mock') {
          toast.warning('Demo mode: Changes will not be saved permanently');
        } else if (mode === 'cached') {
          toast.error('Offline (read-only): Changes are disabled');
          throw new Error('Offline - cannot perform mutation');
        } else {
          toast.error('Cannot save changes while offline');
          throw new Error('Offline - cannot perform mutation');
        }
      }
      return mutationFn(variables);
    },
  });
}
```

**Usage Example**:
```typescript
// Instead of useMutation
const { mutate: saveMeeting } = useGuardedMutation(
  (meeting: Meeting) => getAPI().updateMeeting(meeting),
  {
    onSuccess: () => toast.success('Meeting saved'),
  }
);
```

### Task 6: Automatic Reconnection

**File**: `client/src/api/reconnection.ts`

```typescript
import { getAPI } from './index';
import {
  setConnectionMode,
  incrementReconnectAttempts,
  getConnectionState,
} from './connection-state';

const INITIAL_DELAY = 1000;
const MAX_DELAY = 30000;
const BACKOFF_FACTOR = 2;

let reconnectTimer: ReturnType<typeof setTimeout> | null = null;

export function startReconnection(): void {
  if (reconnectTimer) return;

  const attempt = async () => {
    const state = getConnectionState();
    if (state.mode === 'connected') {
      stopReconnection();
      return;
    }

    setConnectionMode('reconnecting');
    incrementReconnectAttempts();

    try {
      await getAPI().connect();
      setConnectionMode('connected');
      stopReconnection();
    } catch (error) {
      const delay = Math.min(
        INITIAL_DELAY * Math.pow(BACKOFF_FACTOR, state.reconnectAttempts),
        MAX_DELAY
      );
      setConnectionMode('disconnected', error instanceof Error ? error.message : 'Connection failed');
      reconnectTimer = setTimeout(attempt, delay);
    }
  };

  attempt();
}

export function stopReconnection(): void {
  if (reconnectTimer) {
    clearTimeout(reconnectTimer);
    reconnectTimer = null;
  }
}

// Listen for online/offline events
if (typeof window !== 'undefined') {
  window.addEventListener('online', () => {
    if (getConnectionState().mode !== 'connected') {
      startReconnection();
    }
  });

  window.addEventListener('offline', () => {
    setConnectionMode('disconnected', 'Network offline');
  });
}
```

---

## Behavioral Test Specifications

### Connection State Tests

**File**: `client/src/api/connection-state.test.ts`

```typescript
import { describe, it, expect, beforeEach } from 'vitest';
import {
  getConnectionState,
  setConnectionMode,
  subscribe,
  incrementReconnectAttempts,
} from './connection-state';

describe('connection-state', () => {
  beforeEach(() => {
    // Reset to initial state
    setConnectionMode('disconnected');
  });

  describe('mode transitions', () => {
    it('tracks connected state with timestamp', () => {
      setConnectionMode('connected');

      const state = getConnectionState();
      expect(state.mode).toBe('connected');
      expect(state.lastConnectedAt).toBeInstanceOf(Date);
      expect(state.reconnectAttempts).toBe(0);
    });

    it('tracks disconnected state with error', () => {
      setConnectionMode('disconnected', 'Server unreachable');

      const state = getConnectionState();
      expect(state.mode).toBe('disconnected');
      expect(state.error).toBe('Server unreachable');
    });

    it('tracks cached mode', () => {
      setConnectionMode('cached');

      const state = getConnectionState();
      expect(state.mode).toBe('cached');
    });

    it('tracks mock mode (dev-only)', () => {
      setConnectionMode('mock');

      const state = getConnectionState();
      expect(state.mode).toBe('mock');
    });
  });

  describe('reconnect tracking', () => {
    it('increments attempt counter', () => {
      incrementReconnectAttempts();
      incrementReconnectAttempts();

      expect(getConnectionState().reconnectAttempts).toBe(2);
    });

    it('resets counter on successful connection', () => {
      incrementReconnectAttempts();
      incrementReconnectAttempts();
      setConnectionMode('connected');

      expect(getConnectionState().reconnectAttempts).toBe(0);
    });
  });

  describe('subscription', () => {
    it('notifies listeners on state change', () => {
      const states: string[] = [];
      const unsubscribe = subscribe((state) => states.push(state.mode));

      setConnectionMode('connected');
      setConnectionMode('disconnected');

      expect(states).toEqual(['disconnected', 'connected', 'disconnected']);

      unsubscribe();
    });
  });
});
```

### Offline Banner Tests

**File**: `client/src/components/offline-banner.test.tsx`

```typescript
import { render, screen } from '@testing-library/react';
import { describe, it, expect, vi } from 'vitest';
import { OfflineBanner } from './offline-banner';
import { ConnectionProvider } from '../contexts/connection-context';
import * as connectionState from '../api/connection-state';

describe('OfflineBanner', () => {
  describe('when connected', () => {
    it('renders nothing', () => {
      vi.spyOn(connectionState, 'getConnectionState').mockReturnValue({
        mode: 'connected',
        lastConnectedAt: new Date(),
        disconnectedAt: null,
        reconnectAttempts: 0,
        error: null,
      });

      const { container } = render(
        <ConnectionProvider>
          <OfflineBanner />
        </ConnectionProvider>
      );

      expect(container.firstChild).toBeNull();
    });
  });

  describe('when in cached mode', () => {
    it('shows cached read-only warning', () => {
      vi.spyOn(connectionState, 'getConnectionState').mockReturnValue({
        mode: 'cached',
        lastConnectedAt: null,
        disconnectedAt: null,
        reconnectAttempts: 0,
        error: null,
      });

      render(
        <ConnectionProvider>
          <OfflineBanner />
        </ConnectionProvider>
      );

      expect(screen.getByText(/read-only/i)).toBeInTheDocument();
      expect(screen.getByRole('alert')).toHaveClass('bg-yellow-500');
    });
  });

  describe('when disconnected', () => {
    it('shows connection lost message with reconnect button', () => {
      vi.spyOn(connectionState, 'getConnectionState').mockReturnValue({
        mode: 'disconnected',
        lastConnectedAt: new Date(Date.now() - 60000),
        disconnectedAt: new Date(),
        reconnectAttempts: 0,
        error: 'Server unreachable',
      });

      render(
        <ConnectionProvider>
          <OfflineBanner />
        </ConnectionProvider>
      );

      expect(screen.getByText(/Server unreachable/i)).toBeInTheDocument();
      expect(screen.getByRole('button', { name: /reconnect/i })).toBeInTheDocument();
    });
  });

  describe('when reconnecting', () => {
    it('shows reconnecting status with attempt count', () => {
      vi.spyOn(connectionState, 'getConnectionState').mockReturnValue({
        mode: 'reconnecting',
        lastConnectedAt: new Date(Date.now() - 60000),
        disconnectedAt: new Date(Date.now() - 30000),
        reconnectAttempts: 3,
        error: null,
      });

      render(
        <ConnectionProvider>
          <OfflineBanner />
        </ConnectionProvider>
      );

      expect(screen.getByText(/Reconnecting.*attempt 3/i)).toBeInTheDocument();
      expect(screen.queryByRole('button', { name: /reconnect/i })).not.toBeInTheDocument();
    });
  });
});
```

### Guarded Mutation Tests

**File**: `client/src/hooks/use-guarded-mutation.test.ts`

```typescript
import { renderHook, waitFor } from '@testing-library/react';
import { describe, it, expect, vi } from 'vitest';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { useGuardedMutation } from './use-guarded-mutation';
import * as connectionState from '../api/connection-state';
import { toast } from 'sonner';

vi.mock('sonner');

describe('useGuardedMutation', () => {
  const createWrapper = () => {
    const queryClient = new QueryClient();
    return ({ children }: { children: React.ReactNode }) => (
      <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
    );
  };

  describe('when connected', () => {
    it('executes mutation normally', async () => {
      vi.spyOn(connectionState, 'getConnectionState').mockReturnValue({
        mode: 'connected',
      } as any);

      const mutationFn = vi.fn().mockResolvedValue('success');

      const { result } = renderHook(
        () => useGuardedMutation(mutationFn),
        { wrapper: createWrapper() }
      );

      result.current.mutate('data');

      await waitFor(() => {
        expect(mutationFn).toHaveBeenCalledWith('data');
      });
    });
  });

  describe('when in cached mode', () => {
    it('blocks mutation with read-only warning', async () => {
      vi.spyOn(connectionState, 'getConnectionState').mockReturnValue({
        mode: 'cached',
      } as any);

      const mutationFn = vi.fn().mockResolvedValue('success');

      const { result } = renderHook(
        () => useGuardedMutation(mutationFn),
        { wrapper: createWrapper() }
      );

      result.current.mutate('data');

      await waitFor(() => {
        expect(toast.error).toHaveBeenCalledWith(
          expect.stringContaining('read-only')
        );
        expect(mutationFn).not.toHaveBeenCalled();
      });
    });
  });

  describe('when in mock mode (dev-only)', () => {
    it('shows warning but allows mutation', async () => {
      vi.spyOn(connectionState, 'getConnectionState').mockReturnValue({
        mode: 'mock',
      } as any);

      const mutationFn = vi.fn().mockResolvedValue('success');

      const { result } = renderHook(
        () => useGuardedMutation(mutationFn),
        { wrapper: createWrapper() }
      );

      result.current.mutate('data');

      await waitFor(() => {
        expect(toast.warning).toHaveBeenCalledWith(
          expect.stringContaining('Demo mode')
        );
        expect(mutationFn).toHaveBeenCalled();
      });
    });
  });

  describe('when disconnected', () => {
    it('blocks mutation with error toast', async () => {
      vi.spyOn(connectionState, 'getConnectionState').mockReturnValue({
        mode: 'disconnected',
      } as any);

      const mutationFn = vi.fn().mockResolvedValue('success');

      const { result } = renderHook(
        () => useGuardedMutation(mutationFn),
        { wrapper: createWrapper() }
      );

      result.current.mutate('data');

      await waitFor(() => {
        expect(toast.error).toHaveBeenCalledWith(
          expect.stringContaining('offline')
        );
        expect(mutationFn).not.toHaveBeenCalled();
      });
    });
  });
});
```

---

## Acceptance Criteria

### Functional

- [x] Cached read-only mode shows persistent yellow banner
- [x] Mock mode (dev-only) shows yellow banner
- [x] Disconnected mode shows red banner with reconnect button
- [x] Reconnection attempts with exponential backoff
- [x] Write operations blocked when disconnected
- [x] Write operations blocked in cached read-only mode
- [x] Write operations warn (but allow) in mock mode
- [x] Automatic reconnection on network restore

### Technical

- [x] Connection state is globally accessible
- [x] State changes trigger UI updates
- [x] Banner does not overlap content (adjust body padding)
- [x] Network events (online/offline) trigger state changes

### Quality Gates

- [x] `npm run test` passes for all new components
- [x] No silent fallback to cached/mock without user awareness
- [x] Offline behavior documented
- [x] Meets `docs/sprints/QUALITY_STANDARDS.md` (lint + test quality thresholds)

---

## Rollback Plan

1. **Banner issues**: Hide banner, restore silent fallback
2. **Guard issues**: Disable guards, allow all mutations
3. **Reconnection issues**: Disable auto-reconnect, manual only

---

## Post-Sprint

- Add offline queue for deferred mutations
- Show pending changes count in banner
- Add settings for reconnection behavior
- Support offline-first for read operations

---

## Phase 5 Interaction Note (Added 2025-12-29)

> Sprint 22 (Rules v1) uses **backend-evaluated rules**. This means:
>
> - **Trigger detection** (audio, foreground, calendar) remains **client-side** — works offline
> - **Rule application** (templates, outputs, destinations) requires **backend connection**
>
> The offline banner from this sprint will correctly indicate when rule-based automation is unavailable.
> Triggers will still fire and prompt the user, but auto-apply rules won't execute until reconnected.
