Offline First: sync bank, accounts, and categories

This commit is contained in:
Víctor Falcón 2025-11-08 11:29:21 +00:00
parent 80949a8ec8
commit 9d95f884a0
22 changed files with 1313 additions and 25 deletions

View File

@ -0,0 +1,28 @@
<?php
namespace App\Http\Controllers\Sync;
use App\Http\Controllers\Controller;
use App\Models\Account;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class AccountSyncController extends Controller
{
public function index(Request $request): JsonResponse
{
$query = Account::query()
->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,
]);
}
}

View File

@ -0,0 +1,29 @@
<?php
namespace App\Http\Controllers\Sync;
use App\Http\Controllers\Controller;
use App\Models\Bank;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class BankSyncController extends Controller
{
public function index(Request $request): JsonResponse
{
$query = Bank::query()->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,
]);
}
}

View File

@ -0,0 +1,26 @@
<?php
namespace App\Http\Controllers\Sync;
use App\Http\Controllers\Controller;
use App\Models\Category;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class CategorySyncController extends Controller
{
public function index(Request $request): JsonResponse
{
$query = Category::query()->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,
]);
}
}

View File

@ -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=="],

View File

@ -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",

View File

@ -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(
<StrictMode>
<EncryptionKeyProvider>
<App {...props} />
<SyncProvider>
<App {...props} />
</SyncProvider>
</EncryptionKeyProvider>
</StrictMode>,
);

View File

@ -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) {

View File

@ -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({
<SidebarTrigger className="-ml-1" />
<Breadcrumbs breadcrumbs={breadcrumbs} />
</div>
<EncryptionKeyButton />
<div className="flex items-center gap-2">
<SyncStatusButton />
<EncryptionKeyButton />
</div>
</header>
);
}

View File

@ -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 <RefreshCw className="h-4 w-4 animate-spin" />;
}
if (syncStatus === 'error') {
return <CloudAlert className="h-4 w-4" />;
}
if (!isOnline) {
return <CloudOff className="h-4 w-4" />;
}
return <CloudCheck className="h-4 w-4" />;
};
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 (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant={getVariant()}
size="icon"
onClick={sync}
disabled={syncStatus === 'syncing' || !isOnline}
>
{getIcon()}
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{getTooltipText()}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}

View File

@ -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<void>;
error: string | null;
}
const SyncContext = createContext<SyncContextType | undefined>(undefined);
const SYNC_INTERVAL = 5 * 60 * 1000;
export function SyncProvider({ children }: { children: ReactNode }) {
const isOnline = useOnlineStatus();
const [syncStatus, setSyncStatus] = useState<SyncStatus>('idle');
const [lastSyncTime, setLastSyncTime] = useState<Date | null>(null);
const [error, setError] = useState<string | null>(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 (
<SyncContext.Provider
value={{
syncStatus,
lastSyncTime,
isOnline,
sync,
error,
}}
>
{children}
</SyncContext.Provider>
);
}
export function useSyncContext() {
const context = useContext(SyncContext);
if (context === undefined) {
throw new Error('useSyncContext must be used within a SyncProvider');
}
return context;
}

View File

@ -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;
}

View File

@ -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<IDBDatabase> | null = null;
async init(): Promise<IDBDatabase> {
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<T extends IndexedDBRecord>(
storeName: StoreName,
): Promise<T[]> {
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<T extends IndexedDBRecord>(
storeName: StoreName,
id: number,
): Promise<T | null> {
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<T extends IndexedDBRecord>(
storeName: StoreName,
record: T,
): Promise<void> {
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<T extends IndexedDBRecord>(
storeName: StoreName,
records: T[],
): Promise<void> {
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<void> {
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<void> {
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<any> {
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<void> {
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<void> {
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<any[]> {
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<void> {
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();

View File

@ -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<string | null> {
return await indexedDBService.getSyncMetadata(this.lastSyncKey);
}
async setLastSyncTime(timestamp: string): Promise<void> {
await indexedDBService.setSyncMetadata(this.lastSyncKey, timestamp);
}
async sync(): Promise<SyncResult> {
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<void> {
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<IndexedDBRecord>(
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<void> {
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<T extends IndexedDBRecord>(
data: Omit<T, 'id' | 'created_at' | 'updated_at'>,
): Promise<T> {
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<T extends IndexedDBRecord>(
id: number,
data: Partial<T>,
): Promise<void> {
const existing = await indexedDBService.getById<T>(
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<void> {
await indexedDBService.delete(this.options.storeName, id);
await indexedDBService.addPendingChange(
this.options.storeName,
'delete',
{ id },
);
}
async getAll<T extends IndexedDBRecord>(): Promise<T[]> {
return await indexedDBService.getAll<T>(this.options.storeName);
}
async getById<T extends IndexedDBRecord>(id: number): Promise<T | null> {
return await indexedDBService.getById<T>(this.options.storeName, id);
}
isSyncing(): boolean {
return this.syncInProgress;
}
}

View File

@ -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<Account[]>([]);
const [sorting, setSorting] = useState<SortingState>([]);
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>(
{},
);
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<Account>[] = [
{
accessorKey: 'name',

View File

@ -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<Category[]>([]);
const [sorting, setSorting] = useState<SortingState>([]);
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>(
{},
);
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<Category>[] = [
{
accessorKey: 'name',

View File

@ -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<Account[]> {
return await this.syncManager.getAll<Account>();
}
async getById(id: number): Promise<Account | null> {
return await this.syncManager.getById<Account>(id);
}
async create(data: Omit<Account, 'id'>): Promise<Account> {
return await this.syncManager.createLocal<Account>(data as any);
}
async update(id: number, data: Partial<Account>): Promise<void> {
await this.syncManager.updateLocal<Account>(id, data);
}
async delete(id: number): Promise<void> {
await this.syncManager.deleteLocal(id);
}
async getLastSyncTime(): Promise<string | null> {
return await this.syncManager.getLastSyncTime();
}
isSyncing(): boolean {
return this.syncManager.isSyncing();
}
}
export const accountSyncService = new AccountSyncService();

View File

@ -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<Bank[]> {
return await this.syncManager.getAll<Bank>();
}
async getById(id: number): Promise<Bank | null> {
return await this.syncManager.getById<Bank>(id);
}
async search(query: string): Promise<Bank[]> {
const allBanks = await this.getAll();
const lowerQuery = query.toLowerCase();
return allBanks.filter((bank) =>
bank.name.toLowerCase().includes(lowerQuery),
);
}
async getLastSyncTime(): Promise<string | null> {
return await this.syncManager.getLastSyncTime();
}
isSyncing(): boolean {
return this.syncManager.isSyncing();
}
}
export const bankSyncService = new BankSyncService();

View File

@ -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<Category[]> {
return await this.syncManager.getAll<Category>();
}
async getById(id: number): Promise<Category | null> {
return await this.syncManager.getById<Category>(id);
}
async create(data: Omit<Category, 'id'>): Promise<Category> {
return await this.syncManager.createLocal<Category>(data as any);
}
async update(id: number, data: Partial<Category>): Promise<void> {
await this.syncManager.updateLocal<Category>(id, data);
}
async delete(id: number): Promise<void> {
await this.syncManager.deleteLocal(id);
}
async getLastSyncTime(): Promise<string | null> {
return await this.syncManager.getLastSyncTime();
}
isSyncing(): boolean {
return this.syncManager.isSyncing();
}
}
export const categorySyncService = new CategorySyncService();

View File

@ -1,6 +1,9 @@
<?php
use App\Http\Controllers\EncryptionController;
use App\Http\Controllers\Sync\AccountSyncController;
use App\Http\Controllers\Sync\BankSyncController;
use App\Http\Controllers\Sync\CategorySyncController;
use Illuminate\Support\Facades\Route;
use Inertia\Inertia;
use Laravel\Fortify\Features;
@ -19,6 +22,10 @@ Route::middleware(['auth'])->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 () {

View File

@ -0,0 +1,80 @@
<?php
use App\Models\Account;
use App\Models\Bank;
use App\Models\User;
use function Pest\Laravel\actingAs;
use function Pest\Laravel\getJson;
beforeEach(function () {
$this->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();
});

View File

@ -0,0 +1,57 @@
<?php
use App\Models\Bank;
use App\Models\User;
use function Pest\Laravel\actingAs;
use function Pest\Laravel\getJson;
beforeEach(function () {
$this->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();
});

View File

@ -0,0 +1,54 @@
<?php
use App\Models\Category;
use App\Models\User;
use function Pest\Laravel\actingAs;
use function Pest\Laravel\getJson;
beforeEach(function () {
$this->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();
});