diff --git a/src/components/PersistenceDashboard.tsx b/src/components/PersistenceDashboard.tsx index 26550e4..54653b0 100644 --- a/src/components/PersistenceDashboard.tsx +++ b/src/components/PersistenceDashboard.tsx @@ -4,22 +4,286 @@ import { Button } from '@/components/ui/button' import { Badge } from '@/components/ui/badge' import { Separator } from '@/components/ui/separator' import { Switch } from '@/components/ui/switch' -import { Label } from '@/components/ui/label' -import { - Database, - CloudArrowUp, - CloudArrowDown, - ArrowsClockwise, - CheckCircle, +import { + Database, + CloudArrowUp, + CloudArrowDown, + ArrowsClockwise, + CheckCircle, XCircle, Clock, ChartLine, - Gauge + Gauge, } from '@phosphor-icons/react' import { usePersistence } from '@/hooks/use-persistence' -import { useAppDispatch, useAppSelector } from '@/store' -import { syncToFlaskBulk, syncFromFlaskBulk, checkFlaskConnection } from '@/store/slices/syncSlice' +import { useAppDispatch } from '@/store' +import { + syncToFlaskBulk, + syncFromFlaskBulk, + checkFlaskConnection, +} from '@/store/slices/syncSlice' import { toast } from 'sonner' +import copy from '@/data/persistence-dashboard.json' + +const formatDuration = (ms: number) => { + if (ms < 1000) return `${ms}ms` + return `${(ms / 1000).toFixed(1)}s` +} + +type PersistenceStatus = ReturnType['status'] + +type PersistenceMetrics = ReturnType['metrics'] + +type AutoSyncStatus = ReturnType['autoSyncStatus'] + +const getStatusColor = (status: PersistenceStatus) => { + if (!status.flaskConnected) return 'bg-destructive' + if (status.syncStatus === 'syncing') return 'bg-amber-500' + if (status.syncStatus === 'success') return 'bg-accent' + if (status.syncStatus === 'error') return 'bg-destructive' + return 'bg-muted' +} + +const getStatusText = (status: PersistenceStatus) => { + if (!status.flaskConnected) return copy.status.disconnected + if (status.syncStatus === 'syncing') return copy.status.syncing + if (status.syncStatus === 'success') return copy.status.synced + if (status.syncStatus === 'error') return copy.status.error + return copy.status.idle +} + +const formatTime = (timestamp: number | null) => { + if (!timestamp) return copy.format.never + const date = new Date(timestamp) + return date.toLocaleTimeString() +} + +const getSuccessRate = (metrics: PersistenceMetrics) => { + if (metrics.totalOperations === 0) return 0 + return Math.round((metrics.successfulOperations / metrics.totalOperations) * 100) +} + +type HeaderProps = { + status: PersistenceStatus +} + +const DashboardHeader = ({ status }: HeaderProps) => ( +
+

{copy.title}

+ + {getStatusText(status)} + +
+) + +type ConnectionStatusCardProps = { + status: PersistenceStatus +} + +const ConnectionStatusCard = ({ status }: ConnectionStatusCardProps) => ( + +
+

+ + {copy.cards.connection.title} +

+ {status.flaskConnected ? ( + + ) : ( + + )} +
+ +
+
+ {copy.cards.connection.localStorageLabel} + {copy.cards.connection.localStorageValue} +
+
+ {copy.cards.connection.remoteStorageLabel} + + {status.flaskConnected + ? copy.cards.connection.remoteStorageConnected + : copy.cards.connection.remoteStorageDisconnected} + +
+
+ {copy.cards.connection.lastSyncLabel} + {formatTime(status.lastSyncTime)} +
+
+
+) + +type SyncMetricsCardProps = { + metrics: PersistenceMetrics +} + +const SyncMetricsCard = ({ metrics }: SyncMetricsCardProps) => ( + +
+

+ + {copy.cards.metrics.title} +

+ +
+ +
+
+ {copy.cards.metrics.totalOperationsLabel} + {metrics.totalOperations} +
+
+ {copy.cards.metrics.successRateLabel} + {getSuccessRate(metrics)}% +
+
+ {copy.cards.metrics.avgDurationLabel} + {formatDuration(metrics.averageOperationTime)} +
+
+ {copy.cards.metrics.failedLabel} + {metrics.failedOperations} +
+
+
+) + +type AutoSyncCardProps = { + autoSyncStatus: AutoSyncStatus + autoSyncEnabled: boolean + onToggle: (enabled: boolean) => void +} + +const AutoSyncCard = ({ autoSyncStatus, autoSyncEnabled, onToggle }: AutoSyncCardProps) => ( + +
+

+ + {copy.cards.autoSync.title} +

+ +
+ +
+
+ {copy.cards.autoSync.statusLabel} + + {autoSyncStatus.enabled + ? copy.cards.autoSync.statusEnabled + : copy.cards.autoSync.statusDisabled} + +
+
+ {copy.cards.autoSync.changesPendingLabel} + {autoSyncStatus.changeCounter} +
+
+ {copy.cards.autoSync.nextSyncLabel} + + {autoSyncStatus.nextSyncIn !== null + ? formatDuration(autoSyncStatus.nextSyncIn) + : copy.cards.autoSync.nextSyncNotAvailable} + +
+
+
+) + +type ManualSyncCardProps = { + onSyncToFlask: () => void + onSyncFromFlask: () => void + onManualSync: () => void + onCheckConnection: () => void + syncing: boolean + status: PersistenceStatus + autoSyncStatus: AutoSyncStatus +} + +const ManualSyncCard = ({ + onSyncToFlask, + onSyncFromFlask, + onManualSync, + onCheckConnection, + syncing, + status, + autoSyncStatus, +}: ManualSyncCardProps) => ( + +

+ + {copy.cards.manualSync.title} +

+ +
+ + + + +
+
+) + +type SyncErrorCardProps = { + error: string +} + +const SyncErrorCard = ({ error }: SyncErrorCardProps) => ( + +
+ +
+

{copy.cards.error.title}

+

{error}

+
+
+
+) + +const HowPersistenceWorksCard = () => ( + +

{copy.cards.howItWorks.title}

+ +
+ {copy.cards.howItWorks.items.map((item) => ( +

+ {item.title} {item.description} +

+ ))} +
+
+) export function PersistenceDashboard() { const dispatch = useAppDispatch() @@ -40,9 +304,9 @@ export function PersistenceDashboard() { setSyncing(true) try { await dispatch(syncToFlaskBulk()).unwrap() - toast.success('Successfully synced to Flask') + toast.success(copy.toasts.syncToSuccess) } catch (error: any) { - toast.error(`Sync failed: ${error}`) + toast.error(copy.toasts.syncFailed.replace('{{error}}', String(error))) } finally { setSyncing(false) } @@ -52,9 +316,9 @@ export function PersistenceDashboard() { setSyncing(true) try { await dispatch(syncFromFlaskBulk()).unwrap() - toast.success('Successfully synced from Flask') + toast.success(copy.toasts.syncFromSuccess) } catch (error: any) { - toast.error(`Sync failed: ${error}`) + toast.error(copy.toasts.syncFailed.replace('{{error}}', String(error))) } finally { setSyncing(false) } @@ -63,226 +327,45 @@ export function PersistenceDashboard() { const handleAutoSyncToggle = (enabled: boolean) => { setAutoSyncEnabled(enabled) configureAutoSync({ enabled, syncOnChange: true }) - toast.info(enabled ? 'Auto-sync enabled' : 'Auto-sync disabled') + toast.info(enabled ? copy.toasts.autoSyncEnabled : copy.toasts.autoSyncDisabled) } const handleManualSync = async () => { try { await syncNow() - toast.success('Manual sync completed') + toast.success(copy.toasts.manualSyncSuccess) } catch (error: any) { - toast.error(`Manual sync failed: ${error}`) + toast.error(copy.toasts.manualSyncFailed.replace('{{error}}', String(error))) } } - const getStatusColor = () => { - if (!status.flaskConnected) return 'bg-destructive' - if (status.syncStatus === 'syncing') return 'bg-amber-500' - if (status.syncStatus === 'success') return 'bg-accent' - if (status.syncStatus === 'error') return 'bg-destructive' - return 'bg-muted' - } - - const getStatusText = () => { - if (!status.flaskConnected) return 'Disconnected' - if (status.syncStatus === 'syncing') return 'Syncing...' - if (status.syncStatus === 'success') return 'Synced' - if (status.syncStatus === 'error') return 'Error' - return 'Idle' - } - - const formatTime = (timestamp: number | null) => { - if (!timestamp) return 'Never' - const date = new Date(timestamp) - return date.toLocaleTimeString() - } - - const formatDuration = (ms: number) => { - if (ms < 1000) return `${ms}ms` - return `${(ms / 1000).toFixed(1)}s` - } - - const getSuccessRate = () => { - if (metrics.totalOperations === 0) return 0 - return Math.round((metrics.successfulOperations / metrics.totalOperations) * 100) - } - return (
-
-

Persistence & Sync Dashboard

- - {getStatusText()} - -
+
- -
-

- - Connection Status -

- {status.flaskConnected ? ( - - ) : ( - - )} -
- -
-
- Local Storage: - IndexedDB -
-
- Remote Storage: - - {status.flaskConnected ? 'Flask API' : 'Offline'} - -
-
- Last Sync: - {formatTime(status.lastSyncTime)} -
-
-
- - -
-

- - Sync Metrics -

- -
- -
-
- Total Operations: - {metrics.totalOperations} -
-
- Success Rate: - {getSuccessRate()}% -
-
- Avg Duration: - {formatDuration(metrics.averageOperationTime)} -
-
- Failed: - {metrics.failedOperations} -
-
-
- - -
-

- - Auto-Sync -

- -
- -
-
- Status: - - {autoSyncStatus.enabled ? 'Enabled' : 'Disabled'} - -
-
- Changes Pending: - {autoSyncStatus.changeCounter} -
-
- Next Sync: - - {autoSyncStatus.nextSyncIn !== null - ? formatDuration(autoSyncStatus.nextSyncIn) - : 'N/A'} - -
-
-
+ + +
- -

- - Manual Sync Operations -

- -
- - - - -
-
+ dispatch(checkFlaskConnection())} + syncing={syncing} + status={status} + autoSyncStatus={autoSyncStatus} + /> - {status.error && ( - -
- -
-

Sync Error

-

{status.error}

-
-
-
- )} + {status.error && } - -

How Persistence Works

- -
-

- Automatic Persistence: All Redux state changes are automatically persisted to IndexedDB with a 300ms debounce. -

-

- Flask Sync: When connected, data is synced bidirectionally with the Flask API backend. -

-

- Auto-Sync: Enable auto-sync to automatically push changes to Flask at regular intervals (default: 30s). -

-

- Conflict Resolution: When conflicts are detected during sync, you'll be notified to resolve them manually. -

-
-
+
) } diff --git a/src/components/PersistenceExample.tsx b/src/components/PersistenceExample.tsx index 93460e5..69753fc 100644 --- a/src/components/PersistenceExample.tsx +++ b/src/components/PersistenceExample.tsx @@ -7,8 +7,199 @@ import { Badge } from '@/components/ui/badge' import { Separator } from '@/components/ui/separator' import { FloppyDisk, Trash, PencilSimple, CheckCircle, Clock } from '@phosphor-icons/react' import { useAppDispatch, useAppSelector } from '@/store' -import { saveFile, deleteFile } from '@/store/slices/filesSlice' +import { saveFile, deleteFile, type FileItem } from '@/store/slices/filesSlice' import { toast } from 'sonner' +import copy from '@/data/persistence-example.json' + +type HeaderProps = { + title: string + description: string +} + +const PersistenceExampleHeader = ({ title, description }: HeaderProps) => ( +
+

{title}

+

{description}

+
+) + +type FileEditorCardProps = { + fileName: string + fileContent: string + editingId: string | null + onFileNameChange: (value: string) => void + onFileContentChange: (value: string) => void + onSave: () => void + onCancel: () => void +} + +const FileEditorCard = ({ + fileName, + fileContent, + editingId, + onFileNameChange, + onFileContentChange, + onSave, + onCancel, +}: FileEditorCardProps) => ( + +
+

+ {editingId ? copy.editor.titleEdit : copy.editor.titleCreate} +

+ {editingId && ( + + {copy.editor.editingBadge} + + )} +
+ + +
+
+ + onFileNameChange(e.target.value)} + placeholder={copy.editor.fileNamePlaceholder} + /> +
+ +
+ +