From 5f949758f6d0d3fc3c24a2b42eda8acb8fbdbbf4 Mon Sep 17 00:00:00 2001 From: VitekHub Date: Thu, 4 Jun 2026 10:43:09 +0200 Subject: [PATCH 1/6] feat: add offline awareness with banner, paused sync status, and mutation pausing --- src/app/layouts/ProtectedLayout.tsx | 4 + src/app/styles/globals.css | 15 +++ .../fields/model/sync-status-store.ts | 2 +- .../fields/model/use-field-editor.test.ts | 50 +++---- src/features/fields/model/use-field-editor.ts | 20 +-- .../fields/model/use-field-mutation.ts | 2 +- src/features/fields/ui/DashboardPage.tsx | 3 +- src/features/fields/ui/FieldCard.test.tsx | 72 ++++------ src/features/fields/ui/FieldCard.tsx | 21 ++- src/features/fields/ui/SaveIndicator.tsx | 12 +- src/shared/i18n/locales/cs/common.json | 4 +- src/shared/i18n/locales/cs/fields.json | 2 + src/shared/i18n/locales/en/common.json | 4 +- src/shared/i18n/locales/en/fields.json | 2 + src/shared/ui/OfflineBanner.test.tsx | 124 ++++++++++++++++++ src/shared/ui/OfflineBanner.tsx | 83 ++++++++++++ 16 files changed, 335 insertions(+), 85 deletions(-) create mode 100644 src/shared/ui/OfflineBanner.test.tsx create mode 100644 src/shared/ui/OfflineBanner.tsx diff --git a/src/app/layouts/ProtectedLayout.tsx b/src/app/layouts/ProtectedLayout.tsx index 93b35de..ab93c63 100644 --- a/src/app/layouts/ProtectedLayout.tsx +++ b/src/app/layouts/ProtectedLayout.tsx @@ -9,6 +9,7 @@ import { MobileNav } from './MobileNav' import { ResizeHandle } from '@/shared/ui/nav/ResizeHandle' import { VaultIndicator } from '@/features/encryption/ui/VaultIndicator' import { VaultUnlockDialog } from '@/features/encryption/ui/VaultUnlockDialog' +import { OfflineBanner } from '@/shared/ui/OfflineBanner' import { useUiStore } from '@/features/settings/model/ui-store' import { useResizable } from '@/shared/lib/use-resizable' import { useVaultTimeout } from '@/features/encryption/model/use-vault-timeout' @@ -64,6 +65,9 @@ function ProtectedLayout() { + {/* Offline status banner */} + + {/* Main content */}
diff --git a/src/app/styles/globals.css b/src/app/styles/globals.css index 2ce068c..7090bf1 100644 --- a/src/app/styles/globals.css +++ b/src/app/styles/globals.css @@ -169,3 +169,18 @@ .animate-fade-in-up { animation: fade-in-up 0.3s ease-out both; } + +@keyframes fade-out-up { + from { + opacity: 1; + transform: translateY(0); + } + to { + opacity: 0; + transform: translateY(-8px); + } +} + +.animate-fade-out-up { + animation: fade-out-up 0.2s ease-in both; +} diff --git a/src/features/fields/model/sync-status-store.ts b/src/features/fields/model/sync-status-store.ts index f1fc98b..38c05fd 100644 --- a/src/features/fields/model/sync-status-store.ts +++ b/src/features/fields/model/sync-status-store.ts @@ -2,7 +2,7 @@ import { create } from 'zustand' import { devtools } from 'zustand/middleware' import type { FieldName } from '@/shared/types/entities/field.types' -export type SyncStatus = 'idle' | 'saving' | 'saved' | 'error' +export type SyncStatus = 'idle' | 'saving' | 'paused' | 'saved' | 'error' interface SyncStatusState { status: Record diff --git a/src/features/fields/model/use-field-editor.test.ts b/src/features/fields/model/use-field-editor.test.ts index 67dbf7d..0a5d936 100644 --- a/src/features/fields/model/use-field-editor.test.ts +++ b/src/features/fields/model/use-field-editor.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import { renderHook, act, waitFor } from '@testing-library/react' -import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { QueryClient, QueryClientProvider, onlineManager } from '@tanstack/react-query' import { createElement, type ReactNode } from 'react' import { useCryptoStore } from '@/shared/crypto/crypto-store' import { useSyncStatusStore } from '@/features/fields/model/sync-status-store' @@ -62,6 +62,7 @@ describe('useFieldEditor', () => { }) afterEach(() => { + onlineManager.setOnline(true) queryClient.clear() vi.useRealTimers() }) @@ -190,8 +191,7 @@ describe('useFieldEditor', () => { expect(mockSaveField).not.toHaveBeenCalled() }) - it('auto-retries save when browser comes back online', async () => { - mockSaveField.mockRejectedValueOnce(new Error('Network error')) + it('transitions to paused when offline, then saved when back online', async () => { const { result } = renderHook(() => useFieldEditor('note'), { wrapper: createWrapper(queryClient), }) @@ -200,27 +200,32 @@ describe('useFieldEditor', () => { expect(result.current.fieldValue).toBe('initial content') }) + // Go offline — mutations with networkMode: 'online' will pause + act(() => { + onlineManager.setOnline(false) + }) + act(() => { result.current.saveFieldValue('offline content') }) - // Wait for the debounce + failed mutation + // Wait for debounce + mutation pause → status 'paused' await waitFor( () => { - expect(result.current.fieldSyncStatus).toBe('error') + expect(result.current.fieldSyncStatus).toBe('paused') }, { timeout: 5000 }, ) - // Reset mock so next call succeeds - mockSaveField.mockResolvedValue(undefined) + // saveField was NOT called while offline (mutation paused before calling mutationFn) + expect(mockSaveField).not.toHaveBeenCalled() - // Simulate browser coming back online + // Go back online — TanStack Query auto-resumes the paused mutation act(() => { - window.dispatchEvent(new Event('online')) + onlineManager.setOnline(true) }) - // Wait for retry to succeed + // Wait for auto-resume to succeed await waitFor( () => { expect(result.current.fieldSyncStatus).toBe('saved') @@ -228,9 +233,8 @@ describe('useFieldEditor', () => { { timeout: 5000 }, ) - // First call failed, second call succeeded with latest fieldValue - expect(mockSaveField).toHaveBeenCalledTimes(2) - expect(mockSaveField).toHaveBeenLastCalledWith('user-123', 'note', 'offline content') + expect(mockSaveField).toHaveBeenCalledTimes(1) + expect(mockSaveField).toHaveBeenCalledWith('user-123', 'note', 'offline content') }) it('does not auto-retry on online event when status is not error', async () => { @@ -254,8 +258,7 @@ describe('useFieldEditor', () => { expect(mockSaveField).not.toHaveBeenCalled() }) - it('does not retry on online event after error is resolved', async () => { - mockSaveField.mockRejectedValueOnce(new Error('Network error')).mockResolvedValue(undefined) + it('does not trigger extra save after paused mutation resumes', async () => { const { result } = renderHook(() => useFieldEditor('note'), { wrapper: createWrapper(queryClient), }) @@ -264,24 +267,26 @@ describe('useFieldEditor', () => { expect(result.current.fieldValue).toBe('initial content') }) + // Go offline, save → mutation pauses + act(() => { + onlineManager.setOnline(false) + }) act(() => { result.current.saveFieldValue('offline content') }) - // Wait for debounce + failed mutation await waitFor( () => { - expect(result.current.fieldSyncStatus).toBe('error') + expect(result.current.fieldSyncStatus).toBe('paused') }, { timeout: 5000 }, ) - // Simulate browser coming back online → retry succeeds + // Go back online — auto-resume succeeds act(() => { - window.dispatchEvent(new Event('online')) + onlineManager.setOnline(true) }) - // Wait for retry to succeed await waitFor( () => { expect(result.current.fieldSyncStatus).toBe('saved') @@ -290,14 +295,13 @@ describe('useFieldEditor', () => { ) // Fire another online event — should NOT trigger another save - // (handler checks status imperatively; status is now 'saved', not 'error') act(() => { window.dispatchEvent(new Event('online')) }) await new Promise((resolve) => setTimeout(resolve, 100)) - // Only 2 calls: one failed, one succeeded via retry - expect(mockSaveField).toHaveBeenCalledTimes(2) + // Only one save call (the auto-resumed one) + expect(mockSaveField).toHaveBeenCalledTimes(1) }) }) diff --git a/src/features/fields/model/use-field-editor.ts b/src/features/fields/model/use-field-editor.ts index 8ccbcee..f5db6d4 100644 --- a/src/features/fields/model/use-field-editor.ts +++ b/src/features/fields/model/use-field-editor.ts @@ -12,6 +12,7 @@ export interface UseFieldEditorResult { saveFieldValue: (value: string) => void fieldSyncStatus: SyncStatus retrySave: () => void + isOfflineAwaitingData: boolean } /** @@ -58,17 +59,15 @@ function useFieldEditor(fieldName: FieldName): UseFieldEditorResult { const { debounceSave, retrySave } = useSaveScheduler(fieldName, setSyncStatus, saveMutation.mutate, isVaultLocked) - // Auto-retry when the browser regains connectivity - listener is always - // registered; the handler checks status imperatively to avoid add/remove churn. + // Track mutation pause state: when offline, TanStack Query pauses the mutation + // and auto-resumes when back online. Sync the status so SaveIndicator reflects it. useEffect(() => { - const handleOnline = () => { - if (useSyncStatusStore.getState().status[fieldName] === 'error') { - retrySave() - } + if (saveMutation.isPaused && syncStatus === 'saving') { + setSyncStatus(fieldName, 'paused') + } else if (!saveMutation.isPaused && syncStatus === 'paused') { + setSyncStatus(fieldName, 'saving') } - window.addEventListener('online', handleOnline) - return () => window.removeEventListener('online', handleOnline) - }, [fieldName, retrySave]) + }, [saveMutation.isPaused, syncStatus, fieldName, setSyncStatus]) const saveFieldValue = useCallback( (value: string) => { @@ -80,8 +79,9 @@ function useFieldEditor(fieldName: FieldName): UseFieldEditorResult { // FieldValue resolution: draft takes priority while editing, otherwise query data. const fieldValue = isVaultLocked ? '' : (draft ?? fieldQuery.data ?? '') + const isOfflineAwaitingData = fieldQuery.isPaused && !fieldQuery.data - return { fieldValue, saveFieldValue, fieldSyncStatus: syncStatus, retrySave } + return { fieldValue, saveFieldValue, fieldSyncStatus: syncStatus, retrySave, isOfflineAwaitingData } } export { useFieldEditor } diff --git a/src/features/fields/model/use-field-mutation.ts b/src/features/fields/model/use-field-mutation.ts index 0b68d99..4f3929b 100644 --- a/src/features/fields/model/use-field-mutation.ts +++ b/src/features/fields/model/use-field-mutation.ts @@ -15,7 +15,7 @@ export function useFieldMutation(fieldName: FieldName) { const queryKey = ['field', fieldName] as const return useMutation({ - networkMode: 'offlineFirst', // run mutation even when offline, so errors surface immediately + networkMode: 'online', // pause mutations when offline; auto-resume when back online mutationFn: (plaintext: string) => { if (!userId) throw new Error('useFieldMutation requires an authenticated user') return fieldService.saveField(userId, fieldName, plaintext) diff --git a/src/features/fields/ui/DashboardPage.tsx b/src/features/fields/ui/DashboardPage.tsx index b9c6d1d..85043f8 100644 --- a/src/features/fields/ui/DashboardPage.tsx +++ b/src/features/fields/ui/DashboardPage.tsx @@ -16,13 +16,14 @@ import type { FieldName } from '@/shared/types/entities/field.types' function FieldEditorWrapper({ fieldName, entranceIndex }: { fieldName: FieldName; entranceIndex: number }) { const isVaultLocked = useCryptoStore((s) => s.isVaultLocked) const openUnlockDialog = useVaultDialogStore((s) => s.openUnlockDialog) - const { fieldValue, saveFieldValue, fieldSyncStatus, retrySave } = useFieldEditor(fieldName) + const { fieldValue, saveFieldValue, fieldSyncStatus, retrySave, isOfflineAwaitingData } = useFieldEditor(fieldName) return ( diff --git a/src/features/fields/ui/FieldCard.test.tsx b/src/features/fields/ui/FieldCard.test.tsx index f9e5b26..743fbdf 100644 --- a/src/features/fields/ui/FieldCard.test.tsx +++ b/src/features/fields/ui/FieldCard.test.tsx @@ -1,83 +1,69 @@ +import type { ReactNode } from 'react' import { describe, it, expect, vi } from 'vitest' import { render, screen } from '@/test/utils' import userEvent from '@testing-library/user-event' import { FieldCard } from './FieldCard' +import type { FieldName } from '@/shared/types/entities/field.types' + +const baseProps: { + fieldName: FieldName + isLocked: boolean + isOfflineAwaitingData: boolean + children: () => ReactNode +} = { + fieldName: 'note', + isLocked: true, + isOfflineAwaitingData: false, + children: () =>
unlocked content
, +} describe('FieldCard', () => { it('renders locked state with lock icon and locked message', () => { - render( - - {() =>
unlocked content
} -
, - ) + render() expect(screen.getByText('Note')).toBeInTheDocument() expect(screen.getByText('Unlock vault to view')).toBeInTheDocument() expect(screen.queryByText('unlocked content')).not.toBeInTheDocument() }) it('renders unlock button in locked state when onUnlock is provided', () => { - const onUnlock = vi.fn() - render( - - {() =>
unlocked content
} -
, - ) - const button = screen.getByRole('button', { name: 'Unlock' }) - expect(button).toBeInTheDocument() + render() + expect(screen.getByRole('button', { name: 'Unlock' })).toBeInTheDocument() }) it('calls onUnlock when unlock button is clicked', async () => { const onUnlock = vi.fn() const user = userEvent.setup() - render( - - {() =>
unlocked content
} -
, - ) + render() await user.click(screen.getByRole('button', { name: 'Unlock' })) expect(onUnlock).toHaveBeenCalledOnce() }) it('does not render unlock button when onUnlock is not provided', () => { - render( - - {() =>
unlocked content
} -
, - ) + render() expect(screen.queryByRole('button', { name: 'Unlock' })).not.toBeInTheDocument() }) it('renders unlocked state with children', () => { - render( - - {() =>
unlocked content
} -
, - ) + render() expect(screen.getByText('unlocked content')).toBeInTheDocument() expect(screen.queryByText('Unlock vault to view')).not.toBeInTheDocument() }) + it('renders offline awaiting data state instead of children', () => { + render() + expect(screen.getByText('Connect to the internet to load your data')).toBeInTheDocument() + expect(screen.queryByText('unlocked content')).not.toBeInTheDocument() + }) + it('renders correct i18n labels for each field name', () => { - const { rerender } = render( - - {() =>
content
} -
, - ) + const { rerender } = render() expect(screen.getByText('Note')).toBeInTheDocument() - rerender( - - {() =>
content
} -
, - ) + rerender() expect(screen.getByText('Website')).toBeInTheDocument() - rerender( - - {() =>
content
} -
, - ) + rerender() expect(screen.getByText('Email')).toBeInTheDocument() }) }) diff --git a/src/features/fields/ui/FieldCard.tsx b/src/features/fields/ui/FieldCard.tsx index c7d619c..f6cd493 100644 --- a/src/features/fields/ui/FieldCard.tsx +++ b/src/features/fields/ui/FieldCard.tsx @@ -1,5 +1,5 @@ import { useTranslation } from 'react-i18next' -import { Lock } from 'lucide-react' +import { CloudOff, Lock } from 'lucide-react' import { Button } from '@/shared/ui/button' import { Card, CardContent, CardHeader, CardTitle, CardAction } from '@/shared/ui/card' @@ -17,13 +17,22 @@ const FIELD_I18N_KEYS: Record ReactNode onUnlock?: () => void statusIndicator?: ReactNode entranceIndex?: number } -function FieldCard({ fieldName, isLocked, children, onUnlock, statusIndicator, entranceIndex }: FieldCardProps) { +function FieldCard({ + fieldName, + isLocked, + children, + onUnlock, + statusIndicator, + entranceIndex, + isOfflineAwaitingData, +}: FieldCardProps) { const { t } = useTranslation('fields') const keys = FIELD_I18N_KEYS[fieldName] @@ -40,7 +49,7 @@ function FieldCard({ fieldName, isLocked, children, onUnlock, statusIndicator, e {isLocked ? (
-
+

{t(keys.locked)}

{onUnlock && ( @@ -49,6 +58,12 @@ function FieldCard({ fieldName, isLocked, children, onUnlock, statusIndicator, e )}
+ ) : isOfflineAwaitingData ? ( +
+
+ +

{t('offlineAwaitingData')}

+
) : ( children() )} diff --git a/src/features/fields/ui/SaveIndicator.tsx b/src/features/fields/ui/SaveIndicator.tsx index 17a4764..321cc20 100644 --- a/src/features/fields/ui/SaveIndicator.tsx +++ b/src/features/fields/ui/SaveIndicator.tsx @@ -1,5 +1,5 @@ import { useTranslation } from 'react-i18next' -import { Loader2, Check, AlertCircle } from 'lucide-react' +import { Loader2, Check, AlertCircle, CloudOff } from 'lucide-react' import type { SyncStatus } from '@/features/fields/model/sync-status-store' import { cn } from '@/shared/lib/utils' @@ -7,6 +7,7 @@ import { cn } from '@/shared/lib/utils' const STATUS_I18N_KEYS: Record = { idle: { text: '' }, saving: { text: 'status.saving' }, + paused: { text: 'status.paused' }, saved: { text: 'status.saved' }, error: { text: 'status.error', retry: 'status.retry' }, } @@ -33,6 +34,15 @@ function SaveIndicator({ status, onRetry, className }: SaveIndicatorProps) { ) } + if (status === 'paused') { + return ( + + + {t(keys.text)} + + ) + } + if (status === 'saved') { return ( diff --git a/src/shared/i18n/locales/cs/common.json b/src/shared/i18n/locales/cs/common.json index c356e1a..e3f13f9 100644 --- a/src/shared/i18n/locales/cs/common.json +++ b/src/shared/i18n/locales/cs/common.json @@ -14,7 +14,9 @@ }, "status": { "loading": "Načítání...", - "error": "Něco se pokazilo" + "error": "Něco se pokazilo", + "offline": "Jste offline", + "backOnline": "Zpět online" }, "errors": { "networkError": "Chyba sítě. Zkuste to prosím znovu.", diff --git a/src/shared/i18n/locales/cs/fields.json b/src/shared/i18n/locales/cs/fields.json index f785b29..d546c21 100644 --- a/src/shared/i18n/locales/cs/fields.json +++ b/src/shared/i18n/locales/cs/fields.json @@ -17,9 +17,11 @@ "locked": "Odemkněte trezor pro zobrazení", "unlock": "Odemknout" }, + "offlineAwaitingData": "Připojte se k internetu pro načtení dat", "lastUpdated": "Poslední aktualizace: {{date}}", "status": { "saving": "Ukládání...", + "paused": "Čeká na připojení...", "saved": "Uloženo", "error": "Uložení selhalo", "retry": "Zkusit znovu" diff --git a/src/shared/i18n/locales/en/common.json b/src/shared/i18n/locales/en/common.json index 5dcb1cd..266cd59 100644 --- a/src/shared/i18n/locales/en/common.json +++ b/src/shared/i18n/locales/en/common.json @@ -14,7 +14,9 @@ }, "status": { "loading": "Loading...", - "error": "Something went wrong" + "error": "Something went wrong", + "offline": "You are offline", + "backOnline": "Back online" }, "errors": { "networkError": "Network error. Please try again.", diff --git a/src/shared/i18n/locales/en/fields.json b/src/shared/i18n/locales/en/fields.json index 413553d..a76c2d8 100644 --- a/src/shared/i18n/locales/en/fields.json +++ b/src/shared/i18n/locales/en/fields.json @@ -17,9 +17,11 @@ "locked": "Unlock vault to view", "unlock": "Unlock" }, + "offlineAwaitingData": "Connect to the internet to load your data", "lastUpdated": "Last updated: {{date}}", "status": { "saving": "Saving...", + "paused": "Waiting for connection...", "saved": "Saved", "error": "Save failed", "retry": "Retry" diff --git a/src/shared/ui/OfflineBanner.test.tsx b/src/shared/ui/OfflineBanner.test.tsx new file mode 100644 index 0000000..64d32d4 --- /dev/null +++ b/src/shared/ui/OfflineBanner.test.tsx @@ -0,0 +1,124 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { render, screen, act } from '@/test/utils' + +import { OfflineBanner, BACK_ONLINE_DISPLAY_MS, EXIT_ANIMATION_MS } from './OfflineBanner' + +function mockNavigatorOnLine(online: boolean) { + Object.defineProperty(navigator, 'onLine', { + get: () => online, + configurable: true, + }) +} + +function goOffline() { + mockNavigatorOnLine(false) + window.dispatchEvent(new Event('offline')) +} + +function goOnline() { + mockNavigatorOnLine(true) + window.dispatchEvent(new Event('online')) +} + +describe('OfflineBanner', () => { + beforeEach(() => { + vi.useFakeTimers() + mockNavigatorOnLine(true) + }) + + afterEach(() => { + vi.useRealTimers() + mockNavigatorOnLine(true) + }) + + it('renders nothing when online', () => { + const { container } = render() + expect(container.firstChild).toBeNull() + }) + + it('shows offline banner when offline', () => { + mockNavigatorOnLine(false) + render() + expect(screen.getByRole('status')).toHaveTextContent('You are offline') + }) + + it('shows back-online message when coming back online', () => { + mockNavigatorOnLine(false) + render() + expect(screen.getByRole('status')).toHaveTextContent('You are offline') + + act(() => { + goOnline() + }) + expect(screen.getByRole('status')).toHaveTextContent('Back online') + }) + + it('auto-hides after display period when back online', () => { + mockNavigatorOnLine(false) + render() + + act(() => { + goOnline() + }) + + // Still visible before display period ends + act(() => { + vi.advanceTimersByTime(BACK_ONLINE_DISPLAY_MS - 500) + }) + expect(screen.getByRole('status')).toHaveTextContent('Back online') + + // Start of exit animation after display period + act(() => { + vi.advanceTimersByTime(500) + }) + // Hidden after exit animation + act(() => { + vi.advanceTimersByTime(EXIT_ANIMATION_MS) + }) + expect(screen.queryByRole('status')).not.toBeInTheDocument() + }) + + it('cancels auto-hide timer when going offline again', () => { + mockNavigatorOnLine(false) + render() + + act(() => { + goOnline() + }) + + act(() => { + vi.advanceTimersByTime(BACK_ONLINE_DISPLAY_MS - 1000) + }) + + // Go offline again before display timer expires + act(() => { + goOffline() + }) + expect(screen.getByRole('status')).toHaveTextContent('You are offline') + + // Advance past original display period — banner should still be visible + act(() => { + vi.advanceTimersByTime(1500) + }) + expect(screen.getByRole('status')).toHaveTextContent('You are offline') + }) + + it('renders WifiOff icon when offline and Wifi icon when back online', () => { + mockNavigatorOnLine(false) + render() + + const statusEl = screen.getByRole('status') + expect(statusEl.querySelector('svg')).toBeInTheDocument() + + act(() => { + goOnline() + }) + expect(screen.getByRole('status').querySelector('svg')).toBeInTheDocument() + }) + + it('has aria-live="polite" for accessibility', () => { + mockNavigatorOnLine(false) + render() + expect(screen.getByRole('status')).toHaveAttribute('aria-live', 'polite') + }) +}) diff --git a/src/shared/ui/OfflineBanner.tsx b/src/shared/ui/OfflineBanner.tsx new file mode 100644 index 0000000..04365e1 --- /dev/null +++ b/src/shared/ui/OfflineBanner.tsx @@ -0,0 +1,83 @@ +import { useState, useEffect, useRef } from 'react' +import { useTranslation } from 'react-i18next' +import { WifiOff, Wifi } from 'lucide-react' +import { cn } from '@/shared/lib/utils' + +// Static keys so i18next-parser can discover them +const OFFLINE_I18N_KEY = 'status.offline' +const BACK_ONLINE_I18N_KEY = 'status.backOnline' + +type BannerState = 'hidden' | 'offline' | 'back-online' | 'exiting' + +const BACK_ONLINE_DISPLAY_MS = 3000 +const EXIT_ANIMATION_MS = 250 + +function useOfflineBanner() { + const [bannerState, setBannerState] = useState(() => (!navigator.onLine ? 'offline' : 'hidden')) + const timersRef = useRef<{ hide?: ReturnType; exit?: ReturnType }>({}) + + useEffect(() => { + function handleOffline() { + clearTimeout(timersRef.current.hide) + clearTimeout(timersRef.current.exit) + setBannerState('offline') + } + + function handleOnline() { + setBannerState('back-online') + timersRef.current.hide = setTimeout(() => { + setBannerState('exiting') + timersRef.current.exit = setTimeout(() => { + setBannerState('hidden') + }, EXIT_ANIMATION_MS) + }, BACK_ONLINE_DISPLAY_MS) + } + + window.addEventListener('online', handleOnline) + window.addEventListener('offline', handleOffline) + + const timers = timersRef.current + return () => { + window.removeEventListener('online', handleOnline) + window.removeEventListener('offline', handleOffline) + clearTimeout(timers.hide) + clearTimeout(timers.exit) + } + }, []) + + return { bannerState } +} + +function OfflineBanner() { + const { bannerState } = useOfflineBanner() + const { t } = useTranslation('common') + + if (bannerState === 'hidden') return null + + const isBackOnline = bannerState === 'back-online' || bannerState === 'exiting' + const isExiting = bannerState === 'exiting' + + return ( +
+ {isBackOnline ? ( +
+ ) +} + +export { OfflineBanner } +export { BACK_ONLINE_DISPLAY_MS, EXIT_ANIMATION_MS } From 2c9fe403f6e7886d7334fc7e9c77c5ee26e9811c Mon Sep 17 00:00:00 2001 From: VitekHub Date: Thu, 4 Jun 2026 12:12:20 +0200 Subject: [PATCH 2/6] fix: handle Supabase plain-object errors in network detection and error classification --- .../model/crypto-error-messages.test.ts | 19 ++++++++--- .../encryption/model/crypto-error-messages.ts | 2 +- .../encryption/ui/VaultUnlockDialog.test.tsx | 2 +- src/shared/api/api-errors.test.ts | 16 +++++++++ src/shared/auth/auth-errors.test.ts | 16 +++++++++ src/shared/i18n/locales/cs/crypto.json | 3 +- src/shared/i18n/locales/en/crypto.json | 3 +- src/shared/lib/network-errors.test.ts | 29 ++++++++++++++-- src/shared/lib/network-errors.ts | 34 +++++++++++++++---- 9 files changed, 108 insertions(+), 16 deletions(-) diff --git a/src/features/encryption/model/crypto-error-messages.test.ts b/src/features/encryption/model/crypto-error-messages.test.ts index 4723189..ba95a66 100644 --- a/src/features/encryption/model/crypto-error-messages.test.ts +++ b/src/features/encryption/model/crypto-error-messages.test.ts @@ -36,13 +36,24 @@ describe('getCryptoErrorMessage', () => { expect(result).toBe('errors.networkError') }) - it('maps unknown error to errors.decryptFailed', () => { + it('maps network Error to errors.networkError', () => { + const result = getCryptoErrorMessage(new Error('A Network failure occurred'), t) + expect(result).toBe('errors.networkError') + }) + + it('maps Supabase plain-object with network message to errors.networkError', () => { + const supabaseError = { message: 'Failed to fetch', code: '', details: '', hint: '' } + const result = getCryptoErrorMessage(supabaseError, t) + expect(result).toBe('errors.networkError') + }) + + it('maps unknown error to errors.unexpectedError', () => { const result = getCryptoErrorMessage(new Error('something unexpected'), t) - expect(result).toBe('errors.decryptFailed') + expect(result).toBe('errors.unexpectedError') }) - it('maps non-Error thrown value to errors.decryptFailed', () => { + it('maps non-Error thrown value to errors.unexpectedError', () => { const result = getCryptoErrorMessage('string error', t) - expect(result).toBe('errors.decryptFailed') + expect(result).toBe('errors.unexpectedError') }) }) diff --git a/src/features/encryption/model/crypto-error-messages.ts b/src/features/encryption/model/crypto-error-messages.ts index fe634b1..16575e8 100644 --- a/src/features/encryption/model/crypto-error-messages.ts +++ b/src/features/encryption/model/crypto-error-messages.ts @@ -10,5 +10,5 @@ export function getCryptoErrorMessage(error: unknown, t: TFunction<'crypto'>): s if (isNetworkError(error)) return t('errors.networkError') - return t('errors.decryptFailed') + return t('errors.unexpectedError') } diff --git a/src/features/encryption/ui/VaultUnlockDialog.test.tsx b/src/features/encryption/ui/VaultUnlockDialog.test.tsx index 430a4b3..8513602 100644 --- a/src/features/encryption/ui/VaultUnlockDialog.test.tsx +++ b/src/features/encryption/ui/VaultUnlockDialog.test.tsx @@ -129,7 +129,7 @@ describe('VaultUnlockDialog', () => { await user.click(screen.getByRole('button', { name: /unlock/i })) await waitFor(() => { - expect(screen.getByText('Decryption failed. Your data may be corrupted.')).toBeInTheDocument() + expect(screen.getByText('An unexpected error occurred. Please try again.')).toBeInTheDocument() }) }) diff --git a/src/shared/api/api-errors.test.ts b/src/shared/api/api-errors.test.ts index cda875c..6d9a07a 100644 --- a/src/shared/api/api-errors.test.ts +++ b/src/shared/api/api-errors.test.ts @@ -49,6 +49,22 @@ describe('wrapApiError', () => { expect(error.code).toBe(ApiErrorCode.NETWORK_ERROR) }) + it('maps plain-object Supabase PostgrestError with network message to NETWORK_ERROR', () => { + // Supabase returns plain objects, not Error instances, at runtime + // https://github.com/supabase/supabase-js/pull/2240 + const supabaseError = { message: 'Failed to fetch', code: '', details: '', hint: '' } + const error = wrapApiError(supabaseError) + expect(error).toBeInstanceOf(ApiError) + expect(error.code).toBe(ApiErrorCode.NETWORK_ERROR) + }) + + it('maps plain-object error with non-network message to UNEXPECTED', () => { + const supabaseError = { message: 'Invalid input', code: '22P02', details: '', hint: '' } + const error = wrapApiError(supabaseError) + expect(error).toBeInstanceOf(ApiError) + expect(error.code).toBe(ApiErrorCode.UNEXPECTED) + }) + it('maps non-network errors to UNEXPECTED', () => { const original = new Error('something else') const error = wrapApiError(original) diff --git a/src/shared/auth/auth-errors.test.ts b/src/shared/auth/auth-errors.test.ts index be8f6ef..c3f5257 100644 --- a/src/shared/auth/auth-errors.test.ts +++ b/src/shared/auth/auth-errors.test.ts @@ -54,6 +54,22 @@ describe('wrapAuthError', () => { expect(error.code).toBe(AuthErrorCode.NETWORK_ERROR) }) + it('maps plain-object Supabase error with network message to NETWORK_ERROR', () => { + // Supabase returns plain objects, not Error instances, at runtime + // https://github.com/supabase/supabase-js/pull/2240 + const supabaseError = { message: 'Failed to fetch', code: '', details: '', hint: '' } + const error = wrapAuthError(supabaseError) + expect(error).toBeInstanceOf(AuthError) + expect(error.code).toBe(AuthErrorCode.NETWORK_ERROR) + }) + + it('maps plain-object error with non-network message to UNEXPECTED', () => { + const supabaseError = { message: 'Invalid input', code: '22P02', details: '', hint: '' } + const error = wrapAuthError(supabaseError) + expect(error).toBeInstanceOf(AuthError) + expect(error.code).toBe(AuthErrorCode.UNEXPECTED) + }) + it('maps non-network errors to UNEXPECTED', () => { const original = new Error('something else') const error = wrapAuthError(original) diff --git a/src/shared/i18n/locales/cs/crypto.json b/src/shared/i18n/locales/cs/crypto.json index c9b78c1..b464b25 100644 --- a/src/shared/i18n/locales/cs/crypto.json +++ b/src/shared/i18n/locales/cs/crypto.json @@ -21,6 +21,7 @@ "loginSaltsNotFound": "Účet nenalezen. Zkontrolujte prosím uživatelské jméno.", "keysNotFound": "Šifrovací klíče nenalezeny. Kontaktujte prosím podporu.", "unwrapFailed": "Selhalo dešifrování trezoru. Vaše data mohou být poškozena.", - "networkError": "Chyba sítě. Zkuste to prosím znovu." + "networkError": "Chyba sítě. Zkuste to prosím znovu.", + "unexpectedError": "Došlo k neočekávané chybě. Zkuste to prosím znovu." } } diff --git a/src/shared/i18n/locales/en/crypto.json b/src/shared/i18n/locales/en/crypto.json index 59fad7d..5b109bd 100644 --- a/src/shared/i18n/locales/en/crypto.json +++ b/src/shared/i18n/locales/en/crypto.json @@ -21,6 +21,7 @@ "loginSaltsNotFound": "Account not found. Please check your username.", "keysNotFound": "Encryption keys not found. Please contact support.", "unwrapFailed": "Failed to decrypt your vault. Your data may be corrupted.", - "networkError": "Network error. Please try again." + "networkError": "Network error. Please try again.", + "unexpectedError": "An unexpected error occurred. Please try again." } } diff --git a/src/shared/lib/network-errors.test.ts b/src/shared/lib/network-errors.test.ts index 8b07834..64b5383 100644 --- a/src/shared/lib/network-errors.test.ts +++ b/src/shared/lib/network-errors.test.ts @@ -22,14 +22,39 @@ describe('isNetworkError', () => { expect(isNetworkError(new Error('Request failed to fetch data'))).toBe(true) }) + it('detects plain string "Failed to fetch"', () => { + expect(isNetworkError('Failed to fetch')).toBe(true) + }) + + it('detects plain string containing "network" (case-insensitive)', () => { + expect(isNetworkError('A Network failure occurred')).toBe(true) + }) + + it('detects plain string "NetworkError"', () => { + expect(isNetworkError('NetworkError')).toBe(true) + }) + + it('returns false for non-network string', () => { + expect(isNetworkError('something else')).toBe(false) + }) + it('returns false for non-network errors', () => { expect(isNetworkError(new Error('Invalid credentials'))).toBe(false) expect(isNetworkError(new Error('Something went wrong'))).toBe(false) }) - it('returns false for non-Error values', () => { + it('returns false for non-Error, non-string values', () => { expect(isNetworkError(null)).toBe(false) - expect(isNetworkError('string')).toBe(false) expect(isNetworkError(42)).toBe(false) }) + + it('detects Supabase plain-object with network message', () => { + const supabaseError = { message: 'Failed to fetch', code: '', details: '', hint: '' } + expect(isNetworkError(supabaseError)).toBe(true) + }) + + it('returns false for Supabase plain-object with non-network message', () => { + const supabaseError = { message: 'Invalid input', code: '22P02', details: '', hint: '' } + expect(isNetworkError(supabaseError)).toBe(false) + }) }) diff --git a/src/shared/lib/network-errors.ts b/src/shared/lib/network-errors.ts index 27462c5..c3424ce 100644 --- a/src/shared/lib/network-errors.ts +++ b/src/shared/lib/network-errors.ts @@ -1,11 +1,16 @@ /** - * Check whether an error represents a network failure. - * - * Catches raw browser errors (TypeError "Failed to fetch") and Supabase - * network errors that can reach the UI without being wrapped by an adapter. - * Used by both AuthError and ApiError wrappers to classify network failures. + * Core network error message detection. Used internally by isNetworkError. + * Checks strings and Error instances for network-related message patterns. */ -export function isNetworkError(error: unknown): boolean { +function checkIsNetworkError(error: unknown): boolean { + // Accept a plain string (e.g. extracted from a non-Error object's .message) + if (typeof error === 'string') { + if (error === 'Failed to fetch' || error === 'NetworkError') return true + const lower = error.toLowerCase() + if (lower.includes('network') || lower.includes('failed to fetch')) return true + return false + } + if (error instanceof TypeError && error.message === 'Failed to fetch') return true if (error instanceof Error) { const msg = error.message @@ -15,3 +20,20 @@ export function isNetworkError(error: unknown): boolean { } return false } + +/** + * Check whether an error represents a network failure. + * + * Catches raw browser errors (TypeError "Failed to fetch"), Supabase + * plain-object errors (PostgrestError is not an Error instance at runtime), + * and already-wrapped AuthError/ApiError instances with NETWORK_ERROR code. + */ +export function isNetworkError(error: unknown): boolean { + if (checkIsNetworkError(error)) return true + // Handle Supabase plain-object errors (PostgrestError not instanceof Error at runtime) + // https://github.com/supabase/supabase-js/pull/2240 + if (typeof error === 'object' && error !== null && 'message' in error) { + if (checkIsNetworkError((error as { message: unknown }).message)) return true + } + return false +} From cfd78817760d6bf0f8a6409af918f2568e42a4a6 Mon Sep 17 00:00:00 2001 From: VitekHub Date: Thu, 4 Jun 2026 14:24:35 +0200 Subject: [PATCH 3/6] fix: derive sync pause state during render, improve OfflineBanner tests --- src/features/fields/model/use-field-editor.ts | 20 +++++++++---------- src/features/fields/ui/NoteField.tsx | 2 +- src/shared/ui/OfflineBanner.test.tsx | 7 ++++--- src/shared/ui/OfflineBanner.tsx | 5 +++-- 4 files changed, 18 insertions(+), 16 deletions(-) diff --git a/src/features/fields/model/use-field-editor.ts b/src/features/fields/model/use-field-editor.ts index f5db6d4..9f552e9 100644 --- a/src/features/fields/model/use-field-editor.ts +++ b/src/features/fields/model/use-field-editor.ts @@ -59,15 +59,15 @@ function useFieldEditor(fieldName: FieldName): UseFieldEditorResult { const { debounceSave, retrySave } = useSaveScheduler(fieldName, setSyncStatus, saveMutation.mutate, isVaultLocked) - // Track mutation pause state: when offline, TanStack Query pauses the mutation - // and auto-resumes when back online. Sync the status so SaveIndicator reflects it. - useEffect(() => { - if (saveMutation.isPaused && syncStatus === 'saving') { - setSyncStatus(fieldName, 'paused') - } else if (!saveMutation.isPaused && syncStatus === 'paused') { - setSyncStatus(fieldName, 'saving') - } - }, [saveMutation.isPaused, syncStatus, fieldName, setSyncStatus]) + // Derive effective sync status from mutation pause state: + // When offline, TanStack Query pauses the mutation - reflect this in the UI + // by deriving 'paused' during render rather than syncing via effect. + const effectiveSyncStatus: SyncStatus = + saveMutation.isPaused && syncStatus === 'saving' + ? 'paused' + : !saveMutation.isPaused && syncStatus === 'paused' + ? 'saving' + : syncStatus const saveFieldValue = useCallback( (value: string) => { @@ -81,7 +81,7 @@ function useFieldEditor(fieldName: FieldName): UseFieldEditorResult { const fieldValue = isVaultLocked ? '' : (draft ?? fieldQuery.data ?? '') const isOfflineAwaitingData = fieldQuery.isPaused && !fieldQuery.data - return { fieldValue, saveFieldValue, fieldSyncStatus: syncStatus, retrySave, isOfflineAwaitingData } + return { fieldValue, saveFieldValue, fieldSyncStatus: effectiveSyncStatus, retrySave, isOfflineAwaitingData } } export { useFieldEditor } diff --git a/src/features/fields/ui/NoteField.tsx b/src/features/fields/ui/NoteField.tsx index 9c42921..ec27366 100644 --- a/src/features/fields/ui/NoteField.tsx +++ b/src/features/fields/ui/NoteField.tsx @@ -8,7 +8,7 @@ interface NoteFieldProps { function autoResize(textarea: HTMLTextAreaElement) { textarea.style.height = 'auto' - textarea.style.height = `${textarea.scrollHeight}px` + textarea.style.height = `${textarea.scrollHeight + 10}px` } function NoteField({ value, onChange }: NoteFieldProps) { diff --git a/src/shared/ui/OfflineBanner.test.tsx b/src/shared/ui/OfflineBanner.test.tsx index 64d32d4..55f0c54 100644 --- a/src/shared/ui/OfflineBanner.test.tsx +++ b/src/shared/ui/OfflineBanner.test.tsx @@ -107,13 +107,14 @@ describe('OfflineBanner', () => { mockNavigatorOnLine(false) render() - const statusEl = screen.getByRole('status') - expect(statusEl.querySelector('svg')).toBeInTheDocument() + expect(screen.getByTestId('icon-wifi-off')).toBeInTheDocument() + expect(screen.queryByTestId('icon-wifi')).not.toBeInTheDocument() act(() => { goOnline() }) - expect(screen.getByRole('status').querySelector('svg')).toBeInTheDocument() + expect(screen.getByTestId('icon-wifi')).toBeInTheDocument() + expect(screen.queryByTestId('icon-wifi-off')).not.toBeInTheDocument() }) it('has aria-live="polite" for accessibility', () => { diff --git a/src/shared/ui/OfflineBanner.tsx b/src/shared/ui/OfflineBanner.tsx index 04365e1..f06bc6f 100644 --- a/src/shared/ui/OfflineBanner.tsx +++ b/src/shared/ui/OfflineBanner.tsx @@ -10,6 +10,7 @@ const BACK_ONLINE_I18N_KEY = 'status.backOnline' type BannerState = 'hidden' | 'offline' | 'back-online' | 'exiting' const BACK_ONLINE_DISPLAY_MS = 3000 +// Must be >= CSS .animate-fade-out-up duration (0.2s = 200ms) const EXIT_ANIMATION_MS = 250 function useOfflineBanner() { @@ -70,9 +71,9 @@ function OfflineBanner() { )} > {isBackOnline ? ( -
From ab1f2990357d0245f43e662e96431690df66b55f Mon Sep 17 00:00:00 2001 From: VitekHub Date: Thu, 4 Jun 2026 14:49:41 +0200 Subject: [PATCH 4/6] feat: add GitHub repository link to public header --- src/shared/ui/brand/GithubIcon.tsx | 22 ++++++++++++++++++++++ src/shared/ui/nav/PublicHeader.tsx | 9 +++++++++ 2 files changed, 31 insertions(+) create mode 100644 src/shared/ui/brand/GithubIcon.tsx diff --git a/src/shared/ui/brand/GithubIcon.tsx b/src/shared/ui/brand/GithubIcon.tsx new file mode 100644 index 0000000..af90789 --- /dev/null +++ b/src/shared/ui/brand/GithubIcon.tsx @@ -0,0 +1,22 @@ +import { cn } from '@/shared/lib/utils' + +function GithubIcon({ className }: { className?: string }) { + return ( + + ) +} + +export { GithubIcon } diff --git a/src/shared/ui/nav/PublicHeader.tsx b/src/shared/ui/nav/PublicHeader.tsx index e886615..0007854 100644 --- a/src/shared/ui/nav/PublicHeader.tsx +++ b/src/shared/ui/nav/PublicHeader.tsx @@ -1,6 +1,7 @@ import { Link } from '@tanstack/react-router' import { useTranslation } from 'react-i18next' +import { GithubIcon } from '@/shared/ui/brand/GithubIcon' import { CipherNoteIcon } from '@/shared/ui/brand/CipherNoteIcon' import { LanguageSwitcher } from '@/shared/ui/nav/LanguageSwitcher' import { ThemeSwitcher } from '@/shared/ui/nav/ThemeSwitcher' @@ -16,6 +17,14 @@ function PublicHeader() { Cipher Note From 2ade823674a14d929bb6300228b3e4d287102ce1 Mon Sep 17 00:00:00 2001 From: VitekHub Date: Thu, 4 Jun 2026 15:20:56 +0200 Subject: [PATCH 5/6] fix: update layout styling --- src/app/layouts/ProtectedLayout.tsx | 2 +- src/app/layouts/PublicLayout.tsx | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/app/layouts/ProtectedLayout.tsx b/src/app/layouts/ProtectedLayout.tsx index ab93c63..4da2f77 100644 --- a/src/app/layouts/ProtectedLayout.tsx +++ b/src/app/layouts/ProtectedLayout.tsx @@ -36,7 +36,7 @@ function ProtectedLayout() {
{/* Desktop sidebar */}