Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
98bc049
Harden currency export formatting
11sid11 Aug 18, 2026
1b8ff5c
Cover export currency precision
11sid11 Aug 18, 2026
ce739f9
Fix CSV currency precision
11sid11 Aug 18, 2026
2b9cce1
Test multi-precision CSV exports
11sid11 Aug 18, 2026
4c2f658
Fix derived ZIP summaries
11sid11 Aug 18, 2026
2dd32fe
Cover ZIP summary correctness
11sid11 Aug 18, 2026
e365553
Make backup reads snapshot-consistent
11sid11 Aug 18, 2026
f14e43c
Make startup failure recovery safer
11sid11 Aug 18, 2026
eb7a5e7
Handle onboarding failures clearly
11sid11 Aug 18, 2026
1649503
Keep settings DB reads out of routes
11sid11 Aug 18, 2026
e150fdb
Handle settings writes reliably
11sid11 Aug 18, 2026
77d23d9
Add indexed month range helper
11sid11 Aug 18, 2026
d7470f1
Test month query ranges
11sid11 Aug 18, 2026
e863510
Use Track date indexes
11sid11 Aug 18, 2026
ccd0096
Reduce Track live-query scans
11sid11 Aug 18, 2026
b9ad377
Reduce Overview projection scans
11sid11 Aug 18, 2026
557e91f
Reduce Split dashboard scans
11sid11 Aug 18, 2026
b52e401
Move search DB reads into query layer
11sid11 Aug 18, 2026
cbf9379
Simplify global search data flow
11sid11 Aug 18, 2026
75d7238
Simplify backup data flow and copy
11sid11 Aug 18, 2026
52c138b
Tighten repository table typing
11sid11 Aug 18, 2026
7458689
Make Lend ledger cascades atomic
11sid11 Aug 18, 2026
165bd9b
Test Lend cascade rollback
11sid11 Aug 18, 2026
015816d
Make modal focus behavior predictable
11sid11 Aug 18, 2026
ae5bb89
Test modal focus lifecycle
11sid11 Aug 18, 2026
4cf6a61
Preserve Track query behavior
11sid11 Aug 18, 2026
1ad1129
Preserve Track repository ordering
11sid11 Aug 18, 2026
a19a5cd
Tighten Overview query types
11sid11 Aug 18, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 14 additions & 7 deletions src/app/providers.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -50,13 +50,20 @@ export function AppProviders({ children }: AppProvidersProps) {

if (error) {
return (
<div className="grid min-h-screen place-items-center p-4 text-center">
<div className="card max-w-md">
<h1 className="text-lg font-semibold text-rose-600">Database error</h1>
<p className="mt-2 text-sm text-slate-500">{error}</p>
<p className="mt-1 text-xs text-slate-400">
Try clearing site data in your browser settings.
<div className="grid min-h-screen place-items-center p-4">
<div className="card max-w-md text-center">
<h1 className="text-lg font-semibold text-rose-600">AfterSum could not open local data</h1>
<p className="mt-2 text-sm leading-6 text-slate-600 dark:text-slate-300">
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.
</p>
<Button className="mt-4" onClick={() => window.location.reload()}>
Reload AfterSum
</Button>
<details className="mt-4 text-left text-xs text-slate-500">
<summary className="cursor-pointer font-medium">Technical details</summary>
<p className="mt-2 break-words">{error}</p>
</details>
</div>
</div>
);
Expand Down
48 changes: 48 additions & 0 deletions src/components/ui/Modal.test.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<>
<button type="button" onClick={() => setOpen(true)}>Open modal</button>
<Modal open={open} onClose={() => setOpen(false)} title="Test dialog">
<button type="button">Dialog action</button>
</Modal>
</>
);
}

describe('Modal', () => {
it('moves focus into the dialog and restores it when closed', async () => {
const user = userEvent.setup();
render(<ModalHarness />);

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(<ModalHarness />);
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();
});
});
54 changes: 52 additions & 2 deletions src/components/ui/Modal.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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<HTMLDivElement>(null);

useEffect(() => {
if (!open || !lockScroll) return;
Expand All @@ -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<HTMLElement>(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<HTMLElement>(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);
Expand All @@ -48,6 +96,8 @@ export function Modal({ open, onClose, title, children, className, lockScroll =
}}
>
<div
ref={panelRef}
tabIndex={-1}
className={clsx(
'modal-panel max-h-[calc(100dvh-0.35rem)] w-full max-w-md overflow-y-auto overscroll-contain rounded-t-[30px] border border-slate-900/[0.07] bg-[#fbfbfc] p-5 shadow-soft-lg dark:border-white/[0.08] dark:bg-[#111217] sm:max-h-[calc(100dvh-2rem)] sm:rounded-[30px] sm:p-6',
className,
Expand Down
38 changes: 22 additions & 16 deletions src/db/repositories/base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
* - supports soft delete + undo
*/

import type { Table } from 'dexie';
import { newId } from '@shared/ids';
import { nowISO } from '@shared/dates';
import type { BaseEntity } from '@db/schema';
Expand All @@ -22,8 +23,7 @@ export type CreateInput<T extends BaseEntity> = Omit<

/** Insert a new row. */
export async function repoCreate<T extends BaseEntity>(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
table: any,
table: Table<T, string>,
input: CreateInput<T>,
): Promise<T> {
const now = nowISO();
Expand All @@ -40,8 +40,7 @@ export async function repoCreate<T extends BaseEntity>(

/** Update an existing row by id. Increments `revision`. */
export async function repoUpdate<T extends BaseEntity>(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
table: any,
table: Table<T, string>,
id: string,
patch: Partial<Omit<T, 'id' | 'createdAt' | 'revision'>>,
): Promise<T> {
Expand All @@ -55,42 +54,49 @@ export async function repoUpdate<T extends BaseEntity>(
...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<T extends BaseEntity>(
table: Table<T, string>,
id: string,
): Promise<void> {
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<T extends BaseEntity>(
table: Table<T, string>,
id: string,
): Promise<void> {
const now = nowISO();
const existing = await table.get(id);
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<T extends BaseEntity>(
table: Table<T, string>,
id: string,
): Promise<void> {
await table.delete(id);
Expand Down
28 changes: 25 additions & 3 deletions src/export/csv/csv.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
[
{
Expand All @@ -42,19 +42,41 @@ 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');
expect(lines[1]).toContain('1250.50');
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', () => {
Expand Down
Loading
Loading