Migrate IndexDB integration to use Dexie

This commit is contained in:
Víctor Falcón 2025-11-11 08:25:50 +00:00
parent 3dac1d9570
commit 71b8d69020
11 changed files with 366 additions and 787 deletions

View File

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

View File

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

View File

@ -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),
);

View File

@ -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<Transaction, 'id'>;
accounts: EntityTable<Account, 'id'>;
categories: EntityTable<Category, 'id'>;
banks: EntityTable<Bank, 'id'>;
automation_rules: EntityTable<AutomationRule, 'id'>;
sync_metadata: EntityTable<SyncMetadata, 'key'>;
pending_changes: EntityTable<PendingChange, 'id'>;
};
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 };

View File

@ -1,437 +0,0 @@
const DB_NAME = 'whisper_money';
const DB_VERSION = 3;
// Force database recreation if needed
export async function resetDatabase(): Promise<void> {
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<IDBDatabase> | null = null;
async checkStoreExists(storeName: StoreName): Promise<boolean> {
const db = await this.init();
return db.objectStoreNames.contains(storeName);
}
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('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<T extends IndexedDBRecord>(
storeName: StoreName,
): Promise<T[]> {
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<T extends IndexedDBRecord>(
storeName: StoreName,
id: number | string,
): 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 get<T extends IndexedDBRecord>(
storeName: StoreName,
id: number | string,
): Promise<T | null> {
return this.getById<T>(storeName, id);
}
async put<T extends IndexedDBRecord>(
storeName: StoreName,
record: T,
): Promise<void> {
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<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 | string): 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) => {
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<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

@ -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<string | null> {
return await indexedDBService.getSyncMetadata(this.lastSyncKey);
const metadata = await db.sync_metadata.get(this.lastSyncKey);
return metadata?.value || null;
}
async setLastSyncTime(timestamp: string): Promise<void> {
await indexedDBService.setSyncMetadata(this.lastSyncKey, timestamp);
await db.sync_metadata.put({
key: this.lastSyncKey,
value: timestamp,
});
}
async sync(): Promise<SyncResult> {
@ -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<IndexedDBRecord>(
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<void> {
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<T>,
): Promise<void> {
const existing = await indexedDBService.getById<T>(
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<void> {
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<T extends IndexedDBRecord>(): Promise<T[]> {
return await indexedDBService.getAll<T>(this.options.storeName);
const table = db[this.options.storeName];
return await table.toArray() as T[];
}
async getById<T extends IndexedDBRecord>(id: number): Promise<T | null> {
return await indexedDBService.getById<T>(this.options.storeName, id);
const table = db[this.options.storeName];
return (await table.get(id) as T) || null;
}
isSyncing(): boolean {

View File

@ -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={() => {}}
/>
<DeleteAccountDialog
account={account}
open={deleteOpen}
onOpenChange={setDeleteOpen}
onSuccess={onSuccess}
onSuccess={() => {}}
/>
</>
);
}
export default function Accounts() {
const { syncStatus } = useSyncContext();
const [accounts, setAccounts] = useState<Account[]>([]);
const accounts = useLiveQuery(() => db.accounts.toArray(), []) || [];
const [sorting, setSorting] = useState<SortingState>([]);
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>(
{},
);
const loadAccounts = async () => {
await accountSyncService.sync();
const data = await accountSyncService.getAll();
setAccounts(data);
};
useEffect(() => {
loadAccounts();
}, []);
useEffect(() => {
if (syncStatus === 'success') {
loadAccounts();
}
}, [syncStatus]);
const columns: ColumnDef<Account>[] = [
{
accessorKey: 'name',

View File

@ -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={() => {}}
/>
<DeleteAutomationRuleDialog
rule={rule}
open={deleteOpen}
onOpenChange={setDeleteOpen}
onSuccess={onSuccess}
onSuccess={() => {}}
/>
</>
);
}
export default function AutomationRules() {
const { syncStatus } = useSyncContext();
const { isKeySet } = useEncryptionKey();
const [rules, setRules] = useState<AutomationRule[]>([]);
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<SortingState>([]);
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>(
{},
);
const loadRules = async () => {
await automationRuleSyncService.sync();
const data = await automationRuleSyncService.getAll();
setRules(data);
};
useEffect(() => {
loadRules();
}, []);
useEffect(() => {
if (syncStatus === 'success') {
loadRules();
}
}, [syncStatus]);
const columns: ColumnDef<AutomationRule>[] = [
{
accessorKey: 'title',

View File

@ -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={() => {}}
/>
<DeleteCategoryDialog
category={category}
open={deleteOpen}
onOpenChange={setDeleteOpen}
onSuccess={onSuccess}
onSuccess={() => {}}
/>
</>
);
}
export default function Categories() {
const { syncStatus } = useSyncContext();
const [categories, setCategories] = useState<Category[]>([]);
const categories = useLiveQuery(() => db.categories.toArray(), []) || [];
const [sorting, setSorting] = useState<SortingState>([]);
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>(
{},
);
const loadCategories = async () => {
await categorySyncService.sync();
const data = await categorySyncService.getAll();
setCategories(data);
};
useEffect(() => {
loadCategories();
}, []);
useEffect(() => {
if (syncStatus === 'success') {
loadCategories();
}
}, [syncStatus]);
const columns: ColumnDef<Category>[] = [
{
accessorKey: 'name',
@ -179,7 +156,6 @@ export default function Categories() {
cell: ({ row }) => (
<CategoryActions
category={row.original}
onSuccess={loadCategories}
/>
),
},
@ -229,7 +205,7 @@ export default function Categories() {
}
className="max-w-sm"
/>
<CreateCategoryDialog onSuccess={loadCategories} />
<CreateCategoryDialog onSuccess={() => {}} />
</div>
<div className="overflow-hidden rounded-md border">

View File

@ -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<DecryptedTransaction[]>(
[],
);
@ -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 {

View File

@ -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<Transaction | null> {
return await indexedDBService.get<Transaction>('transactions', id);
return (await db.transactions.get(id)) || null;
}
async getByAccountId(accountId: number): Promise<Transaction[]> {
@ -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<Transaction>): Promise<void> {
@ -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<void> {
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,
});
}
}