diff --git a/bun.lock b/bun.lock index d60e20a7..3aa72f1a 100644 --- a/bun.lock +++ b/bun.lock @@ -31,6 +31,8 @@ "cmdk": "^1.1.1", "concurrently": "^9.0.1", "date-fns": "^4.1.0", + "dexie": "^4.2.1", + "dexie-react-hooks": "^4.2.0", "globals": "^15.14.0", "input-otp": "^1.4.2", "json-logic-js": "^2.0.5", @@ -569,6 +571,10 @@ "detect-node-es": ["detect-node-es@1.1.0", "", {}, "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ=="], + "dexie": ["dexie@4.2.1", "", {}, "sha512-Ckej0NS6jxQ4Po3OrSQBFddayRhTCic2DoCAG5zacOfOVB9P2Q5Xc5uL/nVa7ZVs+HdMnvUPzLFCB/JwpB6Csg=="], + + "dexie-react-hooks": ["dexie-react-hooks@4.2.0", "", { "peerDependencies": { "@types/react": ">=16", "dexie": ">=4.2.0-alpha.1 <5.0.0", "react": ">=16" } }, "sha512-u7KqTX9JpBQK8+tEyA9X0yMGXlSCsbm5AU64N6gjvGk/IutYDpLBInMYEAEC83s3qhIvryFS+W+sqLZUBEvePQ=="], + "doctrine": ["doctrine@2.1.0", "", { "dependencies": { "esutils": "^2.0.2" } }, "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw=="], "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], diff --git a/package.json b/package.json index 5950e200..9d31d055 100644 --- a/package.json +++ b/package.json @@ -55,6 +55,8 @@ "cmdk": "^1.1.1", "concurrently": "^9.0.1", "date-fns": "^4.1.0", + "dexie": "^4.2.1", + "dexie-react-hooks": "^4.2.0", "globals": "^15.14.0", "input-otp": "^1.4.2", "json-logic-js": "^2.0.5", diff --git a/resources/js/lib/db-migration-helper.ts b/resources/js/lib/db-migration-helper.ts index 3a483c4a..128fe211 100644 --- a/resources/js/lib/db-migration-helper.ts +++ b/resources/js/lib/db-migration-helper.ts @@ -1,4 +1,4 @@ -import { indexedDBService } from './indexeddb'; +import { db } from './dexie-db'; export async function checkDatabaseVersion(): Promise<{ needsRefresh: boolean; @@ -15,8 +15,8 @@ export async function checkDatabaseVersion(): Promise<{ ]; try { - const db = await indexedDBService.init(); - const existingStores = Array.from(db.objectStoreNames); + await db.open(); + const existingStores = db.tables.map((table) => table.name); const missingStores = requiredStores.filter( (store) => !existingStores.includes(store), ); diff --git a/resources/js/lib/dexie-db.ts b/resources/js/lib/dexie-db.ts new file mode 100644 index 00000000..0f1de32b --- /dev/null +++ b/resources/js/lib/dexie-db.ts @@ -0,0 +1,41 @@ +import Dexie, { type EntityTable } from 'dexie'; +import type { Account, Bank } from '@/types/account'; +import type { Category } from '@/types/category'; +import type { AutomationRule } from '@/types/automation-rule'; +import type { Transaction } from '@/services/transaction-sync'; + +export interface SyncMetadata { + key: string; + value: string; +} + +export interface PendingChange { + id?: number; + store: string; + operation: 'create' | 'update' | 'delete'; + data: any; + timestamp: string; +} + +const db = new Dexie('whisper_money') as Dexie & { + transactions: EntityTable; + accounts: EntityTable; + categories: EntityTable; + banks: EntityTable; + automation_rules: EntityTable; + sync_metadata: EntityTable; + pending_changes: EntityTable; +}; + +db.version(3).stores({ + transactions: 'id, user_id, account_id, updated_at', + accounts: 'id, user_id, bank_id, updated_at', + categories: 'id, user_id, updated_at', + banks: 'id, user_id, updated_at', + automation_rules: 'id, user_id, priority, updated_at', + sync_metadata: 'key', + pending_changes: '++id, store, timestamp', +}); + +export { db }; + diff --git a/resources/js/lib/indexeddb.ts b/resources/js/lib/indexeddb.ts deleted file mode 100644 index aea76965..00000000 --- a/resources/js/lib/indexeddb.ts +++ /dev/null @@ -1,437 +0,0 @@ -const DB_NAME = 'whisper_money'; -const DB_VERSION = 3; - -// Force database recreation if needed -export async function resetDatabase(): Promise { - return new Promise((resolve, reject) => { - const deleteRequest = indexedDB.deleteDatabase(DB_NAME); - - deleteRequest.onsuccess = () => { - console.log('Database deleted successfully'); - resolve(); - }; - - deleteRequest.onerror = () => { - reject(new Error('Failed to delete database')); - }; - - deleteRequest.onblocked = () => { - console.warn('Database deletion blocked. Please close all other tabs.'); - reject(new Error('Database deletion blocked')); - }; - }); -} - -export interface IndexedDBRecord { - id: number | string; - user_id?: number | null; - created_at: string; - updated_at: string; - [key: string]: any; -} - -export type StoreName = 'categories' | 'accounts' | 'banks' | 'automation_rules' | 'transactions'; - -class IndexedDBService { - private db: IDBDatabase | null = null; - private initPromise: Promise | null = null; - - async checkStoreExists(storeName: StoreName): Promise { - const db = await this.init(); - return db.objectStoreNames.contains(storeName); - } - - 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('automation_rules')) { - const automationRuleStore = db.createObjectStore('automation_rules', { - keyPath: 'id', - }); - automationRuleStore.createIndex('user_id', 'user_id', { - unique: false, - }); - automationRuleStore.createIndex('priority', 'priority', { - unique: false, - }); - automationRuleStore.createIndex('updated_at', 'updated_at', { - unique: false, - }); - } - - if (!db.objectStoreNames.contains('transactions')) { - const transactionStore = db.createObjectStore('transactions', { - keyPath: 'id', - }); - transactionStore.createIndex('user_id', 'user_id', { - unique: false, - }); - transactionStore.createIndex('account_id', 'account_id', { - unique: false, - }); - transactionStore.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) => { - try { - if (!db.objectStoreNames.contains(storeName)) { - console.warn(`Object store '${storeName}' not found. Returning empty array.`); - resolve([]); - return; - } - - 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}`)); - }; - } catch (error) { - console.error(`Error accessing ${storeName}:`, error); - resolve([]); - } - }); - } - - async getById( - storeName: StoreName, - id: number | string, - ): 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 get( - storeName: StoreName, - id: number | string, - ): Promise { - return this.getById(storeName, id); - } - - async put( - storeName: StoreName, - record: T, - ): Promise { - const db = await this.init(); - return new Promise((resolve, reject) => { - try { - if (!db.objectStoreNames.contains(storeName)) { - reject(new Error(`Object store '${storeName}' not found. Please refresh the page.`)); - return; - } - - 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}`)); - }; - } catch (error) { - reject(error); - } - }); - } - - 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 | string): 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) => { - try { - 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')); - }; - } catch (error) { - reject(error); - } - }); - } - - 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 index 717c27b1..2f4c6766 100644 --- a/resources/js/lib/sync-manager.ts +++ b/resources/js/lib/sync-manager.ts @@ -1,9 +1,15 @@ import axios from 'axios'; -import { - indexedDBService, - type StoreName, - type IndexedDBRecord, -} from './indexeddb'; +import { db } from './dexie-db'; + +export type StoreName = 'categories' | 'accounts' | 'banks' | 'automation_rules' | 'transactions'; + +export interface IndexedDBRecord { + id: number | string; + user_id?: number | null; + created_at: string; + updated_at: string; + [key: string]: any; +} export interface SyncOptions { storeName: StoreName; @@ -29,11 +35,15 @@ export class SyncManager { } async getLastSyncTime(): Promise { - return await indexedDBService.getSyncMetadata(this.lastSyncKey); + const metadata = await db.sync_metadata.get(this.lastSyncKey); + return metadata?.value || null; } async setLastSyncTime(timestamp: string): Promise { - await indexedDBService.setSyncMetadata(this.lastSyncKey, timestamp); + await db.sync_metadata.put({ + key: this.lastSyncKey, + value: timestamp, + }); } async sync(): Promise { @@ -61,9 +71,10 @@ export class SyncManager { await this.syncFromServer(result); await this.syncToServer(result); - await indexedDBService.clearPendingChanges( - this.options.storeName, - ); + await db.pending_changes + .where('store') + .equals(this.options.storeName) + .delete(); await this.setLastSyncTime(new Date().toISOString()); } catch (error) { @@ -94,10 +105,9 @@ export class SyncManager { 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])); + const table = db[this.options.storeName]; + const localRecords = await table.toArray(); + const localMap = new Map(localRecords.map((r: any) => [r.id, r])); for (const serverRecord of serverData) { const transformed = this.options.transformFromServer @@ -107,20 +117,14 @@ export class SyncManager { const localRecord = localMap.get(transformed.id); if (!localRecord) { - await indexedDBService.put( - this.options.storeName, - transformed, - ); + await table.put(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, - ); + await table.put(transformed); result.updated++; } } @@ -128,9 +132,10 @@ export class SyncManager { } private async syncToServer(result: SyncResult): Promise { - const pendingChanges = await indexedDBService.getPendingChanges( - this.options.storeName, - ); + const pendingChanges = await db.pending_changes + .where('store') + .equals(this.options.storeName) + .toArray(); for (const change of pendingChanges) { try { @@ -178,12 +183,14 @@ export class SyncManager { updated_at: timestamp, } as T; - await indexedDBService.put(this.options.storeName, record); - await indexedDBService.addPendingChange( - this.options.storeName, - 'create', - record, - ); + const table = db[this.options.storeName]; + await table.put(record); + await db.pending_changes.add({ + store: this.options.storeName, + operation: 'create', + data: record, + timestamp, + }); return record; } @@ -192,44 +199,49 @@ export class SyncManager { id: number, data: Partial, ): Promise { - const existing = await indexedDBService.getById( - this.options.storeName, - id, - ); + const table = db[this.options.storeName]; + const existing = await table.get(id); if (!existing) { throw new Error(`Record ${id} not found in ${this.options.storeName}`); } + const timestamp = new Date().toISOString(); const updated = { ...existing, ...data, - updated_at: new Date().toISOString(), + updated_at: timestamp, }; - await indexedDBService.put(this.options.storeName, updated); - await indexedDBService.addPendingChange( - this.options.storeName, - 'update', - updated, - ); + await table.put(updated); + await db.pending_changes.add({ + store: this.options.storeName, + operation: 'update', + data: updated, + timestamp, + }); } async deleteLocal(id: number): Promise { - await indexedDBService.delete(this.options.storeName, id); - await indexedDBService.addPendingChange( - this.options.storeName, - 'delete', - { id }, - ); + const timestamp = new Date().toISOString(); + const table = db[this.options.storeName]; + await table.delete(id); + await db.pending_changes.add({ + store: this.options.storeName, + operation: 'delete', + data: { id }, + timestamp, + }); } async getAll(): Promise { - return await indexedDBService.getAll(this.options.storeName); + const table = db[this.options.storeName]; + return await table.toArray() as T[]; } async getById(id: number): Promise { - return await indexedDBService.getById(this.options.storeName, id); + const table = db[this.options.storeName]; + return (await table.get(id) as T) || null; } isSyncing(): boolean { diff --git a/resources/js/pages/settings/accounts.tsx b/resources/js/pages/settings/accounts.tsx index fb8e0d4b..73f2143c 100644 --- a/resources/js/pages/settings/accounts.tsx +++ b/resources/js/pages/settings/accounts.tsx @@ -11,8 +11,9 @@ import { useReactTable, VisibilityState, } from '@tanstack/react-table'; +import { useLiveQuery } from 'dexie-react-hooks'; import { ArrowUpDown, MoreHorizontal } from 'lucide-react'; -import { useEffect, useState } from 'react'; +import { useState } from 'react'; import { index as accountsIndex } from '@/actions/App/Http/Controllers/Settings/AccountController'; import { CreateAccountDialog } from '@/components/accounts/create-account-dialog'; @@ -38,10 +39,9 @@ import { TableHeader, TableRow, } from '@/components/ui/table'; -import { useSyncContext } from '@/contexts/sync-context'; import AppLayout from '@/layouts/app-layout'; import SettingsLayout from '@/layouts/settings/layout'; -import { accountSyncService } from '@/services/account-sync'; +import { db } from '@/lib/dexie-db'; import { type BreadcrumbItem } from '@/types'; import { type Account, formatAccountType } from '@/types/account'; @@ -52,13 +52,7 @@ const breadcrumbs: BreadcrumbItem[] = [ }, ]; -function AccountActions({ - account, - onSuccess, -}: { - account: Account; - onSuccess: () => void; -}) { +function AccountActions({ account }: { account: Account }) { const [editOpen, setEditOpen] = useState(false); const [deleteOpen, setDeleteOpen] = useState(false); @@ -89,43 +83,26 @@ function AccountActions({ account={account} open={editOpen} onOpenChange={setEditOpen} - onSuccess={onSuccess} + onSuccess={() => {}} /> {}} /> ); } export default function Accounts() { - const { syncStatus } = useSyncContext(); - const [accounts, setAccounts] = useState([]); + const accounts = useLiveQuery(() => db.accounts.toArray(), []) || []; const [sorting, setSorting] = useState([]); const [columnFilters, setColumnFilters] = useState([]); const [columnVisibility, setColumnVisibility] = useState( {}, ); - const loadAccounts = async () => { - await accountSyncService.sync(); - const data = await accountSyncService.getAll(); - setAccounts(data); - }; - - useEffect(() => { - loadAccounts(); - }, []); - - useEffect(() => { - if (syncStatus === 'success') { - loadAccounts(); - } - }, [syncStatus]); - const columns: ColumnDef[] = [ { accessorKey: 'name', diff --git a/resources/js/pages/settings/automation-rules.tsx b/resources/js/pages/settings/automation-rules.tsx index 30ec45b4..bb1b3775 100644 --- a/resources/js/pages/settings/automation-rules.tsx +++ b/resources/js/pages/settings/automation-rules.tsx @@ -11,8 +11,9 @@ import { useReactTable, VisibilityState, } from '@tanstack/react-table'; +import { useLiveQuery } from 'dexie-react-hooks'; import { ArrowUpDown, MoreHorizontal } from 'lucide-react'; -import { useEffect, useState } from 'react'; +import { useState } from 'react'; import { index as automationRulesIndex } from '@/actions/App/Http/Controllers/Settings/AutomationRuleController'; import { CreateAutomationRuleDialog } from '@/components/automation-rules/create-automation-rule-dialog'; @@ -38,10 +39,9 @@ import { TableRow, } from '@/components/ui/table'; import { useEncryptionKey } from '@/contexts/encryption-key-context'; -import { useSyncContext } from '@/contexts/sync-context'; import AppLayout from '@/layouts/app-layout'; import SettingsLayout from '@/layouts/settings/layout'; -import { automationRuleSyncService } from '@/services/automation-rule-sync'; +import { db } from '@/lib/dexie-db'; import { type BreadcrumbItem } from '@/types'; import { type AutomationRule, getRuleActions } from '@/types/automation-rule'; import { getCategoryColorClasses } from '@/types/category'; @@ -53,13 +53,7 @@ const breadcrumbs: BreadcrumbItem[] = [ }, ]; -function AutomationRuleActions({ - rule, - onSuccess, -}: { - rule: AutomationRule; - onSuccess: () => void; -}) { +function AutomationRuleActions({ rule }: { rule: AutomationRule }) { const [editOpen, setEditOpen] = useState(false); const [deleteOpen, setDeleteOpen] = useState(false); @@ -90,44 +84,34 @@ function AutomationRuleActions({ rule={rule} open={editOpen} onOpenChange={setEditOpen} - onSuccess={onSuccess} + onSuccess={() => {}} /> {}} /> ); } export default function AutomationRules() { - const { syncStatus } = useSyncContext(); const { isKeySet } = useEncryptionKey(); - const [rules, setRules] = useState([]); + const rawRules = useLiveQuery(() => db.automation_rules.toArray(), []) || []; + const rules = rawRules.map((rule) => ({ + ...rule, + rules_json: + typeof rule.rules_json === 'string' + ? JSON.parse(rule.rules_json) + : rule.rules_json, + })); const [sorting, setSorting] = useState([]); const [columnFilters, setColumnFilters] = useState([]); const [columnVisibility, setColumnVisibility] = useState( {}, ); - const loadRules = async () => { - await automationRuleSyncService.sync(); - const data = await automationRuleSyncService.getAll(); - setRules(data); - }; - - useEffect(() => { - loadRules(); - }, []); - - useEffect(() => { - if (syncStatus === 'success') { - loadRules(); - } - }, [syncStatus]); - const columns: ColumnDef[] = [ { accessorKey: 'title', diff --git a/resources/js/pages/settings/categories.tsx b/resources/js/pages/settings/categories.tsx index 85574627..5e52cbd5 100644 --- a/resources/js/pages/settings/categories.tsx +++ b/resources/js/pages/settings/categories.tsx @@ -11,9 +11,10 @@ import { useReactTable, VisibilityState, } from '@tanstack/react-table'; +import { useLiveQuery } from 'dexie-react-hooks'; import * as Icons from 'lucide-react'; import { ArrowUpDown, MoreHorizontal } from 'lucide-react'; -import { useEffect, useState } from 'react'; +import { useState } from 'react'; import { index as categoriesIndex } from '@/actions/App/Http/Controllers/Settings/CategoryController'; import { CreateCategoryDialog } from '@/components/categories/create-category-dialog'; @@ -38,10 +39,9 @@ import { TableHeader, TableRow, } from '@/components/ui/table'; -import { useSyncContext } from '@/contexts/sync-context'; import AppLayout from '@/layouts/app-layout'; import SettingsLayout from '@/layouts/settings/layout'; -import { categorySyncService } from '@/services/category-sync'; +import { db } from '@/lib/dexie-db'; import { type BreadcrumbItem } from '@/types'; import { type Category, getCategoryColorClasses } from '@/types/category'; @@ -52,13 +52,7 @@ const breadcrumbs: BreadcrumbItem[] = [ }, ]; -function CategoryActions({ - category, - onSuccess, -}: { - category: Category; - onSuccess: () => void; -}) { +function CategoryActions({ category }: { category: Category }) { const [editOpen, setEditOpen] = useState(false); const [deleteOpen, setDeleteOpen] = useState(false); @@ -89,43 +83,26 @@ function CategoryActions({ category={category} open={editOpen} onOpenChange={setEditOpen} - onSuccess={onSuccess} + onSuccess={() => {}} /> {}} /> ); } export default function Categories() { - const { syncStatus } = useSyncContext(); - const [categories, setCategories] = useState([]); + const categories = useLiveQuery(() => db.categories.toArray(), []) || []; const [sorting, setSorting] = useState([]); const [columnFilters, setColumnFilters] = useState([]); const [columnVisibility, setColumnVisibility] = useState( {}, ); - const loadCategories = async () => { - await categorySyncService.sync(); - const data = await categorySyncService.getAll(); - setCategories(data); - }; - - useEffect(() => { - loadCategories(); - }, []); - - useEffect(() => { - if (syncStatus === 'success') { - loadCategories(); - } - }, [syncStatus]); - const columns: ColumnDef[] = [ { accessorKey: 'name', @@ -179,7 +156,6 @@ export default function Categories() { cell: ({ row }) => ( ), }, @@ -229,7 +205,7 @@ export default function Categories() { } className="max-w-sm" /> - + {}} />
diff --git a/resources/js/pages/transactions/index.tsx b/resources/js/pages/transactions/index.tsx index ae7f111d..a4c81a04 100644 --- a/resources/js/pages/transactions/index.tsx +++ b/resources/js/pages/transactions/index.tsx @@ -8,6 +8,7 @@ import { getSortedRowModel, useReactTable, } from '@tanstack/react-table'; +import { useLiveQuery } from 'dexie-react-hooks'; import { isWithinInterval, parseISO } from 'date-fns'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; @@ -36,6 +37,7 @@ import { useEncryptionKey } from '@/contexts/encryption-key-context'; import AppSidebarLayout from '@/layouts/app/app-sidebar-layout'; import { decrypt, encrypt, importKey } from '@/lib/crypto'; import { consoleDebug } from '@/lib/debug'; +import { db } from '@/lib/dexie-db'; import { getStoredKey } from '@/lib/key-storage'; import { evaluateRules } from '@/lib/rule-engine'; import { automationRuleSyncService } from '@/services/automation-rule-sync'; @@ -80,6 +82,9 @@ function getInitialColumnVisibility(): VisibilityState { export default function Transactions({ categories, accounts, banks }: Props) { const { isKeySet } = useEncryptionKey(); + + const rawTransactions = useLiveQuery(() => db.transactions.toArray(), []); + const [transactions, setTransactions] = useState( [], ); @@ -142,179 +147,183 @@ export default function Transactions({ categories, accounts, banks }: Props) { [setTransactions], ); - const loadTransactions = useCallback(async () => { - setIsLoading(true); - try { - const rawTransactions = await transactionSyncService.getAll(); - const accountsMap = new Map( - accounts.map((account) => [account.id, account]), - ); - const categoriesMap = new Map( - categories.map((category) => [category.id, category]), - ); - const banksMap = new Map(banks.map((bank) => [bank.id, bank])); - - const keyString = getStoredKey(); - let key: CryptoKey | null = null; - - if (keyString && isKeySet) { - try { - key = await importKey(keyString); - } catch (error) { - console.error('Failed to import encryption key:', error); - } - } - - const decrypted = await Promise.all( - rawTransactions.map(async (transaction) => { - try { - let decryptedDescription = ''; - let decryptedNotes: string | null = null; - - if (key) { - try { - decryptedDescription = await decrypt( - transaction.description, - key, - transaction.description_iv, - ); - - if (transaction.notes && transaction.notes_iv) { - decryptedNotes = await decrypt( - transaction.notes, - key, - transaction.notes_iv, - ); - } - } catch (error) { - console.error( - 'Failed to decrypt transaction:', - transaction.id, - error, - ); - } - } - - const account = accountsMap.get(transaction.account_id); - const category = transaction.category_id - ? categoriesMap.get(transaction.category_id) - : null; - const bank = account?.bank?.id - ? banksMap.get(account.bank.id) - : undefined; - - return { - ...transaction, - decryptedDescription, - decryptedNotes, - account, - category: category || null, - bank, - } as DecryptedTransaction; - } catch (error) { - console.error( - 'Failed to process transaction:', - transaction.id, - error, - ); - return null; - } - }), - ); - - const validTransactions = decrypted.filter( - (transaction): transaction is DecryptedTransaction => - transaction !== null, - ); - - const rules = await automationRuleSyncService.getAll(); - - if (rules.length > 0 && key) { - const transactionsToUpdate: Array<{ - id: string; - category_id: number | null; - notes: string | null; - notes_iv: string | null; - }> = []; - - for (const transaction of validTransactions) { - if (!transaction.category_id) { - const result = evaluateRules( - transaction, - rules, - categories, - accounts, - banks, - ); - - if (result) { - let finalNotes = transaction.notes; - let finalNotesIv = transaction.notes_iv; - - if (result.note && result.noteIv) { - if (transaction.decryptedNotes) { - const combinedNote = `${transaction.decryptedNotes}\n${await decrypt(result.note, key, result.noteIv)}`; - const encrypted = await encrypt( - combinedNote, - key, - ); - finalNotes = encrypted.encrypted; - finalNotesIv = encrypted.iv; - } else { - finalNotes = result.note; - finalNotesIv = result.noteIv; - } - } - - transactionsToUpdate.push({ - id: transaction.id, - category_id: result.categoryId, - notes: finalNotes, - notes_iv: finalNotesIv, - }); - - transaction.category_id = result.categoryId; - if (result.categoryId) { - transaction.category = - categories.find( - (c) => c.id === result.categoryId, - ) || null; - } - if (finalNotes && finalNotesIv) { - transaction.notes = finalNotes; - transaction.notes_iv = finalNotesIv; - transaction.decryptedNotes = await decrypt( - finalNotes, - key, - finalNotesIv, - ); - } - } - } - } - - if (transactionsToUpdate.length > 0) { - for (const update of transactionsToUpdate) { - await transactionSyncService.update(update.id, { - category_id: update.category_id, - notes: update.notes, - notes_iv: update.notes_iv, - }); - } - } - } - - setTransactions(validTransactions); - } catch (error) { - console.error('Failed to load transactions:', error); - } finally { - setIsLoading(false); - } - }, [accounts, banks, categories, isKeySet]); - useEffect(() => { - loadTransactions(); - }, [loadTransactions]); + async function processTransactions() { + if (!rawTransactions) { + setIsLoading(true); + return; + } + + setIsLoading(true); + try { + const accountsMap = new Map( + accounts.map((account) => [account.id, account]), + ); + const categoriesMap = new Map( + categories.map((category) => [category.id, category]), + ); + const banksMap = new Map(banks.map((bank) => [bank.id, bank])); + + const keyString = getStoredKey(); + let key: CryptoKey | null = null; + + if (keyString && isKeySet) { + try { + key = await importKey(keyString); + } catch (error) { + console.error('Failed to import encryption key:', error); + } + } + + const decrypted = await Promise.all( + rawTransactions.map(async (transaction) => { + try { + let decryptedDescription = ''; + let decryptedNotes: string | null = null; + + if (key) { + try { + decryptedDescription = await decrypt( + transaction.description, + key, + transaction.description_iv, + ); + + if (transaction.notes && transaction.notes_iv) { + decryptedNotes = await decrypt( + transaction.notes, + key, + transaction.notes_iv, + ); + } + } catch (error) { + console.error( + 'Failed to decrypt transaction:', + transaction.id, + error, + ); + } + } + + const account = accountsMap.get(transaction.account_id); + const category = transaction.category_id + ? categoriesMap.get(transaction.category_id) + : null; + const bank = account?.bank?.id + ? banksMap.get(account.bank.id) + : undefined; + + return { + ...transaction, + decryptedDescription, + decryptedNotes, + account, + category: category || null, + bank, + } as DecryptedTransaction; + } catch (error) { + console.error( + 'Failed to process transaction:', + transaction.id, + error, + ); + return null; + } + }), + ); + + const validTransactions = decrypted.filter( + (transaction): transaction is DecryptedTransaction => + transaction !== null, + ); + + const rules = await automationRuleSyncService.getAll(); + + if (rules.length > 0 && key) { + const transactionsToUpdate: Array<{ + id: string; + category_id: number | null; + notes: string | null; + notes_iv: string | null; + }> = []; + + for (const transaction of validTransactions) { + if (!transaction.category_id) { + const result = evaluateRules( + transaction, + rules, + categories, + accounts, + banks, + ); + + if (result) { + let finalNotes = transaction.notes; + let finalNotesIv = transaction.notes_iv; + + if (result.note && result.noteIv) { + if (transaction.decryptedNotes) { + const combinedNote = `${transaction.decryptedNotes}\n${await decrypt(result.note, key, result.noteIv)}`; + const encrypted = await encrypt( + combinedNote, + key, + ); + finalNotes = encrypted.encrypted; + finalNotesIv = encrypted.iv; + } else { + finalNotes = result.note; + finalNotesIv = result.noteIv; + } + } + + transactionsToUpdate.push({ + id: transaction.id, + category_id: result.categoryId, + notes: finalNotes, + notes_iv: finalNotesIv, + }); + + transaction.category_id = result.categoryId; + if (result.categoryId) { + transaction.category = + categories.find( + (c) => c.id === result.categoryId, + ) || null; + } + if (finalNotes && finalNotesIv) { + transaction.notes = finalNotes; + transaction.notes_iv = finalNotesIv; + transaction.decryptedNotes = await decrypt( + finalNotes, + key, + finalNotesIv, + ); + } + } + } + } + + if (transactionsToUpdate.length > 0) { + for (const update of transactionsToUpdate) { + await transactionSyncService.update(update.id, { + category_id: update.category_id, + notes: update.notes, + notes_iv: update.notes_iv, + }); + } + } + } + + setTransactions(validTransactions); + } catch (error) { + console.error('Failed to load transactions:', error); + } finally { + setIsLoading(false); + } + } + + processTransactions(); + }, [rawTransactions, accounts, banks, categories, isKeySet]); useEffect(() => { try { diff --git a/resources/js/services/transaction-sync.ts b/resources/js/services/transaction-sync.ts index 305b51b2..06d5c8e1 100644 --- a/resources/js/services/transaction-sync.ts +++ b/resources/js/services/transaction-sync.ts @@ -1,5 +1,5 @@ import { encrypt, importKey } from '@/lib/crypto'; -import { indexedDBService } from '@/lib/indexeddb'; +import { db } from '@/lib/dexie-db'; import { getStoredKey } from '@/lib/key-storage'; import { SyncManager } from '@/lib/sync-manager'; import { uuidv7 } from 'uuidv7'; @@ -39,7 +39,7 @@ class TransactionSyncService { } async getById(id: string): Promise { - return await indexedDBService.get('transactions', id); + return (await db.transactions.get(id)) || null; } async getByAccountId(accountId: number): Promise { @@ -79,12 +79,13 @@ class TransactionSyncService { updated_at: timestamp, } as Transaction; - await indexedDBService.put('transactions', record); - await indexedDBService.addPendingChange( - 'transactions', - 'create', - record, - ); + await db.transactions.put(record); + await db.pending_changes.add({ + store: 'transactions', + operation: 'create', + data: record, + timestamp, + }); created.push(record); } @@ -104,18 +105,20 @@ class TransactionSyncService { throw new Error('Transaction not found'); } + const timestamp = new Date().toISOString(); const updated = { ...existing, ...data, - updated_at: new Date().toISOString(), + updated_at: timestamp, }; - await indexedDBService.put('transactions', updated); - await indexedDBService.addPendingChange( - 'transactions', - 'update', - updated, - ); + await db.transactions.put(updated); + await db.pending_changes.add({ + store: 'transactions', + operation: 'update', + data: updated, + timestamp, + }); } async updateMany(ids: string[], data: Partial): Promise { @@ -134,12 +137,13 @@ class TransactionSyncService { updated_at: timestamp, }; - await indexedDBService.put('transactions', updated); - await indexedDBService.addPendingChange( - 'transactions', - 'update', - updated, - ); + await db.transactions.put(updated); + await db.pending_changes.add({ + store: 'transactions', + operation: 'update', + data: updated, + timestamp, + }); } } @@ -149,15 +153,19 @@ class TransactionSyncService { throw new Error('Transaction not found'); } - await indexedDBService.delete('transactions', id); - await indexedDBService.addPendingChange( - 'transactions', - 'delete', - transaction, - ); + const timestamp = new Date().toISOString(); + await db.transactions.delete(id); + await db.pending_changes.add({ + store: 'transactions', + operation: 'delete', + data: transaction, + timestamp, + }); } async deleteMany(ids: string[]): Promise { + const timestamp = new Date().toISOString(); + for (const id of ids) { const transaction = await this.getById(id); if (!transaction) { @@ -165,12 +173,13 @@ class TransactionSyncService { continue; } - await indexedDBService.delete('transactions', id); - await indexedDBService.addPendingChange( - 'transactions', - 'delete', - transaction, - ); + await db.transactions.delete(id); + await db.pending_changes.add({ + store: 'transactions', + operation: 'delete', + data: transaction, + timestamp, + }); } }