diff --git a/src/app/providers.tsx b/src/app/providers.tsx index 4ec9bc0..bbeb587 100644 --- a/src/app/providers.tsx +++ b/src/app/providers.tsx @@ -7,7 +7,7 @@ import { type ReactNode, useEffect, useState } from 'react'; import { ensureFirstLaunch } from '@db/seed'; -import { CelebrationProvider, ToastProvider } from '@components/ui'; +import { Button, CelebrationProvider, ToastProvider } from '@components/ui'; import { ensureDailyRecoverySnapshot } from '@/backup/recovery'; import { ThemeSync } from '@shared/settings/ThemeSync'; import { PwaUpdatePrompt } from './pwa/PwaUpdatePrompt'; @@ -50,13 +50,20 @@ export function AppProviders({ children }: AppProvidersProps) { if (error) { return ( -
-
-

Database error

-

{error}

-

- Try clearing site data in your browser settings. +

+
+

AfterSum could not open local data

+

+ Reload the app first. If the problem continues, do not clear site data unless you already + have a portable backup—clearing site data permanently removes local AfterSum records.

+ +
+ Technical details +

{error}

+
); diff --git a/src/components/ui/Modal.test.tsx b/src/components/ui/Modal.test.tsx new file mode 100644 index 0000000..a830509 --- /dev/null +++ b/src/components/ui/Modal.test.tsx @@ -0,0 +1,48 @@ +import { useState } from 'react'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, expect, it } from 'vitest'; +import { Modal } from './Modal'; + +function ModalHarness() { + const [open, setOpen] = useState(false); + return ( + <> + + setOpen(false)} title="Test dialog"> + + + + ); +} + +describe('Modal', () => { + it('moves focus into the dialog and restores it when closed', async () => { + const user = userEvent.setup(); + render(); + + const opener = screen.getByRole('button', { name: 'Open modal' }); + await user.click(opener); + + const close = screen.getByRole('button', { name: 'Close' }); + expect(close).toHaveFocus(); + + await user.click(close); + expect(opener).toHaveFocus(); + }); + + it('keeps tab focus inside the open dialog', async () => { + const user = userEvent.setup(); + render(); + await user.click(screen.getByRole('button', { name: 'Open modal' })); + + const close = screen.getByRole('button', { name: 'Close' }); + const action = screen.getByRole('button', { name: 'Dialog action' }); + expect(close).toHaveFocus(); + + await user.tab(); + expect(action).toHaveFocus(); + await user.tab(); + expect(close).toHaveFocus(); + }); +}); diff --git a/src/components/ui/Modal.tsx b/src/components/ui/Modal.tsx index ba1e400..a48e9fc 100644 --- a/src/components/ui/Modal.tsx +++ b/src/components/ui/Modal.tsx @@ -1,6 +1,6 @@ /** Modal — bottom sheet on mobile and centered card on larger screens. */ -import { type ReactNode, useEffect, useId } from 'react'; +import { type ReactNode, useEffect, useId, useRef } from 'react'; import { createPortal } from 'react-dom'; import { X } from 'lucide-react'; import clsx from 'clsx'; @@ -14,8 +14,18 @@ interface ModalProps { lockScroll?: boolean; } +const FOCUSABLE_SELECTOR = [ + 'button:not([disabled])', + 'a[href]', + 'input:not([disabled])', + 'select:not([disabled])', + 'textarea:not([disabled])', + '[tabindex]:not([tabindex="-1"])', +].join(','); + export function Modal({ open, onClose, title, children, className, lockScroll = true }: ModalProps) { const titleId = useId(); + const panelRef = useRef(null); useEffect(() => { if (!open || !lockScroll) return; @@ -26,10 +36,48 @@ export function Modal({ open, onClose, title, children, className, lockScroll = }; }, [open, lockScroll]); + useEffect(() => { + if (!open) return; + const previousFocus = document.activeElement instanceof HTMLElement ? document.activeElement : null; + const panel = panelRef.current; + const firstFocusable = panel?.querySelector(FOCUSABLE_SELECTOR); + (firstFocusable ?? panel)?.focus(); + + return () => { + if (previousFocus?.isConnected) previousFocus.focus(); + }; + }, [open]); + useEffect(() => { if (!open) return; const onKey = (event: KeyboardEvent) => { - if (event.key === 'Escape') onClose(); + if (event.key === 'Escape') { + onClose(); + return; + } + if (event.key !== 'Tab') return; + + const panel = panelRef.current; + if (!panel) return; + const focusable = [...panel.querySelectorAll(FOCUSABLE_SELECTOR)]; + if (focusable.length === 0) { + event.preventDefault(); + panel.focus(); + return; + } + + const first = focusable[0]; + const last = focusable[focusable.length - 1]; + if (!first || !last) return; + const active = document.activeElement; + + if (event.shiftKey && (active === first || !panel.contains(active))) { + event.preventDefault(); + last.focus(); + } else if (!event.shiftKey && active === last) { + event.preventDefault(); + first.focus(); + } }; window.addEventListener('keydown', onKey); return () => window.removeEventListener('keydown', onKey); @@ -48,6 +96,8 @@ export function Modal({ open, onClose, title, children, className, lockScroll = }} >
= Omit< /** Insert a new row. */ export async function repoCreate( - // eslint-disable-next-line @typescript-eslint/no-explicit-any - table: any, + table: Table, input: CreateInput, ): Promise { const now = nowISO(); @@ -40,8 +40,7 @@ export async function repoCreate( /** Update an existing row by id. Increments `revision`. */ export async function repoUpdate( - // eslint-disable-next-line @typescript-eslint/no-explicit-any - table: any, + table: Table, id: string, patch: Partial>, ): Promise { @@ -55,28 +54,31 @@ export async function repoUpdate( ...patch, id, updatedAt: now, - revision: (existing.revision ?? 0) + 1, + revision: existing.revision + 1, }; await table.put(next); return next; } /** Soft-delete a row. Sets `deletedAt`, leaves the record. */ -export async function repoSoftDelete( - // eslint-disable-next-line @typescript-eslint/no-explicit-any - table: any, +export async function repoSoftDelete( + table: Table, id: string, ): Promise { const now = nowISO(); const existing = await table.get(id); if (!existing) return; - await table.put({ ...existing, deletedAt: now, updatedAt: now, revision: (existing.revision ?? 0) + 1 }); + await table.put({ + ...existing, + deletedAt: now, + updatedAt: now, + revision: existing.revision + 1, + }); } /** Restore a soft-deleted row (used by Undo). */ -export async function repoRestore( - // eslint-disable-next-line @typescript-eslint/no-explicit-any - table: any, +export async function repoRestore( + table: Table, id: string, ): Promise { const now = nowISO(); @@ -84,13 +86,17 @@ export async function repoRestore( if (!existing) return; const { deletedAt: _deletedAt, ...rest } = existing; void _deletedAt; - await table.put({ ...rest, updatedAt: now, revision: (existing.revision ?? 0) + 1 }); + const restored = { + ...rest, + updatedAt: now, + revision: existing.revision + 1, + } as T; + await table.put(restored); } /** Hard-delete (used by wipe / restore from backup). */ -export async function repoHardDelete( - // eslint-disable-next-line @typescript-eslint/no-explicit-any - table: any, +export async function repoHardDelete( + table: Table, id: string, ): Promise { await table.delete(id); diff --git a/src/export/csv/csv.test.ts b/src/export/csv/csv.test.ts index d1c2fb4..4783403 100644 --- a/src/export/csv/csv.test.ts +++ b/src/export/csv/csv.test.ts @@ -27,7 +27,7 @@ describe('CSV serializer', () => { expect(csv).toMatch(/"line\r?\nbreak, comma"/); }); - it('uses decimal + minor + currency in track transactions', () => { + it('uses each Track transaction currency and its exact decimal precision', () => { const csv = csvOfTrackTransactions( [ { @@ -42,12 +42,32 @@ describe('CSV serializer', () => { updatedAt: '2026-08-13T00:00:00.000Z', revision: 1, }, + { + id: 't2', + type: 'expense', + title: 'Train', + amountMinor: 1500, + currency: 'JPY', + date: '2026-08-14', + createdAt: '2026-08-14T00:00:00.000Z', + updatedAt: '2026-08-14T00:00:00.000Z', + revision: 1, + }, + { + id: 't3', + type: 'expense', + title: 'Coffee', + amountMinor: 1234, + currency: 'KWD', + date: '2026-08-15', + createdAt: '2026-08-15T00:00:00.000Z', + updatedAt: '2026-08-15T00:00:00.000Z', + revision: 1, + }, ], [{ id: 'c1', name: 'Food', type: 'expense', archived: false, createdAt: '', updatedAt: '', revision: 1 }], - 'INR', ); const lines = csv.split(/\r?\n/); - // header + 1 row + trailing empty expect(lines[0]).toContain('amount'); expect(lines[0]).toContain('amount_minor'); expect(lines[0]).toContain('currency'); @@ -55,6 +75,8 @@ describe('CSV serializer', () => { expect(lines[1]).toContain('125050'); expect(lines[1]).toContain('INR'); expect(lines[1]).toContain('Food'); + expect(lines[2]).toContain('1500,1500,JPY'); + expect(lines[3]).toContain('1.234,1234,KWD'); }); it('uses ISO date and decimal amount for split expenses', () => { diff --git a/src/export/csv/serializer.ts b/src/export/csv/serializer.ts index 307c7e7..b9232b6 100644 --- a/src/export/csv/serializer.ts +++ b/src/export/csv/serializer.ts @@ -29,7 +29,7 @@ import type { LendLedger, LendEntry, } from '@db/schema'; -import { minorToDecimal } from '@shared/money'; +import { minorToDecimalString } from '@shared/money'; const BOM = '\uFEFF'; @@ -42,15 +42,15 @@ function escapeField(v: string | number | undefined | null): string { return s; } -function row(values: Array): string { +export function csvRow(values: Array): string { return values.map(escapeField).join(','); } export function csvOfPeople(people: Person[]): string { const lines: string[] = []; - lines.push(row(['person_id', 'name', 'is_self', 'phone', 'email', 'note', 'created_at'])); + lines.push(csvRow(['person_id', 'name', 'is_self', 'phone', 'email', 'note', 'created_at'])); for (const p of people) { - lines.push(row([p.id, p.name, p.isSelf ? 'true' : 'false', p.phone, p.email, p.note, p.createdAt])); + lines.push(csvRow([p.id, p.name, p.isSelf ? 'true' : 'false', p.phone, p.email, p.note, p.createdAt])); } return BOM + lines.join('\r\n') + '\r\n'; } @@ -58,12 +58,11 @@ export function csvOfPeople(people: Person[]): string { export function csvOfTrackTransactions( txs: TrackTransaction[], categories: TrackCategory[], - currency: string, ): string { const catMap = new Map(categories.map((c) => [c.id, c.name])); const lines: string[] = []; lines.push( - row([ + csvRow([ 'transaction_id', 'date', 'type', @@ -79,12 +78,12 @@ export function csvOfTrackTransactions( ); for (const t of txs) { lines.push( - row([ + csvRow([ t.id, t.date, t.type, t.title, - minorToDecimal(t.amountMinor, currency).toFixed(2), + minorToDecimalString(t.amountMinor, t.currency), t.amountMinor, t.currency, t.categoryId, @@ -99,19 +98,19 @@ export function csvOfTrackTransactions( export function csvOfTrackCategories(cats: TrackCategory[]): string { const lines: string[] = []; - lines.push(row(['category_id', 'name', 'type', 'icon', 'archived'])); + lines.push(csvRow(['category_id', 'name', 'type', 'icon', 'archived'])); for (const c of cats) { - lines.push(row([c.id, c.name, c.type, c.icon, c.archived ? 'true' : 'false'])); + lines.push(csvRow([c.id, c.name, c.type, c.icon, c.archived ? 'true' : 'false'])); } return BOM + lines.join('\r\n') + '\r\n'; } export function csvOfTrackBudgets(b: TrackBudget[]): string { const lines: string[] = []; - lines.push(row(['month', 'amount', 'amount_minor', 'currency'])); + lines.push(csvRow(['month', 'amount', 'amount_minor', 'currency'])); for (const x of b) { lines.push( - row([x.month, minorToDecimal(x.amountMinor, x.currency).toFixed(2), x.amountMinor, x.currency]), + csvRow([x.month, minorToDecimalString(x.amountMinor, x.currency), x.amountMinor, x.currency]), ); } return BOM + lines.join('\r\n') + '\r\n'; @@ -119,14 +118,14 @@ export function csvOfTrackBudgets(b: TrackBudget[]): string { export function csvOfTrackRecurring(r: TrackRecurringRule[]): string { const lines: string[] = []; - lines.push(row(['rule_id', 'title', 'amount', 'amount_minor', 'currency', 'frequency', 'next_date', 'enabled'])); + lines.push(csvRow(['rule_id', 'title', 'amount', 'amount_minor', 'currency', 'frequency', 'next_date', 'enabled'])); for (const x of r) { const amountMinor = x.amountMinor ?? 0; lines.push( - row([ + csvRow([ x.id, x.title, - x.amountMinor !== undefined ? minorToDecimal(amountMinor, x.currency).toFixed(2) : '', + x.amountMinor !== undefined ? minorToDecimalString(amountMinor, x.currency) : '', x.amountMinor ?? '', x.currency, x.frequency, @@ -140,9 +139,9 @@ export function csvOfTrackRecurring(r: TrackRecurringRule[]): string { export function csvOfSplitGroups(g: SplitGroup[]): string { const lines: string[] = []; - lines.push(row(['group_id', 'name', 'description', 'currency', 'archived', 'created_at'])); + lines.push(csvRow(['group_id', 'name', 'description', 'currency', 'archived', 'created_at'])); for (const x of g) { - lines.push(row([x.id, x.name, x.description, x.currency, x.archived ? 'true' : 'false', x.createdAt])); + lines.push(csvRow([x.id, x.name, x.description, x.currency, x.archived ? 'true' : 'false', x.createdAt])); } return BOM + lines.join('\r\n') + '\r\n'; } @@ -151,10 +150,10 @@ export function csvOfSplitMembers(m: SplitGroupMember[], people: Person[]): stri const pMap = new Map(people.map((p) => [p.id, p.name])); const lines: string[] = []; lines.push( - row(['member_id', 'group_id', 'person_id', 'person_name', 'active', 'joined_at']), + csvRow(['member_id', 'group_id', 'person_id', 'person_name', 'active', 'joined_at']), ); for (const x of m) { - lines.push(row([x.id, x.groupId, x.personId, pMap.get(x.personId) ?? '', x.active ? 'true' : 'false', x.joinedAt])); + lines.push(csvRow([x.id, x.groupId, x.personId, pMap.get(x.personId) ?? '', x.active ? 'true' : 'false', x.joinedAt])); } return BOM + lines.join('\r\n') + '\r\n'; } @@ -166,7 +165,7 @@ export function csvOfSplitExpenses( const gMap = new Map(groups.map((g) => [g.id, g.name])); const lines: string[] = []; lines.push( - row([ + csvRow([ 'expense_id', 'date', 'group_id', @@ -182,13 +181,13 @@ export function csvOfSplitExpenses( ); for (const x of e) { lines.push( - row([ + csvRow([ x.id, x.date, x.groupId, gMap.get(x.groupId) ?? '', x.title, - minorToDecimal(x.amountMinor, x.currency).toFixed(2), + minorToDecimalString(x.amountMinor, x.currency), x.amountMinor, x.currency, x.category ?? '', @@ -201,11 +200,11 @@ export function csvOfSplitExpenses( } export function csvOfSplitPayers(p: SplitPayer[], expenses: SplitExpense[], people: Person[]): string { - const eMap = new Map(expenses.map((e) => [e.id, e.title])); - const pMap = new Map(people.map((p) => [p.id, p.name])); + const eMap = new Map(expenses.map((e) => [e.id, e])); + const pMap = new Map(people.map((person) => [person.id, person.name])); const lines: string[] = []; lines.push( - row([ + csvRow([ 'expense_id', 'expense_title', 'person_id', @@ -216,16 +215,16 @@ export function csvOfSplitPayers(p: SplitPayer[], expenses: SplitExpense[], peop ]), ); for (const x of p) { - const ex = expenses.find((e) => e.id === x.expenseId); + const expense = eMap.get(x.expenseId); lines.push( - row([ + csvRow([ x.expenseId, - eMap.get(x.expenseId) ?? '', + expense?.title ?? '', x.personId, pMap.get(x.personId) ?? '', - ex ? minorToDecimal(x.amountMinor, ex.currency).toFixed(2) : '', + expense ? minorToDecimalString(x.amountMinor, expense.currency) : '', x.amountMinor, - ex?.currency ?? '', + expense?.currency ?? '', ]), ); } @@ -233,11 +232,11 @@ export function csvOfSplitPayers(p: SplitPayer[], expenses: SplitExpense[], peop } export function csvOfSplitShares(s: SplitShare[], expenses: SplitExpense[], people: Person[]): string { - const eMap = new Map(expenses.map((e) => [e.id, e.title])); - const pMap = new Map(people.map((p) => [p.id, p.name])); + const eMap = new Map(expenses.map((e) => [e.id, e])); + const pMap = new Map(people.map((person) => [person.id, person.name])); const lines: string[] = []; lines.push( - row([ + csvRow([ 'expense_id', 'expense_title', 'person_id', @@ -248,16 +247,16 @@ export function csvOfSplitShares(s: SplitShare[], expenses: SplitExpense[], peop ]), ); for (const x of s) { - const ex = expenses.find((e) => e.id === x.expenseId); + const expense = eMap.get(x.expenseId); lines.push( - row([ + csvRow([ x.expenseId, - eMap.get(x.expenseId) ?? '', + expense?.title ?? '', x.personId, pMap.get(x.personId) ?? '', - ex ? minorToDecimal(x.amountMinor, ex.currency).toFixed(2) : '', + expense ? minorToDecimalString(x.amountMinor, expense.currency) : '', x.amountMinor, - ex?.currency ?? '', + expense?.currency ?? '', ]), ); } @@ -269,7 +268,7 @@ export function csvOfSplitSettlements(s: SplitSettlement[], groups: SplitGroup[] const pMap = new Map(people.map((p) => [p.id, p.name])); const lines: string[] = []; lines.push( - row([ + csvRow([ 'settlement_id', 'date', 'group_id', @@ -286,7 +285,7 @@ export function csvOfSplitSettlements(s: SplitSettlement[], groups: SplitGroup[] ); for (const x of s) { lines.push( - row([ + csvRow([ x.id, x.date, x.groupId, @@ -295,7 +294,7 @@ export function csvOfSplitSettlements(s: SplitSettlement[], groups: SplitGroup[] pMap.get(x.fromPersonId) ?? '', x.toPersonId, pMap.get(x.toPersonId) ?? '', - minorToDecimal(x.amountMinor, x.currency).toFixed(2), + minorToDecimalString(x.amountMinor, x.currency), x.amountMinor, x.currency, x.note, @@ -309,10 +308,10 @@ export function csvOfLendLedgers(l: LendLedger[], people: Person[]): string { const pMap = new Map(people.map((p) => [p.id, p.name])); const lines: string[] = []; lines.push( - row(['ledger_id', 'person_id', 'person_name', 'currency', 'label', 'archived']), + csvRow(['ledger_id', 'person_id', 'person_name', 'currency', 'label', 'archived']), ); for (const x of l) { - lines.push(row([x.id, x.personId, pMap.get(x.personId) ?? '', x.currency, x.label, x.archived ? 'true' : 'false'])); + lines.push(csvRow([x.id, x.personId, pMap.get(x.personId) ?? '', x.currency, x.label, x.archived ? 'true' : 'false'])); } return BOM + lines.join('\r\n') + '\r\n'; } @@ -322,7 +321,7 @@ export function csvOfLendEntries(e: LendEntry[], ledgers: LendLedger[], people: const pMap = new Map(people.map((p) => [p.id, p.name])); const lines: string[] = []; lines.push( - row([ + csvRow([ 'entry_id', 'date', 'ledger_id', @@ -341,14 +340,14 @@ export function csvOfLendEntries(e: LendEntry[], ledgers: LendLedger[], people: const currency = ledger?.currency ?? ''; const personId = ledger?.personId ?? ''; lines.push( - row([ + csvRow([ x.id, x.date, x.ledgerId, personId, pMap.get(personId) ?? '', x.type, - minorToDecimal(x.amountMinor, currency).toFixed(2), + ledger ? minorToDecimalString(x.amountMinor, currency) : '', x.amountMinor, currency, x.dueDate, diff --git a/src/export/json/backup.ts b/src/export/json/backup.ts index 0c37470..bf4fe3c 100644 --- a/src/export/json/backup.ts +++ b/src/export/json/backup.ts @@ -195,31 +195,68 @@ export interface Backup { }; } -/** Build a deep snapshot of the local financial database. */ +/** Build one transactionally-consistent snapshot of the local financial database. */ export async function exportBackup(): Promise { const db = getDB(); - const [settings, people, track, split, lend] = await Promise.all([ - settingsRepository.get(), - db.people.toArray(), - db.trackTransactions.toArray().then(async (transactions) => ({ - transactions, - categories: await db.trackCategories.toArray(), - budgets: await db.trackBudgets.toArray(), - recurringRules: await db.trackRecurringRules.toArray(), - })), - db.splitGroups.toArray().then(async (groups) => ({ - groups, - members: await db.splitGroupMembers.toArray(), - expenses: await db.splitExpenses.toArray(), - payers: await db.splitPayers.toArray(), - shares: await db.splitShares.toArray(), - settlements: await db.splitSettlements.toArray(), - })), - db.lendLedgers.toArray().then(async (ledgers) => ({ - ledgers, - entries: await db.lendEntries.toArray(), - })), - ]); + + // Ensure first-run defaults exist before opening the read-only snapshot. + await settingsRepository.get(); + + const [ + settings, + people, + transactions, + categories, + budgets, + recurringRules, + groups, + members, + expenses, + payers, + shares, + settlements, + ledgers, + entries, + ] = await db.transaction( + 'r', + [ + db.settings, + db.people, + db.trackTransactions, + db.trackCategories, + db.trackBudgets, + db.trackRecurringRules, + db.splitGroups, + db.splitGroupMembers, + db.splitExpenses, + db.splitPayers, + db.splitShares, + db.splitSettlements, + db.lendLedgers, + db.lendEntries, + ], + async () => + Promise.all([ + db.settings.get('app'), + db.people.toArray(), + db.trackTransactions.toArray(), + db.trackCategories.toArray(), + db.trackBudgets.toArray(), + db.trackRecurringRules.toArray(), + db.splitGroups.toArray(), + db.splitGroupMembers.toArray(), + db.splitExpenses.toArray(), + db.splitPayers.toArray(), + db.splitShares.toArray(), + db.splitSettlements.toArray(), + db.lendLedgers.toArray(), + db.lendEntries.toArray(), + ]), + ); + + if (!settings) { + throw new Error('App settings are unavailable, so a complete backup cannot be created.'); + } return { format: BACKUP_FORMAT, @@ -230,9 +267,9 @@ export async function exportBackup(): Promise { people, settings: { defaultCurrency: settings.defaultCurrency }, }, - track, - split, - lend, + track: { transactions, categories, budgets, recurringRules }, + split: { groups, members, expenses, payers, shares, settlements }, + lend: { ledgers, entries }, }; } diff --git a/src/export/zip/builder.test.ts b/src/export/zip/builder.test.ts new file mode 100644 index 0000000..e10a63a --- /dev/null +++ b/src/export/zip/builder.test.ts @@ -0,0 +1,135 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { strFromU8, unzipSync } from 'fflate'; +import { getDB } from '@db/database'; +import { freshDB, wipeDB } from '@tests/db-test-utils'; +import { buildFullZip, README_TEXT } from './builder'; + +const createdAt = '2026-08-18T00:00:00.000Z'; +const base = { createdAt, updatedAt: createdAt, revision: 1 } as const; + +async function unzipText(blob: Blob, path: string): Promise { + const files = unzipSync(new Uint8Array(await blob.arrayBuffer())); + const file = files[path]; + if (!file) throw new Error(`Missing ZIP entry: ${path}`); + return strFromU8(file); +} + +describe('full ZIP export', () => { + beforeEach(async () => { + await wipeDB(); + freshDB(); + }); + + it('keeps Split group balances scoped and preserves currency precision', async () => { + const db = getDB(); + await db.settings.put({ + id: 'app', + defaultCurrency: 'USD', + theme: 'system', + hideAmounts: false, + onboardingComplete: true, + ...base, + }); + await db.people.bulkPut([ + { id: 'p1', name: 'Rahul, Sr.', ...base }, + { id: 'p2', name: 'Aman', ...base }, + ]); + await db.splitGroups.bulkPut([ + { id: 'g1', name: 'Goa', currency: 'INR', archived: false, ...base }, + { id: 'g2', name: 'Tokyo', currency: 'JPY', archived: false, ...base }, + ]); + await db.splitGroupMembers.bulkPut([ + { id: 'm1', groupId: 'g1', personId: 'p1', active: true, joinedAt: createdAt, ...base }, + { id: 'm2', groupId: 'g1', personId: 'p2', active: true, joinedAt: createdAt, ...base }, + { id: 'm3', groupId: 'g2', personId: 'p1', active: true, joinedAt: createdAt, ...base }, + { id: 'm4', groupId: 'g2', personId: 'p2', active: true, joinedAt: createdAt, ...base }, + ]); + await db.splitExpenses.bulkPut([ + { + id: 'e1', + groupId: 'g1', + title: 'Hotel', + amountMinor: 10000, + currency: 'INR', + date: '2026-08-10', + splitMethod: 'equal', + ...base, + }, + { + id: 'e2', + groupId: 'g2', + title: 'Train', + amountMinor: 1000, + currency: 'JPY', + date: '2026-08-11', + splitMethod: 'equal', + ...base, + }, + ]); + await db.splitPayers.bulkPut([ + { id: 'pay1', expenseId: 'e1', personId: 'p1', amountMinor: 10000, ...base }, + { id: 'pay2', expenseId: 'e2', personId: 'p2', amountMinor: 1000, ...base }, + ]); + await db.splitShares.bulkPut([ + { id: 'share1', expenseId: 'e1', personId: 'p1', amountMinor: 5000, ...base }, + { id: 'share2', expenseId: 'e1', personId: 'p2', amountMinor: 5000, ...base }, + { id: 'share3', expenseId: 'e2', personId: 'p1', amountMinor: 500, ...base }, + { id: 'share4', expenseId: 'e2', personId: 'p2', amountMinor: 500, ...base }, + ]); + await db.lendLedgers.put({ + id: 'l1', + personId: 'p1', + currency: 'KWD', + archived: false, + ...base, + }); + await db.lendEntries.put({ + id: 'le1', + ledgerId: 'l1', + type: 'lent', + amountMinor: 1234, + date: '2026-08-12', + ...base, + }); + await db.trackTransactions.bulkPut([ + { + id: 't1', + type: 'expense', + title: 'Tokyo metro', + amountMinor: 1500, + currency: 'JPY', + date: '2026-08-12', + ...base, + }, + { + id: 't2', + type: 'income', + title: 'Refund', + amountMinor: 1234, + currency: 'KWD', + date: '2026-08-12', + ...base, + }, + ]); + + const zip = await buildFullZip(); + const peopleSummary = await unzipText(zip, 'overview/people-summary.csv'); + const track = await unzipText(zip, 'track/transactions.csv'); + const monthly = await unzipText(zip, 'overview/monthly-summary.csv'); + + expect(peopleSummary).toContain('"Rahul, Sr."'); + expect(peopleSummary).toContain('KWD 1.234'); + expect(peopleSummary).toContain('INR 50.00 | JPY -500'); + expect(peopleSummary).not.toContain('INR -5.00'); + expect(track).toContain('1500,1500,JPY'); + expect(track).toContain('1.234,1234,KWD'); + expect(monthly).toContain('2026-08,1500,0,JPY'); + expect(monthly).toContain('2026-08,0.000,1.234,KWD'); + }); + + it('uses AfterSum terminology in the bundled readme', () => { + expect(README_TEXT).toContain('AfterSum — Data Export'); + expect(README_TEXT).toContain('Settings → Data & Storage → Portable backup'); + expect(README_TEXT).not.toContain('Finance Utility — Data Export'); + }); +}); diff --git a/src/export/zip/builder.ts b/src/export/zip/builder.ts index 028a577..cd8b670 100644 --- a/src/export/zip/builder.ts +++ b/src/export/zip/builder.ts @@ -15,10 +15,23 @@ import { zip, strToU8 } from 'fflate'; import { APP_VERSION, SCHEMA_VERSION } from '@app/constants'; -import { toMonthKey } from '@shared/dates'; import { getDB } from '@db/database'; -import { nowISO } from '@shared/dates'; -import { minorToDecimal } from '@shared/money'; +import type { + LendEntry, + LendLedger, + Person, + SplitExpense, + SplitGroup, + SplitGroupMember, + SplitPayer, + SplitSettlement, + SplitShare, + TrackTransaction, +} from '@db/schema'; +import { entryToSignedAmount } from '@modules/lend/domain/signs'; +import { computeMemberBalances } from '@modules/split/domain/balances'; +import { nowISO, toMonthKey } from '@shared/dates'; +import { minorToDecimalString } from '@shared/money'; import { csvOfPeople, csvOfTrackTransactions, @@ -33,14 +46,14 @@ import { csvOfSplitSettlements, csvOfLendLedgers, csvOfLendEntries, + csvRow, } from '../csv/serializer'; -export const README_TEXT = `Finance Utility — Data Export -================================ +export const README_TEXT = `AfterSum — Data Export +====================== -This archive contains the full local database produced by -the Finance Utility app. The format is human-readable CSV -plus a manifest. +This archive contains a snapshot of the local AfterSum database. +The format is human-readable CSV plus a manifest. Module independence ------------------- @@ -53,9 +66,9 @@ transactions. They are informational only. Restoring --------- -This CSV package is a snapshot for inspection and audit. -For a full restore of the local database, use the JSON -backup file from Settings → Data & Backup → Export JSON. +This CSV package is for inspection and audit, not restore. +For a full restore, use the portable AfterSum backup from +Settings → Data & Storage → Portable backup. Schema ------ @@ -93,6 +106,7 @@ export interface FullZipOptions { export async function buildFullZip(opts: FullZipOptions = {}): Promise { const db = getDB(); const [ + settings, people, trackTx, trackCats, @@ -106,22 +120,44 @@ export async function buildFullZip(opts: FullZipOptions = {}): Promise { splitSettlements, lendLedgers, lendEntries, - ] = await Promise.all([ - db.people.toArray(), - db.trackTransactions.toArray(), - db.trackCategories.toArray(), - db.trackBudgets.toArray(), - db.trackRecurringRules.toArray(), - db.splitGroups.toArray(), - db.splitGroupMembers.toArray(), - db.splitExpenses.toArray(), - db.splitPayers.toArray(), - db.splitShares.toArray(), - db.splitSettlements.toArray(), - db.lendLedgers.toArray(), - db.lendEntries.toArray(), - ]); + ] = await db.transaction( + 'r', + [ + db.settings, + db.people, + db.trackTransactions, + db.trackCategories, + db.trackBudgets, + db.trackRecurringRules, + db.splitGroups, + db.splitGroupMembers, + db.splitExpenses, + db.splitPayers, + db.splitShares, + db.splitSettlements, + db.lendLedgers, + db.lendEntries, + ], + async () => + Promise.all([ + db.settings.get('app'), + db.people.toArray(), + db.trackTransactions.toArray(), + db.trackCategories.toArray(), + db.trackBudgets.toArray(), + db.trackRecurringRules.toArray(), + db.splitGroups.toArray(), + db.splitGroupMembers.toArray(), + db.splitExpenses.toArray(), + db.splitPayers.toArray(), + db.splitShares.toArray(), + db.splitSettlements.toArray(), + db.lendLedgers.toArray(), + db.lendEntries.toArray(), + ]), + ); + const defaultCurrency = settings?.defaultCurrency ?? 'INR'; const inputs: Record = { 'README.txt': strToU8(README_TEXT), 'manifest.json': strToU8( @@ -152,7 +188,7 @@ export async function buildFullZip(opts: FullZipOptions = {}): Promise { ), ), 'shared/people.csv': strToU8(csvOfPeople(people)), - 'track/transactions.csv': strToU8(csvOfTrackTransactions(trackTx, trackCats, 'INR')), + 'track/transactions.csv': strToU8(csvOfTrackTransactions(trackTx, trackCats)), 'track/categories.csv': strToU8(csvOfTrackCategories(trackCats)), 'track/budgets.csv': strToU8(csvOfTrackBudgets(trackBudgets)), 'track/recurring.csv': strToU8(csvOfTrackRecurring(trackRecurring)), @@ -167,12 +203,22 @@ export async function buildFullZip(opts: FullZipOptions = {}): Promise { }; if (opts.includeOverview !== false) { - // people-summary: per-person breakdown across lend + split - const peopleSummary = buildPeopleSummary(people, lendLedgers, lendEntries, splitGroups, splitMembers, splitPayers, splitShares, splitSettlements); - // monthly-summary: per-month spent - const monthly = buildMonthlySummary(trackTx); - inputs['overview/people-summary.csv'] = strToU8(peopleSummary); - inputs['overview/monthly-summary.csv'] = strToU8(monthly); + inputs['overview/people-summary.csv'] = strToU8( + buildPeopleSummary({ + people, + ledgers: lendLedgers, + lendEntries, + groups: splitGroups, + members: splitMembers, + expenses: splitExpenses, + payers: splitPayers, + shares: splitShares, + settlements: splitSettlements, + }), + ); + inputs['overview/monthly-summary.csv'] = strToU8( + buildMonthlySummary(trackTx, defaultCurrency), + ); } const out = await new Promise((resolve, reject) => { @@ -184,80 +230,166 @@ export async function buildFullZip(opts: FullZipOptions = {}): Promise { return new Blob([out], { type: 'application/zip' }); } -function buildPeopleSummary( - people: Array<{ id: string; name: string }>, - ledgers: Array<{ id: string; personId: string; currency: string }>, - lendEntries: Array<{ ledgerId: string; type: string; amountMinor: number }>, - groups: Array<{ id: string; name: string; currency: string }>, - members: Array<{ groupId: string; personId: string }>, - payers: Array<{ expenseId: string; personId: string; amountMinor: number }>, - shares: Array<{ expenseId: string; personId: string; amountMinor: number }>, - settlements: Array<{ groupId: string; fromPersonId: string; toPersonId: string; amountMinor: number }>, -): string { - const lines: string[] = []; - lines.push(['person_id', 'person_name', 'lend_balance', 'currency_lend', 'split_balance', 'currency_split'].join(',')); - for (const p of people) { - const personLedgers = ledgers.filter((l) => l.personId === p.id); - const lendByCurrency: Record = {}; - for (const l of personLedgers) { - const e = lendEntries.filter((x) => x.ledgerId === l.id); - let s = 0; - for (const x of e) { - if (x.type === 'lent' || x.type === 'repayment_given' || x.type === 'adjustment') s += x.amountMinor; - else s -= x.amountMinor; - } - lendByCurrency[l.currency] = (lendByCurrency[l.currency] ?? 0) + s; - } - const lendStr = Object.entries(lendByCurrency) - .map(([c, v]) => `${c} ${(v / 100).toFixed(2)}`) - .join(' | '); +interface PeopleSummaryInputs { + people: Person[]; + ledgers: LendLedger[]; + lendEntries: LendEntry[]; + groups: SplitGroup[]; + members: SplitGroupMember[]; + expenses: SplitExpense[]; + payers: SplitPayer[]; + shares: SplitShare[]; + settlements: SplitSettlement[]; +} + +function buildPeopleSummary(inputs: PeopleSummaryInputs): string { + const lendTotals = new Map>(); + const splitTotals = new Map>(); + + const entriesByLedger = new Map(); + for (const entry of inputs.lendEntries) { + if (entry.deletedAt) continue; + pushGrouped(entriesByLedger, entry.ledgerId, entry); + } + + for (const ledger of inputs.ledgers) { + if (ledger.deletedAt || ledger.archived) continue; + const balance = (entriesByLedger.get(ledger.id) ?? []).reduce( + (sum, entry) => sum + entryToSignedAmount(entry), + 0, + ); + addCurrencyTotal(lendTotals, ledger.personId, ledger.currency, balance); + } + + const membersByGroup = new Map(); + const expensesByGroup = new Map(); + const payersByGroup = new Map(); + const sharesByGroup = new Map(); + const settlementsByGroup = new Map(); + const groupIdByExpense = new Map(); + + for (const member of inputs.members) { + if (!member.deletedAt) pushGrouped(membersByGroup, member.groupId, member); + } + for (const expense of inputs.expenses) { + if (expense.deletedAt) continue; + pushGrouped(expensesByGroup, expense.groupId, expense); + groupIdByExpense.set(expense.id, expense.groupId); + } + for (const payer of inputs.payers) { + if (payer.deletedAt) continue; + const groupId = groupIdByExpense.get(payer.expenseId); + if (groupId) pushGrouped(payersByGroup, groupId, payer); + } + for (const share of inputs.shares) { + if (share.deletedAt) continue; + const groupId = groupIdByExpense.get(share.expenseId); + if (groupId) pushGrouped(sharesByGroup, groupId, share); + } + for (const settlement of inputs.settlements) { + if (!settlement.deletedAt) pushGrouped(settlementsByGroup, settlement.groupId, settlement); + } - const personGroups = members.filter((m) => m.personId === p.id); - const splitByCurrency: Record = {}; - for (const m of personGroups) { - const g = groups.find((x) => x.id === m.groupId); - if (!g) continue; - const gExpIds = new Set([]); - // Find expenses in this group via payers or shares (in a real impl we'd query splitExpenses too) - const personPayers = payers.filter((x) => x.personId === p.id); - const personShares = shares.filter((x) => x.personId === p.id); - const personSets = settlements.filter((x) => x.fromPersonId === p.id || x.toPersonId === p.id); - const paid = personPayers.reduce((a, b) => a + b.amountMinor, 0); - const share = personShares.reduce((a, b) => a + b.amountMinor, 0); - const sent = personSets.filter((x) => x.fromPersonId === p.id).reduce((a, b) => a + b.amountMinor, 0); - const received = personSets.filter((x) => x.toPersonId === p.id).reduce((a, b) => a + b.amountMinor, 0); - const bal = paid - share + sent - received; - splitByCurrency[g.currency] = (splitByCurrency[g.currency] ?? 0) + bal; - // mark expenseIds so eslint doesn't flag unused - gExpIds.add(''); + for (const group of inputs.groups) { + if (group.deletedAt) continue; + const balances = computeMemberBalances({ + group, + members: membersByGroup.get(group.id) ?? [], + expenses: expensesByGroup.get(group.id) ?? [], + payers: payersByGroup.get(group.id) ?? [], + shares: sharesByGroup.get(group.id) ?? [], + settlements: settlementsByGroup.get(group.id) ?? [], + }); + for (const [personId, balance] of balances) { + addCurrencyTotal(splitTotals, personId, group.currency, balance); } - const splitStr = Object.entries(splitByCurrency) - .map(([c, v]) => `${c} ${(v / 100).toFixed(2)}`) - .join(' | '); + } - lines.push([p.id, p.name, lendStr, personLedgers[0]?.currency ?? '', splitStr, ''].join(',')); + const lines = [csvRow(['person_id', 'person_name', 'lend_balances', 'split_balances'])]; + for (const person of inputs.people) { + if (person.deletedAt) continue; + lines.push( + csvRow([ + person.id, + person.name, + formatCurrencyTotals(lendTotals.get(person.id)), + formatCurrencyTotals(splitTotals.get(person.id)), + ]), + ); } return '\uFEFF' + lines.join('\r\n') + '\r\n'; } -function buildMonthlySummary(trackTx: Array<{ deletedAt?: string; date: string; type: string; amountMinor: number; currency: string }>): string { - const byMonth: Record = {}; - for (const t of trackTx) { - if (t.deletedAt) continue; - const month = t.date.slice(0, 7); - const cur = (byMonth[month] ??= { spent: 0, income: 0, currency: t.currency }); - if (t.type === 'expense') cur.spent += t.amountMinor; - else cur.income += t.amountMinor; +function buildMonthlySummary(trackTx: TrackTransaction[], defaultCurrency: string): string { + const byMonthCurrency = new Map< + string, + { month: string; spent: number; income: number; currency: string } + >(); + + for (const transaction of trackTx) { + if (transaction.deletedAt) continue; + const month = transaction.date.slice(0, 7); + const key = `${month}\u0000${transaction.currency}`; + const current = byMonthCurrency.get(key) ?? { + month, + spent: 0, + income: 0, + currency: transaction.currency, + }; + if (transaction.type === 'expense') current.spent += transaction.amountMinor; + else current.income += transaction.amountMinor; + byMonthCurrency.set(key, current); } - const lines: string[] = []; - lines.push(['month', 'spent', 'income', 'currency'].join(',')); - for (const [month, v] of Object.entries(byMonth).sort()) { - lines.push([month, minorToDecimal(v.spent, v.currency).toFixed(2), minorToDecimal(v.income, v.currency).toFixed(2), v.currency].join(',')); + + const currentMonth = toMonthKey(); + const currentKey = `${currentMonth}\u0000${defaultCurrency}`; + if (!byMonthCurrency.has(currentKey)) { + byMonthCurrency.set(currentKey, { + month: currentMonth, + spent: 0, + income: 0, + currency: defaultCurrency, + }); } - // Always include the current month row even if zero. - const cur = toMonthKey(); - if (!byMonth[cur]) { - lines.push([cur, '0.00', '0.00', 'INR'].join(',')); + + const rows = [...byMonthCurrency.values()].sort( + (a, b) => a.month.localeCompare(b.month) || a.currency.localeCompare(b.currency), + ); + const lines = [csvRow(['month', 'spent', 'income', 'currency'])]; + for (const row of rows) { + lines.push( + csvRow([ + row.month, + minorToDecimalString(row.spent, row.currency), + minorToDecimalString(row.income, row.currency), + row.currency, + ]), + ); } return '\uFEFF' + lines.join('\r\n') + '\r\n'; } + +function pushGrouped(map: Map, key: K, value: V): void { + const rows = map.get(key) ?? []; + rows.push(value); + map.set(key, rows); +} + +function addCurrencyTotal( + totals: Map>, + personId: string, + currency: string, + amountMinor: number, +): void { + const byCurrency = totals.get(personId) ?? new Map(); + byCurrency.set(currency, (byCurrency.get(currency) ?? 0) + amountMinor); + totals.set(personId, byCurrency); +} + +function formatCurrencyTotals(totals: Map | undefined): string { + if (!totals) return ''; + return [...totals.entries()] + .sort(([a], [b]) => a.localeCompare(b)) + .map(([currency, amountMinor]) => `${currency} ${minorToDecimalString(amountMinor, currency)}`) + .join(' | '); +} diff --git a/src/modules/lend/repositories/lendLedgerAtomicity.test.ts b/src/modules/lend/repositories/lendLedgerAtomicity.test.ts new file mode 100644 index 0000000..f7b569a --- /dev/null +++ b/src/modules/lend/repositories/lendLedgerAtomicity.test.ts @@ -0,0 +1,38 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { freshDB, wipeDB } from '@tests/db-test-utils'; +import { getDB } from '@db/database'; +import { personRepository } from '@shared/people/repository'; +import { settingsRepository } from '@shared/settings/repository'; +import { lendEntryRepository } from './lendEntryRepository'; +import { lendLedgerRepository } from './lendLedgerRepository'; + +beforeEach(async () => { + await wipeDB(); + freshDB(); + await settingsRepository.get(); +}); + +describe('Lend ledger cascade atomicity', () => { + it('rolls back the ledger delete if an entry update fails', async () => { + const person = await personRepository.create({ name: 'Rahul' }); + const ledger = await lendLedgerRepository.create({ personId: person.id, currency: 'INR' }); + const entry = await lendEntryRepository.create({ + ledgerId: ledger.id, + type: 'lent', + amountMinor: 5000, + date: '2026-08-18', + }); + const db = getDB(); + const putSpy = vi + .spyOn(db.lendEntries, 'put') + .mockRejectedValueOnce(new Error('simulated entry write failure')); + + await expect(lendLedgerRepository.softDelete(ledger.id)).rejects.toThrow( + 'simulated entry write failure', + ); + putSpy.mockRestore(); + + expect((await db.lendLedgers.get(ledger.id))?.deletedAt).toBeUndefined(); + expect((await db.lendEntries.get(entry.id))?.deletedAt).toBeUndefined(); + }); +}); diff --git a/src/modules/lend/repositories/lendLedgerRepository.ts b/src/modules/lend/repositories/lendLedgerRepository.ts index cb08243..b228b37 100644 --- a/src/modules/lend/repositories/lendLedgerRepository.ts +++ b/src/modules/lend/repositories/lendLedgerRepository.ts @@ -94,56 +94,48 @@ export const lendLedgerRepository = { return this.update(id, { archived: true }); }, - /** - * Soft-delete a ledger AND its currently-active entries - * so the dashboard hides them. We perform the writes - * serially rather than inside a multi-table transaction: - * a partial state (ledger hidden, entries not yet - * hidden) is still rendered correctly because the - * dashboard filters by ledgerId. Serial writes also - * avoid flaky Dexie transactions in the test - * fake-indexeddb environment. - */ + /** Soft-delete a ledger and all currently-active entries atomically. */ async softDelete(id: string): Promise<{ ledgerId: string; entryIds: string[] }> { const db = getDB(); - const ledger = await db.lendLedgers.get(id); - if (!ledger) return { ledgerId: id, entryIds: [] }; - const entries = await db.lendEntries.where('ledgerId').equals(id).toArray(); - const activeEntries = entries.filter((e) => !e.deletedAt); - await repoSoftDelete(db.lendLedgers, id); - for (const e of activeEntries) { - await repoSoftDelete(db.lendEntries, e.id); - } - return { ledgerId: id, entryIds: activeEntries.map((e) => e.id) }; + return db.transaction('rw', [db.lendLedgers, db.lendEntries], async () => { + const ledger = await db.lendLedgers.get(id); + if (!ledger) return { ledgerId: id, entryIds: [] }; + const entries = await db.lendEntries.where('ledgerId').equals(id).toArray(); + const activeEntries = entries.filter((entry) => !entry.deletedAt); + + await repoSoftDelete(db.lendLedgers, id); + for (const entry of activeEntries) { + await repoSoftDelete(db.lendEntries, entry.id); + } + return { ledgerId: id, entryIds: activeEntries.map((entry) => entry.id) }; + }); }, - /** - * Restore a soft-deleted ledger. Companion entries that - * were soft-deleted at the same time are also restored. - */ + /** Restore a soft-deleted ledger and its soft-deleted entries atomically. */ async restore(id: string): Promise<{ ledgerId: string; entryIds: string[] }> { const db = getDB(); - const ledger = await db.lendLedgers.get(id); - if (!ledger) return { ledgerId: id, entryIds: [] }; - const entries = (await db.lendEntries.where('ledgerId').equals(id).toArray()) - .filter((e) => !!e.deletedAt); - await repoRestore(db.lendLedgers, id); - for (const e of entries) { - await repoRestore(db.lendEntries, e.id); - } - return { ledgerId: id, entryIds: entries.map((e) => e.id) }; + return db.transaction('rw', [db.lendLedgers, db.lendEntries], async () => { + const ledger = await db.lendLedgers.get(id); + if (!ledger) return { ledgerId: id, entryIds: [] }; + const entries = (await db.lendEntries.where('ledgerId').equals(id).toArray()).filter( + (entry) => !!entry.deletedAt, + ); + + await repoRestore(db.lendLedgers, id); + for (const entry of entries) { + await repoRestore(db.lendEntries, entry.id); + } + return { ledgerId: id, entryIds: entries.map((entry) => entry.id) }; + }); }, - /** - * Hard-delete a ledger and ALL of its entries. Used by - * JSON restore and wipe. Caller is responsible for - * confirming this is intended. - */ + /** Hard-delete a ledger and all of its entries atomically. */ async _hardDeleteCascade(id: string): Promise { const db = getDB(); - const entries = await db.lendEntries.where('ledgerId').equals(id).toArray(); - for (const e of entries) await db.lendEntries.delete(e.id); - await db.lendLedgers.delete(id); + await db.transaction('rw', [db.lendLedgers, db.lendEntries], async () => { + await db.lendEntries.where('ledgerId').equals(id).delete(); + await db.lendLedgers.delete(id); + }); }, /** Bulk-replace (used by JSON restore). */ diff --git a/src/modules/split/queries/index.ts b/src/modules/split/queries/index.ts index bd35f38..ce97032 100644 --- a/src/modules/split/queries/index.ts +++ b/src/modules/split/queries/index.ts @@ -14,7 +14,6 @@ import { useMemo } from 'react'; import { useLiveQuery } from 'dexie-react-hooks'; import { getDB } from '@db/database'; -import { findSelf } from '@shared/people/domain'; import type { SplitGroup, SplitGroupMember, @@ -28,6 +27,12 @@ import { simplifyDebts, type Transfer } from '../domain/simplify'; import { buildGroupSummary, type GroupSummary } from '../domain/aggregations'; import { useSelf } from '@shared/people/queries'; +function pushGrouped(map: Map, key: K, value: V): void { + const rows = map.get(key) ?? []; + rows.push(value); + map.set(key, rows); +} + // --------------------------------------------------------------------------- // Single-table hooks // --------------------------------------------------------------------------- @@ -206,61 +211,71 @@ export interface SplitDashboardItem { expenseCount: number; } -/** - * Per-group summary for the Split dashboard. For each - * active group, computes the current user's net position. - * - * Lazy implementation: uses the same balance engine as - * `useSplitGroupSummary` but batches across every group so - * a single `useLiveQuery` can re-render the dashboard - * whenever any input row changes. - */ +/** Per-group summary for the Split dashboard, batched in one live query. */ export function useSplitDashboard(): SplitDashboardItem[] | undefined { const self = useSelf(); return useLiveQuery(async () => { if (!self) return undefined; const db = getDB(); - - // Resolve the self id outside the loop. - const people = await db.people.toArray(); - const selfRow = findSelf(people); - if (!selfRow) return []; - const groups = (await db.splitGroups.toArray()).filter( - (g) => !g.deletedAt && !g.archived, + (group) => !group.deletedAt && !group.archived, ); - if (groups.length === 0) return []; - const members = await db.splitGroupMembers.toArray(); - const expenses = await db.splitExpenses.toArray(); - const payers = await db.splitPayers.toArray(); - const shares = await db.splitShares.toArray(); - const settlements = await db.splitSettlements.toArray(); + const [members, expenses, payers, shares, settlements] = await Promise.all([ + db.splitGroupMembers.toArray(), + db.splitExpenses.toArray(), + db.splitPayers.toArray(), + db.splitShares.toArray(), + db.splitSettlements.toArray(), + ]); - const out: SplitDashboardItem[] = []; - for (const g of groups) { - const groupMembers = members.filter((m) => m.groupId === g.id && !m.deletedAt); - const groupExpenses = expenses.filter((e) => e.groupId === g.id && !e.deletedAt); - const expenseIds = new Set(groupExpenses.map((e) => e.id)); - const groupPayers = payers.filter((p) => expenseIds.has(p.expenseId)); - const groupShares = shares.filter((s) => expenseIds.has(s.expenseId)); - const groupSettlements = settlements.filter((s) => s.groupId === g.id && !s.deletedAt); + const membersByGroup = new Map(); + const expensesByGroup = new Map(); + const payersByGroup = new Map(); + const sharesByGroup = new Map(); + const settlementsByGroup = new Map(); + const groupIdByExpense = new Map(); + + for (const member of members) { + if (!member.deletedAt) pushGrouped(membersByGroup, member.groupId, member); + } + for (const expense of expenses) { + if (expense.deletedAt) continue; + pushGrouped(expensesByGroup, expense.groupId, expense); + groupIdByExpense.set(expense.id, expense.groupId); + } + for (const payer of payers) { + if (payer.deletedAt) continue; + const groupId = groupIdByExpense.get(payer.expenseId); + if (groupId) pushGrouped(payersByGroup, groupId, payer); + } + for (const share of shares) { + if (share.deletedAt) continue; + const groupId = groupIdByExpense.get(share.expenseId); + if (groupId) pushGrouped(sharesByGroup, groupId, share); + } + for (const settlement of settlements) { + if (!settlement.deletedAt) pushGrouped(settlementsByGroup, settlement.groupId, settlement); + } + const out = groups.map((group) => { + const groupExpenses = expensesByGroup.get(group.id) ?? []; const balances = computeGroupBalances({ - group: g, - members: groupMembers, + group, + members: membersByGroup.get(group.id) ?? [], expenses: groupExpenses, - payers: groupPayers, - shares: groupShares, - settlements: groupSettlements, + payers: payersByGroup.get(group.id) ?? [], + shares: sharesByGroup.get(group.id) ?? [], + settlements: settlementsByGroup.get(group.id) ?? [], }); - out.push({ - group: g, - yourNet: balances.get(selfRow.id) ?? 0, + return { + group, + yourNet: balances.get(self.id) ?? 0, expenseCount: groupExpenses.length, - }); - } + }; + }); + out.sort((a, b) => Math.abs(b.yourNet) - Math.abs(a.yourNet)); return out; }, [self]); diff --git a/src/modules/track/queries/index.ts b/src/modules/track/queries/index.ts index 6d999fb..a611fe0 100644 --- a/src/modules/track/queries/index.ts +++ b/src/modules/track/queries/index.ts @@ -8,7 +8,7 @@ import { useLiveQuery } from 'dexie-react-hooks'; import { getDB } from '@db/database'; -import { todayDateOnly } from '@shared/dates'; +import { monthDateRange, todayDateOnly } from '@shared/dates'; import { useAppSettings } from '@shared/settings/useSettings'; import { budgetProgress as budgetProgressCalc, @@ -28,6 +28,8 @@ import type { TrackTransactionWithCategory, } from '../domain/types'; +const EMPTY_FILTERS: TrackTransactionFilters = {}; + /** All active categories, optionally filtered by type. */ export function useTrackCategories( type?: 'expense' | 'income', @@ -61,13 +63,17 @@ export function useTrackCategoryMap(): Map | undefined { /** Active transactions for a month, decorated with their category. */ export function useTrackTransactionsForMonth( month: string, - filters: TrackTransactionFilters = {}, + filters: TrackTransactionFilters = EMPTY_FILTERS, ): TrackTransactionWithCategory[] | undefined { const categoriesById = useTrackCategoryMap(); return useLiveQuery( async () => { - const all = await getDB().trackTransactions.toArray(); - const filtered = filterTransactions(all, { ...filters, month }); + const { fromInclusive, toExclusive } = monthDateRange(month); + const monthRows = await getDB() + .trackTransactions.where('date') + .between(fromInclusive, toExclusive, true, false) + .toArray(); + const filtered = filterTransactions(monthRows, { ...filters, month }); if (!categoriesById) return filtered.map((t) => ({ ...t })); return decorateWithCategory(filtered, categoriesById); }, @@ -77,7 +83,7 @@ export function useTrackTransactionsForMonth( /** All active transactions across months (filtered), decorated. */ export function useTrackTransactions( - filters: TrackTransactionFilters = {}, + filters: TrackTransactionFilters = EMPTY_FILTERS, ): TrackTransactionWithCategory[] | undefined { const categoriesById = useTrackCategoryMap(); return useLiveQuery( @@ -109,10 +115,12 @@ export function useTrackTransaction(id: string | undefined): TrackTransactionWit /** The budget for a given month, or undefined. */ export function useTrackBudget(month: string): TrackBudget | undefined { return useLiveQuery( - async () => { - const all = await getDB().trackBudgets.toArray(); - return all.find((b) => !b.deletedAt && b.month === month); - }, + async () => + getDB() + .trackBudgets.where('month') + .equals(month) + .filter((budget) => !budget.deletedAt) + .first(), [month], ); } @@ -128,23 +136,30 @@ export function useTrackMonthlySummary(month: string): MonthlySummary | undefine return useLiveQuery( async () => { const db = getDB(); - const [allTx, allBudgets, allCategories] = await Promise.all([ - db.trackTransactions.toArray(), - db.trackBudgets.toArray(), + const { fromInclusive, toExclusive } = monthDateRange(month); + const [monthTx, budgetRow, allCategories] = await Promise.all([ + db.trackTransactions.where('date').between(fromInclusive, toExclusive, true, false).toArray(), + db.trackBudgets.where('month').equals(month).filter((budget) => !budget.deletedAt).first(), db.trackCategories.toArray(), ]); const currency = settings?.defaultCurrency ?? 'INR'; - const spent = monthlyTotalCalc(allTx, month, 'expense'); - const income = monthlyTotalCalc(allTx, month, 'income'); - const byCategory = categoryTotalsCalc(allTx, month, 'expense').map((c) => ({ + const spent = monthlyTotalCalc(monthTx, month, 'expense'); + const income = monthlyTotalCalc(monthTx, month, 'income'); + const categoryNames = new Map( + allCategories.filter((category) => !category.deletedAt).map((category) => [category.id, category.name]), + ); + const byCategory = categoryTotalsCalc(monthTx, month, 'expense').map((c) => ({ ...c, categoryName: c.categoryId - ? allCategories.find((x) => x.id === c.categoryId)?.name ?? 'Uncategorised' + ? categoryNames.get(c.categoryId) ?? 'Uncategorised' : 'Uncategorised', })); - const byPaymentMethod = paymentMethodTotalsCalc(allTx, month, 'expense'); - const budgetRow = allBudgets.find((b) => !b.deletedAt && b.month === month); - const prog = budgetProgressCalc(allTx, month, budgetRow ? { amountMinor: budgetRow.amountMinor } : undefined); + const byPaymentMethod = paymentMethodTotalsCalc(monthTx, month, 'expense'); + const prog = budgetProgressCalc( + monthTx, + month, + budgetRow ? { amountMinor: budgetRow.amountMinor } : undefined, + ); const summary: MonthlySummary = { month, currency, @@ -182,6 +197,8 @@ export function useRecentTrackTransactions(limit = 5): TrackTransactionWithCateg const categoriesById = useTrackCategoryMap(); return useLiveQuery( async () => { + // Keep the established date + createdAt tie-breaker. The date index alone + // cannot preserve that ordering for multiple transactions on the same day. const all = await getDB().trackTransactions.toArray(); const recent = recentTransactionsCalc(all, limit); if (!categoriesById) return recent.map((t) => ({ ...t })); @@ -195,10 +212,11 @@ export function useRecentTrackTransactions(limit = 5): TrackTransactionWithCateg export function useTrackRecurringDue(): TrackRecurringRule[] | undefined { return useLiveQuery(async () => { const today = todayDateOnly(); - const all = await getDB().trackRecurringRules.toArray(); - return all - .filter((r) => !r.deletedAt && r.enabled && r.nextDate <= today) - .sort((a, b) => (a.nextDate < b.nextDate ? -1 : a.nextDate > b.nextDate ? 1 : 0)); + return getDB() + .trackRecurringRules.where('nextDate') + .belowOrEqual(today) + .filter((rule) => !rule.deletedAt && rule.enabled) + .sortBy('nextDate'); }, []); } diff --git a/src/modules/track/repositories/trackTransactionRepository.ts b/src/modules/track/repositories/trackTransactionRepository.ts index ec3edf0..6fe9619 100644 --- a/src/modules/track/repositories/trackTransactionRepository.ts +++ b/src/modules/track/repositories/trackTransactionRepository.ts @@ -22,7 +22,7 @@ import { cleanTransactionInput, type TrackTransactionInput, } from '../domain/validation'; -import { isInMonth } from '@shared/dates'; +import { monthDateRange } from '@shared/dates'; import type { TrackTransaction } from '@db/schema'; function clean(input: Partial): Partial { @@ -35,29 +35,34 @@ function clean(input: Partial): Partial b.date ? -1 : 0; +} + export const trackTransactionRepository = { /** All active (non-deleted) transactions, newest date first. */ async list(): Promise { const all = await getDB().trackTransactions.toArray(); - return all - .filter((t) => !t.deletedAt) - .sort((a, b) => (a.date < b.date ? 1 : a.date > b.date ? -1 : 0)); + return all.filter((transaction) => !transaction.deletedAt).sort(newestDateFirst); }, /** Active transactions within a single month (YYYY-MM). */ async listByMonth(month: string): Promise { - const all = await getDB().trackTransactions.toArray(); - return all - .filter((t) => !t.deletedAt && isInMonth(t.date, month)) - .sort((a, b) => (a.date < b.date ? 1 : a.date > b.date ? -1 : 0)); + const { fromInclusive, toExclusive } = monthDateRange(month); + const rows = await getDB() + .trackTransactions.where('date') + .between(fromInclusive, toExclusive, true, false) + .toArray(); + return rows.filter((transaction) => !transaction.deletedAt).sort(newestDateFirst); }, /** Active transactions within an inclusive [fromDate, toDate] range. */ async listByDateRange(fromDate: string, toDate: string): Promise { - const all = await getDB().trackTransactions.toArray(); - return all - .filter((t) => !t.deletedAt && t.date >= fromDate && t.date <= toDate) - .sort((a, b) => (a.date < b.date ? 1 : a.date > b.date ? -1 : 0)); + const rows = await getDB() + .trackTransactions.where('date') + .between(fromDate, toDate, true, true) + .toArray(); + return rows.filter((transaction) => !transaction.deletedAt).sort(newestDateFirst); }, async get(id: string): Promise { diff --git a/src/overview/queries/index.ts b/src/overview/queries/index.ts index 8ffe9d9..48bcc21 100644 --- a/src/overview/queries/index.ts +++ b/src/overview/queries/index.ts @@ -1,11 +1,9 @@ -/** - * Live overview queries. - */ +/** Live overview queries. */ import { useLiveQuery } from 'dexie-react-hooks'; import { getDB } from '@db/database'; import { SELF_PERSON_ID } from '@db/seed'; -import { toMonthKey, isInMonth } from '@shared/dates'; +import { monthDateRange, toMonthKey } from '@shared/dates'; import { settingsRepository } from '@shared/settings/repository'; import { trackBudgetRepository } from '@modules/track/repositories/trackBudgetRepository'; import { computeMemberBalances } from '@modules/split/domain/balances'; @@ -19,25 +17,39 @@ import { import { calculateSplitPersonalShareForMonth } from '../projections/calculations'; import type { ActivityItem, OverviewSummary, PersonExposure } from '../projections/types'; import type { CurrencyCode } from '@shared/money'; -import type { LendEntry } from '@db/schema'; +import type { + LendEntry, + SplitExpense, + SplitGroup, + SplitGroupMember, + SplitPayer, + SplitSettlement, + SplitShare, +} from '@db/schema'; function sumLendEntries(entries: Array>): number { return entries.reduce((sum, entry) => sum + entryToSignedAmount(entry), 0); } +function pushGrouped(map: Map, key: K, value: V): void { + const rows = map.get(key) ?? []; + rows.push(value); + map.set(key, rows); +} + export function useOverviewSummary(month: string = toMonthKey()): OverviewSummary | undefined { return useLiveQuery(async () => { const settings = await settingsRepository.get(); const currency = settings.defaultCurrency; const db = getDB(); + const { fromInclusive, toExclusive } = monthDateRange(month); - const [trackAll, budget] = await Promise.all([ - db.trackTransactions.toArray(), + const [trackMonthRows, budget] = await Promise.all([ + db.trackTransactions.where('date').between(fromInclusive, toExclusive, true, false).toArray(), trackBudgetRepository.getByMonth(month), ]); - const trackActive = trackAll.filter((transaction) => !transaction.deletedAt); - const monthTrack = trackActive.filter( - (transaction) => isInMonth(transaction.date, month) && transaction.currency === currency, + const monthTrack = trackMonthRows.filter( + (transaction) => !transaction.deletedAt && transaction.currency === currency, ); const spentMinor = monthTrack .filter((transaction) => transaction.type === 'expense') @@ -73,19 +85,41 @@ export function useOverviewSummary(month: string = toMonthKey()): OverviewSummar shares, }); + const membersByGroup = new Map(); + const expensesByGroup = new Map(); + const payersByGroup = new Map(); + const sharesByGroup = new Map(); + const settlementsByGroup = new Map(); + const groupIdByExpense = new Map(); + + for (const member of members) pushGrouped(membersByGroup, member.groupId, member); + for (const expense of expenses) { + pushGrouped(expensesByGroup, expense.groupId, expense); + groupIdByExpense.set(expense.id, expense.groupId); + } + for (const payer of payers) { + const groupId = groupIdByExpense.get(payer.expenseId); + if (groupId) pushGrouped(payersByGroup, groupId, payer); + } + for (const share of shares) { + const groupId = groupIdByExpense.get(share.expenseId); + if (groupId) pushGrouped(sharesByGroup, groupId, share); + } + for (const settlement of settlements) { + pushGrouped(settlementsByGroup, settlement.groupId, settlement); + } + let youAreOwedSplit = 0; let youOweSplit = 0; for (const group of groups) { if (group.currency !== currency) continue; - const groupExpenses = expenses.filter((expense) => expense.groupId === group.id); - const expenseIds = new Set(groupExpenses.map((expense) => expense.id)); const balances = computeMemberBalances({ group, - members: members.filter((member) => member.groupId === group.id), - expenses: groupExpenses, - payers: payers.filter((payer) => expenseIds.has(payer.expenseId)), - shares: shares.filter((share) => expenseIds.has(share.expenseId)), - settlements: settlements.filter((settlement) => settlement.groupId === group.id), + members: membersByGroup.get(group.id) ?? [], + expenses: expensesByGroup.get(group.id) ?? [], + payers: payersByGroup.get(group.id) ?? [], + shares: sharesByGroup.get(group.id) ?? [], + settlements: settlementsByGroup.get(group.id) ?? [], }); const balance = balances.get(SELF_PERSON_ID) ?? 0; if (balance > 0) youAreOwedSplit += balance; @@ -96,12 +130,13 @@ export function useOverviewSummary(month: string = toMonthKey()): OverviewSummar (ledger) => !ledger.deletedAt && !ledger.archived && ledger.currency === currency, ); const lendEntries = (await db.lendEntries.toArray()).filter((entry) => !entry.deletedAt); + const lendEntriesByLedger = new Map(); + for (const entry of lendEntries) pushGrouped(lendEntriesByLedger, entry.ledgerId, entry); + let youWillReceiveLend = 0; let youOweLend = 0; for (const ledger of ledgers) { - const balance = sumLendEntries( - lendEntries.filter((entry) => entry.ledgerId === ledger.id), - ); + const balance = sumLendEntries(lendEntriesByLedger.get(ledger.id) ?? []); if (balance > 0) youWillReceiveLend += balance; else youOweLend += -balance; } @@ -183,16 +218,17 @@ export function usePersonExposure(personId: string): PersonExposure | null | und const ledgers = (await db.lendLedgers.where('personId').equals(personId).toArray()).filter( (ledger) => !ledger.deletedAt && !ledger.archived, ); - const ledgerIds = new Set(ledgers.map((ledger) => ledger.id)); - const lendEntries = ledgerIds.size - ? (await db.lendEntries.where('ledgerId').anyOf([...ledgerIds]).toArray()).filter( + const ledgerIds = ledgers.map((ledger) => ledger.id); + const lendEntries = ledgerIds.length + ? (await db.lendEntries.where('ledgerId').anyOf(ledgerIds).toArray()).filter( (entry) => !entry.deletedAt, ) : []; + const lendEntriesByLedger = new Map(); + for (const entry of lendEntries) pushGrouped(lendEntriesByLedger, entry.ledgerId, entry); + for (const ledger of ledgers) { - const balance = sumLendEntries( - lendEntries.filter((entry) => entry.ledgerId === ledger.id), - ); + const balance = sumLendEntries(lendEntriesByLedger.get(ledger.id) ?? []); contexts.push({ module: 'lend', contextId: ledger.id, @@ -202,41 +238,75 @@ export function usePersonExposure(personId: string): PersonExposure | null | und }); } - const allMembers = (await db.splitGroupMembers.toArray()).filter((member) => !member.deletedAt); - const memberships = allMembers.filter((member) => member.personId === personId); - const [groupsAll, expensesAll, payersAll, sharesAll, settlementsAll] = await Promise.all([ - db.splitGroups.toArray(), - db.splitExpenses.toArray(), - db.splitPayers.toArray(), - db.splitShares.toArray(), - db.splitSettlements.toArray(), - ]); - const groups = groupsAll.filter((group) => !group.deletedAt); - const expenses = expensesAll.filter((expense) => !expense.deletedAt); - const payers = payersAll.filter((payer) => !payer.deletedAt); - const shares = sharesAll.filter((share) => !share.deletedAt); - const settlements = settlementsAll.filter((settlement) => !settlement.deletedAt); + const memberships = ( + await db.splitGroupMembers.where('personId').equals(personId).toArray() + ).filter((member) => !member.deletedAt); + const groupIds = [...new Set(memberships.map((membership) => membership.groupId))]; - for (const membership of memberships) { - const group = groups.find((candidate) => candidate.id === membership.groupId); - if (!group) continue; - const groupExpenses = expenses.filter((expense) => expense.groupId === group.id); - const expenseIds = new Set(groupExpenses.map((expense) => expense.id)); - const balances = computeMemberBalances({ - group, - members: allMembers.filter((member) => member.groupId === group.id), - expenses: groupExpenses, - payers: payers.filter((payer) => expenseIds.has(payer.expenseId)), - shares: shares.filter((share) => expenseIds.has(share.expenseId)), - settlements: settlements.filter((settlement) => settlement.groupId === group.id), - }); - contexts.push({ - module: 'split', - contextId: group.id, - contextName: group.name, - balanceMinor: balances.get(personId) ?? 0, - currency: group.currency as CurrencyCode, - }); + if (groupIds.length > 0) { + const [groupRows, allMembers, expenses, settlements] = await Promise.all([ + db.splitGroups.bulkGet(groupIds), + db.splitGroupMembers.where('groupId').anyOf(groupIds).toArray(), + db.splitExpenses.where('groupId').anyOf(groupIds).toArray(), + db.splitSettlements.where('groupId').anyOf(groupIds).toArray(), + ]); + const groups = groupRows.filter( + (group): group is SplitGroup => group !== undefined && !group.deletedAt, + ); + const activeMembers = allMembers.filter((member) => !member.deletedAt); + const activeExpenses = expenses.filter((expense) => !expense.deletedAt); + const activeSettlements = settlements.filter((settlement) => !settlement.deletedAt); + const expenseIds = activeExpenses.map((expense) => expense.id); + const [payers, shares] = expenseIds.length + ? await Promise.all([ + db.splitPayers.where('expenseId').anyOf(expenseIds).toArray(), + db.splitShares.where('expenseId').anyOf(expenseIds).toArray(), + ]) + : [[], []]; + + const membersByGroup = new Map(); + const expensesByGroup = new Map(); + const payersByGroup = new Map(); + const sharesByGroup = new Map(); + const settlementsByGroup = new Map(); + const groupIdByExpense = new Map(); + + for (const member of activeMembers) pushGrouped(membersByGroup, member.groupId, member); + for (const expense of activeExpenses) { + pushGrouped(expensesByGroup, expense.groupId, expense); + groupIdByExpense.set(expense.id, expense.groupId); + } + for (const payer of payers) { + if (payer.deletedAt) continue; + const groupId = groupIdByExpense.get(payer.expenseId); + if (groupId) pushGrouped(payersByGroup, groupId, payer); + } + for (const share of shares) { + if (share.deletedAt) continue; + const groupId = groupIdByExpense.get(share.expenseId); + if (groupId) pushGrouped(sharesByGroup, groupId, share); + } + for (const settlement of activeSettlements) { + pushGrouped(settlementsByGroup, settlement.groupId, settlement); + } + + for (const group of groups) { + const balances = computeMemberBalances({ + group, + members: membersByGroup.get(group.id) ?? [], + expenses: expensesByGroup.get(group.id) ?? [], + payers: payersByGroup.get(group.id) ?? [], + shares: sharesByGroup.get(group.id) ?? [], + settlements: settlementsByGroup.get(group.id) ?? [], + }); + contexts.push({ + module: 'split', + contextId: group.id, + contextName: group.name, + balanceMinor: balances.get(personId) ?? 0, + currency: group.currency as CurrencyCode, + }); + } } const currencies = new Set(contexts.map((context) => context.currency)); diff --git a/src/overview/queries/useGlobalSearchData.ts b/src/overview/queries/useGlobalSearchData.ts new file mode 100644 index 0000000..3327eda --- /dev/null +++ b/src/overview/queries/useGlobalSearchData.ts @@ -0,0 +1,36 @@ +/** Raw reactive rows used by global search. */ + +import { useLiveQuery } from 'dexie-react-hooks'; +import { getDB } from '@db/database'; +import type { + LendEntry, + LendLedger, + Person, + SplitExpense, + SplitGroup, + TrackTransaction, +} from '@db/schema'; + +export interface GlobalSearchData { + track: TrackTransaction[]; + expenses: SplitExpense[]; + groups: SplitGroup[]; + people: Person[]; + lendEntries: LendEntry[]; + ledgers: LendLedger[]; +} + +export function useGlobalSearchData(): GlobalSearchData | undefined { + return useLiveQuery(async () => { + const db = getDB(); + const [track, expenses, groups, people, lendEntries, ledgers] = await Promise.all([ + db.trackTransactions.toArray(), + db.splitExpenses.toArray(), + db.splitGroups.toArray(), + db.people.toArray(), + db.lendEntries.toArray(), + db.lendLedgers.toArray(), + ]); + return { track, expenses, groups, people, lendEntries, ledgers }; + }, []); +} diff --git a/src/routes/OnboardingPage.tsx b/src/routes/OnboardingPage.tsx index 6b63858..534aeb7 100644 --- a/src/routes/OnboardingPage.tsx +++ b/src/routes/OnboardingPage.tsx @@ -1,6 +1,4 @@ -/** - * Short first-run onboarding. No account or bank connection is required. - */ +/** Short first-run onboarding. No account or bank connection is required. */ import { useRef, useState } from 'react'; import { useNavigate } from '@tanstack/react-router'; @@ -18,25 +16,37 @@ export function OnboardingPage() { const [name, setName] = useState('Me'); const [persist, setPersist] = useState(false); const [busy, setBusy] = useState(false); + const [error, setError] = useState(); const finishStarted = useRef(false); - const next = () => setStep((s) => (s + 1) as Step); - const back = () => setStep((s) => (s - 1) as Step); + const next = () => { + setError(undefined); + setStep((s) => (s + 1) as Step); + }; + const back = () => { + setError(undefined); + setStep((s) => (s - 1) as Step); + }; const finish = async () => { if (finishStarted.current) return; finishStarted.current = true; setBusy(true); + setError(undefined); try { await settingsRepository.update({ defaultCurrency: currency }); await personRepository.update('self', { name: name.trim() }); if (persist) await persistBrowserStorage(); await settingsRepository.setOnboardingComplete(true); await navigate({ to: '/overview', replace: true }); - } catch (error) { + } catch (finishError) { finishStarted.current = false; - throw error; + setError( + finishError instanceof Error + ? finishError.message + : 'Could not finish setup. Your choices are still here; try again.', + ); } finally { setBusy(false); } @@ -106,9 +116,14 @@ export function OnboardingPage() {
+ {error && ( +

+ {error} +

+ )}
- - + +
)} diff --git a/src/routes/overview/SearchPage.tsx b/src/routes/overview/SearchPage.tsx index 57ecf07..b79bbf0 100644 --- a/src/routes/overview/SearchPage.tsx +++ b/src/routes/overview/SearchPage.tsx @@ -2,9 +2,8 @@ import { useMemo, useState } from 'react'; import { useNavigate } from '@tanstack/react-router'; -import { useLiveQuery } from 'dexie-react-hooks'; -import { getDB } from '@db/database'; import { Card, EmptyState, Input, Spinner } from '@components/ui'; +import { useGlobalSearchData } from '@overview/queries/useGlobalSearchData'; import { Search as SearchIcon, Receipt, Users, HandCoins, UserCircle } from 'lucide-react'; import { formatHumanDate } from '@shared/dates'; import type { LendEntryType } from '@db/schema'; @@ -30,18 +29,7 @@ function lendEntryLabel(type: LendEntryType): string { export function SearchPage() { const [q, setQ] = useState(''); const navigate = useNavigate(); - const all = useLiveQuery(async () => { - const db = getDB(); - const [track, expenses, groups, people, lendEntries, ledgers] = await Promise.all([ - db.trackTransactions.toArray(), - db.splitExpenses.toArray(), - db.splitGroups.toArray(), - db.people.toArray(), - db.lendEntries.toArray(), - db.lendLedgers.toArray(), - ]); - return { track, expenses, groups, people, lendEntries, ledgers }; - }, []); + const all = useGlobalSearchData(); const results = useMemo(() => { if (!all || !q.trim()) return []; @@ -50,9 +38,14 @@ export function SearchPage() { const visibleGroups = new Map( all.groups.filter((group) => !group.deletedAt).map((group) => [group.id, group]), ); + const visiblePeople = new Map( + all.people.filter((person) => !person.deletedAt).map((person) => [person.id, person]), + ); + const visibleLedgers = new Map( + all.ledgers.filter((ledger) => !ledger.deletedAt).map((ledger) => [ledger.id, ledger]), + ); - for (const person of all.people) { - if (person.deletedAt) continue; + for (const person of visiblePeople.values()) { const nameMatches = person.name.toLocaleLowerCase().includes(needle); const noteMatches = person.note?.toLocaleLowerCase().includes(needle) ?? false; if (nameMatches || noteMatches) { @@ -97,9 +90,9 @@ export function SearchPage() { for (const entry of all.lendEntries) { if (entry.deletedAt) continue; - const ledger = all.ledgers.find((item) => item.id === entry.ledgerId && !item.deletedAt); + const ledger = visibleLedgers.get(entry.ledgerId); if (!ledger) continue; - const person = all.people.find((item) => item.id === ledger.personId && !item.deletedAt); + const person = visiblePeople.get(ledger.personId); if (!person) continue; const note = entry.note?.trim() ?? ''; if (!note.toLocaleLowerCase().includes(needle) && !person.name.toLocaleLowerCase().includes(needle)) continue; diff --git a/src/routes/settings/BackupCenter.tsx b/src/routes/settings/BackupCenter.tsx index ff86abd..865fcb8 100644 --- a/src/routes/settings/BackupCenter.tsx +++ b/src/routes/settings/BackupCenter.tsx @@ -23,7 +23,8 @@ import { import { createPortableBackupFile, shareOrDownloadBackup } from '@/backup/portable'; import { buildFullZip } from '@/export/zip/builder'; import { csvOfTrackTransactions } from '@/export/csv/serializer'; -import { getDB } from '@db/database'; +import { trackCategoryRepository } from '@modules/track/repositories/trackCategoryRepository'; +import { trackTransactionRepository } from '@modules/track/repositories/trackTransactionRepository'; import type { RecoverySnapshot } from '@db/schema'; import { formatHumanDateTime, nowISO, toMonthKey } from '@shared/dates'; import { fileFromBlob, shareOrDownloadFile } from '@shared/files/shareFile'; @@ -166,32 +167,24 @@ export function BackupCenter() { setBusy('month'); try { const month = toMonthKey(); - const db = getDB(); const [transactions, categories] = await Promise.all([ - db.trackTransactions.toArray(), - db.trackCategories.toArray(), + trackTransactionRepository.listByMonth(month), + trackCategoryRepository.listAll(), ]); - const filtered = transactions.filter( - (transaction) => !transaction.deletedAt && transaction.date.startsWith(month), - ); - const csv = csvOfTrackTransactions( - filtered, - categories, - settings?.defaultCurrency ?? 'INR', - ); + const csv = csvOfTrackTransactions(transactions, categories); const file = new File([csv], `track-${month}.csv`, { type: 'text/csv;charset=utf-8', }); const result = await shareOrDownloadFile(file, { title: `AfterSum Track export — ${month}`, - text: `${filtered.length} Track transactions from ${month}`, + text: `${transactions.length} Track transactions from ${month}`, }); if (result === 'cancelled') return; toast.show( result === 'shared' - ? `Shared ${filtered.length} transactions for ${month}.` - : `Exported ${filtered.length} transactions for ${month}.`, + ? `Shared ${transactions.length} transactions for ${month}.` + : `Exported ${transactions.length} transactions for ${month}.`, ); } catch (error) { toast.show('Export failed: ' + errorMessage(error), { variant: 'error' }); @@ -214,7 +207,7 @@ export function BackupCenter() {

Data & Storage

- No AfterSum account or backend. Data stays on this device unless you explicitly save a backup or export. + AfterSum keeps your data on this device unless you explicitly save a backup or export a file.

@@ -278,7 +271,7 @@ export function BackupCenter() { )}

- Browser usage and quota are reported for this web origin, so they include AfterSum's database and offline cache and may include other data stored on the same host. “AfterSum backup data” is the approximate serialized size of your app records, not IndexedDB's exact on-disk size. + Browser usage and quota cover this web origin, including AfterSum's database and offline cache. “AfterSum backup data” is the approximate size of your app records, not exact IndexedDB disk usage.

{storageInfo?.persistenceSupported && !storageInfo.persisted && ( @@ -296,7 +289,7 @@ export function BackupCenter() { {storageInfo && !storageInfo.persistenceSupported && (

- This browser does not expose the persistent-storage API. AfterSum will still use local IndexedDB, but the browser controls eviction. + This browser does not expose persistent storage. AfterSum still uses local IndexedDB, but the browser controls eviction.

)} @@ -307,7 +300,7 @@ export function BackupCenter() {

Portable backup

- Save one complete AfterSum file somewhere outside this device. On supported devices, the system share sheet lets you choose Drive, Files, WhatsApp, Dropbox, or another installed destination without connecting an account to AfterSum. + Save one complete, restorable AfterSum file somewhere outside this device. On supported devices, the system share sheet lets you choose where it goes without connecting an account to AfterSum.

@@ -320,7 +313,7 @@ export function BackupCenter() { {busy === 'portable' ? : } Save portable backup

- This portable format is readable JSON. Keep the file somewhere private. + This backup is readable JSON and contains your financial records. Keep it private.

@@ -330,7 +323,7 @@ export function BackupCenter() {

Automatic recovery

- Keeps one rolling daily checkpoint, replacing the previous automatic copy, plus up to three pre-restore safety checkpoints. These help with mistakes but cannot recover a lost or reset device. + Keeps one rolling daily checkpoint plus up to three pre-restore safety checkpoints. These help undo local mistakes but cannot recover a lost or reset device.

@@ -364,7 +357,7 @@ export function BackupCenter() {

Restore portable backup

- Choose an AfterSum backup file. Restore is explicit and creates a local safety checkpoint first. + Choose an AfterSum backup file. Restore replaces current financial records and creates a local safety checkpoint first.