From 9d95f884a063ce452d60a5a3e9e33c0a1a56f798 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vi=CC=81ctor=20Falco=CC=81n?= Date: Sat, 8 Nov 2025 11:29:21 +0000 Subject: [PATCH] Offline First: sync bank, accounts, and categories --- .../Sync/AccountSyncController.php | 28 ++ .../Controllers/Sync/BankSyncController.php | 29 ++ .../Sync/CategorySyncController.php | 26 ++ bun.lock | 7 +- package.json | 3 +- resources/js/app.tsx | 5 +- .../js/components/accounts/bank-combobox.tsx | 10 +- .../js/components/app-sidebar-header.tsx | 6 +- .../js/components/sync-status-button.tsx | 79 ++++ resources/js/contexts/sync-context.tsx | 132 +++++++ resources/js/hooks/use-online-status.ts | 24 ++ resources/js/lib/indexeddb.ts | 350 ++++++++++++++++++ resources/js/lib/sync-manager.ts | 239 ++++++++++++ resources/js/pages/settings/accounts.tsx | 31 +- resources/js/pages/settings/categories.tsx | 31 +- resources/js/services/account-sync.ts | 48 +++ resources/js/services/bank-sync.ts | 44 +++ resources/js/services/category-sync.ts | 48 +++ routes/web.php | 7 + tests/Feature/Sync/AccountSyncTest.php | 80 ++++ tests/Feature/Sync/BankSyncTest.php | 57 +++ tests/Feature/Sync/CategorySyncTest.php | 54 +++ 22 files changed, 1313 insertions(+), 25 deletions(-) create mode 100644 app/Http/Controllers/Sync/AccountSyncController.php create mode 100644 app/Http/Controllers/Sync/BankSyncController.php create mode 100644 app/Http/Controllers/Sync/CategorySyncController.php create mode 100644 resources/js/components/sync-status-button.tsx create mode 100644 resources/js/contexts/sync-context.tsx create mode 100644 resources/js/hooks/use-online-status.ts create mode 100644 resources/js/lib/indexeddb.ts create mode 100644 resources/js/lib/sync-manager.ts create mode 100644 resources/js/services/account-sync.ts create mode 100644 resources/js/services/bank-sync.ts create mode 100644 resources/js/services/category-sync.ts create mode 100644 tests/Feature/Sync/AccountSyncTest.php create mode 100644 tests/Feature/Sync/BankSyncTest.php create mode 100644 tests/Feature/Sync/CategorySyncTest.php diff --git a/app/Http/Controllers/Sync/AccountSyncController.php b/app/Http/Controllers/Sync/AccountSyncController.php new file mode 100644 index 00000000..07aa6b0c --- /dev/null +++ b/app/Http/Controllers/Sync/AccountSyncController.php @@ -0,0 +1,28 @@ +with('bank') + ->where('user_id', $request->user()->id); + + if ($request->has('since')) { + $query->where('updated_at', '>', $request->input('since')); + } + + $accounts = $query->orderBy('updated_at', 'asc')->get(); + + return response()->json([ + 'data' => $accounts, + ]); + } +} diff --git a/app/Http/Controllers/Sync/BankSyncController.php b/app/Http/Controllers/Sync/BankSyncController.php new file mode 100644 index 00000000..2980df14 --- /dev/null +++ b/app/Http/Controllers/Sync/BankSyncController.php @@ -0,0 +1,29 @@ +where(function ($q) use ($request) { + $q->whereNull('user_id') + ->orWhere('user_id', $request->user()->id); + }); + + if ($request->has('since')) { + $query->where('updated_at', '>', $request->input('since')); + } + + $banks = $query->orderBy('updated_at', 'asc')->get(); + + return response()->json([ + 'data' => $banks, + ]); + } +} diff --git a/app/Http/Controllers/Sync/CategorySyncController.php b/app/Http/Controllers/Sync/CategorySyncController.php new file mode 100644 index 00000000..7dd2f824 --- /dev/null +++ b/app/Http/Controllers/Sync/CategorySyncController.php @@ -0,0 +1,26 @@ +where('user_id', $request->user()->id); + + if ($request->has('since')) { + $query->where('updated_at', '>', $request->input('since')); + } + + $categories = $query->orderBy('updated_at', 'asc')->get(); + + return response()->json([ + 'data' => $categories, + ]); + } +} diff --git a/bun.lock b/bun.lock index 5ae7e6d6..9c737603 100644 --- a/bun.lock +++ b/bun.lock @@ -29,10 +29,11 @@ "clsx": "^2.1.1", "cmdk": "^1.1.1", "concurrently": "^9.0.1", + "date-fns": "^4.1.0", "globals": "^15.14.0", "input-otp": "^1.4.2", "laravel-vite-plugin": "^2.0", - "lucide-react": "^0.475.0", + "lucide-react": "^0.553.0", "react": "^19.2.0", "react-dom": "^19.2.0", "tailwind-merge": "^3.0.1", @@ -534,6 +535,8 @@ "data-view-byte-offset": ["data-view-byte-offset@1.0.1", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "is-data-view": "^1.0.1" } }, "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ=="], + "date-fns": ["date-fns@4.1.0", "", {}, "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg=="], + "debug": ["debug@4.4.1", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ=="], "deep-is": ["deep-is@0.1.4", "", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="], @@ -806,7 +809,7 @@ "lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], - "lucide-react": ["lucide-react@0.475.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-NJzvVu1HwFVeZ+Gwq2q00KygM1aBhy/ZrhY9FsAgJtpB+E4R7uxRk9M2iKvHa6/vNxZydIB59htha4c2vvwvVg=="], + "lucide-react": ["lucide-react@0.553.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-BRgX5zrWmNy/lkVAe0dXBgd7XQdZ3HTf+Hwe3c9WK6dqgnj9h+hxV+MDncM88xDWlCq27+TKvHGE70ViODNILw=="], "magic-string": ["magic-string@0.30.17", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0" } }, "sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA=="], diff --git a/package.json b/package.json index f2b04265..a9c044c1 100644 --- a/package.json +++ b/package.json @@ -52,10 +52,11 @@ "clsx": "^2.1.1", "cmdk": "^1.1.1", "concurrently": "^9.0.1", + "date-fns": "^4.1.0", "globals": "^15.14.0", "input-otp": "^1.4.2", "laravel-vite-plugin": "^2.0", - "lucide-react": "^0.475.0", + "lucide-react": "^0.553.0", "react": "^19.2.0", "react-dom": "^19.2.0", "tailwind-merge": "^3.0.1", diff --git a/resources/js/app.tsx b/resources/js/app.tsx index 48e51b6e..32684899 100644 --- a/resources/js/app.tsx +++ b/resources/js/app.tsx @@ -5,6 +5,7 @@ import { resolvePageComponent } from 'laravel-vite-plugin/inertia-helpers'; import { StrictMode } from 'react'; import { createRoot } from 'react-dom/client'; import { EncryptionKeyProvider } from './contexts/encryption-key-context'; +import { SyncProvider } from './contexts/sync-context'; import { initializeTheme } from './hooks/use-appearance'; const appName = import.meta.env.VITE_APP_NAME || 'Laravel'; @@ -22,7 +23,9 @@ createInertiaApp({ root.render( - + + + , ); diff --git a/resources/js/components/accounts/bank-combobox.tsx b/resources/js/components/accounts/bank-combobox.tsx index 3db91ec7..4c301569 100644 --- a/resources/js/components/accounts/bank-combobox.tsx +++ b/resources/js/components/accounts/bank-combobox.tsx @@ -1,7 +1,7 @@ import { useState, useEffect, useCallback } from 'react'; import { Check, ChevronsUpDown } from 'lucide-react'; -import axios from 'axios'; import { cn } from '@/lib/utils'; +import { bankSyncService } from '@/services/bank-sync'; import { Button } from '@/components/ui/button'; import { Command, @@ -52,13 +52,7 @@ export function BankCombobox({ setIsLoading(true); try { - const response = await axios.get<{ banks: Bank[] }>( - '/settings/banks/search', - { - params: { query }, - }, - ); - const results = response.data.banks; + const results = await bankSyncService.search(query); bankCache.set(query, results); setBanks(results); } catch (error) { diff --git a/resources/js/components/app-sidebar-header.tsx b/resources/js/components/app-sidebar-header.tsx index ea264b08..f8038af8 100644 --- a/resources/js/components/app-sidebar-header.tsx +++ b/resources/js/components/app-sidebar-header.tsx @@ -1,5 +1,6 @@ import { Breadcrumbs } from '@/components/breadcrumbs'; import { EncryptionKeyButton } from '@/components/encryption-key-button'; +import { SyncStatusButton } from '@/components/sync-status-button'; import { SidebarTrigger } from '@/components/ui/sidebar'; import { type BreadcrumbItem as BreadcrumbItemType } from '@/types'; @@ -14,7 +15,10 @@ export function AppSidebarHeader({ - +
+ + +
); } diff --git a/resources/js/components/sync-status-button.tsx b/resources/js/components/sync-status-button.tsx new file mode 100644 index 00000000..baa209b3 --- /dev/null +++ b/resources/js/components/sync-status-button.tsx @@ -0,0 +1,79 @@ +import { Cloud, CloudAlert, CloudCheck, CloudOff, RefreshCw } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from '@/components/ui/tooltip'; +import { useSyncContext } from '@/contexts/sync-context'; +import { formatDistanceToNow } from 'date-fns'; + +export function SyncStatusButton() { + const { syncStatus, lastSyncTime, isOnline, sync, error } = + useSyncContext(); + + const getIcon = () => { + if (syncStatus === 'syncing') { + return ; + } + + if (syncStatus === 'error') { + return ; + } + + if (!isOnline) { + return ; + } + + return ; + }; + + const getTooltipText = () => { + if (syncStatus === 'syncing') { + return 'Syncing...'; + } + + if (!isOnline) { + return 'Offline - Click to retry when online'; + } + + if (syncStatus === 'error') { + return error || 'Sync failed - Click to retry'; + } + + if (lastSyncTime) { + return `Synced ${formatDistanceToNow(lastSyncTime, { addSuffix: true })}`; + } + + return 'Not synced yet - Click to sync'; + }; + + const getVariant = () => { + if (syncStatus === 'error' || !isOnline) { + return 'destructive' as const; + } + return 'ghost' as const; + }; + + return ( + + + + + + +

{getTooltipText()}

+
+
+
+ ); +} + diff --git a/resources/js/contexts/sync-context.tsx b/resources/js/contexts/sync-context.tsx new file mode 100644 index 00000000..5c49c492 --- /dev/null +++ b/resources/js/contexts/sync-context.tsx @@ -0,0 +1,132 @@ +import React, { + createContext, + useContext, + useState, + useEffect, + useCallback, + type ReactNode, +} from 'react'; +import { useOnlineStatus } from '@/hooks/use-online-status'; +import { categorySyncService } from '@/services/category-sync'; +import { accountSyncService } from '@/services/account-sync'; +import { bankSyncService } from '@/services/bank-sync'; + +export type SyncStatus = 'idle' | 'syncing' | 'success' | 'error'; + +interface SyncContextType { + syncStatus: SyncStatus; + lastSyncTime: Date | null; + isOnline: boolean; + sync: () => Promise; + error: string | null; +} + +const SyncContext = createContext(undefined); + +const SYNC_INTERVAL = 5 * 60 * 1000; + +export function SyncProvider({ children }: { children: ReactNode }) { + const isOnline = useOnlineStatus(); + const [syncStatus, setSyncStatus] = useState('idle'); + const [lastSyncTime, setLastSyncTime] = useState(null); + const [error, setError] = useState(null); + const [wasOffline, setWasOffline] = useState(!isOnline); + + const sync = useCallback(async () => { + if (!isOnline) { + setError('Cannot sync while offline'); + return; + } + + if (syncStatus === 'syncing') { + return; + } + + setSyncStatus('syncing'); + setError(null); + + try { + const [categoriesResult, accountsResult, banksResult] = + await Promise.all([ + categorySyncService.sync(), + accountSyncService.sync(), + bankSyncService.sync(), + ]); + + const allErrors = [ + ...categoriesResult.errors, + ...accountsResult.errors, + ...banksResult.errors, + ]; + + if (allErrors.length > 0) { + setError(allErrors.join(', ')); + setSyncStatus('error'); + } else { + setSyncStatus('success'); + setLastSyncTime(new Date()); + + setTimeout(() => { + setSyncStatus('idle'); + }, 3000); + } + } catch (err) { + console.error('Sync failed:', err); + setError( + err instanceof Error ? err.message : 'Unknown sync error', + ); + setSyncStatus('error'); + + setTimeout(() => { + setSyncStatus('idle'); + }, 5000); + } + }, [isOnline, syncStatus]); + + useEffect(() => { + if (isOnline && wasOffline) { + sync(); + } + setWasOffline(!isOnline); + }, [isOnline, wasOffline, sync]); + + useEffect(() => { + if (!isOnline) { + return; + } + + const interval = setInterval(() => { + sync(); + }, SYNC_INTERVAL); + + return () => clearInterval(interval); + }, [isOnline, sync]); + + useEffect(() => { + sync(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return ( + + {children} + + ); +} + +export function useSyncContext() { + const context = useContext(SyncContext); + if (context === undefined) { + throw new Error('useSyncContext must be used within a SyncProvider'); + } + return context; +} + diff --git a/resources/js/hooks/use-online-status.ts b/resources/js/hooks/use-online-status.ts new file mode 100644 index 00000000..92d8d235 --- /dev/null +++ b/resources/js/hooks/use-online-status.ts @@ -0,0 +1,24 @@ +import { useState, useEffect } from 'react'; + +export function useOnlineStatus() { + const [isOnline, setIsOnline] = useState(() => { + if (typeof window === 'undefined') return true; + return navigator.onLine; + }); + + 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; +} + diff --git a/resources/js/lib/indexeddb.ts b/resources/js/lib/indexeddb.ts new file mode 100644 index 00000000..0a95a37b --- /dev/null +++ b/resources/js/lib/indexeddb.ts @@ -0,0 +1,350 @@ +const DB_NAME = 'whisper_money'; +const DB_VERSION = 1; + +export interface IndexedDBRecord { + id: number; + user_id?: number | null; + created_at: string; + updated_at: string; + [key: string]: any; +} + +export type StoreName = 'categories' | 'accounts' | 'banks'; + +class IndexedDBService { + private db: IDBDatabase | null = null; + private initPromise: Promise | null = null; + + async init(): Promise { + if (this.db) { + return this.db; + } + + if (this.initPromise) { + return this.initPromise; + } + + this.initPromise = new Promise((resolve, reject) => { + const request = indexedDB.open(DB_NAME, DB_VERSION); + + request.onerror = () => { + reject(new Error('Failed to open IndexedDB')); + }; + + request.onsuccess = () => { + this.db = request.result; + resolve(this.db); + }; + + request.onupgradeneeded = (event) => { + const db = (event.target as IDBOpenDBRequest).result; + + if (!db.objectStoreNames.contains('categories')) { + const categoryStore = db.createObjectStore('categories', { + keyPath: 'id', + }); + categoryStore.createIndex('user_id', 'user_id', { + unique: false, + }); + categoryStore.createIndex('updated_at', 'updated_at', { + unique: false, + }); + } + + if (!db.objectStoreNames.contains('accounts')) { + const accountStore = db.createObjectStore('accounts', { + keyPath: 'id', + }); + accountStore.createIndex('user_id', 'user_id', { + unique: false, + }); + accountStore.createIndex('updated_at', 'updated_at', { + unique: false, + }); + } + + if (!db.objectStoreNames.contains('banks')) { + const bankStore = db.createObjectStore('banks', { + keyPath: 'id', + }); + bankStore.createIndex('user_id', 'user_id', { + unique: false, + }); + bankStore.createIndex('updated_at', 'updated_at', { + unique: false, + }); + } + + if (!db.objectStoreNames.contains('sync_metadata')) { + db.createObjectStore('sync_metadata', { keyPath: 'key' }); + } + + if (!db.objectStoreNames.contains('pending_changes')) { + const pendingStore = db.createObjectStore('pending_changes', { + keyPath: 'id', + autoIncrement: true, + }); + pendingStore.createIndex('store', 'store', { + unique: false, + }); + pendingStore.createIndex('timestamp', 'timestamp', { + unique: false, + }); + } + }; + }); + + return this.initPromise; + } + + async getAll( + storeName: StoreName, + ): Promise { + const db = await this.init(); + return new Promise((resolve, reject) => { + const transaction = db.transaction(storeName, 'readonly'); + const store = transaction.objectStore(storeName); + const request = store.getAll(); + + request.onsuccess = () => { + resolve(request.result as T[]); + }; + + request.onerror = () => { + reject(new Error(`Failed to get all from ${storeName}`)); + }; + }); + } + + async getById( + storeName: StoreName, + id: number, + ): Promise { + const db = await this.init(); + return new Promise((resolve, reject) => { + const transaction = db.transaction(storeName, 'readonly'); + const store = transaction.objectStore(storeName); + const request = store.get(id); + + request.onsuccess = () => { + resolve(request.result as T | null); + }; + + request.onerror = () => { + reject(new Error(`Failed to get from ${storeName}`)); + }; + }); + } + + async put( + storeName: StoreName, + record: T, + ): Promise { + const db = await this.init(); + return new Promise((resolve, reject) => { + const transaction = db.transaction(storeName, 'readwrite'); + const store = transaction.objectStore(storeName); + const request = store.put(record); + + request.onsuccess = () => { + resolve(); + }; + + request.onerror = () => { + reject(new Error(`Failed to put to ${storeName}`)); + }; + }); + } + + async putMany( + storeName: StoreName, + records: T[], + ): Promise { + const db = await this.init(); + return new Promise((resolve, reject) => { + const transaction = db.transaction(storeName, 'readwrite'); + const store = transaction.objectStore(storeName); + + let completed = 0; + let hasError = false; + + records.forEach((record) => { + const request = store.put(record); + + request.onsuccess = () => { + completed++; + if (completed === records.length && !hasError) { + resolve(); + } + }; + + request.onerror = () => { + if (!hasError) { + hasError = true; + reject( + new Error(`Failed to put many to ${storeName}`), + ); + } + }; + }); + + if (records.length === 0) { + resolve(); + } + }); + } + + async delete(storeName: StoreName, id: number): Promise { + const db = await this.init(); + return new Promise((resolve, reject) => { + const transaction = db.transaction(storeName, 'readwrite'); + const store = transaction.objectStore(storeName); + const request = store.delete(id); + + request.onsuccess = () => { + resolve(); + }; + + request.onerror = () => { + reject(new Error(`Failed to delete from ${storeName}`)); + }; + }); + } + + async clear(storeName: StoreName): Promise { + const db = await this.init(); + return new Promise((resolve, reject) => { + const transaction = db.transaction(storeName, 'readwrite'); + const store = transaction.objectStore(storeName); + const request = store.clear(); + + request.onsuccess = () => { + resolve(); + }; + + request.onerror = () => { + reject(new Error(`Failed to clear ${storeName}`)); + }; + }); + } + + async getSyncMetadata(key: string): Promise { + const db = await this.init(); + return new Promise((resolve, reject) => { + const transaction = db.transaction('sync_metadata', 'readonly'); + const store = transaction.objectStore('sync_metadata'); + const request = store.get(key); + + request.onsuccess = () => { + resolve(request.result?.value); + }; + + request.onerror = () => { + reject(new Error('Failed to get sync metadata')); + }; + }); + } + + async setSyncMetadata(key: string, value: any): Promise { + const db = await this.init(); + return new Promise((resolve, reject) => { + const transaction = db.transaction('sync_metadata', 'readwrite'); + const store = transaction.objectStore('sync_metadata'); + const request = store.put({ key, value }); + + request.onsuccess = () => { + resolve(); + }; + + request.onerror = () => { + reject(new Error('Failed to set sync metadata')); + }; + }); + } + + async addPendingChange( + storeName: StoreName, + operation: 'create' | 'update' | 'delete', + data: any, + ): Promise { + const db = await this.init(); + return new Promise((resolve, reject) => { + const transaction = db.transaction('pending_changes', 'readwrite'); + const store = transaction.objectStore('pending_changes'); + const request = store.add({ + store: storeName, + operation, + data, + timestamp: new Date().toISOString(), + }); + + request.onsuccess = () => { + resolve(); + }; + + request.onerror = () => { + reject(new Error('Failed to add pending change')); + }; + }); + } + + async getPendingChanges(storeName?: StoreName): Promise { + const db = await this.init(); + return new Promise((resolve, reject) => { + const transaction = db.transaction('pending_changes', 'readonly'); + const store = transaction.objectStore('pending_changes'); + + let request: IDBRequest; + if (storeName) { + const index = store.index('store'); + request = index.getAll(storeName); + } else { + request = store.getAll(); + } + + request.onsuccess = () => { + resolve(request.result); + }; + + request.onerror = () => { + reject(new Error('Failed to get pending changes')); + }; + }); + } + + async clearPendingChanges(storeName?: StoreName): Promise { + const db = await this.init(); + return new Promise((resolve, reject) => { + const transaction = db.transaction('pending_changes', 'readwrite'); + const store = transaction.objectStore('pending_changes'); + + if (!storeName) { + const request = store.clear(); + request.onsuccess = () => resolve(); + request.onerror = () => + reject(new Error('Failed to clear pending changes')); + return; + } + + const index = store.index('store'); + const request = index.openCursor(IDBKeyRange.only(storeName)); + + request.onsuccess = (event) => { + const cursor = (event.target as IDBRequest).result; + if (cursor) { + cursor.delete(); + cursor.continue(); + } else { + resolve(); + } + }; + + request.onerror = () => { + reject(new Error('Failed to clear pending changes')); + }; + }); + } +} + +export const indexedDBService = new IndexedDBService(); + diff --git a/resources/js/lib/sync-manager.ts b/resources/js/lib/sync-manager.ts new file mode 100644 index 00000000..717c27b1 --- /dev/null +++ b/resources/js/lib/sync-manager.ts @@ -0,0 +1,239 @@ +import axios from 'axios'; +import { + indexedDBService, + type StoreName, + type IndexedDBRecord, +} from './indexeddb'; + +export interface SyncOptions { + storeName: StoreName; + endpoint: string; + transformFromServer?: (data: any) => any; + transformToServer?: (data: any) => any; +} + +export interface SyncResult { + success: boolean; + inserted: number; + updated: number; + deleted: number; + errors: string[]; +} + +export class SyncManager { + private syncInProgress = false; + private lastSyncKey: string; + + constructor(private options: SyncOptions) { + this.lastSyncKey = `last_sync_${options.storeName}`; + } + + async getLastSyncTime(): Promise { + return await indexedDBService.getSyncMetadata(this.lastSyncKey); + } + + async setLastSyncTime(timestamp: string): Promise { + await indexedDBService.setSyncMetadata(this.lastSyncKey, timestamp); + } + + async sync(): Promise { + if (this.syncInProgress) { + return { + success: false, + inserted: 0, + updated: 0, + deleted: 0, + errors: ['Sync already in progress'], + }; + } + + this.syncInProgress = true; + + const result: SyncResult = { + success: true, + inserted: 0, + updated: 0, + deleted: 0, + errors: [], + }; + + try { + await this.syncFromServer(result); + await this.syncToServer(result); + + await indexedDBService.clearPendingChanges( + this.options.storeName, + ); + + await this.setLastSyncTime(new Date().toISOString()); + } catch (error) { + result.success = false; + result.errors.push( + error instanceof Error ? error.message : 'Unknown error', + ); + } finally { + this.syncInProgress = false; + } + + return result; + } + + private async syncFromServer(result: SyncResult): Promise { + const lastSync = await this.getLastSyncTime(); + + const params: any = {}; + if (lastSync) { + params.since = lastSync; + } + + const response = await axios.get(this.options.endpoint, { params }); + + const serverData = response.data.data || response.data; + + if (!Array.isArray(serverData)) { + throw new Error('Invalid server response format'); + } + + const localRecords = await indexedDBService.getAll( + this.options.storeName, + ); + const localMap = new Map(localRecords.map((r) => [r.id, r])); + + for (const serverRecord of serverData) { + const transformed = this.options.transformFromServer + ? this.options.transformFromServer(serverRecord) + : serverRecord; + + const localRecord = localMap.get(transformed.id); + + if (!localRecord) { + await indexedDBService.put( + this.options.storeName, + transformed, + ); + result.inserted++; + } else { + const serverDate = new Date(transformed.updated_at); + const localDate = new Date(localRecord.updated_at); + + if (serverDate > localDate) { + await indexedDBService.put( + this.options.storeName, + transformed, + ); + result.updated++; + } + } + } + } + + private async syncToServer(result: SyncResult): Promise { + const pendingChanges = await indexedDBService.getPendingChanges( + this.options.storeName, + ); + + for (const change of pendingChanges) { + try { + const data = this.options.transformToServer + ? this.options.transformToServer(change.data) + : change.data; + + switch (change.operation) { + case 'create': + await axios.post(this.options.endpoint, data); + result.inserted++; + break; + case 'update': + await axios.patch( + `${this.options.endpoint}/${change.data.id}`, + data, + ); + result.updated++; + break; + case 'delete': + await axios.delete( + `${this.options.endpoint}/${change.data.id}`, + ); + result.deleted++; + break; + } + } catch (error) { + result.errors.push( + `Failed to sync ${change.operation} for ${this.options.storeName}: ${error}`, + ); + } + } + } + + async createLocal( + data: Omit, + ): Promise { + const timestamp = new Date().toISOString(); + const tempId = Date.now(); + + const record = { + ...data, + id: tempId, + created_at: timestamp, + updated_at: timestamp, + } as T; + + await indexedDBService.put(this.options.storeName, record); + await indexedDBService.addPendingChange( + this.options.storeName, + 'create', + record, + ); + + return record; + } + + async updateLocal( + id: number, + data: Partial, + ): Promise { + const existing = await indexedDBService.getById( + this.options.storeName, + id, + ); + + if (!existing) { + throw new Error(`Record ${id} not found in ${this.options.storeName}`); + } + + const updated = { + ...existing, + ...data, + updated_at: new Date().toISOString(), + }; + + await indexedDBService.put(this.options.storeName, updated); + await indexedDBService.addPendingChange( + this.options.storeName, + 'update', + updated, + ); + } + + async deleteLocal(id: number): Promise { + await indexedDBService.delete(this.options.storeName, id); + await indexedDBService.addPendingChange( + this.options.storeName, + 'delete', + { id }, + ); + } + + async getAll(): Promise { + return await indexedDBService.getAll(this.options.storeName); + } + + async getById(id: number): Promise { + return await indexedDBService.getById(this.options.storeName, id); + } + + isSyncing(): boolean { + return this.syncInProgress; + } +} + diff --git a/resources/js/pages/settings/accounts.tsx b/resources/js/pages/settings/accounts.tsx index d96babc4..e0235cb6 100644 --- a/resources/js/pages/settings/accounts.tsx +++ b/resources/js/pages/settings/accounts.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useState, useEffect } from 'react'; import { Head } from '@inertiajs/react'; import { ColumnDef, @@ -45,10 +45,8 @@ import { } from '@/types/account'; import { type BreadcrumbItem } from '@/types'; import { index as accountsIndex } from '@/actions/App/Http/Controllers/Settings/AccountController'; - -interface AccountsProps { - accounts: Account[]; -} +import { accountSyncService } from '@/services/account-sync'; +import { useSyncContext } from '@/contexts/sync-context'; const breadcrumbs: BreadcrumbItem[] = [ { @@ -98,13 +96,34 @@ function AccountActions({ account }: { account: Account }) { ); } -export default function Accounts({ accounts }: AccountsProps) { +export default function Accounts() { + const { syncStatus } = useSyncContext(); + const [accounts, setAccounts] = useState([]); const [sorting, setSorting] = useState([]); const [columnFilters, setColumnFilters] = useState([]); const [columnVisibility, setColumnVisibility] = useState( {}, ); + useEffect(() => { + const loadAccounts = async () => { + const data = await accountSyncService.getAll(); + setAccounts(data); + }; + + loadAccounts(); + }, []); + + useEffect(() => { + if (syncStatus === 'success') { + const reloadAccounts = async () => { + const data = await accountSyncService.getAll(); + setAccounts(data); + }; + reloadAccounts(); + } + }, [syncStatus]); + const columns: ColumnDef[] = [ { accessorKey: 'name', diff --git a/resources/js/pages/settings/categories.tsx b/resources/js/pages/settings/categories.tsx index 86cf232e..c3fd8633 100644 --- a/resources/js/pages/settings/categories.tsx +++ b/resources/js/pages/settings/categories.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useState, useEffect } from 'react'; import { Head } from '@inertiajs/react'; import { ColumnDef, @@ -45,10 +45,8 @@ import { } from '@/types/category'; import { type BreadcrumbItem } from '@/types'; import { index as categoriesIndex } from '@/actions/App/Http/Controllers/Settings/CategoryController'; - -interface CategoriesProps { - categories: Category[]; -} +import { categorySyncService } from '@/services/category-sync'; +import { useSyncContext } from '@/contexts/sync-context'; const breadcrumbs: BreadcrumbItem[] = [ { @@ -98,13 +96,34 @@ function CategoryActions({ category }: { category: Category }) { ); } -export default function Categories({ categories }: CategoriesProps) { +export default function Categories() { + const { syncStatus } = useSyncContext(); + const [categories, setCategories] = useState([]); const [sorting, setSorting] = useState([]); const [columnFilters, setColumnFilters] = useState([]); const [columnVisibility, setColumnVisibility] = useState( {}, ); + useEffect(() => { + const loadCategories = async () => { + const data = await categorySyncService.getAll(); + setCategories(data); + }; + + loadCategories(); + }, []); + + useEffect(() => { + if (syncStatus === 'success') { + const reloadCategories = async () => { + const data = await categorySyncService.getAll(); + setCategories(data); + }; + reloadCategories(); + } + }, [syncStatus]); + const columns: ColumnDef[] = [ { accessorKey: 'name', diff --git a/resources/js/services/account-sync.ts b/resources/js/services/account-sync.ts new file mode 100644 index 00000000..0e1a4927 --- /dev/null +++ b/resources/js/services/account-sync.ts @@ -0,0 +1,48 @@ +import { SyncManager } from '@/lib/sync-manager'; +import type { Account } from '@/types/account'; + +class AccountSyncService { + private syncManager: SyncManager; + + constructor() { + this.syncManager = new SyncManager({ + storeName: 'accounts', + endpoint: '/api/sync/accounts', + }); + } + + async sync() { + return await this.syncManager.sync(); + } + + async getAll(): Promise { + return await this.syncManager.getAll(); + } + + async getById(id: number): Promise { + return await this.syncManager.getById(id); + } + + async create(data: Omit): Promise { + return await this.syncManager.createLocal(data as any); + } + + async update(id: number, data: Partial): Promise { + await this.syncManager.updateLocal(id, data); + } + + async delete(id: number): Promise { + await this.syncManager.deleteLocal(id); + } + + async getLastSyncTime(): Promise { + return await this.syncManager.getLastSyncTime(); + } + + isSyncing(): boolean { + return this.syncManager.isSyncing(); + } +} + +export const accountSyncService = new AccountSyncService(); + diff --git a/resources/js/services/bank-sync.ts b/resources/js/services/bank-sync.ts new file mode 100644 index 00000000..0ebdd303 --- /dev/null +++ b/resources/js/services/bank-sync.ts @@ -0,0 +1,44 @@ +import { SyncManager } from '@/lib/sync-manager'; +import type { Bank } from '@/types/account'; + +class BankSyncService { + private syncManager: SyncManager; + + constructor() { + this.syncManager = new SyncManager({ + storeName: 'banks', + endpoint: '/api/sync/banks', + }); + } + + async sync() { + return await this.syncManager.sync(); + } + + async getAll(): Promise { + return await this.syncManager.getAll(); + } + + async getById(id: number): Promise { + return await this.syncManager.getById(id); + } + + async search(query: string): Promise { + const allBanks = await this.getAll(); + const lowerQuery = query.toLowerCase(); + return allBanks.filter((bank) => + bank.name.toLowerCase().includes(lowerQuery), + ); + } + + async getLastSyncTime(): Promise { + return await this.syncManager.getLastSyncTime(); + } + + isSyncing(): boolean { + return this.syncManager.isSyncing(); + } +} + +export const bankSyncService = new BankSyncService(); + diff --git a/resources/js/services/category-sync.ts b/resources/js/services/category-sync.ts new file mode 100644 index 00000000..2ec313c3 --- /dev/null +++ b/resources/js/services/category-sync.ts @@ -0,0 +1,48 @@ +import { SyncManager } from '@/lib/sync-manager'; +import type { Category } from '@/types/category'; + +class CategorySyncService { + private syncManager: SyncManager; + + constructor() { + this.syncManager = new SyncManager({ + storeName: 'categories', + endpoint: '/api/sync/categories', + }); + } + + async sync() { + return await this.syncManager.sync(); + } + + async getAll(): Promise { + return await this.syncManager.getAll(); + } + + async getById(id: number): Promise { + return await this.syncManager.getById(id); + } + + async create(data: Omit): Promise { + return await this.syncManager.createLocal(data as any); + } + + async update(id: number, data: Partial): Promise { + await this.syncManager.updateLocal(id, data); + } + + async delete(id: number): Promise { + await this.syncManager.deleteLocal(id); + } + + async getLastSyncTime(): Promise { + return await this.syncManager.getLastSyncTime(); + } + + isSyncing(): boolean { + return this.syncManager.isSyncing(); + } +} + +export const categorySyncService = new CategorySyncService(); + diff --git a/routes/web.php b/routes/web.php index 46ec7004..1111db83 100644 --- a/routes/web.php +++ b/routes/web.php @@ -1,6 +1,9 @@ group(function () { Route::post('api/encryption/setup', [EncryptionController::class, 'setup']); Route::get('api/encryption/message', [EncryptionController::class, 'getMessage']); Route::put('api/encryption/message', [EncryptionController::class, 'updateMessage']); + + Route::get('api/sync/categories', [CategorySyncController::class, 'index']); + Route::get('api/sync/accounts', [AccountSyncController::class, 'index']); + Route::get('api/sync/banks', [BankSyncController::class, 'index']); }); Route::middleware(['auth', 'verified', 'redirect.encryption'])->group(function () { diff --git a/tests/Feature/Sync/AccountSyncTest.php b/tests/Feature/Sync/AccountSyncTest.php new file mode 100644 index 00000000..d5be0633 --- /dev/null +++ b/tests/Feature/Sync/AccountSyncTest.php @@ -0,0 +1,80 @@ +user = User::factory()->create(); +}); + +it('returns all accounts for authenticated user', function () { + actingAs($this->user); + + $bank = Bank::factory()->create(['user_id' => $this->user->id]); + + $accounts = Account::factory() + ->count(3) + ->create([ + 'user_id' => $this->user->id, + 'bank_id' => $bank->id, + ]); + + Account::factory() + ->count(2) + ->create(); + + $response = getJson('/api/sync/accounts'); + + $response->assertSuccessful(); + $response->assertJsonCount(3, 'data'); +}); + +it('returns only accounts updated after specified timestamp', function () { + actingAs($this->user); + + $bank = Bank::factory()->create(['user_id' => $this->user->id]); + + $oldAccount = Account::factory()->create([ + 'user_id' => $this->user->id, + 'bank_id' => $bank->id, + 'updated_at' => now()->subDays(2), + ]); + + $newAccount = Account::factory()->create([ + 'user_id' => $this->user->id, + 'bank_id' => $bank->id, + 'updated_at' => now(), + ]); + + $response = getJson('/api/sync/accounts?since='.now()->subDay()->toIso8601String()); + + $response->assertSuccessful(); + $response->assertJsonCount(1, 'data'); + $response->assertJsonPath('data.0.id', $newAccount->id); +}); + +it('includes bank relationship', function () { + actingAs($this->user); + + $bank = Bank::factory()->create(['user_id' => $this->user->id]); + + $account = Account::factory()->create([ + 'user_id' => $this->user->id, + 'bank_id' => $bank->id, + ]); + + $response = getJson('/api/sync/accounts'); + + $response->assertSuccessful(); + $response->assertJsonPath('data.0.bank.id', $bank->id); +}); + +it('requires authentication', function () { + $response = getJson('/api/sync/accounts'); + + $response->assertUnauthorized(); +}); diff --git a/tests/Feature/Sync/BankSyncTest.php b/tests/Feature/Sync/BankSyncTest.php new file mode 100644 index 00000000..21d91e2d --- /dev/null +++ b/tests/Feature/Sync/BankSyncTest.php @@ -0,0 +1,57 @@ +user = User::factory()->create(); +}); + +it('returns shared banks and user banks but not other users banks', function () { + actingAs($this->user); + + $otherUser = User::factory()->create(); + + $sharedBank = Bank::factory()->create(['user_id' => null]); + $userBank = Bank::factory()->create(['user_id' => $this->user->id]); + $otherUserBank = Bank::factory()->create(['user_id' => $otherUser->id]); + + $response = getJson('/api/sync/banks'); + + $response->assertSuccessful(); + + $bankIds = collect($response->json('data'))->pluck('id')->toArray(); + + expect($bankIds)->toContain($sharedBank->id); + expect($bankIds)->toContain($userBank->id); + expect($bankIds)->not->toContain($otherUserBank->id); +}); + +it('returns only banks updated after specified timestamp', function () { + actingAs($this->user); + + $oldBank = Bank::factory()->create([ + 'user_id' => null, + 'updated_at' => now()->subDays(2), + ]); + + $newBank = Bank::factory()->create([ + 'user_id' => null, + 'updated_at' => now(), + ]); + + $response = getJson('/api/sync/banks?since='.now()->subDay()->toIso8601String()); + + $response->assertSuccessful(); + $response->assertJsonCount(1, 'data'); + $response->assertJsonPath('data.0.id', $newBank->id); +}); + +it('requires authentication', function () { + $response = getJson('/api/sync/banks'); + + $response->assertUnauthorized(); +}); diff --git a/tests/Feature/Sync/CategorySyncTest.php b/tests/Feature/Sync/CategorySyncTest.php new file mode 100644 index 00000000..871e91a8 --- /dev/null +++ b/tests/Feature/Sync/CategorySyncTest.php @@ -0,0 +1,54 @@ +user = User::factory()->create(); +}); + +it('returns all categories for authenticated user', function () { + actingAs($this->user); + + $categories = Category::factory() + ->count(3) + ->create(['user_id' => $this->user->id]); + + Category::factory() + ->count(2) + ->create(); + + $response = getJson('/api/sync/categories'); + + $response->assertSuccessful(); + $response->assertJsonCount(3, 'data'); +}); + +it('returns only categories updated after specified timestamp', function () { + actingAs($this->user); + + $oldCategory = Category::factory()->create([ + 'user_id' => $this->user->id, + 'updated_at' => now()->subDays(2), + ]); + + $newCategory = Category::factory()->create([ + 'user_id' => $this->user->id, + 'updated_at' => now(), + ]); + + $response = getJson('/api/sync/categories?since='.now()->subDay()->toIso8601String()); + + $response->assertSuccessful(); + $response->assertJsonCount(1, 'data'); + $response->assertJsonPath('data.0.id', $newCategory->id); +}); + +it('requires authentication', function () { + $response = getJson('/api/sync/categories'); + + $response->assertUnauthorized(); +});