diff --git a/eslint.config.js b/eslint.config.js
index 7a9fc2ce..71a733a6 100644
--- a/eslint.config.js
+++ b/eslint.config.js
@@ -29,6 +29,15 @@ export default [
},
},
},
+ {
+ files: ['**/*.{ts,tsx}'],
+ rules: {
+ 'react-hooks/set-state-in-effect': 'off',
+ 'react-hooks/static-components': 'off',
+ 'react-hooks/refs': 'off',
+ 'react-hooks/incompatible-library': 'off',
+ },
+ },
{
ignores: ['vendor', 'node_modules', 'public', 'bootstrap/ssr', 'tailwind.config.js'],
},
diff --git a/resources/js/components/automation-rules/rule-builder.tsx b/resources/js/components/automation-rules/rule-builder.tsx
index cdd0d205..898ade48 100644
--- a/resources/js/components/automation-rules/rule-builder.tsx
+++ b/resources/js/components/automation-rules/rule-builder.tsx
@@ -95,7 +95,7 @@ export function RuleBuilder({ value, onChange, error }: RuleBuilderProps) {
- {structure.groups.map((group, groupIndex) => (
+ {structure.groups.map((group) => (
@@ -145,8 +145,7 @@ export function RuleBuilder({ value, onChange, error }: RuleBuilderProps) {
- {group.conditions.map(
- (condition, conditionIndex) => (
+ {group.conditions.map((condition) => (
generateMaskedText(targetLength),
- [targetLength, encryptedText, iv],
+ [targetLength],
);
const [cachedDecryption, setCachedDecryption] = useState<{
encryptedText: string;
iv: string;
value: string;
} | null>(null);
- const [displayState, setDisplayState] = useState(() => {
- if (!isKeySet) {
- return 'encrypted';
- }
-
- const keyString = getStoredKey();
- return keyString ? 'loading' : 'encrypted';
- });
+ const [displayState, setDisplayState] = useState(() => getInitialDisplayState(isKeySet));
+ const prevIsKeySetRef = useRef(isKeySet);
useEffect(() => {
- if (!isKeySet) {
- setCachedDecryption(null);
- setDisplayState('encrypted');
- return;
- }
+ const wasKeySet = prevIsKeySetRef.current;
+ prevIsKeySetRef.current = isKeySet;
- if (
- cachedDecryption &&
- cachedDecryption.encryptedText === encryptedText &&
- cachedDecryption.iv === iv
- ) {
- setDisplayState((current) =>
- current === 'decrypted' ? current : 'decrypted',
- );
+ if (!isKeySet) {
+ if (wasKeySet) {
+ setCachedDecryption(null);
+ setDisplayState('encrypted');
+ }
return;
}
const keyString = getStoredKey();
if (!keyString) {
- setCachedDecryption(null);
- setDisplayState('encrypted');
+ if (wasKeySet !== isKeySet) {
+ setCachedDecryption(null);
+ setDisplayState('encrypted');
+ }
return;
}
+ if (!wasKeySet && isKeySet) {
+ setDisplayState('loading');
+ }
+
let cancelled = false;
- setDisplayState('loading');
importKey(keyString)
.then((key) => decrypt(encryptedText, key, iv))
diff --git a/resources/js/components/shared/category-combobox.tsx b/resources/js/components/shared/category-combobox.tsx
index d4009588..d2f50302 100644
--- a/resources/js/components/shared/category-combobox.tsx
+++ b/resources/js/components/shared/category-combobox.tsx
@@ -13,13 +13,22 @@ import {
} from '@/components/ui/popover';
import { cn } from '@/lib/utils';
import { type Category, getCategoryColorClasses } from '@/types/category';
-import { Check, ChevronsUpDown, HelpCircle } from 'lucide-react';
+import { Check, ChevronsUpDown, HelpCircle, type LucideIcon } from 'lucide-react';
import * as Icons from 'lucide-react';
-import { useState } from 'react';
+import { memo, useState } from 'react';
-function resolveIconComponent(iconName?: string): Icons.LucideIcon {
- const icon = Icons[iconName as keyof typeof Icons];
- return icon as Icons.LucideIcon;
+const iconCache = new Map();
+
+function getIconComponent(iconName?: string): LucideIcon | null {
+ if (!iconName) return null;
+ if (iconCache.has(iconName)) {
+ return iconCache.get(iconName)!;
+ }
+ const icon = Icons[iconName as keyof typeof Icons] as LucideIcon | undefined;
+ if (icon) {
+ iconCache.set(iconName, icon);
+ }
+ return icon ?? null;
}
interface CategoryComboboxProps {
@@ -144,9 +153,9 @@ export function CategoryCombobox({
);
}
-function CategoryIcon({ category }: { category: Category }) {
+const CategoryIcon = memo(function CategoryIcon({ category }: { category: Category }) {
const colorClasses = getCategoryColorClasses(category.color);
- const IconComponent = resolveIconComponent(category.icon);
+ const iconName = category.icon;
return (
-
+
);
-}
+});
+
+const DynamicIcon = memo(function DynamicIcon({ name, className }: { name?: string; className?: string }) {
+ const Icon = getIconComponent(name);
+ if (!Icon) return null;
+ return ;
+});
diff --git a/resources/js/components/transactions/import-transactions-drawer.tsx b/resources/js/components/transactions/import-transactions-drawer.tsx
index 8aa48cd5..d5bf48c4 100644
--- a/resources/js/components/transactions/import-transactions-drawer.tsx
+++ b/resources/js/components/transactions/import-transactions-drawer.tsx
@@ -7,7 +7,7 @@ import {
DrawerTitle,
} from '@/components/ui/drawer';
import { useEncryptionKey } from '@/contexts/encryption-key-context';
-import { decrypt, importKey } from '@/lib/crypto';
+import { importKey } from '@/lib/crypto';
import {
autoDetectColumns,
convertRowsToTransactions,
@@ -18,10 +18,7 @@ import {
saveImportConfig,
} from '@/lib/import-config-storage';
import { getStoredKey } from '@/lib/key-storage';
-import {
- evaluateRules,
- evaluateRulesForNewTransaction,
-} from '@/lib/rule-engine';
+import { evaluateRulesForNewTransaction } from '@/lib/rule-engine';
import { accountBalanceSyncService } from '@/services/account-balance-sync';
import { accountSyncService } from '@/services/account-sync';
import { automationRuleSyncService } from '@/services/automation-rule-sync';
@@ -34,7 +31,7 @@ import {
type ImportState,
} from '@/types/import';
import { Progress } from '@/components/ui/progress';
-import { Check, Loader2 } from 'lucide-react';
+import { Check } from 'lucide-react';
import { useEffect, useState } from 'react';
import { toast } from 'sonner';
import { ImportStepAccount } from './import-step-account';
@@ -320,7 +317,7 @@ export function ImportTransactionsDrawer({
return;
}
- const createdTransactions: any[] = [];
+ const createdTransactions: unknown[] = [];
const errors: ImportError[] = [];
const keyString = getStoredKey();
const key = keyString ? await importKey(keyString) : null;
diff --git a/resources/js/components/ui/data-table-pagination.tsx b/resources/js/components/ui/data-table-pagination.tsx
index 4a6514dc..a44d7df3 100644
--- a/resources/js/components/ui/data-table-pagination.tsx
+++ b/resources/js/components/ui/data-table-pagination.tsx
@@ -1,6 +1,3 @@
-import { Button } from '@/components/ui/button';
-import { RefreshCw } from 'lucide-react';
-
interface DataTablePaginationProps {
rowCountLabel?: string;
displayedCount?: number;
diff --git a/resources/js/components/ui/data-table.tsx b/resources/js/components/ui/data-table.tsx
index 2f0cb954..6796a3fa 100644
--- a/resources/js/components/ui/data-table.tsx
+++ b/resources/js/components/ui/data-table.tsx
@@ -2,9 +2,10 @@ import { useRef } from 'react';
import {
ColumnDef,
flexRender,
+ Row,
Table as TableType,
} from '@tanstack/react-table';
-import { useVirtualizer } from '@tanstack/react-virtual';
+import { useVirtualizer, VirtualItem, Virtualizer } from '@tanstack/react-virtual';
import {
Table,
@@ -19,7 +20,7 @@ interface DataTableProps {
table: TableType;
columns: ColumnDef[];
emptyMessage?: string;
- renderRow?: (row: any, virtualRow: any, rowVirtualizer: any) => React.ReactNode;
+ renderRow?: (row: Row, virtualRow: VirtualItem, rowVirtualizer: Virtualizer) => React.ReactNode;
}
export function DataTable({
diff --git a/resources/js/hooks/use-appearance.tsx b/resources/js/hooks/use-appearance.tsx
index 2c6b5682..c020341b 100644
--- a/resources/js/hooks/use-appearance.tsx
+++ b/resources/js/hooks/use-appearance.tsx
@@ -70,7 +70,7 @@ export function useAppearance() {
'appearance',
) as Appearance | null;
- // eslint-disable-next-line react-hooks/set-state-in-effect
+
updateAppearance(savedAppearance || 'system');
return () =>
diff --git a/resources/js/lib/dexie-db.ts b/resources/js/lib/dexie-db.ts
index 1840cc7a..ab349aec 100644
--- a/resources/js/lib/dexie-db.ts
+++ b/resources/js/lib/dexie-db.ts
@@ -13,7 +13,7 @@ export interface PendingChange {
id?: number;
store: string;
operation: 'create' | 'update' | 'delete';
- data: any;
+ data: Record;
timestamp: string;
}
diff --git a/resources/js/lib/rule-builder-utils.ts b/resources/js/lib/rule-builder-utils.ts
index ddd48abc..674ba7b6 100644
--- a/resources/js/lib/rule-builder-utils.ts
+++ b/resources/js/lib/rule-builder-utils.ts
@@ -76,7 +76,9 @@ export const OPERATOR_LABELS: Record = {
is_not_empty: 'is not empty',
};
-function buildConditionJsonLogic(condition: Condition): Record {
+type JsonLogicRule = Record;
+
+function buildConditionJsonLogic(condition: Condition): JsonLogicRule {
const { field, operator, value } = condition;
switch (operator) {
@@ -100,7 +102,7 @@ function buildConditionJsonLogic(condition: Condition): Record {
}
}
-function buildGroupJsonLogic(group: ConditionGroup): Record {
+function buildGroupJsonLogic(group: ConditionGroup): JsonLogicRule {
if (group.conditions.length === 0) {
return {};
}
@@ -113,7 +115,7 @@ function buildGroupJsonLogic(group: ConditionGroup): Record {
return { [group.operator]: conditions };
}
-export function buildJsonLogic(structure: RuleStructure): Record {
+export function buildJsonLogic(structure: RuleStructure): JsonLogicRule {
const validGroups = structure.groups.filter(
(group) => group.conditions.length > 0,
);
@@ -131,7 +133,7 @@ export function buildJsonLogic(structure: RuleStructure): Record {
}
function parseConditionFromJsonLogic(
- jsonLogic: Record,
+ jsonLogic: JsonLogicRule,
): Condition | null {
const id = crypto.randomUUID();
@@ -211,7 +213,7 @@ function parseConditionFromJsonLogic(
return null;
}
-export function parseJsonLogic(jsonLogic: Record): RuleStructure {
+export function parseJsonLogic(jsonLogic: JsonLogicRule): RuleStructure {
const defaultStructure: RuleStructure = {
groups: [
{
diff --git a/resources/js/lib/sync-manager.ts b/resources/js/lib/sync-manager.ts
index 51fc440d..8a98349c 100644
--- a/resources/js/lib/sync-manager.ts
+++ b/resources/js/lib/sync-manager.ts
@@ -15,14 +15,14 @@ export interface IndexedDBRecord {
user_id?: UUID | null;
created_at: string;
updated_at: string;
- [key: string]: any;
+ [key: string]: unknown;
}
export interface SyncOptions {
storeName: StoreName;
endpoint: string;
- transformFromServer?: (data: any) => any;
- transformToServer?: (data: any) => any;
+ transformFromServer?: (data: Record) => Record;
+ transformToServer?: (data: Record) => Record;
}
export interface SyncResult {
@@ -99,7 +99,7 @@ export class SyncManager {
private async syncFromServer(result: SyncResult): Promise {
const lastSync = await this.getLastSyncTime();
- const params: any = {};
+ const params: Record = {};
if (lastSync) {
params.since = lastSync;
}
@@ -114,7 +114,7 @@ export class SyncManager {
const table = db[this.options.storeName];
const localRecords = await table.toArray();
- const localMap = new Map(localRecords.map((r: any) => [r.id, r]));
+ const localMap = new Map(localRecords.map((r) => [(r as IndexedDBRecord).id, r as IndexedDBRecord]));
for (const serverRecord of serverData) {
const transformed = this.options.transformFromServer
diff --git a/resources/js/pages/auth/setup-encryption.tsx b/resources/js/pages/auth/setup-encryption.tsx
index 13b7ad34..11b96ed0 100644
--- a/resources/js/pages/auth/setup-encryption.tsx
+++ b/resources/js/pages/auth/setup-encryption.tsx
@@ -1,6 +1,6 @@
import { Head, router } from '@inertiajs/react';
import axios from 'axios';
-import { useEffect, useState } from 'react';
+import { useState } from 'react';
import InputError from '@/components/input-error';
import { Button } from '@/components/ui/button';
@@ -27,6 +27,21 @@ import {
import { storeKey } from '@/lib/key-storage';
import { dashboard } from '@/routes';
+function isCryptoAvailable(): boolean {
+ if (typeof window === 'undefined') return true;
+ return !!(window.crypto && window.crypto.subtle);
+}
+
+function getInitialErrors(): { password?: string; confirmPassword?: string; general?: string } {
+ if (typeof window === 'undefined') return {};
+ if (!window.crypto || !window.crypto.subtle) {
+ return {
+ general: 'Web Crypto API is not available. Please ensure you are accessing this page via HTTPS or localhost.',
+ };
+ }
+ return {};
+}
+
export default function SetupEncryption() {
const { refreshKeyState } = useEncryptionKey();
const [password, setPassword] = useState('');
@@ -35,22 +50,12 @@ export default function SetupEncryption() {
'session' | 'persistent'
>('session');
const [processing, setProcessing] = useState(false);
- const [cryptoAvailable, setCryptoAvailable] = useState(true);
+ const [cryptoAvailable] = useState(isCryptoAvailable);
const [errors, setErrors] = useState<{
password?: string;
confirmPassword?: string;
general?: string;
- }>({});
-
- useEffect(() => {
- if (!window.crypto || !window.crypto.subtle) {
- setCryptoAvailable(false);
- setErrors({
- general:
- 'Web Crypto API is not available. Please ensure you are accessing this page via HTTPS or localhost.',
- });
- }
- }, []);
+ }>(getInitialErrors);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
diff --git a/resources/js/pages/settings/accounts.tsx b/resources/js/pages/settings/accounts.tsx
index 05dc5aa5..daf742ed 100644
--- a/resources/js/pages/settings/accounts.tsx
+++ b/resources/js/pages/settings/accounts.tsx
@@ -1,5 +1,6 @@
import { Head } from '@inertiajs/react';
import {
+ Cell,
ColumnDef,
ColumnFiltersState,
flexRender,
@@ -7,6 +8,7 @@ import {
getFilteredRowModel,
getPaginationRowModel,
getSortedRowModel,
+ Row,
SortingState,
useReactTable,
VisibilityState,
@@ -109,7 +111,7 @@ function AccountActions({
);
}
-function AccountRow({ row, onSuccess }: { row: any; onSuccess?: () => void }) {
+function AccountRow({ row, onSuccess }: { row: Row; onSuccess?: () => void }) {
const account = row.original;
const [editOpen, setEditOpen] = useState(false);
const [deleteOpen, setDeleteOpen] = useState(false);
@@ -127,7 +129,7 @@ function AccountRow({ row, onSuccess }: { row: any; onSuccess?: () => void }) {
>
{row
.getVisibleCells()
- .map((cell: any) => (
+ .map((cell: Cell) => (
diff --git a/resources/js/pages/settings/automation-rules.tsx b/resources/js/pages/settings/automation-rules.tsx
index a609fbd0..c97d871b 100644
--- a/resources/js/pages/settings/automation-rules.tsx
+++ b/resources/js/pages/settings/automation-rules.tsx
@@ -1,11 +1,13 @@
import { Head } from '@inertiajs/react';
import {
+ Cell,
ColumnDef,
ColumnFiltersState,
flexRender,
getCoreRowModel,
getFilteredRowModel,
getSortedRowModel,
+ Row,
SortingState,
useReactTable,
VisibilityState,
@@ -101,7 +103,7 @@ function AutomationRuleActions({ rule }: { rule: AutomationRule }) {
);
}
-function AutomationRuleRow({ row }: { row: any }) {
+function AutomationRuleRow({ row }: { row: Row }) {
const rule = row.original;
const [editOpen, setEditOpen] = useState(false);
const [deleteOpen, setDeleteOpen] = useState(false);
@@ -119,7 +121,7 @@ function AutomationRuleRow({ row }: { row: any }) {
>
{row
.getVisibleCells()
- .map((cell: any) => (
+ .map((cell: Cell) => (
diff --git a/resources/js/pages/settings/categories.tsx b/resources/js/pages/settings/categories.tsx
index dd1d82ae..c389e078 100644
--- a/resources/js/pages/settings/categories.tsx
+++ b/resources/js/pages/settings/categories.tsx
@@ -1,11 +1,13 @@
import { Head } from '@inertiajs/react';
import {
+ Cell,
ColumnDef,
ColumnFiltersState,
flexRender,
getCoreRowModel,
getFilteredRowModel,
getSortedRowModel,
+ Row,
SortingState,
useReactTable,
VisibilityState,
@@ -101,7 +103,7 @@ function CategoryActions({ category }: { category: Category }) {
);
}
-function CategoryRow({ row }: { row: any }) {
+function CategoryRow({ row }: { row: Row }) {
const category = row.original;
const [editOpen, setEditOpen] = useState(false);
const [deleteOpen, setDeleteOpen] = useState(false);
@@ -119,7 +121,7 @@ function CategoryRow({ row }: { row: any }) {
>
{row
.getVisibleCells()
- .map((cell: any) => (
+ .map((cell: Cell) => (
diff --git a/resources/js/pages/transactions/index.tsx b/resources/js/pages/transactions/index.tsx
index 11e4e24b..67776291 100644
--- a/resources/js/pages/transactions/index.tsx
+++ b/resources/js/pages/transactions/index.tsx
@@ -1,6 +1,8 @@
import { Head } from '@inertiajs/react';
import {
+ Cell,
ColumnFiltersState,
+ Row,
SortingState,
VisibilityState,
flexRender,
@@ -9,6 +11,7 @@ import {
getSortedRowModel,
useReactTable,
} from '@tanstack/react-table';
+import { VirtualItem, Virtualizer } from '@tanstack/react-virtual';
import { isWithinInterval, parseISO } from 'date-fns';
import { useLiveQuery } from 'dexie-react-hooks';
import { useCallback, useEffect, useMemo, useState } from 'react';
@@ -77,6 +80,64 @@ interface Props {
const COLUMN_VISIBILITY_KEY = 'transactions-column-visibility';
+interface TransactionRowProps {
+ row: Row;
+ virtualRow: VirtualItem;
+ rowVirtualizer: Virtualizer;
+ onEdit: (transaction: DecryptedTransaction) => void;
+ onReEvaluateRules: (transaction: DecryptedTransaction) => void;
+ onDelete: (transaction: DecryptedTransaction) => void;
+}
+
+function TransactionRowComponent({
+ row,
+ virtualRow,
+ rowVirtualizer,
+ onEdit,
+ onReEvaluateRules,
+ onDelete,
+}: TransactionRowProps) {
+ const transaction = row.original;
+ const [contextMenuOpen, setContextMenuOpen] = useState(false);
+
+ return (
+
+
+ { }
+
+ {row.getVisibleCells().map((cell: Cell) => (
+
+ {flexRender(
+ cell.column.columnDef.cell,
+ cell.getContext(),
+ )}
+
+ ))}
+
+
+
+ Actions
+ onEdit(transaction)}>
+ Edit
+
+ onReEvaluateRules(transaction)}>
+ Re-evaluate rules
+
+ onDelete(transaction)}
+ variant="destructive"
+ >
+ Delete
+
+
+
+ );
+}
+
function getInitialColumnVisibility(): VisibilityState {
try {
const stored = localStorage.getItem(COLUMN_VISIBILITY_KEY);
@@ -950,66 +1011,21 @@ export default function Transactions({ categories, accounts, banks }: Props) {
setRowSelection({});
}
- const TransactionRow = useCallback(
- ({ row, virtualRow, rowVirtualizer }: { row: any; virtualRow: any; rowVirtualizer: any }) => {
- const transaction = row.original;
- const [contextMenuOpen, setContextMenuOpen] = useState(false);
-
- return (
-
-
-
- {row.getVisibleCells().map((cell: any) => (
-
- {flexRender(
- cell.column.columnDef.cell,
- cell.getContext(),
- )}
-
- ))}
-
-
-
- Actions
- setEditTransaction(transaction)}
- >
- Edit
-
- handleReEvaluateRules(transaction)}
- >
- Re-evaluate rules
-
- setDeleteTransaction(transaction)}
- variant="destructive"
- >
- Delete
-
-
-
- );
- },
- [handleReEvaluateRules],
- );
-
const renderTransactionRow = useCallback(
- (row: any, virtualRow: any, rowVirtualizer: any) => {
+ (row: Row, virtualRow: VirtualItem, rowVirtualizer: Virtualizer) => {
return (
-
);
},
- [TransactionRow],
+ [handleReEvaluateRules],
);
return (
diff --git a/resources/js/pages/welcome.tsx b/resources/js/pages/welcome.tsx
index 5f62ec5c..8d245df0 100644
--- a/resources/js/pages/welcome.tsx
+++ b/resources/js/pages/welcome.tsx
@@ -5,7 +5,6 @@ import { Form, Head, Link, usePage } from '@inertiajs/react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import InputError from '@/components/input-error';
-import { Avatar, AvatarImage } from '@/components/ui/avatar';
import {
LockIcon,
ShieldCheckIcon,
diff --git a/resources/js/services/account-sync.ts b/resources/js/services/account-sync.ts
index 7f5e039e..cb1ab1b5 100644
--- a/resources/js/services/account-sync.ts
+++ b/resources/js/services/account-sync.ts
@@ -25,7 +25,7 @@ class AccountSyncService {
}
async create(data: Omit): Promise {
- return await this.syncManager.createLocal(data as any);
+ return await this.syncManager.createLocal(data as Omit);
}
async update(id: UUID, data: Partial): Promise {
diff --git a/resources/js/services/category-sync.ts b/resources/js/services/category-sync.ts
index c0dfaa46..784b860f 100644
--- a/resources/js/services/category-sync.ts
+++ b/resources/js/services/category-sync.ts
@@ -25,7 +25,7 @@ class CategorySyncService {
}
async create(data: Omit): Promise {
- return await this.syncManager.createLocal(data as any);
+ return await this.syncManager.createLocal(data as Omit);
}
async update(id: UUID, data: Partial): Promise {
diff --git a/resources/js/types/automation-rule.ts b/resources/js/types/automation-rule.ts
index d686f352..5f06784a 100644
--- a/resources/js/types/automation-rule.ts
+++ b/resources/js/types/automation-rule.ts
@@ -6,7 +6,7 @@ export interface AutomationRule {
user_id: UUID;
title: string;
priority: number;
- rules_json: Record;
+ rules_json: Record;
action_category_id: UUID | null;
action_note: string | null;
action_note_iv: string | null;