Milestone 3: launch (20261008.05)

Backup and restore: restore from Settings (any backup in the folder, or an
uploaded file) or from the command line with scripts/restore.sh. Each restore
checks the file first, saves a copy of the current data so it can be undone,
and keeps the person restoring signed in. Backups are now single
self-contained files. Drilled end to end: last night's backup brings
everything back exactly, including onto an empty new machine.

Speed: a phone's first load is 147 KB compressed (target 150). The ideas box,
account menu and sign-in screens load on demand, a small built-in toast
replaces sonner, the Inter font is trimmed to the characters used, and files
and API answers are served compressed. Every screen's data comes back in
under 100 ms with 50,000 transactions (server/bench).

Accessibility: no WCAG 2.2 AA issues on any screen in either theme. Darker
status text colors, 44 px touch targets on phones, reduced motion honoured,
and focus returns after dialogs close.

Also: an in-app guide ("How Spendlight works"), OFX/QFX/QBO import, and tags
on individual parts of a split purchase.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-08 12:23:45 -04:00
parent 4ecd64132b
commit a68c343647
82 changed files with 3822 additions and 265 deletions

View File

@@ -0,0 +1,79 @@
// Speed check at scale: years of heavy use (about 50,000 made-up
// transactions) and the time each screen's data takes. Run with
// scripts/dev.sh npx vitest run --root server --config server/bench/vitest.config.ts
import { it } from 'vitest';
import { makeApp, setUpAdmin } from '../test/helpers';
it('answers every screen quickly with years of data', async () => {
const t = await makeApp();
const admin = await setUpAdmin(t);
const { sqlite } = t.ctx;
const cats = (sqlite.prepare("SELECT id FROM category WHERE kind = 'expense'").all() as { id: number }[]).map((c) => c.id);
const income = (sqlite.prepare("SELECT id FROM category WHERE kind = 'income'").get() as { id: number }).id;
const now = new Date().toISOString();
const accounts = [1, 2, 3, 4, 5].map(
(i) => (sqlite.prepare("INSERT INTO account (name, kind, color, created_at, updated_at, balance_cents, balance_as_of) VALUES (?, ?, 'indigo', ?, ?, 100000, '2022-01-01') RETURNING id").get(`Account ${i}`, i === 1 ? 'checking' : 'credit_card', now, now) as { id: number }).id,
);
const merchants = Array.from({ length: 400 }, (_, i) => `Merchant ${i}`);
let seed = 7;
const rnd = () => ((seed = (seed * 16807) % 2147483647) / 2147483647);
const insert = sqlite.prepare(
'INSERT INTO txn (account_id, date, amount_cents, description, merchant, category_id, needs_review, dedupe_key, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, 0, ?, ?, ?)',
);
const start = Date.UTC(2022, 0, 1);
const days = Math.floor((Date.now() - start) / 86_400_000);
sqlite.transaction(() => {
for (let i = 0; i < 50_000; i++) {
const date = new Date(start + Math.floor(rnd() * days) * 86_400_000).toISOString().slice(0, 10);
const m = merchants[Math.floor(rnd() * merchants.length)]!;
const isIncome = rnd() < 0.03;
insert.run(accounts[Math.floor(rnd() * 5)], date, isIncome ? 250000 : -Math.round(500 + rnd() * 15000), m, m, isIncome ? income : cats[Math.floor(rnd() * cats.length)], `k${i}`, now, now);
}
// Monthly subscriptions, so the scan and alerts have real work.
for (let s = 0; s < 25; s++) {
for (let d = new Date(2024, 0, 3 + s); d < new Date(); d.setMonth(d.getMonth() + 1)) {
insert.run(accounts[2], d.toISOString().slice(0, 10), -(999 + s * 100), `SERVICE ${s}`, `Service ${s}`, cats[0], `s${s}-${d.getTime()}`, now, now);
}
}
})();
const year = new Date().getFullYear();
await admin.post('/api/v1/budgets/fill', { month: `${year}-${String(new Date().getMonth() + 1).padStart(2, '0')}`, source: 'average3' });
const time = async (label: string, run: () => Promise<{ status: number }>) => {
const times: number[] = [];
for (let i = 0; i < 5; i++) {
const t0 = performance.now();
const res = await run();
times.push(performance.now() - t0);
if (res.status !== 200) throw new Error(`${label}: ${res.status}`);
}
times.sort((a, b) => a - b);
console.log(`${label.padEnd(34)} first ${times[0]!.toFixed(0).padStart(5)} ms median ${times[2]!.toFixed(0).padStart(5)} ms worst ${times[4]!.toFixed(0).padStart(5)} ms`);
};
const t0 = performance.now();
await admin.post('/api/v1/subscriptions/scan');
console.log(`subscription scan (first, 50k rows) ${(performance.now() - t0).toFixed(0)} ms`);
await time('Home: overview', () => admin.get('/api/v1/reports/overview'));
await time('Home: alerts (data changed)', async () => {
sqlite.prepare("UPDATE txn SET updated_at = ? WHERE id = 1").run(new Date().toISOString() + Math.random());
return admin.get('/api/v1/alerts?limit=3');
});
await time('Home: alerts (unchanged)', () => admin.get('/api/v1/alerts?limit=3'));
await time('Budget: year of months', () => admin.get(`/api/v1/reports/months?from=${year}-01&to=${year}-12`));
await time('Budget: budgets', () => admin.get(`/api/v1/budgets?year=${year}`));
await time('Insights: merchants', () => admin.get(`/api/v1/reports/merchants?from=${year}-01-01&to=${year}-12-31&limit=100`));
await time('Insights: balances', () => admin.get('/api/v1/reports/balances?months=12'));
await time('Insights: tags', () => admin.get('/api/v1/reports/tags'));
await time('Activity: first 100', () => admin.get('/api/v1/transactions?limit=100'));
await time('Activity: search', () => admin.get('/api/v1/transactions?q=Merchant%2012&limit=100'));
await time('Review', () => admin.get('/api/v1/review'));
await time('Subscriptions', () => admin.get('/api/v1/subscriptions'));
await time('Accounts', () => admin.get('/api/v1/accounts'));
const e0 = performance.now();
const res = await t.app.request('http://localhost/api/v1/export/everything.xlsx', { headers: { Cookie: `sl_session=${admin.cookie('sl_session')}` } });
const size = (await res.arrayBuffer()).byteLength;
console.log(`export everything (Excel) ${(performance.now() - e0).toFixed(0)} ms, ${(size / 1024 / 1024).toFixed(1)} MB`);
console.log(`memory: rss ${(process.memoryUsage().rss / 1048576).toFixed(0)} MB, heap ${(process.memoryUsage().heapUsed / 1048576).toFixed(0)} MB`);
t.cleanup();
}, 600_000);

View File

@@ -0,0 +1,3 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({ test: { include: ['bench/**/*.bench.ts'], testTimeout: 600_000 } });