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();
+});