diff --git a/apps/web/index.html b/apps/web/index.html index 25dd863e..4b6a302d 100644 --- a/apps/web/index.html +++ b/apps/web/index.html @@ -4,6 +4,9 @@ + + + ObjectOS Console diff --git a/apps/web/public/manifest.json b/apps/web/public/manifest.json new file mode 100644 index 00000000..bb9941ab --- /dev/null +++ b/apps/web/public/manifest.json @@ -0,0 +1,17 @@ +{ + "name": "ObjectOS Console", + "short_name": "ObjectOS", + "description": "Business Operating System — Admin Console", + "start_url": "/console/", + "scope": "/console/", + "display": "standalone", + "background_color": "#ffffff", + "theme_color": "#1a1a2e", + "icons": [ + { + "src": "favicon.svg", + "sizes": "any", + "type": "image/svg+xml" + } + ] +} diff --git a/apps/web/public/sw.js b/apps/web/public/sw.js new file mode 100644 index 00000000..dc05fa9c --- /dev/null +++ b/apps/web/public/sw.js @@ -0,0 +1,92 @@ +/** + * ObjectOS Service Worker — Offline-first PWA support. + * + * Strategy: + * - Static assets → Cache-first (install-time precache) + * - API calls → Network-first with cache fallback + * - Navigation → Network-first, fall back to cached shell + */ + +const CACHE_NAME = 'objectos-v1'; +const STATIC_ASSETS = ['/console/', '/console/index.html']; + +// ── Install: pre-cache the app shell ──────────────────────────── +self.addEventListener('install', (event) => { + event.waitUntil( + caches.open(CACHE_NAME).then((cache) => cache.addAll(STATIC_ASSETS)), + ); + self.skipWaiting(); +}); + +// ── Activate: clean old caches ────────────────────────────────── +self.addEventListener('activate', (event) => { + event.waitUntil( + caches.keys().then((keys) => + Promise.all(keys.filter((k) => k !== CACHE_NAME).map((k) => caches.delete(k))), + ), + ); + self.clients.claim(); +}); + +// ── Fetch: strategy router ────────────────────────────────────── +self.addEventListener('fetch', (event) => { + const { request } = event; + const url = new URL(request.url); + + // Skip non-GET requests + if (request.method !== 'GET') return; + + // API requests → network-first + if (url.pathname.startsWith('/api/')) { + event.respondWith(networkFirst(request)); + return; + } + + // Static assets & navigation → cache-first + event.respondWith(cacheFirst(request)); +}); + +async function cacheFirst(request) { + const cached = await caches.match(request); + if (cached) return cached; + try { + const response = await fetch(request); + if (response.ok) { + const cache = await caches.open(CACHE_NAME); + cache.put(request, response.clone()); + } + return response; + } catch { + // For navigation requests, fall back to cached shell + if (request.mode === 'navigate') { + const shell = await caches.match('/console/index.html'); + if (shell) return shell; + } + return new Response('Offline', { status: 503, statusText: 'Service Unavailable' }); + } +} + +async function networkFirst(request) { + try { + const response = await fetch(request); + if (response.ok) { + const cache = await caches.open(CACHE_NAME); + cache.put(request, response.clone()); + } + return response; + } catch { + const cached = await caches.match(request); + if (cached) return cached; + return new Response(JSON.stringify({ error: 'offline' }), { + status: 503, + headers: { 'Content-Type': 'application/json' }, + }); + } +} + +// ── Message handler for sync operations ───────────────────────── +self.addEventListener('message', (event) => { + if (event.data?.type === 'SKIP_WAITING') { + self.skipWaiting(); + } +}); diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index fb5133c8..7c73ef8b 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -4,6 +4,7 @@ import { ProtectedRoute } from './components/auth/ProtectedRoute'; import { RequireOrgAdmin } from './components/auth/RequireOrgAdmin'; import { SettingsLayout } from './components/layouts/SettingsLayout'; import { AppLayout } from './components/layouts/AppLayout'; +import { SkipLink } from './components/ui/skip-link'; // ── Public pages ────────────────────────────────────────────── const HomePage = lazy(() => import('./pages/home')); @@ -44,55 +45,60 @@ export function App() { ); return ( - - - {/* Public routes */} - } /> - } /> - } /> - } /> - } /> - } /> + <> + +
+ + + {/* Public routes */} + } /> + } /> + } /> + } /> + } /> + } /> - {/* Protected routes */} - }> + {/* Protected routes */} + }> - {/* ── Create Org (accessible to any authenticated user) ── */} - } /> + {/* ── Create Org (accessible to any authenticated user) ── */} + } /> - {/* ── Admin Console (/settings/*) — owner / admin only ── */} - }> - }> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - - + {/* ── Admin Console (/settings/*) — owner / admin only ── */} + }> + }> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + - {/* ── Business Apps (/apps/:appId/*) ── */} - }> - } /> - } /> - } /> - + {/* ── Business Apps (/apps/:appId/*) ── */} + }> + } /> + } /> + } /> + - + - {/* Fallback */} - } /> - - + {/* Fallback */} + } /> + + +
+ ); } diff --git a/apps/web/src/__tests__/components/phase6-ui.test.ts b/apps/web/src/__tests__/components/phase6-ui.test.ts new file mode 100644 index 00000000..06072a49 --- /dev/null +++ b/apps/web/src/__tests__/components/phase6-ui.test.ts @@ -0,0 +1,16 @@ +/** + * Tests for UI components (Phase 6). + */ +import { describe, it, expect } from 'vitest'; +import { ThemeToggle } from '@/components/ui/theme-toggle'; +import { SkipLink } from '@/components/ui/skip-link'; + +describe('Phase 6 UI component exports', () => { + it('exports ThemeToggle', () => { + expect(ThemeToggle).toBeTypeOf('function'); + }); + + it('exports SkipLink', () => { + expect(SkipLink).toBeTypeOf('function'); + }); +}); diff --git a/apps/web/src/__tests__/components/sync.test.ts b/apps/web/src/__tests__/components/sync.test.ts new file mode 100644 index 00000000..cffdf301 --- /dev/null +++ b/apps/web/src/__tests__/components/sync.test.ts @@ -0,0 +1,15 @@ +/** + * Tests for sync components. + */ +import { describe, it, expect } from 'vitest'; +import { OfflineIndicator, ConflictResolutionDialog } from '@/components/sync'; + +describe('sync component exports', () => { + it('exports OfflineIndicator', () => { + expect(OfflineIndicator).toBeTypeOf('function'); + }); + + it('exports ConflictResolutionDialog', () => { + expect(ConflictResolutionDialog).toBeTypeOf('function'); + }); +}); diff --git a/apps/web/src/__tests__/hooks/use-i18n.test.ts b/apps/web/src/__tests__/hooks/use-i18n.test.ts new file mode 100644 index 00000000..8965c165 --- /dev/null +++ b/apps/web/src/__tests__/hooks/use-i18n.test.ts @@ -0,0 +1,15 @@ +/** + * Tests for use-i18n hook. + */ +import { describe, it, expect } from 'vitest'; +import { I18nProvider, useI18n } from '@/hooks/use-i18n'; + +describe('use-i18n exports', () => { + it('exports I18nProvider as a function', () => { + expect(I18nProvider).toBeTypeOf('function'); + }); + + it('exports useI18n as a function', () => { + expect(useI18n).toBeTypeOf('function'); + }); +}); diff --git a/apps/web/src/__tests__/hooks/use-keyboard-shortcuts.test.ts b/apps/web/src/__tests__/hooks/use-keyboard-shortcuts.test.ts new file mode 100644 index 00000000..c28922d4 --- /dev/null +++ b/apps/web/src/__tests__/hooks/use-keyboard-shortcuts.test.ts @@ -0,0 +1,22 @@ +/** + * Tests for use-keyboard-shortcuts hook. + */ +import { describe, it, expect } from 'vitest'; +import { useKeyboardShortcuts, SHORTCUT_PRESETS } from '@/hooks/use-keyboard-shortcuts'; + +describe('useKeyboardShortcuts', () => { + it('exports useKeyboardShortcuts as a function', () => { + expect(useKeyboardShortcuts).toBeTypeOf('function'); + }); + + it('exports SHORTCUT_PRESETS with expected keys', () => { + expect(SHORTCUT_PRESETS).toBeDefined(); + expect(SHORTCUT_PRESETS.search).toEqual({ key: 'k', ctrl: true, description: 'Open search' }); + expect(SHORTCUT_PRESETS.save).toEqual({ key: 's', ctrl: true, description: 'Save' }); + expect(SHORTCUT_PRESETS.escape).toEqual({ key: 'Escape', description: 'Close / Cancel' }); + expect(SHORTCUT_PRESETS.newRecord).toEqual({ key: 'n', ctrl: true, shift: true, description: 'New record' }); + expect(SHORTCUT_PRESETS.goHome).toEqual({ key: 'h', ctrl: true, shift: true, description: 'Go home' }); + expect(SHORTCUT_PRESETS.goSettings).toEqual({ key: ',', ctrl: true, description: 'Open settings' }); + expect(SHORTCUT_PRESETS.help).toEqual({ key: '?', shift: true, description: 'Show keyboard shortcuts' }); + }); +}); diff --git a/apps/web/src/__tests__/hooks/use-offline.test.ts b/apps/web/src/__tests__/hooks/use-offline.test.ts new file mode 100644 index 00000000..ad90b0cd --- /dev/null +++ b/apps/web/src/__tests__/hooks/use-offline.test.ts @@ -0,0 +1,11 @@ +/** + * Tests for use-offline hook. + */ +import { describe, it, expect } from 'vitest'; +import { useOfflineStatus } from '@/hooks/use-offline'; + +describe('useOfflineStatus', () => { + it('exports useOfflineStatus as a function', () => { + expect(useOfflineStatus).toBeTypeOf('function'); + }); +}); diff --git a/apps/web/src/__tests__/hooks/use-sync.test.ts b/apps/web/src/__tests__/hooks/use-sync.test.ts new file mode 100644 index 00000000..93003e8f --- /dev/null +++ b/apps/web/src/__tests__/hooks/use-sync.test.ts @@ -0,0 +1,11 @@ +/** + * Tests for use-sync hook. + */ +import { describe, it, expect } from 'vitest'; +import { useSyncEngine } from '@/hooks/use-sync'; + +describe('useSyncEngine', () => { + it('exports useSyncEngine as a function', () => { + expect(useSyncEngine).toBeTypeOf('function'); + }); +}); diff --git a/apps/web/src/__tests__/hooks/use-theme.test.ts b/apps/web/src/__tests__/hooks/use-theme.test.ts new file mode 100644 index 00000000..3460635a --- /dev/null +++ b/apps/web/src/__tests__/hooks/use-theme.test.ts @@ -0,0 +1,16 @@ +/** + * Tests for use-theme hook. + */ +import { describe, it, expect, beforeEach } from 'vitest'; +import { useTheme } from '@/hooks/use-theme'; + +describe('useTheme', () => { + beforeEach(() => { + localStorage.clear(); + document.documentElement.classList.remove('dark'); + }); + + it('exports useTheme as a function', () => { + expect(useTheme).toBeTypeOf('function'); + }); +}); diff --git a/apps/web/src/__tests__/lib/i18n.test.ts b/apps/web/src/__tests__/lib/i18n.test.ts new file mode 100644 index 00000000..76e87ab6 --- /dev/null +++ b/apps/web/src/__tests__/lib/i18n.test.ts @@ -0,0 +1,119 @@ +/** + * Tests for i18n library functions. + */ +import { describe, it, expect } from 'vitest'; +import { + resolveKey, + interpolate, + translate, + createI18nState, + loadTranslations, +} from '@/lib/i18n'; + +describe('resolveKey', () => { + const map = { + common: { save: 'Save', nested: { deep: 'Deep Value' } }, + top: 'Top Level', + }; + + it('resolves a top-level key', () => { + expect(resolveKey(map, 'top')).toBe('Top Level'); + }); + + it('resolves a nested key', () => { + expect(resolveKey(map, 'common.save')).toBe('Save'); + }); + + it('resolves a deeply nested key', () => { + expect(resolveKey(map, 'common.nested.deep')).toBe('Deep Value'); + }); + + it('returns undefined for missing key', () => { + expect(resolveKey(map, 'missing.key')).toBeUndefined(); + }); + + it('returns undefined for a non-leaf node', () => { + expect(resolveKey(map, 'common')).toBeUndefined(); + }); +}); + +describe('interpolate', () => { + it('replaces {{variable}} placeholders', () => { + expect(interpolate('Hello {{name}}!', { name: 'World' })).toBe('Hello World!'); + }); + + it('handles multiple placeholders', () => { + expect(interpolate('{{a}} + {{b}} = {{c}}', { a: '1', b: '2', c: '3' })).toBe('1 + 2 = 3'); + }); + + it('leaves unknown placeholders intact', () => { + expect(interpolate('Hello {{name}}', {})).toBe('Hello {{name}}'); + }); + + it('handles numeric values', () => { + expect(interpolate('Count: {{count}}', { count: 42 })).toBe('Count: 42'); + }); +}); + +describe('translate', () => { + const translations = { + en: { common: { save: 'Save' }, greeting: 'Hello {{name}}' }, + es: { common: { save: 'Guardar' } }, + }; + + it('translates a key in the current locale', () => { + expect(translate(translations, 'en', 'en', 'common.save')).toBe('Save'); + }); + + it('translates a key in a non-default locale', () => { + expect(translate(translations, 'es', 'en', 'common.save')).toBe('Guardar'); + }); + + it('falls back to fallback locale', () => { + expect(translate(translations, 'es', 'en', 'greeting', { name: 'World' })).toBe('Hello World'); + }); + + it('returns the raw key when not found', () => { + expect(translate(translations, 'en', 'en', 'missing.key')).toBe('missing.key'); + }); + + it('interpolates values', () => { + expect(translate(translations, 'en', 'en', 'greeting', { name: 'Alice' })).toBe('Hello Alice'); + }); +}); + +describe('createI18nState', () => { + it('creates state with default English locale', () => { + const state = createI18nState(); + expect(state.locale).toBe('en'); + expect(state.fallbackLocale).toBe('en'); + expect(state.translations.en).toBeDefined(); + }); + + it('creates state with custom locale', () => { + const state = createI18nState('fr'); + expect(state.locale).toBe('fr'); + }); + + it('includes default English translations', () => { + const state = createI18nState(); + expect(resolveKey(state.translations.en, 'common.save')).toBe('Save'); + expect(resolveKey(state.translations.en, 'auth.signIn')).toBe('Sign In'); + expect(resolveKey(state.translations.en, 'sync.offline')).toBe('Offline'); + expect(resolveKey(state.translations.en, 'theme.dark')).toBe('Dark'); + }); +}); + +describe('loadTranslations', () => { + it('adds translations for a new locale', () => { + const state = createI18nState(); + const updated = loadTranslations(state, 'es', { common: { save: 'Guardar' } }); + expect(resolveKey(updated.translations.es, 'common.save')).toBe('Guardar'); + }); + + it('preserves existing translations', () => { + const state = createI18nState(); + const updated = loadTranslations(state, 'en', { custom: { key: 'value' } }); + expect(resolveKey(updated.translations.en, 'common.save')).toBe('Save'); + }); +}); diff --git a/apps/web/src/__tests__/lib/service-worker.test.ts b/apps/web/src/__tests__/lib/service-worker.test.ts new file mode 100644 index 00000000..803d12c6 --- /dev/null +++ b/apps/web/src/__tests__/lib/service-worker.test.ts @@ -0,0 +1,21 @@ +/** + * Tests for service-worker registration utility. + */ +import { describe, it, expect } from 'vitest'; +import { registerServiceWorker, unregisterServiceWorker } from '@/lib/service-worker'; + +describe('service-worker registration', () => { + it('exports registerServiceWorker as a function', () => { + expect(registerServiceWorker).toBeTypeOf('function'); + }); + + it('exports unregisterServiceWorker as a function', () => { + expect(unregisterServiceWorker).toBeTypeOf('function'); + }); + + it('registerServiceWorker does not throw in jsdom (no real SW support)', () => { + // jsdom doesn't support full ServiceWorker API — this validates + // that the guard check prevents errors. + expect(() => registerServiceWorker({ onError: () => {} })).not.toThrow(); + }); +}); diff --git a/apps/web/src/__tests__/lib/sync-engine.test.ts b/apps/web/src/__tests__/lib/sync-engine.test.ts new file mode 100644 index 00000000..b253f2dd --- /dev/null +++ b/apps/web/src/__tests__/lib/sync-engine.test.ts @@ -0,0 +1,147 @@ +/** + * Tests for sync-engine. + */ +import { describe, it, expect, beforeEach } from 'vitest'; +import { SyncEngine } from '@/lib/sync-engine'; + +describe('SyncEngine', () => { + let engine: SyncEngine; + + beforeEach(() => { + engine = new SyncEngine(); + }); + + it('starts with idle state and no pending mutations', () => { + const state = engine.getState(); + expect(state.pendingCount).toBe(0); + expect(state.conflicts).toEqual([]); + expect(state.lastSyncedAt).toBeNull(); + expect(state.cursor).toBeNull(); + }); + + it('pushMutation adds to pending count', () => { + engine.pushMutation('accounts', 'acc-1', 'update', { name: 'Acme' }); + expect(engine.getState().pendingCount).toBe(1); + + engine.pushMutation('accounts', 'acc-2', 'create', { name: 'Beta' }); + expect(engine.getState().pendingCount).toBe(2); + }); + + it('pushMutation returns a MutationEntry with correct properties', () => { + const entry = engine.pushMutation('contacts', 'ct-1', 'create', { email: 'a@b.com' }); + expect(entry.objectName).toBe('contacts'); + expect(entry.recordId).toBe('ct-1'); + expect(entry.type).toBe('create'); + expect(entry.data).toEqual({ email: 'a@b.com' }); + expect(entry.synced).toBe(false); + expect(entry.id).toMatch(/^mut_/); + }); + + it('getPendingMutations returns only unsynced entries', () => { + engine.pushMutation('deals', 'd-1', 'update', { stage: 'won' }); + engine.pushMutation('deals', 'd-2', 'delete', {}); + expect(engine.getPendingMutations()).toHaveLength(2); + }); + + it('pushToServer marks synced entries and updates lastSyncedAt', async () => { + const m1 = engine.pushMutation('accounts', 'a-1', 'create', { name: 'A' }); + const m2 = engine.pushMutation('accounts', 'a-2', 'create', { name: 'B' }); + + await engine.pushToServer(async (mutations) => { + expect(mutations).toHaveLength(2); + return { synced: [m1.id, m2.id], conflicts: [] }; + }); + + expect(engine.getState().pendingCount).toBe(0); + expect(engine.getState().lastSyncedAt).toBeGreaterThan(0); + }); + + it('pushToServer adds conflicts when returned', async () => { + engine.pushMutation('accounts', 'a-1', 'update', { name: 'Local' }); + + await engine.pushToServer(async () => ({ + synced: [], + conflicts: [ + { + id: 'conflict-1', + objectName: 'accounts', + recordId: 'a-1', + localData: { name: 'Local' }, + serverData: { name: 'Server' }, + localTimestamp: Date.now(), + serverTimestamp: Date.now(), + }, + ], + })); + + expect(engine.getConflicts()).toHaveLength(1); + expect(engine.getConflicts()[0].id).toBe('conflict-1'); + }); + + it('resolveConflict with local keeps localData', () => { + engine.pushMutation('accounts', 'a-1', 'update', { name: 'Local' }); + + // Manually inject a conflict + engine['conflicts'].push({ + id: 'c-1', + objectName: 'accounts', + recordId: 'a-1', + localData: { name: 'Local' }, + serverData: { name: 'Server' }, + localTimestamp: Date.now(), + serverTimestamp: Date.now(), + }); + + engine.resolveConflict('c-1', 'local'); + expect(engine.getConflicts()).toHaveLength(0); + }); + + it('resolveConflict with server keeps serverData', () => { + engine['conflicts'].push({ + id: 'c-2', + objectName: 'contacts', + recordId: 'ct-1', + localData: { email: 'local@test.com' }, + serverData: { email: 'server@test.com' }, + localTimestamp: Date.now(), + serverTimestamp: Date.now(), + }); + + engine.resolveConflict('c-2', 'server'); + expect(engine.getConflicts()).toHaveLength(0); + }); + + it('pullFromServer updates cursor and lastSyncedAt', async () => { + const deltas = await engine.pullFromServer(async (cursor) => { + expect(cursor).toBeNull(); + return { + deltas: [{ objectName: 'accounts', recordId: 'a-1', type: 'update' as const, data: { name: 'Updated' }, serverTimestamp: Date.now() }], + cursor: 'cursor-1', + }; + }); + + expect(deltas).toHaveLength(1); + expect(engine.getState().cursor).toBe('cursor-1'); + expect(engine.getState().lastSyncedAt).toBeGreaterThan(0); + }); + + it('subscribe notifies on state changes', () => { + const states: number[] = []; + engine.subscribe((state) => states.push(state.pendingCount)); + + engine.pushMutation('x', 'y', 'create', {}); + engine.pushMutation('x', 'z', 'create', {}); + + expect(states).toEqual([1, 2]); + }); + + it('clearSyncedMutations removes synced entries', async () => { + const m = engine.pushMutation('a', '1', 'create', {}); + engine.pushMutation('a', '2', 'create', {}); + + await engine.pushToServer(async () => ({ synced: [m.id], conflicts: [] })); + engine.clearSyncedMutations(); + + expect(engine.getPendingMutations()).toHaveLength(1); + }); +}); diff --git a/apps/web/src/components/sync/ConflictResolutionDialog.tsx b/apps/web/src/components/sync/ConflictResolutionDialog.tsx new file mode 100644 index 00000000..e93f4c49 --- /dev/null +++ b/apps/web/src/components/sync/ConflictResolutionDialog.tsx @@ -0,0 +1,138 @@ +/** + * ConflictResolutionDialog — manual conflict resolution UI. + * + * Displays a side-by-side comparison of local vs server data + * and lets the user choose which version to keep (or merge manually). + */ + +import { useState } from 'react'; +import type { SyncConflict } from '@/lib/sync-engine'; + +interface ConflictResolutionDialogProps { + conflict: SyncConflict; + onResolve: ( + conflictId: string, + resolution: 'local' | 'server' | 'manual', + manualData?: Record, + ) => void; + onCancel: () => void; +} + +export function ConflictResolutionDialog({ + conflict, + onResolve, + onCancel, +}: ConflictResolutionDialogProps) { + const [selectedResolution, setSelectedResolution] = useState<'local' | 'server'>('server'); + + const allKeys = Array.from( + new Set([...Object.keys(conflict.localData), ...Object.keys(conflict.serverData)]), + ).filter((k) => k !== 'id'); + + const diffKeys = allKeys.filter( + (k) => JSON.stringify(conflict.localData[k]) !== JSON.stringify(conflict.serverData[k]), + ); + + return ( +
+
+

Sync Conflict

+

+ The record {conflict.recordId} on{' '} + {conflict.objectName} was modified both locally and on the server. +

+ + {/* Diff table */} +
+ + + + + + + + + + {diffKeys.map((key) => ( + + + + + + ))} + {diffKeys.length === 0 && ( + + + + )} + +
Field + Local + + Server +
{key}{formatValue(conflict.localData[key])}{formatValue(conflict.serverData[key])}
+ No field differences detected +
+
+ + {/* Resolution options */} +
+ Choose resolution: +
+ + +
+
+ + {/* Actions */} +
+ + +
+
+
+ ); +} + +function formatValue(value: unknown): string { + if (value === null || value === undefined) return '—'; + if (typeof value === 'object') return JSON.stringify(value); + return String(value); +} diff --git a/apps/web/src/components/sync/OfflineIndicator.tsx b/apps/web/src/components/sync/OfflineIndicator.tsx new file mode 100644 index 00000000..d331bb58 --- /dev/null +++ b/apps/web/src/components/sync/OfflineIndicator.tsx @@ -0,0 +1,51 @@ +/** + * OfflineIndicator — visual indicator for connectivity status. + * + * Shows a subtle banner or badge when the user is offline, + * along with pending sync count. + */ + +import { Wifi, WifiOff, RefreshCw } from 'lucide-react'; +import { useOfflineStatus } from '@/hooks/use-offline'; +import { useSyncEngine } from '@/hooks/use-sync'; + +export function OfflineIndicator() { + const { isOnline } = useOfflineStatus(); + const { pendingCount, status } = useSyncEngine(); + + if (isOnline && pendingCount === 0 && status !== 'syncing') { + return null; + } + + return ( +
+ {!isOnline ? ( + <> +
+ ); +} diff --git a/apps/web/src/components/sync/index.ts b/apps/web/src/components/sync/index.ts new file mode 100644 index 00000000..5370879c --- /dev/null +++ b/apps/web/src/components/sync/index.ts @@ -0,0 +1,8 @@ +/** + * Sync component re-exports. + * + * Phase 5 — offline & sync UI components. + */ + +export { OfflineIndicator } from './OfflineIndicator'; +export { ConflictResolutionDialog } from './ConflictResolutionDialog'; diff --git a/apps/web/src/components/ui/skip-link.tsx b/apps/web/src/components/ui/skip-link.tsx new file mode 100644 index 00000000..2d415f22 --- /dev/null +++ b/apps/web/src/components/ui/skip-link.tsx @@ -0,0 +1,26 @@ +/** + * SkipLink — accessibility skip-to-content link. + * + * Hidden by default, becomes visible when focused via keyboard. + * Allows screen reader and keyboard users to skip navigation. + * WCAG 2.1 AA: 2.4.1 Bypass Blocks. + */ + +interface SkipLinkProps { + /** Target element id (without #). Defaults to "main-content". */ + targetId?: string; + /** Link text. Defaults to "Skip to main content". */ + label?: string; +} + +export function SkipLink({ targetId = 'main-content', label = 'Skip to main content' }: SkipLinkProps) { + return ( + + {label} + + ); +} diff --git a/apps/web/src/components/ui/theme-toggle.tsx b/apps/web/src/components/ui/theme-toggle.tsx new file mode 100644 index 00000000..738499c3 --- /dev/null +++ b/apps/web/src/components/ui/theme-toggle.tsx @@ -0,0 +1,46 @@ +/** + * ThemeToggle — light / dark / system mode switcher. + * + * Renders a button that cycles through theme modes. + */ + +import { Sun, Moon, Monitor } from 'lucide-react'; +import { useTheme, type Theme } from '@/hooks/use-theme'; + +const THEME_ICONS: Record = { + light: Sun, + dark: Moon, + system: Monitor, +}; + +const THEME_LABELS: Record = { + light: 'Light', + dark: 'Dark', + system: 'System', +}; + +const THEME_CYCLE: Theme[] = ['light', 'dark', 'system']; + +export function ThemeToggle() { + const { theme, setTheme } = useTheme(); + + const Icon = THEME_ICONS[theme]; + + const nextTheme = () => { + const idx = THEME_CYCLE.indexOf(theme); + setTheme(THEME_CYCLE[(idx + 1) % THEME_CYCLE.length]); + }; + + return ( + + ); +} diff --git a/apps/web/src/hooks/use-i18n.tsx b/apps/web/src/hooks/use-i18n.tsx new file mode 100644 index 00000000..41c4e554 --- /dev/null +++ b/apps/web/src/hooks/use-i18n.tsx @@ -0,0 +1,64 @@ +/** + * useI18n — translation hook. + * + * Provides a `t()` function for translating keys with optional + * interpolation. Uses React Context for locale state. + */ + +import { createContext, useContext, useState, useCallback, useMemo } from 'react'; +import type { ReactNode } from 'react'; +import { + createI18nState, + translate, + loadTranslations, + type I18nState, + type Locale, + type TranslationMap, +} from '@/lib/i18n'; + +interface I18nContextValue { + locale: Locale; + setLocale: (locale: Locale) => void; + t: (key: string, values?: Record) => string; + addTranslations: (locale: Locale, translations: TranslationMap) => void; +} + +const I18nContext = createContext(null); + +interface I18nProviderProps { + locale?: Locale; + children: ReactNode; +} + +export function I18nProvider({ locale: initialLocale = 'en', children }: I18nProviderProps) { + const [state, setState] = useState(() => createI18nState(initialLocale)); + + const setLocale = useCallback((locale: Locale) => { + setState((prev) => ({ ...prev, locale })); + }, []); + + const addTranslations = useCallback((locale: Locale, translations: TranslationMap) => { + setState((prev) => loadTranslations(prev, locale, translations)); + }, []); + + const t = useCallback( + (key: string, values?: Record) => + translate(state.translations, state.locale, state.fallbackLocale, key, values), + [state], + ); + + const value = useMemo( + () => ({ locale: state.locale, setLocale, t, addTranslations }), + [state.locale, setLocale, t, addTranslations], + ); + + return {children}; +} + +export function useI18n(): I18nContextValue { + const ctx = useContext(I18nContext); + if (!ctx) { + throw new Error('useI18n must be used within an I18nProvider'); + } + return ctx; +} diff --git a/apps/web/src/hooks/use-keyboard-shortcuts.ts b/apps/web/src/hooks/use-keyboard-shortcuts.ts new file mode 100644 index 00000000..c1d05ec7 --- /dev/null +++ b/apps/web/src/hooks/use-keyboard-shortcuts.ts @@ -0,0 +1,85 @@ +/** + * useKeyboardShortcuts — global keyboard shortcut support. + * + * Registers keyboard shortcuts and invokes callbacks. Supports + * modifier keys (Ctrl/Cmd, Shift, Alt) and prevents conflicts + * when the user is typing in an input field. + */ + +import { useEffect, useCallback, useRef } from 'react'; + +export interface KeyboardShortcut { + /** Key identifier (e.g. 'k', 'Escape', '/') */ + key: string; + /** Require Ctrl (or Cmd on Mac) */ + ctrl?: boolean; + /** Require Shift */ + shift?: boolean; + /** Require Alt */ + alt?: boolean; + /** Callback on activation */ + handler: (event: KeyboardEvent) => void; + /** Short description for help menu */ + description?: string; + /** If true, also fire when focused on an input */ + allowInInput?: boolean; +} + +const INPUT_TAGS = new Set(['INPUT', 'TEXTAREA', 'SELECT']); + +function isInputFocused(): boolean { + const el = document.activeElement; + if (!el) return false; + if (INPUT_TAGS.has(el.tagName)) return true; + if ((el as HTMLElement).isContentEditable) return true; + return false; +} + +export function useKeyboardShortcuts(shortcuts: KeyboardShortcut[]) { + const shortcutsRef = useRef(shortcuts); + shortcutsRef.current = shortcuts; + + const handleKeyDown = useCallback((event: KeyboardEvent) => { + for (const shortcut of shortcutsRef.current) { + const ctrlMatch = shortcut.ctrl + ? event.ctrlKey || event.metaKey + : !event.ctrlKey && !event.metaKey; + const shiftMatch = shortcut.shift ? event.shiftKey : !event.shiftKey; + const altMatch = shortcut.alt ? event.altKey : !event.altKey; + + // Compare key with case sensitivity: use event.key directly for + // special keys (length > 1) or shifted characters (e.g. '?'). + // For single alpha characters, compare case-insensitively. + const keyMatches = + shortcut.key.length === 1 && /[a-zA-Z]/.test(shortcut.key) + ? event.key.toLowerCase() === shortcut.key.toLowerCase() + : event.key === shortcut.key; + + if (keyMatches && ctrlMatch && shiftMatch && altMatch) { + if (!shortcut.allowInInput && isInputFocused()) continue; + + event.preventDefault(); + shortcut.handler(event); + return; + } + } + }, []); + + useEffect(() => { + document.addEventListener('keydown', handleKeyDown); + return () => document.removeEventListener('keydown', handleKeyDown); + }, [handleKeyDown]); +} + +/** + * Pre-defined shortcut sets for common operations. + */ +export const SHORTCUT_PRESETS = { + search: { key: 'k', ctrl: true, description: 'Open search' }, + save: { key: 's', ctrl: true, description: 'Save' }, + escape: { key: 'Escape', description: 'Close / Cancel' }, + newRecord: { key: 'n', ctrl: true, shift: true, description: 'New record' }, + goHome: { key: 'h', ctrl: true, shift: true, description: 'Go home' }, + goSettings: { key: ',', ctrl: true, description: 'Open settings' }, + help: { key: '?', shift: true, description: 'Show keyboard shortcuts' }, +} as const; diff --git a/apps/web/src/hooks/use-offline.ts b/apps/web/src/hooks/use-offline.ts new file mode 100644 index 00000000..66b3b6eb --- /dev/null +++ b/apps/web/src/hooks/use-offline.ts @@ -0,0 +1,29 @@ +/** + * useOfflineStatus — reactive online/offline detection. + * + * Listens to browser online/offline events and exposes the current + * connectivity state for UI indicators. + */ + +import { useState, useEffect } from 'react'; + +export function useOfflineStatus() { + const [isOnline, setIsOnline] = useState( + typeof navigator !== 'undefined' ? navigator.onLine : true, + ); + + useEffect(() => { + const handleOnline = () => setIsOnline(true); + const handleOffline = () => setIsOnline(false); + + window.addEventListener('online', handleOnline); + window.addEventListener('offline', handleOffline); + + return () => { + window.removeEventListener('online', handleOnline); + window.removeEventListener('offline', handleOffline); + }; + }, []); + + return { isOnline, isOffline: !isOnline }; +} diff --git a/apps/web/src/hooks/use-records.ts b/apps/web/src/hooks/use-records.ts index 350d414c..a7cf9495 100644 --- a/apps/web/src/hooks/use-records.ts +++ b/apps/web/src/hooks/use-records.ts @@ -3,6 +3,7 @@ * * Uses the official @objectstack/client SDK to fetch from the server. * Falls back to mock data when the server is unreachable. + * Supports optimistic updates for instant UI feedback (Phase 5). */ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; @@ -70,6 +71,22 @@ export function useRecord({ objectName, recordId }: UseRecordOptions) { }); } +// ── Optimistic update context types ───────────────────────────── + +type QueryListSnapshot = [readonly unknown[], RecordListResponse | undefined][]; + +interface CreateMutationContext { + previous: QueryListSnapshot; +} + +interface UpdateMutationContext { + previous: RecordData | undefined; +} + +interface DeleteMutationContext { + previous: QueryListSnapshot; +} + // ── Create record ─────────────────────────────────────────────── interface UseCreateRecordOptions { @@ -79,12 +96,30 @@ interface UseCreateRecordOptions { export function useCreateRecord({ objectName }: UseCreateRecordOptions) { const queryClient = useQueryClient(); - return useMutation>({ + return useMutation, CreateMutationContext>({ mutationFn: async (data) => { const result = await objectStackClient.data.create(objectName, data); return (result?.record ?? data) as RecordData; }, - onSuccess: () => { + // Optimistic update: append the new record to the cached list immediately + onMutate: async (newData) => { + await queryClient.cancelQueries({ queryKey: ['records', objectName] }); + const previous = queryClient.getQueriesData({ queryKey: ['records', objectName] }); + queryClient.setQueriesData( + { queryKey: ['records', objectName] }, + (old) => old ? { ...old, records: [...old.records, { id: crypto.randomUUID(), ...newData } as RecordData], total: old.total + 1 } : old, + ); + return { previous }; + }, + onError: (_err, _vars, context) => { + // Rollback on error + if (context?.previous) { + for (const [key, data] of context.previous) { + queryClient.setQueryData(key, data); + } + } + }, + onSettled: () => { void queryClient.invalidateQueries({ queryKey: ['records', objectName] }); }, }); @@ -100,12 +135,27 @@ interface UseUpdateRecordOptions { export function useUpdateRecord({ objectName, recordId }: UseUpdateRecordOptions) { const queryClient = useQueryClient(); - return useMutation>({ + return useMutation, UpdateMutationContext>({ mutationFn: async (data) => { const result = await objectStackClient.data.update(objectName, recordId, data); return (result?.record ?? data) as RecordData; }, - onSuccess: () => { + // Optimistic update: merge changes into the cached record immediately + onMutate: async (newData) => { + await queryClient.cancelQueries({ queryKey: ['record', objectName, recordId] }); + const previous = queryClient.getQueryData(['record', objectName, recordId]); + queryClient.setQueryData( + ['record', objectName, recordId], + (old) => old ? { ...old, ...newData } : old, + ); + return { previous }; + }, + onError: (_err, _vars, context) => { + if (context?.previous) { + queryClient.setQueryData(['record', objectName, recordId], context.previous); + } + }, + onSettled: () => { void queryClient.invalidateQueries({ queryKey: ['records', objectName] }); void queryClient.invalidateQueries({ queryKey: ['record', objectName, recordId] }); }, @@ -121,11 +171,33 @@ interface UseDeleteRecordOptions { export function useDeleteRecord({ objectName }: UseDeleteRecordOptions) { const queryClient = useQueryClient(); - return useMutation({ + return useMutation({ mutationFn: async (recordId) => { await objectStackClient.data.delete(objectName, recordId); }, - onSuccess: (_data, recordId) => { + // Optimistic update: remove the record from the cached list immediately + onMutate: async (recordId) => { + await queryClient.cancelQueries({ queryKey: ['records', objectName] }); + const previous = queryClient.getQueriesData({ queryKey: ['records', objectName] }); + queryClient.setQueriesData( + { queryKey: ['records', objectName] }, + (old) => { + if (!old) return old; + const filtered = old.records.filter((r) => String(r.id) !== recordId); + const removed = filtered.length < old.records.length; + return { ...old, records: filtered, total: removed ? Math.max(0, old.total - 1) : old.total }; + }, + ); + return { previous }; + }, + onError: (_err, _recordId, context) => { + if (context?.previous) { + for (const [key, data] of context.previous) { + queryClient.setQueryData(key, data); + } + } + }, + onSettled: (_data, _err, recordId) => { void queryClient.invalidateQueries({ queryKey: ['records', objectName] }); void queryClient.removeQueries({ queryKey: ['record', objectName, recordId] }); }, diff --git a/apps/web/src/hooks/use-sync.ts b/apps/web/src/hooks/use-sync.ts new file mode 100644 index 00000000..cd4f8cf7 --- /dev/null +++ b/apps/web/src/hooks/use-sync.ts @@ -0,0 +1,42 @@ +/** + * useSyncEngine — hook for the sync engine. + * + * Provides reactive access to sync state including pending mutations, + * conflict count, and sync status. Wraps the singleton SyncEngine. + */ + +import { useState, useEffect, useCallback } from 'react'; +import { syncEngine, type SyncState, type SyncConflict } from '@/lib/sync-engine'; + +export function useSyncEngine() { + const [state, setState] = useState(syncEngine.getState()); + + useEffect(() => { + return syncEngine.subscribe(setState); + }, []); + + const pushMutation = useCallback( + (objectName: string, recordId: string, type: 'create' | 'update' | 'delete', data: Record) => { + return syncEngine.pushMutation(objectName, recordId, type, data); + }, + [], + ); + + const resolveConflict = useCallback( + (conflictId: string, resolution: 'local' | 'server' | 'manual', manualData?: Record) => { + syncEngine.resolveConflict(conflictId, resolution, manualData); + }, + [], + ); + + const getConflicts = useCallback((): SyncConflict[] => { + return syncEngine.getConflicts(); + }, []); + + return { + ...state, + pushMutation, + resolveConflict, + getConflicts, + }; +} diff --git a/apps/web/src/hooks/use-theme.ts b/apps/web/src/hooks/use-theme.ts new file mode 100644 index 00000000..052cb708 --- /dev/null +++ b/apps/web/src/hooks/use-theme.ts @@ -0,0 +1,53 @@ +/** + * useTheme — light / dark / system theme management. + * + * Persists the user's preference in localStorage and applies the + * `.dark` class on the document root for Tailwind CSS dark-mode. + */ + +import { useState, useEffect, useCallback } from 'react'; + +export type Theme = 'light' | 'dark' | 'system'; + +const STORAGE_KEY = 'objectos-theme'; + +function getStoredTheme(): Theme { + if (typeof window === 'undefined') return 'system'; + return (localStorage.getItem(STORAGE_KEY) as Theme) ?? 'system'; +} + +function getSystemTheme(): 'light' | 'dark' { + if (typeof window === 'undefined') return 'light'; + return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; +} + +function applyTheme(theme: Theme): void { + const resolved = theme === 'system' ? getSystemTheme() : theme; + document.documentElement.classList.toggle('dark', resolved === 'dark'); +} + +export function useTheme() { + const [theme, setThemeState] = useState(getStoredTheme); + + const setTheme = useCallback((newTheme: Theme) => { + setThemeState(newTheme); + localStorage.setItem(STORAGE_KEY, newTheme); + applyTheme(newTheme); + }, []); + + const resolvedTheme = theme === 'system' ? getSystemTheme() : theme; + + // Apply theme on mount and listen for system changes + useEffect(() => { + applyTheme(theme); + + if (theme !== 'system') return; + + const mq = window.matchMedia('(prefers-color-scheme: dark)'); + const handler = () => applyTheme('system'); + mq.addEventListener('change', handler); + return () => mq.removeEventListener('change', handler); + }, [theme]); + + return { theme, resolvedTheme, setTheme }; +} diff --git a/apps/web/src/lib/i18n.ts b/apps/web/src/lib/i18n.ts new file mode 100644 index 00000000..58e09925 --- /dev/null +++ b/apps/web/src/lib/i18n.ts @@ -0,0 +1,144 @@ +/** + * i18n integration for the frontend. + * + * Provides a lightweight i18n context for the React app, compatible + * with the @objectos/i18n package conventions. Supports nested key + * lookup, interpolation, and locale switching. + */ + +export type Locale = string; + +export interface TranslationMap { + [key: string]: string | TranslationMap; +} + +export interface I18nState { + locale: Locale; + fallbackLocale: Locale; + translations: Record; +} + +// ── Default translations (English) ────────────────────────────── + +const defaultTranslations: TranslationMap = { + common: { + save: 'Save', + cancel: 'Cancel', + delete: 'Delete', + edit: 'Edit', + create: 'Create', + search: 'Search', + loading: 'Loading…', + error: 'An error occurred', + retry: 'Retry', + confirm: 'Confirm', + back: 'Back', + next: 'Next', + yes: 'Yes', + no: 'No', + }, + auth: { + signIn: 'Sign In', + signUp: 'Sign Up', + signOut: 'Sign Out', + forgotPassword: 'Forgot Password?', + resetPassword: 'Reset Password', + email: 'Email', + password: 'Password', + }, + sync: { + online: 'Online', + offline: 'Offline', + syncing: 'Syncing…', + syncError: 'Sync error', + pendingChanges: '{{count}} pending change(s)', + conflictsDetected: '{{count}} conflict(s) detected', + lastSynced: 'Last synced: {{time}}', + resolveConflict: 'Resolve Conflict', + keepLocal: 'Keep Local', + keepServer: 'Keep Server', + mergeManually: 'Merge Manually', + }, + nav: { + home: 'Home', + settings: 'Settings', + apps: 'Apps', + skipToContent: 'Skip to main content', + }, + theme: { + light: 'Light', + dark: 'Dark', + system: 'System', + }, +}; + +/** + * Resolve a dotted key path (e.g. "common.save") from a nested translation map. + */ +export function resolveKey(translations: TranslationMap, key: string): string | undefined { + const parts = key.split('.'); + let current: TranslationMap | string = translations; + for (const part of parts) { + if (typeof current !== 'object' || current === null) return undefined; + current = current[part] as TranslationMap | string; + } + return typeof current === 'string' ? current : undefined; +} + +/** + * Simple interpolation: replace {{variable}} placeholders. + */ +export function interpolate(template: string, values: Record): string { + return template.replace(/\{\{(\w+)\}\}/g, (_, key: string) => + values[key] !== undefined ? String(values[key]) : `{{${key}}}`, + ); +} + +/** + * Translate a key with optional interpolation values. + */ +export function translate( + translations: Record, + locale: Locale, + fallbackLocale: Locale, + key: string, + values?: Record, +): string { + const result = + resolveKey(translations[locale] ?? {}, key) ?? + resolveKey(translations[fallbackLocale] ?? {}, key) ?? + key; + + return values ? interpolate(result, values) : result; +} + +/** + * Create an initial i18n state with English defaults. + */ +export function createI18nState(locale: Locale = 'en'): I18nState { + return { + locale, + fallbackLocale: 'en', + translations: { en: defaultTranslations }, + }; +} + +/** + * Load translations for a locale into the state. + */ +export function loadTranslations( + state: I18nState, + locale: Locale, + translations: TranslationMap, +): I18nState { + return { + ...state, + translations: { + ...state.translations, + [locale]: { + ...state.translations[locale], + ...translations, + }, + }, + }; +} diff --git a/apps/web/src/lib/service-worker.ts b/apps/web/src/lib/service-worker.ts new file mode 100644 index 00000000..5374fb7f --- /dev/null +++ b/apps/web/src/lib/service-worker.ts @@ -0,0 +1,52 @@ +/** + * Service Worker registration utility. + * + * Registers the SW for PWA offline support and exposes + * update lifecycle events to the application. + */ + +export interface SWRegistrationCallbacks { + onSuccess?: (registration: ServiceWorkerRegistration) => void; + onUpdate?: (registration: ServiceWorkerRegistration) => void; + onError?: (error: Error) => void; +} + +const SW_URL = `${import.meta.env.BASE_URL}sw.js`; + +export function registerServiceWorker(callbacks: SWRegistrationCallbacks = {}): void { + if (!('serviceWorker' in navigator)) return; + + window.addEventListener('load', async () => { + try { + const registration = await navigator.serviceWorker.register(SW_URL, { + scope: import.meta.env.BASE_URL, + }); + + registration.onupdatefound = () => { + const installing = registration.installing; + if (!installing) return; + + installing.onstatechange = () => { + if (installing.state !== 'installed') return; + if (navigator.serviceWorker.controller) { + // New content is available; trigger update callback + callbacks.onUpdate?.(registration); + } else { + // Content is cached for the first time + callbacks.onSuccess?.(registration); + } + }; + }; + } catch (err) { + callbacks.onError?.(err instanceof Error ? err : new Error(String(err))); + } + }); +} + +export function unregisterServiceWorker(): void { + if (!('serviceWorker' in navigator)) return; + + navigator.serviceWorker.ready.then((registration) => { + registration.unregister(); + }); +} diff --git a/apps/web/src/lib/sync-engine.ts b/apps/web/src/lib/sync-engine.ts new file mode 100644 index 00000000..bf0b3556 --- /dev/null +++ b/apps/web/src/lib/sync-engine.ts @@ -0,0 +1,193 @@ +/** + * Sync engine — client-side mutation log and delta sync. + * + * Implements a simple push/pull sync protocol: + * - **Push:** Local mutations are queued and sent to the server in order. + * - **Pull:** Server deltas (changes since a cursor) are fetched and merged. + * - **Conflict detection:** When server and client both modify the same record, + * a conflict is surfaced for manual resolution. + */ + +// ── Types ─────────────────────────────────────────────────────── + +export type MutationType = 'create' | 'update' | 'delete'; + +export interface MutationEntry { + id: string; + objectName: string; + recordId: string; + type: MutationType; + data: Record; + timestamp: number; + synced: boolean; +} + +export interface DeltaEntry { + objectName: string; + recordId: string; + type: MutationType; + data: Record; + serverTimestamp: number; +} + +export interface SyncConflict { + id: string; + objectName: string; + recordId: string; + localData: Record; + serverData: Record; + localTimestamp: number; + serverTimestamp: number; + resolvedBy?: 'local' | 'server' | 'manual'; + resolvedData?: Record; +} + +export type SyncStatus = 'idle' | 'syncing' | 'error' | 'offline'; + +export interface SyncState { + status: SyncStatus; + pendingCount: number; + conflicts: SyncConflict[]; + lastSyncedAt: number | null; + cursor: string | null; +} + +// ── Sync Engine ───────────────────────────────────────────────── + +let counter = 0; +function generateId(): string { + return `mut_${Date.now()}_${++counter}`; +} + +export class SyncEngine { + private mutationLog: MutationEntry[] = []; + private conflicts: SyncConflict[] = []; + private cursor: string | null = null; + private lastSyncedAt: number | null = null; + private listeners = new Set<(state: SyncState) => void>(); + + getState(): SyncState { + return { + status: navigator.onLine ? 'idle' : 'offline', + pendingCount: this.mutationLog.filter((m) => !m.synced).length, + conflicts: [...this.conflicts], + lastSyncedAt: this.lastSyncedAt, + cursor: this.cursor, + }; + } + + /** + * Queue a local mutation for sync. + */ + pushMutation( + objectName: string, + recordId: string, + type: MutationType, + data: Record, + ): MutationEntry { + const entry: MutationEntry = { + id: generateId(), + objectName, + recordId, + type, + data, + timestamp: Date.now(), + synced: false, + }; + this.mutationLog.push(entry); + this.notify(); + return entry; + } + + /** + * Attempt to push pending mutations to the server. + */ + async pushToServer( + sendFn: (mutations: MutationEntry[]) => Promise<{ synced: string[]; conflicts: SyncConflict[] }>, + ): Promise { + const pending = this.mutationLog.filter((m) => !m.synced); + if (pending.length === 0) return; + + const result = await sendFn(pending); + + // Mark synced entries + for (const id of result.synced) { + const entry = this.mutationLog.find((m) => m.id === id); + if (entry) entry.synced = true; + } + + // Add any new conflicts + if (result.conflicts.length > 0) { + this.conflicts.push(...result.conflicts); + } + + this.lastSyncedAt = Date.now(); + this.notify(); + } + + /** + * Pull deltas from the server since the last cursor. + */ + async pullFromServer( + fetchFn: (cursor: string | null) => Promise<{ deltas: DeltaEntry[]; cursor: string }>, + ): Promise { + const result = await fetchFn(this.cursor); + this.cursor = result.cursor; + this.lastSyncedAt = Date.now(); + this.notify(); + return result.deltas; + } + + /** + * Resolve a conflict by choosing local, server, or manually merged data. + */ + resolveConflict( + conflictId: string, + resolution: 'local' | 'server' | 'manual', + manualData?: Record, + ): void { + const conflict = this.conflicts.find((c) => c.id === conflictId); + if (!conflict) return; + + conflict.resolvedBy = resolution; + switch (resolution) { + case 'local': + conflict.resolvedData = conflict.localData; + break; + case 'server': + conflict.resolvedData = conflict.serverData; + break; + case 'manual': + conflict.resolvedData = manualData ?? conflict.serverData; + break; + } + + this.conflicts = this.conflicts.filter((c) => c.id !== conflictId); + this.notify(); + } + + getPendingMutations(): MutationEntry[] { + return this.mutationLog.filter((m) => !m.synced); + } + + getConflicts(): SyncConflict[] { + return [...this.conflicts]; + } + + clearSyncedMutations(): void { + this.mutationLog = this.mutationLog.filter((m) => !m.synced); + } + + subscribe(listener: (state: SyncState) => void): () => void { + this.listeners.add(listener); + return () => this.listeners.delete(listener); + } + + private notify(): void { + const state = this.getState(); + this.listeners.forEach((fn) => fn(state)); + } +} + +/** Singleton sync engine instance */ +export const syncEngine = new SyncEngine(); diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index 9ec92d6d..523ba2ad 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -3,6 +3,8 @@ import { createRoot } from 'react-dom/client'; import { BrowserRouter } from 'react-router-dom'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { App } from './App'; +import { I18nProvider } from './hooks/use-i18n'; +import { registerServiceWorker } from './lib/service-worker'; import './index.css'; const queryClient = new QueryClient({ @@ -18,12 +20,20 @@ const queryClient = new QueryClient({ // Vercel → "/" (stripped to ""), Local → "/console/" (stripped to "/console") const basename = import.meta.env.BASE_URL.replace(/\/+$/, ''); +// Register service worker for PWA offline support +registerServiceWorker({ + onSuccess: () => console.log('[SW] Content cached for offline use'), + onUpdate: () => console.log('[SW] New content available; please refresh'), +}); + createRoot(document.getElementById('root')!).render( - - - + + + + + , ); diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index cda8757e..90e9f803 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -35,5 +35,16 @@ export default defineConfig({ build: { outDir: 'dist', sourcemap: true, + // Performance budget: warn if chunks exceed 250 KB + chunkSizeWarningLimit: 250, + rollupOptions: { + output: { + manualChunks: { + vendor: ['react', 'react-dom'], + router: ['react-router-dom'], + query: ['@tanstack/react-query'], + }, + }, + }, }, });