-
Notifications
You must be signed in to change notification settings - Fork 0
feat: Phase 5 (Offline & Sync) and Phase 6 (Polish & Performance) #223
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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" | ||
| } | ||
| ] | ||
| } |
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -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)); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+45
to
+48
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // Static assets & navigation → cache-first | |
| event.respondWith(cacheFirst(request)); | |
| }); | |
| // Navigation → network-first with shell fallback | |
| if (request.mode === 'navigate') { | |
| event.respondWith(navigationNetworkFirst(request)); | |
| return; | |
| } | |
| // Static assets → cache-first | |
| event.respondWith(cacheFirst(request)); | |
| }); | |
| async function navigationNetworkFirst(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 shell = await caches.match('/console/index.html'); | |
| if (shell) return shell; | |
| return new Response('Offline', { status: 503, statusText: 'Service Unavailable' }); | |
| } | |
| } |
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -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 ( | ||||||
| <Suspense fallback={fallback}> | ||||||
| <Routes> | ||||||
| {/* Public routes */} | ||||||
| <Route path="/" element={<HomePage />} /> | ||||||
| <Route path="/sign-in" element={<SignInPage />} /> | ||||||
| <Route path="/sign-up" element={<SignUpPage />} /> | ||||||
| <Route path="/forgot-password" element={<ForgotPasswordPage />} /> | ||||||
| <Route path="/reset-password" element={<ResetPasswordPage />} /> | ||||||
| <Route path="/verify-2fa" element={<Verify2FAPage />} /> | ||||||
| <> | ||||||
| <SkipLink /> | ||||||
| <main id="main-content"> | ||||||
|
||||||
| <main id="main-content"> | |
| <main id="main-content" tabIndex={-1} role="main"> |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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'); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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'); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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'); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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' }); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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'); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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'); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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'); | ||
| }); | ||
| }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
STATIC_ASSETSand the navigation fallback are hard-coded to/console/*, butvite.config.tssetsbaseto/on Vercel. This will cause the SW to precache the wrong URLs and fail to serve the cached shell when deployed at the root. Derive these paths fromself.registration.scope(or use relative URLs like./and./index.html) so the SW works under both/and/console/.