From a68c3436479ecc5dca866ca015b4804fae16a621 Mon Sep 17 00:00:00 2001 From: Chris Olson Date: Thu, 8 Oct 2026 12:23:45 -0400 Subject: [PATCH] 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 --- README.md | 21 +- VERSION | 2 +- package-lock.json | 27 - scripts/restore.sh | 19 + server/bench/speed.bench.ts | 79 + server/bench/vitest.config.ts | 3 + server/drizzle/0003_split_tags.sql | 9 + server/drizzle/meta/0003_snapshot.json | 2079 +++++++++++++++++++ server/drizzle/meta/_journal.json | 7 + server/src/app.ts | 3 + server/src/backups/backup.ts | 11 +- server/src/backups/restore.ts | 131 ++ server/src/cli.ts | 36 + server/src/db/schema.ts | 14 + server/src/import/ofx.ts | 99 + server/src/import/service.ts | 51 + server/src/index.ts | 2 +- server/src/routes/backups.ts | 115 +- server/src/routes/categories.ts | 20 +- server/src/routes/export.ts | 22 +- server/src/routes/reports.ts | 12 +- server/src/routes/transactions.ts | 24 +- server/src/schemas.ts | 2 +- server/src/static.ts | 27 +- server/src/tags.ts | 17 + server/src/transactions/dto.ts | 12 +- server/src/transactions/filter.ts | 28 +- server/test/full-picture.test.ts | 32 + server/test/ofx.test.ts | 88 + server/test/restore.test.ts | 128 ++ shared/src/index.ts | 1 + web/package.json | 4 +- web/scripts/compress.mjs | 25 + web/src/assets/fonts/LICENSE-Inter.txt | 93 + web/src/assets/fonts/inter-latin-wght.woff2 | Bin 0 -> 36156 bytes web/src/components/AccountButton.tsx | 29 + web/src/components/AlertCard.tsx | 6 +- web/src/components/AppShell.tsx | 28 +- web/src/components/AuthLayout.tsx | 2 +- web/src/components/Avatar.tsx | 14 + web/src/components/BudgetEditor.tsx | 2 +- web/src/components/IdeaButton.tsx | 34 + web/src/components/IdeaDialog.tsx | 18 +- web/src/components/RequireAuth.tsx | 2 +- web/src/components/RouteError.tsx | 2 +- web/src/components/TransactionSheet.tsx | 32 +- web/src/components/UserMenu.tsx | 25 +- web/src/components/brand.tsx | 2 +- web/src/components/meter.tsx | 8 +- web/src/components/money.tsx | 2 +- web/src/components/nav.ts | 3 +- web/src/components/toast.tsx | 115 + web/src/components/ui/button.tsx | 5 +- web/src/components/ui/dialog.tsx | 18 +- web/src/components/ui/menu.tsx | 2 +- web/src/components/ui/misc.tsx | 25 +- web/src/lib/useSetTheme.ts | 2 +- web/src/main.tsx | 14 +- web/src/pages/AccountsPage.tsx | 4 +- web/src/pages/ActivityPage.tsx | 8 +- web/src/pages/BudgetPage.tsx | 6 +- web/src/pages/HelpPage.tsx | 174 ++ web/src/pages/HomePage.tsx | 11 +- web/src/pages/ImportPage.tsx | 10 +- web/src/pages/InsightsPage.tsx | 8 +- web/src/pages/MorePage.tsx | 6 +- web/src/pages/ReviewPage.tsx | 2 +- web/src/pages/SetupPage.tsx | 2 +- web/src/pages/SubscriptionsPage.tsx | 21 +- web/src/pages/settings/AboutPage.tsx | 4 +- web/src/pages/settings/AiPage.tsx | 4 +- web/src/pages/settings/AlertsPage.tsx | 2 +- web/src/pages/settings/BackupsPage.tsx | 127 +- web/src/pages/settings/CategoriesPage.tsx | 10 +- web/src/pages/settings/IdeasPage.tsx | 2 +- web/src/pages/settings/PeoplePage.tsx | 4 +- web/src/pages/settings/ProfilePage.tsx | 4 +- web/src/pages/settings/RulesPage.tsx | 2 +- web/src/pages/settings/SecurityPage.tsx | 2 +- web/src/pages/settings/TagsPage.tsx | 2 +- web/src/router.tsx | 6 +- web/src/styles.css | 33 +- 82 files changed, 3822 insertions(+), 265 deletions(-) create mode 100755 scripts/restore.sh create mode 100644 server/bench/speed.bench.ts create mode 100644 server/bench/vitest.config.ts create mode 100644 server/drizzle/0003_split_tags.sql create mode 100644 server/drizzle/meta/0003_snapshot.json create mode 100644 server/src/backups/restore.ts create mode 100644 server/src/import/ofx.ts create mode 100644 server/src/tags.ts create mode 100644 server/test/ofx.test.ts create mode 100644 server/test/restore.test.ts create mode 100644 web/scripts/compress.mjs create mode 100644 web/src/assets/fonts/LICENSE-Inter.txt create mode 100644 web/src/assets/fonts/inter-latin-wght.woff2 create mode 100644 web/src/components/AccountButton.tsx create mode 100644 web/src/components/Avatar.tsx create mode 100644 web/src/components/IdeaButton.tsx create mode 100644 web/src/components/toast.tsx create mode 100644 web/src/pages/HelpPage.tsx diff --git a/README.md b/README.md index debe7d7..22910ec 100644 --- a/README.md +++ b/README.md @@ -4,13 +4,14 @@ A private, self-hosted household budgeting app: import bank and card statements, categorize in a tap, budget by month, and keep an eye on subscriptions. Runs as a single container on arm64 or amd64. -Status: milestones 0 to 2 are built (foundation, money in, the full picture). -Milestone 3, launch hardening, is next. +Status: milestones 0 to 3 are built: foundation, money in, the full picture, +and launch (accessibility, speed, backup and restore, an in-app guide). ## What it does - **Import** CSV statements from Bank of America, Discover, Chase, Capital One, Citi and Apple Card, plus any other bank through a one-time column mapper. + OFX, QFX and QBO files (Quicken, Money, Web Connect) work too. Duplicates are skipped, card payments and transfers are paired, and any import can be undone. - **Review** new transactions with one-tap category suggestions that learn @@ -38,6 +39,22 @@ scripts/deploy.sh # builds a versioned image and starts it HTTPS comes from your own reverse proxy (see `deploy/nginx.conf.example`). The API is documented at `/api/docs` on a running instance. +## Backups and restoring + +Every night at 02:30 (`BACKUP_TIME`) a complete copy of the database lands in +the backups folder, plus one before every upgrade and every restore. Point any +backup tool at that folder to keep copies off the server. + +- **Everyday restore:** Settings → Backups → Restore, as an admin. Spendlight + checks the file, saves a "before restore" copy of the current data, and + switches over. Restoring that copy undoes it. "Restore from a file" takes a + backup from somewhere else, such as another machine. +- **When the app can't be used:** put the backup file in the backups folder and run + `scripts/restore.sh spendlight-nightly-20261007-023000.db`. It stops the app, + restores, and starts it again. +- **Moving to a new machine:** copy the data folder, or start with an empty one + and run the restore script before the first sign-in. + ## Developing The host only needs Docker; Node 24 runs in a container. diff --git a/VERSION b/VERSION index f794101..713b640 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -20261007.12 +20261008.05 diff --git a/package-lock.json b/package-lock.json index b243ed1..49ef9e1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -960,15 +960,6 @@ "integrity": "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==", "license": "MIT" }, - "node_modules/@fontsource-variable/inter": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/@fontsource-variable/inter/-/inter-5.3.0.tgz", - "integrity": "sha512-OupL48va4JNofb97w6NYeF9S7W/kHNKM0Er8Dem5nqi4jeOLrVJDoE8tZEpnMJmtkvNbB1EIPPwHcdkF6b1oUA==", - "license": "OFL-1.1", - "funding": { - "url": "https://github.com/sponsors/ayuhito" - } - }, "node_modules/@hexagon/base64": { "version": "1.1.28", "resolved": "https://registry.npmjs.org/@hexagon/base64/-/base64-1.1.28.tgz", @@ -6417,22 +6408,6 @@ } } }, - "node_modules/sonner": { - "version": "2.0.8", - "resolved": "https://registry.npmjs.org/sonner/-/sonner-2.0.8.tgz", - "integrity": "sha512-UM/ByIoFra8yzV75n1o0Puu0bw5U/9UNnDacrJNspekBewIfsQ3D6ez1nvlWpt7aTsO6rujQtifBpycwIivqlg==", - "license": "MIT", - "peerDependencies": { - "@types/react": "^18.0.0 || ^19.0.0", - "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", - "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, "node_modules/source-map": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", @@ -7205,7 +7180,6 @@ "web": { "name": "@spendlight/web", "dependencies": { - "@fontsource-variable/inter": "^5.3.0", "@simplewebauthn/browser": "^14.0.0", "@spendlight/shared": "*", "@tanstack/react-query": "^5.104.1", @@ -7218,7 +7192,6 @@ "react-is": "^19.3.0", "react-router": "^8.4.0", "recharts": "^3.10.1", - "sonner": "^2.0.8", "tailwind-merge": "^3.7.0" }, "devDependencies": { diff --git a/scripts/restore.sh b/scripts/restore.sh new file mode 100755 index 0000000..9c6510c --- /dev/null +++ b/scripts/restore.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash +# Restore Spendlight from a backup when the app itself can't be used. For an +# everyday restore, use Settings → Backups instead. +# +# scripts/restore.sh spendlight-nightly-20261007-023000.db +# +# The file must be in the backups folder. A "before restore" copy of the +# current database is saved there first. +set -euo pipefail +cd "$(dirname "$0")/.." + +name="$(basename "${1:?Usage: scripts/restore.sh }")" +docker compose stop spendlight +if ! docker compose run --rm --no-deps spendlight spendlight restore "/backups/${name}"; then + docker compose start spendlight + exit 1 +fi +docker compose start spendlight +echo "Spendlight is running again on the restored data." diff --git a/server/bench/speed.bench.ts b/server/bench/speed.bench.ts new file mode 100644 index 0000000..344bd43 --- /dev/null +++ b/server/bench/speed.bench.ts @@ -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); diff --git a/server/bench/vitest.config.ts b/server/bench/vitest.config.ts new file mode 100644 index 0000000..22eb52a --- /dev/null +++ b/server/bench/vitest.config.ts @@ -0,0 +1,3 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ test: { include: ['bench/**/*.bench.ts'], testTimeout: 600_000 } }); diff --git a/server/drizzle/0003_split_tags.sql b/server/drizzle/0003_split_tags.sql new file mode 100644 index 0000000..a0dfc57 --- /dev/null +++ b/server/drizzle/0003_split_tags.sql @@ -0,0 +1,9 @@ +CREATE TABLE `split_tag` ( + `split_id` integer NOT NULL, + `tag_id` integer NOT NULL, + FOREIGN KEY (`split_id`) REFERENCES `split`(`id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`tag_id`) REFERENCES `tag`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE UNIQUE INDEX `split_tag_pk` ON `split_tag` (`split_id`,`tag_id`);--> statement-breakpoint +CREATE INDEX `split_tag_tag_idx` ON `split_tag` (`tag_id`); \ No newline at end of file diff --git a/server/drizzle/meta/0003_snapshot.json b/server/drizzle/meta/0003_snapshot.json new file mode 100644 index 0000000..81bad70 --- /dev/null +++ b/server/drizzle/meta/0003_snapshot.json @@ -0,0 +1,2079 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "32209082-b057-4505-8c21-4d5425278b6a", + "prevId": "12dd39b8-6a3f-405a-8ea8-7ae5a62c8b48", + "tables": { + "account": { + "name": "account", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "institution": { + "name": "institution", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last4": { + "name": "last4", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'indigo'" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'open'" + }, + "balance_cents": { + "name": "balance_cents", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "balance_as_of": { + "name": "balance_as_of", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "balance_source": { + "name": "balance_source", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "layout_key": { + "name": "layout_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "file_name_pattern": { + "name": "file_name_pattern", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "retired_at": { + "name": "retired_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "alert": { + "name": "alert", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "subject_type": { + "name": "subject_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "subject_id": { + "name": "subject_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "period": { + "name": "period", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "area": { + "name": "area", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'money'" + }, + "severity": { + "name": "severity", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'info'" + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "link": { + "name": "link", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rank": { + "name": "rank", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "read_at": { + "name": "read_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "dismissed_at": { + "name": "dismissed_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "resolved_at": { + "name": "resolved_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "alert_unique": { + "name": "alert_unique", + "columns": [ + "kind", + "subject_type", + "subject_id", + "period" + ], + "isUnique": true + }, + "alert_open_idx": { + "name": "alert_open_idx", + "columns": [ + "resolved_at", + "dismissed_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "budget": { + "name": "budget", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "category_id": { + "name": "category_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "year": { + "name": "year", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "month": { + "name": "month", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "amount_cents": { + "name": "amount_cents", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "budget_unique": { + "name": "budget_unique", + "columns": [ + "category_id", + "year", + "month" + ], + "isUnique": true + }, + "budget_year_idx": { + "name": "budget_year_idx", + "columns": [ + "year" + ], + "isUnique": false + } + }, + "foreignKeys": { + "budget_category_id_category_id_fk": { + "name": "budget_category_id_category_id_fk", + "tableFrom": "budget", + "tableTo": "category", + "columnsFrom": [ + "category_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "category": { + "name": "category", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "group_id": { + "name": "group_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'expense'" + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'circle'" + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'slate'" + }, + "archived": { + "name": "archived", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + } + }, + "indexes": { + "category_group_idx": { + "name": "category_group_idx", + "columns": [ + "group_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "category_group_id_category_group_id_fk": { + "name": "category_group_id_category_group_id_fk", + "tableFrom": "category", + "tableTo": "category_group", + "columnsFrom": [ + "group_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "category_group": { + "name": "category_group", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "change_log": { + "name": "change_log", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "at": { + "name": "at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "entity": { + "name": "entity", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "detail": { + "name": "detail", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "change_log_at_idx": { + "name": "change_log_at_idx", + "columns": [ + "at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "change_log_user_id_user_id_fk": { + "name": "change_log_user_id_user_id_fk", + "tableFrom": "change_log", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "idea": { + "name": "idea", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'new'" + }, + "admin_note": { + "name": "admin_note", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "app_version": { + "name": "app_version", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idea_status_idx": { + "name": "idea_status_idx", + "columns": [ + "status" + ], + "isUnique": false + } + }, + "foreignKeys": { + "idea_user_id_user_id_fk": { + "name": "idea_user_id_user_id_fk", + "tableFrom": "idea", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "import_layout": { + "name": "import_layout", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "signature": { + "name": "signature", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "config": { + "name": "config", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "import": { + "name": "import", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "account_id": { + "name": "account_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "file_name": { + "name": "file_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "file_hash": { + "name": "file_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "layout_key": { + "name": "layout_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "date_from": { + "name": "date_from", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "date_to": { + "name": "date_to", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "row_count": { + "name": "row_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "added_count": { + "name": "added_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "duplicate_count": { + "name": "duplicate_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "skipped_count": { + "name": "skipped_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "transfer_count": { + "name": "transfer_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'committed'" + }, + "previous_balance": { + "name": "previous_balance", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "undone_at": { + "name": "undone_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "import_account_idx": { + "name": "import_account_idx", + "columns": [ + "account_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "import_account_id_account_id_fk": { + "name": "import_account_id_account_id_fk", + "tableFrom": "import", + "tableTo": "account", + "columnsFrom": [ + "account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "import_user_id_user_id_fk": { + "name": "import_user_id_user_id_fk", + "tableFrom": "import", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "passkey": { + "name": "passkey", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "public_key": { + "name": "public_key", + "type": "blob", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "counter": { + "name": "counter", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "transports": { + "name": "transports", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "device_type": { + "name": "device_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "backed_up": { + "name": "backed_up", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_used_at": { + "name": "last_used_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "passkey_user_idx": { + "name": "passkey_user_idx", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "passkey_user_id_user_id_fk": { + "name": "passkey_user_id_user_id_fk", + "tableFrom": "passkey", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "rule": { + "name": "rule", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "merchant": { + "name": "merchant", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "account_id": { + "name": "account_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "category_id": { + "name": "category_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rename_to": { + "name": "rename_to", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'learned'" + }, + "hits": { + "name": "hits", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_used_at": { + "name": "last_used_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "rule_merchant_idx": { + "name": "rule_merchant_idx", + "columns": [ + "merchant" + ], + "isUnique": false + } + }, + "foreignKeys": { + "rule_account_id_account_id_fk": { + "name": "rule_account_id_account_id_fk", + "tableFrom": "rule", + "tableTo": "account", + "columnsFrom": [ + "account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "rule_category_id_category_id_fk": { + "name": "rule_category_id_category_id_fk", + "tableFrom": "rule", + "tableTo": "category", + "columnsFrom": [ + "category_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "session": { + "name": "session", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'browser'" + }, + "device_label": { + "name": "device_label", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ip": { + "name": "ip", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "session_user_idx": { + "name": "session_user_idx", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "setting": { + "name": "setting", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "split_tag": { + "name": "split_tag", + "columns": { + "split_id": { + "name": "split_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tag_id": { + "name": "tag_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "split_tag_pk": { + "name": "split_tag_pk", + "columns": [ + "split_id", + "tag_id" + ], + "isUnique": true + }, + "split_tag_tag_idx": { + "name": "split_tag_tag_idx", + "columns": [ + "tag_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "split_tag_split_id_split_id_fk": { + "name": "split_tag_split_id_split_id_fk", + "tableFrom": "split_tag", + "tableTo": "split", + "columnsFrom": [ + "split_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "split_tag_tag_id_tag_id_fk": { + "name": "split_tag_tag_id_tag_id_fk", + "tableFrom": "split_tag", + "tableTo": "tag", + "columnsFrom": [ + "tag_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "split": { + "name": "split", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "transaction_id": { + "name": "transaction_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "category_id": { + "name": "category_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "amount_cents": { + "name": "amount_cents", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "note": { + "name": "note", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "split_txn_idx": { + "name": "split_txn_idx", + "columns": [ + "transaction_id" + ], + "isUnique": false + }, + "split_category_idx": { + "name": "split_category_idx", + "columns": [ + "category_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "split_transaction_id_txn_id_fk": { + "name": "split_transaction_id_txn_id_fk", + "tableFrom": "split", + "tableTo": "txn", + "columnsFrom": [ + "transaction_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "split_category_id_category_id_fk": { + "name": "split_category_id_category_id_fk", + "tableFrom": "split", + "tableTo": "category", + "columnsFrom": [ + "category_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "subscription": { + "name": "subscription", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'subscription'" + }, + "merchant": { + "name": "merchant", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "aliases": { + "name": "aliases", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + }, + "catalog_key": { + "name": "catalog_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cadence": { + "name": "cadence", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'monthly'" + }, + "expected_cents": { + "name": "expected_cents", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "account_id": { + "name": "account_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "category_id": { + "name": "category_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'suggested'" + }, + "confidence": { + "name": "confidence", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'likely'" + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'detected'" + }, + "trial_ends_on": { + "name": "trial_ends_on", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cancelled_on": { + "name": "cancelled_on", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "first_charge_on": { + "name": "first_charge_on", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_charge_on": { + "name": "last_charge_on", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "next_expected_on": { + "name": "next_expected_on", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "manage_url": { + "name": "manage_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "note": { + "name": "note", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "alerts_on": { + "name": "alerts_on", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "subscription_merchant_idx": { + "name": "subscription_merchant_idx", + "columns": [ + "merchant" + ], + "isUnique": false + }, + "subscription_status_idx": { + "name": "subscription_status_idx", + "columns": [ + "status" + ], + "isUnique": false + } + }, + "foreignKeys": { + "subscription_account_id_account_id_fk": { + "name": "subscription_account_id_account_id_fk", + "tableFrom": "subscription", + "tableTo": "account", + "columnsFrom": [ + "account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "subscription_category_id_category_id_fk": { + "name": "subscription_category_id_category_id_fk", + "tableFrom": "subscription", + "tableTo": "category", + "columnsFrom": [ + "category_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "tag": { + "name": "tag", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'slate'" + } + }, + "indexes": { + "tag_name_unique": { + "name": "tag_name_unique", + "columns": [ + "name" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "txn_tag": { + "name": "txn_tag", + "columns": { + "transaction_id": { + "name": "transaction_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tag_id": { + "name": "tag_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "txn_tag_pk": { + "name": "txn_tag_pk", + "columns": [ + "transaction_id", + "tag_id" + ], + "isUnique": true + }, + "txn_tag_tag_idx": { + "name": "txn_tag_tag_idx", + "columns": [ + "tag_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "txn_tag_transaction_id_txn_id_fk": { + "name": "txn_tag_transaction_id_txn_id_fk", + "tableFrom": "txn_tag", + "tableTo": "txn", + "columnsFrom": [ + "transaction_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "txn_tag_tag_id_tag_id_fk": { + "name": "txn_tag_tag_id_tag_id_fk", + "tableFrom": "txn_tag", + "tableTo": "tag", + "columnsFrom": [ + "tag_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "txn": { + "name": "txn", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "account_id": { + "name": "account_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "import_id": { + "name": "import_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "date": { + "name": "date", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "posted_date": { + "name": "posted_date", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "amount_cents": { + "name": "amount_cents", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "merchant": { + "name": "merchant", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "category_id": { + "name": "category_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "needs_review": { + "name": "needs_review", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "suggested_by": { + "name": "suggested_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_split": { + "name": "is_split", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "bank_category": { + "name": "bank_category", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "bank_type": { + "name": "bank_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "note": { + "name": "note", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "transfer_peer_id": { + "name": "transfer_peer_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "subscription_id": { + "name": "subscription_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'import'" + }, + "dedupe_key": { + "name": "dedupe_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "txn_account_date_idx": { + "name": "txn_account_date_idx", + "columns": [ + "account_id", + "date" + ], + "isUnique": false + }, + "txn_date_idx": { + "name": "txn_date_idx", + "columns": [ + "date" + ], + "isUnique": false + }, + "txn_category_date_idx": { + "name": "txn_category_date_idx", + "columns": [ + "category_id", + "date" + ], + "isUnique": false + }, + "txn_merchant_idx": { + "name": "txn_merchant_idx", + "columns": [ + "merchant" + ], + "isUnique": false + }, + "txn_dedupe_idx": { + "name": "txn_dedupe_idx", + "columns": [ + "dedupe_key" + ], + "isUnique": false + }, + "txn_review_idx": { + "name": "txn_review_idx", + "columns": [ + "needs_review" + ], + "isUnique": false + }, + "txn_import_idx": { + "name": "txn_import_idx", + "columns": [ + "import_id" + ], + "isUnique": false + }, + "txn_subscription_idx": { + "name": "txn_subscription_idx", + "columns": [ + "subscription_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "txn_account_id_account_id_fk": { + "name": "txn_account_id_account_id_fk", + "tableFrom": "txn", + "tableTo": "account", + "columnsFrom": [ + "account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "txn_import_id_import_id_fk": { + "name": "txn_import_id_import_id_fk", + "tableFrom": "txn", + "tableTo": "import", + "columnsFrom": [ + "import_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "txn_category_id_category_id_fk": { + "name": "txn_category_id_category_id_fk", + "tableFrom": "txn", + "tableTo": "category", + "columnsFrom": [ + "category_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "user": { + "name": "user", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "access": { + "name": "access", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'full'" + }, + "is_admin": { + "name": "is_admin", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "theme": { + "name": "theme", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'system'" + }, + "must_change_password": { + "name": "must_change_password", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "user_username_unique": { + "name": "user_username_unique", + "columns": [ + "username" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/server/drizzle/meta/_journal.json b/server/drizzle/meta/_journal.json index 0c83e03..4e0924b 100644 --- a/server/drizzle/meta/_journal.json +++ b/server/drizzle/meta/_journal.json @@ -22,6 +22,13 @@ "when": 1791417594406, "tag": "0002_full_picture", "breakpoints": true + }, + { + "idx": 3, + "version": "6", + "when": 1791473201347, + "tag": "0003_split_tags", + "breakpoints": true } ] } \ No newline at end of file diff --git a/server/src/app.ts b/server/src/app.ts index 2f64f3f..ded739c 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -1,6 +1,7 @@ import { OpenAPIHono } from '@hono/zod-openapi'; import type { MiddlewareHandler } from 'hono'; import { HTTPException } from 'hono/http-exception'; +import { compress } from 'hono/compress'; import { secureHeaders } from 'hono/secure-headers'; import { sessionMiddleware } from './auth/guards'; import type { AppContext } from './context'; @@ -82,6 +83,8 @@ export function createApp(ctx: AppContext) { await next(); c.header('Cache-Control', 'no-store'); }); + // JSON answers can be large (a year of reports, 500 transactions); send them gzipped. + app.use('/api/*', compress()); app.use('/api/*', sessionMiddleware(ctx)); app.use('/api/*', sameOriginWrites(ctx)); diff --git a/server/src/backups/backup.ts b/server/src/backups/backup.ts index 226530d..b2d4359 100644 --- a/server/src/backups/backup.ts +++ b/server/src/backups/backup.ts @@ -1,4 +1,5 @@ import type { BackupInfo, BackupKind } from '@spendlight/shared'; +import Database from 'better-sqlite3'; import { existsSync, mkdirSync, readdirSync, renameSync, statSync, unlinkSync } from 'node:fs'; import { join } from 'node:path'; import type { Sqlite } from '../db/client'; @@ -48,6 +49,11 @@ export async function createBackup( } const partial = join(dir, `.${name}.partial`); await sqlite.backup(partial); + // A backup is one self-contained file: no write-ahead log, so reading it + // later never leaves -wal or -shm files beside it in the backup folder. + const copy = new Database(partial); + copy.pragma('journal_mode = DELETE'); + copy.close(); renameSync(partial, join(dir, name)); return { name, kind, size: statSync(join(dir, name)).size, createdAt: when.toISOString() }; } @@ -122,7 +128,8 @@ export function backupPath(dir: string, name: string): string | null { /** Run a nightly backup at the configured local time, plus a catch-up if the last one is overdue. */ export function scheduleNightlyBackups(options: { - sqlite: Sqlite; + /** The open database; a function, because a restore swaps it for another. */ + sqlite: () => Sqlite; dir: string; at: { hour: number; minute: number }; policy: RetentionPolicy; @@ -133,7 +140,7 @@ export function scheduleNightlyBackups(options: { const run = async () => { try { - const backup = await createBackup(sqlite, dir, 'nightly'); + const backup = await createBackup(sqlite(), dir, 'nightly'); const removed = pruneBackups(dir, policy); log.info(`Nightly backup ${backup.name} (${removed.length} old copies removed)`); } catch (error) { diff --git a/server/src/backups/restore.ts b/server/src/backups/restore.ts new file mode 100644 index 0000000..6a8c8f0 --- /dev/null +++ b/server/src/backups/restore.ts @@ -0,0 +1,131 @@ +import Database from 'better-sqlite3'; +import { randomUUID } from 'node:crypto'; +import { closeSync, copyFileSync, existsSync, openSync, readFileSync, readSync, renameSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import type { BackupInfo } from '@spendlight/shared'; +import { APP_MARKER, migrateDatabase, migrationStatus, openDatabase, type DB, type Sqlite } from '../db/client'; +import type { SessionRow } from '../db/schema'; +import { createBackup, pruneBackups, type RetentionPolicy } from './backup'; + +// Restoring a backup replaces the whole database. Before anything changes, +// the file is checked (a SQLite database, intact, made by Spendlight, and not +// by a newer version), and a "before restore" copy of the current database is +// saved, so a restore can itself be undone. + +export interface BackupCheck { + /** Database upgrades the copy has had; newer ones run after restoring. */ + applied: number; + counts: { transactions: number; accounts: number; people: number; budgets: number; subscriptions: number }; + newestTransaction: string | null; +} + +export class RestoreError extends Error { + constructor( + public code: string, + message: string, + ) { + super(message); + } +} + +const SQLITE_HEADER = 'SQLite format 3\u0000'; + +/** Check a backup by reading a throwaway copy, so the original is never touched. */ +export function checkBackup(file: string, migrationsDir: string, scratchDir = tmpdir()): BackupCheck { + if (!existsSync(file)) throw new RestoreError('not_found', 'That backup wasn’t found.'); + const copy = join(scratchDir, `.check-${randomUUID()}.db`); + copyFileSync(file, copy); + try { + return inspect(copy, migrationsDir); + } finally { + for (const extra of ['', '-wal', '-shm', '-journal']) rmSync(copy + extra, { force: true }); + } +} + +function inspect(file: string, migrationsDir: string): BackupCheck { + const head = Buffer.alloc(16); + const fd = openSync(file, 'r'); + try { + readSync(fd, head, 0, 16, 0); + } finally { + closeSync(fd); + } + if (head.toString('latin1') !== SQLITE_HEADER) throw new RestoreError('not_sqlite', 'That file isn’t a Spendlight backup.'); + + let sqlite: Sqlite | null = null; + try { + sqlite = new Database(file, { fileMustExist: true }); + const ok = sqlite.pragma('quick_check', { simple: true }); + if (ok !== 'ok') throw new RestoreError('damaged', 'That backup is damaged and can’t be restored.'); + const table = (name: string) => !!sqlite!.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?").get(name); + const marker = table('setting') ? (sqlite.prepare('SELECT value FROM setting WHERE key = ?').get(APP_MARKER.key) as { value: string } | undefined) : undefined; + if (marker?.value !== APP_MARKER.value) throw new RestoreError('not_spendlight', 'That file isn’t a Spendlight backup.'); + const known = (JSON.parse(readFileSync(join(migrationsDir, 'meta', '_journal.json'), 'utf8')) as { entries: unknown[] }).entries.length; + const { applied } = migrationStatus(sqlite, migrationsDir); + if (applied > known) throw new RestoreError('too_new', 'That backup was made by a newer version of Spendlight. Update Spendlight first, then restore it.'); + const count = (name: string) => (table(name) ? (sqlite!.prepare(`SELECT count(*) AS n FROM ${name}`).get() as { n: number }).n : 0); + const people = table('user') ? (sqlite.prepare('SELECT count(*) AS n FROM user WHERE deleted_at IS NULL').get() as { n: number }).n : 0; + if (!people) throw new RestoreError('empty', 'That backup has nobody who can sign in, so it can’t be restored.'); + return { + applied, + counts: { transactions: count('txn'), accounts: count('account'), people, budgets: count('budget'), subscriptions: count('subscription') }, + newestTransaction: table('txn') ? ((sqlite.prepare('SELECT max(date) AS d FROM txn').get() as { d: string | null }).d ?? null) : null, + }; + } catch (error) { + if (error instanceof RestoreError) throw error; + throw new RestoreError('unreadable', 'That backup couldn’t be read.'); + } finally { + sqlite?.close(); + } +} + +export interface RestoreOptions { + dataDir: string; + backupDir: string; + migrationsDir: string; + policy: RetentionPolicy; + /** The open database, which gets closed and replaced. Null when the app isn't running. */ + current: Sqlite | null; + /** The session of the person restoring, carried into the restored copy. */ + keepSession?: { userId: number; username: string; session: SessionRow }; +} + +/** Replace the database with a backup. Returns the newly opened database. */ +export async function restoreDatabase(source: string, options: RestoreOptions): Promise<{ sqlite: Sqlite; db: DB; safety: BackupInfo | null; check: BackupCheck }> { + const check = checkBackup(source, options.migrationsDir, options.dataDir); + const file = join(options.dataDir, 'spendlight.db'); + + // 1. A safety copy of how things are now. + let safety: BackupInfo | null = null; + const current = options.current ?? (existsSync(file) ? openDatabase(file).sqlite : null); + if (current) { + safety = await createBackup(current, options.backupDir, 'pre-restore'); + pruneBackups(options.backupDir, options.policy); + } + + // 2. Stage the copy next to the database, so the swap is a single rename. + const staged = join(options.dataDir, '.restore.db'); + rmSync(staged, { force: true }); + copyFileSync(source, staged); + + // 3. Swap: close, replace, and drop the old write-ahead log with it. + current?.close(); + for (const extra of ['-wal', '-shm']) rmSync(file + extra, { force: true }); + renameSync(staged, file); + + // 4. Open it and bring it up to date with this version. + const { sqlite, db } = openDatabase(file); + await migrateDatabase(db, sqlite, options.migrationsDir); + // Whoever restored stays signed in, if they're in the restored copy. + const keep = options.keepSession; + if (keep) { + const same = sqlite.prepare('SELECT 1 FROM user WHERE id = ? AND username = ? AND deleted_at IS NULL').get(keep.userId, keep.username); + if (same) { + sqlite + .prepare('INSERT OR IGNORE INTO session (id, user_id, kind, device_label, user_agent, ip, created_at, last_seen_at, expires_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)') + .run(keep.session.id, keep.session.userId, keep.session.kind, keep.session.deviceLabel, keep.session.userAgent, keep.session.ip, keep.session.createdAt, keep.session.lastSeenAt, keep.session.expiresAt); + } + } + return { sqlite, db, safety, check }; +} diff --git a/server/src/cli.ts b/server/src/cli.ts index a4d47d3..a367966 100644 --- a/server/src/cli.ts +++ b/server/src/cli.ts @@ -5,6 +5,7 @@ import { join } from 'node:path'; import { hashPassword, temporaryPassword } from './auth/password'; import { revokeUserSessions } from './auth/sessions'; import { createBackup, pruneBackups } from './backups/backup'; +import { checkBackup, RestoreError, restoreDatabase } from './backups/restore'; import { loadConfig } from './config'; import type { AppContext } from './context'; import { openDatabase } from './db/client'; @@ -12,12 +13,14 @@ import { users } from './db/schema'; import { ideasToMarkdown } from './ideas/markdown'; import { listIdeas } from './ideas/queries'; import { silentLogger } from './log'; +import { MIGRATIONS_DIR } from './paths'; import { findActiveUser } from './users'; // Admin commands, run inside the container: // spendlight ideas [--status=new|planned|done|declined] // spendlight reset-password // spendlight backup +// spendlight restore // spendlight version const HELP = `Spendlight admin commands @@ -25,6 +28,7 @@ const HELP = `Spendlight admin commands spendlight ideas [--status=new] Print ideas as Markdown (all statuses by default) spendlight reset-password Give someone a temporary password spendlight backup Back up the database now + spendlight restore Replace the database with a backup (stop the app first) spendlight version Show the running version `; @@ -81,6 +85,38 @@ switch (command) { console.log(`Backed up to ${join(config.backupDir, backup.name)}`); break; } + case 'restore': { + const source = args[0]; + if (!source) { + console.error('Usage: spendlight restore (for example /backups/spendlight-nightly-20261007-023000.db)'); + process.exit(1); + } + // Restoring underneath a running app would lose whatever it writes next. + const running = await fetch(`http://127.0.0.1:${config.port}/api/v1/health`) + .then((r) => r.ok) + .catch(() => false); + if (running) { + console.error('Spendlight is running. Restore from Settings → Backups instead, or stop the app first.'); + process.exit(1); + } + try { + const check = checkBackup(source, MIGRATIONS_DIR); + console.log(`Backup holds ${check.counts.transactions} transactions, ${check.counts.accounts} accounts and ${check.counts.people} people.`); + const { sqlite, safety } = await restoreDatabase(source, { + dataDir: config.dataDir, + backupDir: config.backupDir, + migrationsDir: MIGRATIONS_DIR, + policy: { keepDaily: config.keepDaily, keepMonthly: config.keepMonthly }, + current: null, + }); + sqlite.close(); + console.log(`Restored. The previous database is saved as ${safety ? join(config.backupDir, safety.name) : '(there was none)'}.`); + } catch (error) { + console.error(error instanceof RestoreError ? error.message : `Restore failed: ${(error as Error).message}`); + process.exit(1); + } + break; + } case 'version': console.log(config.version); break; diff --git a/server/src/db/schema.ts b/server/src/db/schema.ts index 8d51b5e..477989e 100644 --- a/server/src/db/schema.ts +++ b/server/src/db/schema.ts @@ -271,6 +271,20 @@ export const transactionTags = sqliteTable( (t) => [uniqueIndex('txn_tag_pk').on(t.transactionId, t.tagId), index('txn_tag_tag_idx').on(t.tagId)], ); +/** Tags on one part of a split purchase, such as "Reimbursable" on the work lunch only. */ +export const splitTags = sqliteTable( + 'split_tag', + { + splitId: integer('split_id') + .notNull() + .references(() => splits.id, { onDelete: 'cascade' }), + tagId: integer('tag_id') + .notNull() + .references(() => tags.id, { onDelete: 'cascade' }), + }, + (t) => [uniqueIndex('split_tag_pk').on(t.splitId, t.tagId), index('split_tag_tag_idx').on(t.tagId)], +); + /** "Always file this merchant this way." Created when someone says so, or by hand. */ export const rules = sqliteTable( 'rule', diff --git a/server/src/import/ofx.ts b/server/src/import/ofx.ts new file mode 100644 index 0000000..d0b4b70 --- /dev/null +++ b/server/src/import/ofx.ts @@ -0,0 +1,99 @@ +import type { AccountKind } from '@spendlight/shared'; + +// OFX, QFX and QBO files ("Quicken", "Money" or "Web Connect" downloads). Both +// flavors are read: the older SGML one, where tags aren't closed, and XML. +// Amounts are already signed from the account holder's side: money out is +// negative, for bank accounts and cards alike. + +export interface OfxTransaction { + date: string; + postedDate: string | null; + amountCents: number; + name: string; + memo: string; + type: string; + id: string | null; +} + +export interface OfxStatement { + kind: AccountKind; + institution: string | null; + accountLast4: string | null; + transactions: OfxTransaction[]; + ledger: { cents: number; date: string } | null; +} + +export const looksLikeOfx = (text: string) => /OFXHEADER\s*:|/i.test(text.slice(0, 4000)); + +const decode = (s: string) => + s + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/"/g, '"') + .replace(/'/g, "'") + .replace(/ /g, ' ') + .replace(/&/g, '&') + .trim(); + +/** The value of a leaf tag: -12.34 (SGML) or -12.34 (XML). */ +function leaf(block: string, tag: string): string | null { + const m = new RegExp(`<${tag}>([^<\\r\\n]*)`, 'i').exec(block); + return m ? decode(m[1]!) : null; +} + +/** 20261007, 20261007120000, 20261007120000.000[-5:EST] → 2026-10-07 */ +function ofxDate(value: string | null): string | null { + const m = value ? /^(\d{4})(\d{2})(\d{2})/.exec(value) : null; + if (!m) return null; + const [, y, mo, d] = m; + const date = new Date(Date.UTC(Number(y), Number(mo) - 1, Number(d))); + return date.getUTCMonth() === Number(mo) - 1 ? `${y}-${mo}-${d}` : null; +} + +function cents(value: string | null): number | null { + if (!value) return null; + // Some banks write decimal commas: "-12,34". + const clean = value.replace(/\s/g, '').replace(/,(\d{1,2})$/, '.$1').replace(/,/g, ''); + if (!/^[+-]?\d*(\.\d+)?$/.test(clean) || clean === '' || clean === '-' || clean === '+') return null; + return Math.round(Number(clean) * 100); +} + +export function parseOfx(text: string): OfxStatement { + const card = /|/i.test(text); + const acctType = leaf(text, 'ACCTTYPE')?.toUpperCase(); + const kind: AccountKind = card ? 'credit_card' : acctType === 'SAVINGS' || acctType === 'MONEYMRKT' ? 'savings' : 'checking'; + const acctId = leaf(text, 'ACCTID'); + const accountLast4 = acctId ? (/(\d{4})\D*$/.exec(acctId)?.[1] ?? null) : null; + const institution = leaf(text, 'ORG'); + + const transactions: OfxTransaction[] = []; + const blocks = text.split(//i).slice(1); + for (const raw of blocks) { + const block = raw.split(/<\/STMTTRN>|<\/BANKTRANLIST>/i)[0]!; + const posted = ofxDate(leaf(block, 'DTPOSTED')); + const user = ofxDate(leaf(block, 'DTUSER')); + const amount = cents(leaf(block, 'TRNAMT')); + const date = user ?? posted; + if (!date || amount === null) continue; + transactions.push({ + date, + postedDate: user && posted && posted !== user ? posted : null, + amountCents: amount, + name: leaf(block, 'NAME') ?? leaf(block, 'PAYEE') ?? '', + memo: leaf(block, 'MEMO') ?? '', + type: (leaf(block, 'TRNTYPE') ?? '').toUpperCase(), + id: leaf(block, 'FITID'), + }); + } + + const ledgerBlock = /([\s\S]*?)(<\/LEDGERBAL>||<\/STMTRS>|<\/CCSTMTRS>|$)/i.exec(text)?.[1] ?? ''; + const ledgerCents = cents(leaf(ledgerBlock, 'BALAMT')); + const ledgerDate = ofxDate(leaf(ledgerBlock, 'DTASOF')); + return { + kind, + institution: institution && !/^\d+$/.test(institution) ? institution : null, + accountLast4, + transactions, + ledger: ledgerCents !== null && ledgerDate ? { cents: ledgerCents, date: ledgerDate } : null, + }; +} diff --git a/server/src/import/service.ts b/server/src/import/service.ts index a0438e9..2a98f73 100644 --- a/server/src/import/service.ts +++ b/server/src/import/service.ts @@ -13,6 +13,7 @@ import { scanSubscriptions } from '../subscriptions/engine'; import { detectBuiltIn, findHeaderRow, guessMap, headerSignature, type ResolvedLayout } from './layouts'; import { cleanMerchant, dedupeKey } from './merchant'; import { daysBetween, parseCents, parseDate, readTable, shiftDate } from './parse'; +import { looksLikeOfx, parseOfx } from './ofx'; import { pairTransfers } from './transfers'; // The import pipeline: stage a file, preview what it would add, then commit. @@ -53,6 +54,11 @@ export class StagingStore { return staged; } + /** Forget every staged file, as after a restore replaces the accounts they were meant for. */ + clear(): void { + this.files.clear(); + } + delete(id: string): void { this.files.delete(id); } @@ -93,7 +99,52 @@ function fileNameLast4(fileName: string): string | null { /** The file name with its digits generalized, so "Chase1234_Activity_20261007.csv" matches next month's file. */ const fileNamePattern = (fileName: string) => fileName.replace(/\d{5,}/g, '#').replace(/\(\d+\)/g, ''); +/** OFX, QFX and QBO downloads carry their own structure, so they need no layout or mapping. */ +function parseOfxFile(staged: Staged): Parsed { + const statement = parseOfx(staged.text); + const rows: Row[] = statement.transactions.map((t) => { + const description = [t.name, t.memo && !t.name.includes(t.memo) ? t.memo : ''].filter(Boolean).join(' ').replace(/\s+/g, ' ').trim() || 'Transaction'; + const { merchant, mentionsLast4 } = cleanMerchant(description); + return { + date: t.date, + postedDate: t.postedDate, + description, + merchant, + mentionsLast4, + amountCents: t.amountCents, + bankCategory: null, + bankType: t.type === 'XFER' ? 'Payment' : null, + balanceCents: null, + }; + }); + // The statement's ending balance, for checking and savings; card files differ on its sign, so cards are left to the person. + if (statement.ledger && statement.kind !== 'credit_card' && rows.length) { + const last = rows.reduce((a, r) => (r.date > a.date ? r : a)); + if (last.date <= statement.ledger.date) last.balanceCents = statement.ledger.cents; + } + const institution = statement.institution; + return { + layout: { + key: 'ofx', + name: institution ? `${institution} (OFX/QFX)` : 'OFX/QFX file', + institution, + kind: statement.kind, + headerRow: 0, + map: { date: 0, description: 0, amount: 0, positiveIs: 'in', dateOrder: 'YMD' }, + }, + rows, + skipped: 0, + header: null, + sample: [], + suggestedMap: null, + signature: 'ofx', + last4Hint: statement.accountLast4 ?? fileNameLast4(staged.fileName), + openingBalance: null, + }; +} + function parseFile(ctx: AppContext, staged: Staged, mapping?: ImportColumnMap): Parsed { + if (looksLikeOfx(staged.text)) return parseOfxFile(staged); const table = readTable(staged.text); let layout = mapping ? null : detectBuiltIn(table); const headerRow = layout?.headerRow ?? findHeaderRow(table); diff --git a/server/src/index.ts b/server/src/index.ts index b60ce10..35483ad 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -9,7 +9,7 @@ const ctx = await createContext(config); const app = createApp(ctx); const backups = scheduleNightlyBackups({ - sqlite: ctx.sqlite, + sqlite: () => ctx.sqlite, dir: config.backupDir, at: config.backupTime, policy: { keepDaily: config.keepDaily, keepMonthly: config.keepMonthly }, diff --git a/server/src/routes/backups.ts b/server/src/routes/backups.ts index bf7e585..9257224 100644 --- a/server/src/routes/backups.ts +++ b/server/src/routes/backups.ts @@ -1,13 +1,32 @@ import { createRoute, OpenAPIHono, z } from '@hono/zod-openapi'; -import { createReadStream, existsSync, statSync } from 'node:fs'; +import { randomUUID } from 'node:crypto'; +import { createReadStream, existsSync, rmSync, statSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; import { Readable } from 'node:stream'; +import { bodyLimit } from 'hono/body-limit'; import { logChange } from '../audit'; import { requireAdmin } from '../auth/guards'; import { backupPath, createBackup, listBackups, pruneBackups } from '../backups/backup'; +import { checkBackup, RestoreError, restoreDatabase } from '../backups/restore'; import type { AppContext } from '../context'; +import type { SessionRow, UserRow } from '../db/schema'; import { fail, type Env } from '../http'; +import { refreshAlerts } from '../insights/engine'; +import { MIGRATIONS_DIR } from '../paths'; import { BackupSchema, jsonResponse, validationHook } from '../schemas'; +const MAX_RESTORE_BYTES = 200 * 1024 * 1024; + +const CheckSchema = z + .object({ + applied: z.number().int(), + counts: z.object({ transactions: z.number().int(), accounts: z.number().int(), people: z.number().int(), budgets: z.number().int(), subscriptions: z.number().int() }), + newestTransaction: z.string().nullable(), + }) + .openapi('BackupCheck'); +const RestoredSchema = z.object({ check: CheckSchema, safetyCopy: z.string().nullable() }).openapi('Restored'); +const NameParam = z.object({ name: z.string().openapi({ param: { name: 'name', in: 'path' } }) }); + const pad = (n: number) => String(n).padStart(2, '0'); export function backupRoutes(ctx: AppContext) { @@ -58,6 +77,100 @@ export function backupRoutes(ctx: AppContext) { }, ); + const guard = (run: () => T): T => { + try { + return run(); + } catch (error) { + if (error instanceof RestoreError) fail(400, error.code, error.message); + throw error; + } + }; + + /** Swap the app over to a backup; everyone keeps working against the restored data. */ + const restore = async (source: string, admin: UserRow, session: SessionRow | null, label: string) => { + const adminId = admin.id; + let result; + try { + result = await restoreDatabase(source, { + dataDir: ctx.config.dataDir, + backupDir, + migrationsDir: MIGRATIONS_DIR, + policy: { keepDaily, keepMonthly }, + current: ctx.sqlite, + keepSession: session ? { userId: admin.id, username: admin.username, session } : undefined, + }); + } catch (error) { + if (error instanceof RestoreError) fail(400, error.code, error.message); + throw error; + } + ctx.sqlite = result.sqlite; + ctx.db = result.db; + ctx.staging.clear(); + try { + refreshAlerts(ctx); + } catch (error) { + ctx.log.error('Could not refresh alerts after restoring', error); + } + ctx.log.info(`Restored ${label}; the previous database is saved as ${result.safety?.name ?? '(none)'}`); + // The person who restored may not exist in the copy; the log entry goes in only if they do. + try { + logChange(ctx, adminId, 'restore', 'backup', label, { safetyCopy: result.safety?.name ?? null }); + } catch { + logChange(ctx, null, 'restore', 'backup', label, { safetyCopy: result.safety?.name ?? null }); + } + return { check: result.check, safetyCopy: result.safety?.name ?? null }; + }; + + r.openapi( + createRoute({ + method: 'get', + path: '/backups/{name}/check', + tags: ['Backups'], + summary: 'What a backup holds, checked before restoring it', + request: { params: NameParam }, + responses: jsonResponse(CheckSchema, 'Check'), + }), + (c) => { + requireAdmin(c); + const path = backupPath(backupDir, c.req.valid('param').name); + if (!path || !existsSync(path)) fail(404, 'not_found', 'That backup wasn’t found.'); + return c.json(guard(() => checkBackup(path, MIGRATIONS_DIR, ctx.config.dataDir)), 200); + }, + ); + + r.openapi( + createRoute({ + method: 'post', + path: '/backups/{name}/restore', + tags: ['Backups'], + summary: 'Replace everything with a backup from the backup folder', + description: 'A "before restore" copy of the current data is saved first, so a restore can be undone.', + request: { params: NameParam }, + responses: jsonResponse(RestoredSchema, 'Restored'), + }), + async (c) => { + const admin = requireAdmin(c); + const { name } = c.req.valid('param'); + const path = backupPath(backupDir, name); + if (!path || !existsSync(path)) fail(404, 'not_found', 'That backup wasn’t found.'); + return c.json(await restore(path, admin, c.get('session'), name), 200); + }, + ); + + r.post('/backups/restore-upload', bodyLimit({ maxSize: MAX_RESTORE_BYTES, onError: () => fail(413, 'too_big', 'That file is larger than 200 MB, which is far bigger than any Spendlight backup.') }), async (c) => { + const admin = requireAdmin(c); + const form = await c.req.parseBody(); + const file = form.file; + if (!(file instanceof File)) fail(400, 'no_file', 'Please choose a backup file.'); + const upload = join(ctx.config.dataDir, `.upload-${randomUUID()}.db`); + writeFileSync(upload, Buffer.from(await file.arrayBuffer())); + try { + return c.json(await restore(upload, admin, c.get('session'), file.name || 'uploaded file'), 200); + } finally { + rmSync(upload, { force: true }); + } + }); + // A file download, so it sits outside the JSON API description. r.get('/backups/:name/download', (c) => { requireAdmin(c); diff --git a/server/src/routes/categories.ts b/server/src/routes/categories.ts index 630b2b2..3adbf08 100644 --- a/server/src/routes/categories.ts +++ b/server/src/routes/categories.ts @@ -4,7 +4,7 @@ import { logChange } from '../audit'; import { requireUser, requireWrite } from '../auth/guards'; import { rememberRule } from '../categorize/suggest'; import type { AppContext } from '../context'; -import { budgets, categories, categoryGroups, rules, splits, subscriptions, tags, transactions, transactionTags, type CategoryRow } from '../db/schema'; +import { budgets, categories, categoryGroups, rules, splits, splitTags, subscriptions, tags, transactions, transactionTags, type CategoryRow } from '../db/schema'; import { fail, nowIso, type Env } from '../http'; import { CategoryGroupSchema, CategorySchema, IdParam, jsonBody, jsonResponse, PaletteSchema, validationHook } from '../schemas'; import { listCategoryGroups } from '../transactions/dto'; @@ -249,13 +249,14 @@ export function categoryRoutes(ctx: AppContext) { }), (c) => { requireUser(c); - const rows = ctx.db - .select({ id: tags.id, name: tags.name, count: count(transactionTags.transactionId) }) - .from(tags) - .leftJoin(transactionTags, eq(transactionTags.tagId, tags.id)) - .groupBy(tags.id) - .orderBy(desc(count(transactionTags.transactionId)), tags.name) - .all(); + // Uses: transactions tagged, plus purchases with a tagged part. + const rows = ctx.sqlite + .prepare( + `SELECT t.id, t.name, (SELECT count(*) FROM txn_tag tt WHERE tt.tag_id = t.id) + + (SELECT count(DISTINCT s.transaction_id) FROM split_tag st JOIN split s ON s.id = st.split_id WHERE st.tag_id = t.id) AS count + FROM tag t ORDER BY count DESC, t.name`, + ) + .all() as { id: number; name: string; count: number }[]; return c.json(rows, 200); }, ); @@ -281,6 +282,9 @@ export function categoryRoutes(ctx: AppContext) { for (const link of ctx.db.select().from(transactionTags).where(eq(transactionTags.tagId, id)).all()) { ctx.db.insert(transactionTags).values({ transactionId: link.transactionId, tagId: other.id }).onConflictDoNothing().run(); } + for (const link of ctx.db.select().from(splitTags).where(eq(splitTags.tagId, id)).all()) { + ctx.db.insert(splitTags).values({ splitId: link.splitId, tagId: other.id }).onConflictDoNothing().run(); + } ctx.db.delete(tags).where(eq(tags.id, id)).run(); result = { id: other.id, name: other.name }; } else ctx.db.update(tags).set({ name }).where(eq(tags.id, id)).run(); diff --git a/server/src/routes/export.ts b/server/src/routes/export.ts index 9f62e85..2ecfe6f 100644 --- a/server/src/routes/export.ts +++ b/server/src/routes/export.ts @@ -5,7 +5,7 @@ import { currentBalance } from '../accounts/balance'; import { requireUser } from '../auth/guards'; import { budgetYear, effective } from '../budgets/budgets'; import type { AppContext } from '../context'; -import { accounts, budgets, rules, splits, subscriptions, tags, transactions, transactionTags, type TransactionRow } from '../db/schema'; +import { accounts, budgets, rules, splits, splitTags, subscriptions, tags, transactions, transactionTags, type TransactionRow } from '../db/schema'; import { today } from '../dates'; import { buildCsv, buildXlsx, type Cell, type Sheet } from '../export/xlsx'; import type { Env } from '../http'; @@ -27,9 +27,19 @@ function transactionSheet(ctx: AppContext, filter: TransactionFilter): Sheet { const chunks = (list: T[], size = 900) => Array.from({ length: Math.ceil(list.length / size) }, (_, i) => list.slice(i * size, i * size + size)); const parts = new Map(); const tagNames = new Map(); + const partTags = new Map(); for (const chunk of chunks(ids)) { - for (const s of ctx.db.select().from(splits).where(inArray(splits.transactionId, chunk)).orderBy(asc(splits.id)).all()) { - parts.set(s.transactionId, [...(parts.get(s.transactionId) ?? []), s]); + const partRows = ctx.db.select().from(splits).where(inArray(splits.transactionId, chunk)).orderBy(asc(splits.id)).all(); + for (const s of partRows) parts.set(s.transactionId, [...(parts.get(s.transactionId) ?? []), s]); + for (const partChunk of chunks(partRows.map((s) => s.id))) { + for (const pt of ctx.db + .select({ id: splitTags.splitId, name: tags.name }) + .from(splitTags) + .innerJoin(tags, eq(tags.id, splitTags.tagId)) + .where(inArray(splitTags.splitId, partChunk)) + .all()) { + partTags.set(pt.id, [...(partTags.get(pt.id) ?? []), pt.name]); + } } for (const t of ctx.db .select({ id: transactionTags.transactionId, name: tags.name }) @@ -48,7 +58,7 @@ function transactionSheet(ctx: AppContext, filter: TransactionFilter): Sheet { const out: Cell[][] = []; for (const t of rows) { - const base = (categoryId: number | null, cents: number, note: string | null, splitOf: number | null): Cell[] => { + const base = (categoryId: number | null, cents: number, note: string | null, splitOf: number | null, extraTags: string[] = []): Cell[] => { const cat = categoryId ? cats.get(categoryId) : undefined; return [ { date: t.date }, @@ -59,7 +69,7 @@ function transactionSheet(ctx: AppContext, filter: TransactionFilter): Sheet { { money: cents }, splitOf === null ? '' : { money: splitOf }, t.transferPeerId ? (accountNames.get(peers.get(t.transferPeerId) ?? -1) ?? '') : '', - (tagNames.get(t.id) ?? []).join(', '), + [...new Set([...(tagNames.get(t.id) ?? []), ...extraTags])].join(', '), note ?? '', t.subscriptionId ? (subNames.get(t.subscriptionId) ?? '') : '', t.needsReview ? 'To review' : 'Filed', @@ -67,7 +77,7 @@ function transactionSheet(ctx: AppContext, filter: TransactionFilter): Sheet { ]; }; const p = parts.get(t.id); - if (t.isSplit && p?.length) for (const part of p) out.push(base(part.categoryId, part.amountCents, part.note ?? t.note, t.amountCents)); + if (t.isSplit && p?.length) for (const part of p) out.push(base(part.categoryId, part.amountCents, part.note ?? t.note, t.amountCents, partTags.get(part.id))); else out.push(base(t.categoryId, t.amountCents, t.note, null)); } return { diff --git a/server/src/routes/reports.ts b/server/src/routes/reports.ts index a7c1d96..27613a7 100644 --- a/server/src/routes/reports.ts +++ b/server/src/routes/reports.ts @@ -211,16 +211,20 @@ export function reportRoutes(ctx: AppContext) { const rows = ctx.sqlite .prepare( `SELECT tag, categoryId, sum(net) AS net, count(DISTINCT txnId) AS n FROM ( - SELECT tg.name AS tag, t.category_id AS categoryId, t.amount_cents AS net, t.id AS txnId + SELECT tg.name AS tag, 'txn' AS unitKind, t.id AS unit, t.category_id AS categoryId, t.amount_cents AS net, t.id AS txnId FROM txn_tag tt JOIN tag tg ON tg.id = tt.tag_id JOIN txn t ON t.id = tt.transaction_id WHERE t.is_split = 0 AND t.transfer_peer_id IS NULL AND t.date >= ? AND t.date <= ? - UNION ALL - SELECT tg.name, s.category_id, s.amount_cents, t.id + UNION + SELECT tg.name, 'part', s.id, s.category_id, s.amount_cents, t.id FROM txn_tag tt JOIN tag tg ON tg.id = tt.tag_id JOIN txn t ON t.id = tt.transaction_id JOIN split s ON s.transaction_id = t.id WHERE t.is_split = 1 AND t.date >= ? AND t.date <= ? + UNION + SELECT tg.name, 'part', s.id, s.category_id, s.amount_cents, t.id + FROM split_tag st JOIN tag tg ON tg.id = st.tag_id JOIN split s ON s.id = st.split_id JOIN txn t ON t.id = s.transaction_id + WHERE t.is_split = 1 AND t.date >= ? AND t.date <= ? ) GROUP BY tag, categoryId`, ) - .all(from, to, from, to) as { tag: string; categoryId: number | null; net: number; n: number }[]; + .all(from, to, from, to, from, to) as { tag: string; categoryId: number | null; net: number; n: number }[]; const byTag = new Map(); for (const row of rows) { const kind = row.categoryId === null ? (row.net < 0 ? 'expense' : 'income') : kinds.get(row.categoryId); diff --git a/server/src/routes/transactions.ts b/server/src/routes/transactions.ts index bcce07f..f4cc1e1 100644 --- a/server/src/routes/transactions.ts +++ b/server/src/routes/transactions.ts @@ -5,7 +5,7 @@ import { logChange } from '../audit'; import { requireUser, requireWrite } from '../auth/guards'; import { rememberRule, Suggester } from '../categorize/suggest'; import type { AppContext } from '../context'; -import { accounts, categories, rules, splits, subscriptions, tags, transactions, transactionTags, type TransactionRow } from '../db/schema'; +import { accounts, categories, rules, splits, splitTags, subscriptions, transactions, transactionTags, type TransactionRow } from '../db/schema'; import { fail, nowIso, type Env } from '../http'; import { dedupeKey } from '../import/merchant'; import { unpair } from '../import/transfers'; @@ -19,6 +19,7 @@ import { TransactionSchema, validationHook, } from '../schemas'; +import { tagIdsFor } from '../tags'; import { newestFirst, toTransaction, toTransactions } from '../transactions/dto'; import { TransactionQuery, transactionWhere } from '../transactions/filter'; @@ -43,19 +44,7 @@ export function transactionRoutes(ctx: AppContext) { function setTags(transactionId: number, names: string[]) { ctx.db.delete(transactionTags).where(eq(transactionTags.transactionId, transactionId)).run(); - // The first spelling of a tag wins: "Back to school" and "back to school" are one tag. - const byKey = new Map(); - for (const n of names) if (n.trim() && !byKey.has(n.trim().toLowerCase())) byKey.set(n.trim().toLowerCase(), n.trim()); - const unique = [...byKey.values()]; - for (const name of unique) { - const existing = ctx.db - .select() - .from(tags) - .where(sql`lower(${tags.name}) = ${name.toLowerCase()}`) - .get(); - const tagId = existing?.id ?? ctx.db.insert(tags).values({ name }).returning().get().id; - ctx.db.insert(transactionTags).values({ transactionId, tagId }).onConflictDoNothing().run(); - } + for (const tagId of tagIdsFor(ctx, names)) ctx.db.insert(transactionTags).values({ transactionId, tagId }).onConflictDoNothing().run(); } /** Other transactions from the same merchant still waiting in Review, for "also file these?". */ @@ -214,7 +203,7 @@ export function transactionRoutes(ctx: AppContext) { ...jsonBody( z.object({ parts: z - .array(z.object({ categoryId: z.number().int(), amountCents: Cents, note: z.string().trim().max(200).nullable().optional() })) + .array(z.object({ categoryId: z.number().int(), amountCents: Cents, note: z.string().trim().max(200).nullable().optional(), tags: TagList.optional() })) .min(2, 'A split needs at least two parts.') .max(20), }), @@ -236,7 +225,10 @@ export function transactionRoutes(ctx: AppContext) { ctx.sqlite.transaction(() => { if (t.transferPeerId) unpair(ctx, t.id); ctx.db.delete(splits).where(eq(splits.transactionId, id)).run(); - for (const p of parts) ctx.db.insert(splits).values({ transactionId: id, categoryId: p.categoryId, amountCents: p.amountCents, note: p.note ?? null }).run(); + for (const p of parts) { + const part = ctx.db.insert(splits).values({ transactionId: id, categoryId: p.categoryId, amountCents: p.amountCents, note: p.note ?? null }).returning().get(); + for (const tagId of tagIdsFor(ctx, p.tags ?? [])) ctx.db.insert(splitTags).values({ splitId: part.id, tagId }).onConflictDoNothing().run(); + } ctx.db .update(transactions) .set({ isSplit: true, categoryId: null, needsReview: false, suggestedBy: null, updatedAt: nowIso() }) diff --git a/server/src/schemas.ts b/server/src/schemas.ts index 5a75fd2..8c1b670 100644 --- a/server/src/schemas.ts +++ b/server/src/schemas.ts @@ -167,7 +167,7 @@ export const CategoryGroupSchema = z .openapi('CategoryGroup'); export const SplitSchema = z - .object({ id: z.number().int(), categoryId: z.number().int(), amountCents: z.number().int(), note: z.string().nullable() }) + .object({ id: z.number().int(), categoryId: z.number().int(), amountCents: z.number().int(), note: z.string().nullable(), tags: z.array(z.string()) }) .openapi('Split'); export const TransactionSchema = z diff --git a/server/src/static.ts b/server/src/static.ts index 389839c..3836d24 100644 --- a/server/src/static.ts +++ b/server/src/static.ts @@ -5,7 +5,8 @@ import { Readable } from 'node:stream'; // Serves the built web app. Hashed files under /assets are cached for a year; // everything else revalidates so a new version shows up straight away. Any -// path that isn't a file gets index.html, so the app can route it. +// path that isn't a file gets index.html, so the app can route it. Text files +// go out pre-compressed (brotli, else gzip) when the browser accepts it. const TYPES: Record = { '.html': 'text/html; charset=utf-8', @@ -31,13 +32,25 @@ function cacheControlFor(urlPath: string): string { return 'public, max-age=86400'; } -function fileResponse(path: string, urlPath: string, head: boolean): Response { - const headers = { +function fileResponse(path: string, urlPath: string, head: boolean, acceptEncoding = ''): Response { + const headers: Record = { 'Content-Type': TYPES[extname(path)] ?? 'application/octet-stream', - 'Content-Length': String(statSync(path).size), 'Cache-Control': cacheControlFor(urlPath), }; - const body = head ? null : (Readable.toWeb(createReadStream(path)) as ReadableStream); + let file = path; + for (const [encoding, suffix] of [ + ['br', '.br'], + ['gzip', '.gz'], + ] as const) { + if (new RegExp(`\\b${encoding}\\b`).test(acceptEncoding) && existsSync(path + suffix)) { + file = path + suffix; + headers['Content-Encoding'] = encoding; + break; + } + } + if (existsSync(path + '.br') || existsSync(path + '.gz')) headers.Vary = 'Accept-Encoding'; + headers['Content-Length'] = String(statSync(file).size); + const body = head ? null : (Readable.toWeb(createReadStream(file)) as ReadableStream); return new Response(body, { headers }); } @@ -52,12 +65,12 @@ export function webApp(root: string): MiddlewareHandler { const candidate = normalize(join(root, urlPath)); if (candidate.startsWith(rootWithSep) && existsSync(candidate) && statSync(candidate).isFile()) { - return fileResponse(candidate, urlPath, method === 'HEAD'); + return fileResponse(candidate, urlPath, method === 'HEAD', c.req.header('accept-encoding')); } if (extname(urlPath)) return c.text('Not found', 404); if (!existsSync(index)) { return c.text('The Spendlight web app is not built yet. Run "npm run build" (or "npm run dev:web" while developing).', 503); } - return fileResponse(index, '/index.html', method === 'HEAD'); + return fileResponse(index, '/index.html', method === 'HEAD', c.req.header('accept-encoding')); }; } diff --git a/server/src/tags.ts b/server/src/tags.ts new file mode 100644 index 0000000..4cb0920 --- /dev/null +++ b/server/src/tags.ts @@ -0,0 +1,17 @@ +import { sql } from 'drizzle-orm'; +import type { AppContext } from './context'; +import { tags } from './db/schema'; + +/** Tag IDs for these names, creating any that are new. The first spelling of a tag wins: "Back to school" and "back to school" are one tag. */ +export function tagIdsFor(ctx: AppContext, names: string[]): number[] { + const byKey = new Map(); + for (const n of names) if (n.trim() && !byKey.has(n.trim().toLowerCase())) byKey.set(n.trim().toLowerCase(), n.trim()); + return [...byKey.values()].map((name) => { + const existing = ctx.db + .select() + .from(tags) + .where(sql`lower(${tags.name}) = ${name.toLowerCase()}`) + .get(); + return existing?.id ?? ctx.db.insert(tags).values({ name }).returning().get().id; + }); +} diff --git a/server/src/transactions/dto.ts b/server/src/transactions/dto.ts index 4af87b0..a46bf87 100644 --- a/server/src/transactions/dto.ts +++ b/server/src/transactions/dto.ts @@ -2,13 +2,21 @@ import type { Account, Category, CategoryGroup, PaletteColor, Transaction } from import { and, asc, count, desc, eq, inArray, max } from 'drizzle-orm'; import { currentBalance } from '../accounts/balance'; import type { AppContext } from '../context'; -import { accounts, categories, categoryGroups, imports, splits, tags, transactions, transactionTags, type AccountRow, type TransactionRow } from '../db/schema'; +import { accounts, categories, categoryGroups, imports, splits, splitTags, tags, transactions, transactionTags, type AccountRow, type TransactionRow } from '../db/schema'; /** Full transactions, with splits, tags and the other half of any transfer, loaded in a few queries. */ export function toTransactions(ctx: AppContext, rows: TransactionRow[]): Transaction[] { if (!rows.length) return []; const ids = rows.map((r) => r.id); const splitRows = ctx.db.select().from(splits).where(inArray(splits.transactionId, ids)).orderBy(asc(splits.id)).all(); + const splitTagRows = splitRows.length + ? ctx.db + .select({ splitId: splitTags.splitId, name: tags.name }) + .from(splitTags) + .innerJoin(tags, eq(tags.id, splitTags.tagId)) + .where(inArray(splitTags.splitId, splitRows.map((s) => s.id))) + .all() + : []; const tagRows = ctx.db .select({ transactionId: transactionTags.transactionId, name: tags.name }) .from(transactionTags) @@ -38,7 +46,7 @@ export function toTransactions(ctx: AppContext, rows: TransactionRow[]): Transac isSplit: r.isSplit, splits: splitRows .filter((s) => s.transactionId === r.id) - .map((s) => ({ id: s.id, categoryId: s.categoryId, amountCents: s.amountCents, note: s.note })), + .map((s) => ({ id: s.id, categoryId: s.categoryId, amountCents: s.amountCents, note: s.note, tags: splitTagRows.filter((x) => x.splitId === s.id).map((x) => x.name) })), note: r.note, tags: tagRows.filter((t) => t.transactionId === r.id).map((t) => t.name), transferPeer: peers.find((p) => p.id === r.transferPeerId) ?? null, diff --git a/server/src/transactions/filter.ts b/server/src/transactions/filter.ts index 8085182..0a509b2 100644 --- a/server/src/transactions/filter.ts +++ b/server/src/transactions/filter.ts @@ -1,7 +1,7 @@ import { z } from '@hono/zod-openapi'; import { and, eq, exists, gte, isNull, like, lte, or, sql, type SQL } from 'drizzle-orm'; import type { AppContext } from '../context'; -import { splits, tags, transactions, transactionTags } from '../db/schema'; +import { splits, splitTags, tags, transactions, transactionTags } from '../db/schema'; import { DateString } from '../schemas'; /** The search and filters shared by the transaction list and its exports. */ @@ -36,14 +36,26 @@ export function transactionWhere(ctx: AppContext, q: TransactionFilter): SQL | u ); } if (q.tag) { + const name = sql`lower(${tags.name}) = ${q.tag.toLowerCase()}`; + // On the transaction itself, or on any part of a split. where.push( - exists( - ctx.db - .select() - .from(transactionTags) - .innerJoin(tags, eq(tags.id, transactionTags.tagId)) - .where(and(eq(transactionTags.transactionId, transactions.id), sql`lower(${tags.name}) = ${q.tag.toLowerCase()}`)), - ), + or( + exists( + ctx.db + .select() + .from(transactionTags) + .innerJoin(tags, eq(tags.id, transactionTags.tagId)) + .where(and(eq(transactionTags.transactionId, transactions.id), name)), + ), + exists( + ctx.db + .select() + .from(splitTags) + .innerJoin(tags, eq(tags.id, splitTags.tagId)) + .innerJoin(splits, eq(splits.id, splitTags.splitId)) + .where(and(eq(splits.transactionId, transactions.id), name)), + ), + )!, ); } if (q.from) where.push(gte(transactions.date, q.from)); diff --git a/server/test/full-picture.test.ts b/server/test/full-picture.test.ts index 591c9f1..067bbc7 100644 --- a/server/test/full-picture.test.ts +++ b/server/test/full-picture.test.ts @@ -333,6 +333,38 @@ describe('managing categories, rules and tags', () => { }); }); +describe('tags on split parts', () => { + it('finds, totals and exports a tag on one part of a purchase', async () => { + const page = (await admin.get('/api/v1/transactions?q=Kroger&limit=1')).json; + const kroger = page.items[0]; + const groceries = await category('Groceries'); + const household = await category('Household Supplies'); + const split = await admin.call('PUT', `/api/v1/transactions/${kroger.id}/split`, { + parts: [ + { categoryId: groceries, amountCents: kroger.amountCents + 1500 }, + { categoryId: household, amountCents: -1500, tags: ['Reimbursable'] }, + ], + }); + expect(split.json.splits.map((x: { tags: string[] }) => x.tags)).toEqual([[], ['Reimbursable']]); + + const found = (await admin.get('/api/v1/transactions?tag=reimbursable')).json; + expect(found.items.map((x: { id: number }) => x.id)).toEqual([kroger.id]); + const report = (await admin.get('/api/v1/reports/tags')).json as { tag: string; spent: number; count: number; byCategory: { categoryId: number }[] }[]; + expect(report.find((r) => r.tag === 'Reimbursable')).toMatchObject({ spent: 1500, count: 1, byCategory: [{ categoryId: household, cents: 1500 }] }); + expect((await admin.get('/api/v1/tags')).json).toEqual([{ id: expect.any(Number), name: 'Reimbursable', count: 1 }]); + + // Tagging the whole purchase too doesn't count the tagged part twice. + await admin.patch(`/api/v1/transactions/${kroger.id}`, { tags: ['Reimbursable'] }); + const both = (await admin.get('/api/v1/reports/tags')).json as { tag: string; spent: number }[]; + expect(both.find((r) => r.tag === 'Reimbursable')!.spent).toBe(-kroger.amountCents); + + const res = await t.app.request(`http://localhost/api/v1/export/transactions.csv?tag=Reimbursable`, { headers: { Cookie: `sl_session=${admin.cookie('sl_session')}` } }); + const lines = Buffer.from(await res.arrayBuffer()).subarray(3).toString('utf8').trim().split('\r\n'); + expect(lines).toHaveLength(3); + expect(lines.every((l, i) => i === 0 || l.includes('Reimbursable'))).toBe(true); + }); +}); + describe('export', () => { it('writes CSV that defuses formulas and splits rows by part', async () => { expect(buildCsv(['A', 'B'], [['=HYPERLINK("x")', { money: -1234 }], ['a,b', 5]])).toBe('\uFEFFA,B\r\n"\'=HYPERLINK(""x"")",-12.34\r\n"a,b",5\r\n'); diff --git a/server/test/ofx.test.ts b/server/test/ofx.test.ts new file mode 100644 index 0000000..cb3e0d3 --- /dev/null +++ b/server/test/ofx.test.ts @@ -0,0 +1,88 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { parseOfx } from '../src/import/ofx'; +import { makeApp, setUpAdmin, type Client, type TestApp } from './helpers'; + +// Made-up OFX files in both flavors banks send: the older SGML style with +// unclosed tags (bank account), and XML (credit card, as in a .qfx). + +const SGML = `OFXHEADER:100 +DATA:OFXSGML +VERSION:102 +SECURITY:NONE +ENCODING:USASCII +CHARSET:1252 + + +0INFO20261007120000ENGBank of America5959 +10INFO +USD000000000000099991234CHECKING +2026090120260930 +CREDIT20260905120000.000[-5:EST]3000.00A1ACME CORP PAYROLLPPD ID: 123 +DEBIT20260912-54.20A2KROGER #123 CHARLOTTE NC +XFER20260915-300.00A3Online Banking transfer to SAV5678 Confirmation# 1 +DEBIT20260920-12,50A4JOES COFFEE & BAGELS +4633.30202609304633.3020260930 +`; + +const XML = ` + +1USD +4111XXXXXXXX9876 +2026090120260930 +DEBIT2026090320260902-23.99C1AMAZON MKTPL*AB12CD3E4 +CREDIT2026090718.50C2AMAZON MKTPL*QR56ST7U8Return +DEBIT20260912-15.49C3NETFLIX.COM +-412.3320260930 +`; + +describe('reading OFX', () => { + it('reads the older SGML style', () => { + const s = parseOfx(SGML); + expect(s).toMatchObject({ kind: 'checking', institution: 'Bank of America', accountLast4: '1234', ledger: { cents: 463330, date: '2026-09-30' } }); + expect(s.transactions.map((t) => [t.date, t.amountCents, t.name])).toEqual([ + ['2026-09-05', 300000, 'ACME CORP PAYROLL'], + ['2026-09-12', -5420, 'KROGER #123 CHARLOTTE NC'], + ['2026-09-15', -30000, 'Online Banking transfer to SAV'], + ['2026-09-20', -1250, 'JOES COFFEE & BAGELS'], + ]); + }); + + it('reads XML, using the purchase date when there is one', () => { + const s = parseOfx(XML); + expect(s).toMatchObject({ kind: 'credit_card', accountLast4: '9876' }); + expect(s.transactions[0]).toMatchObject({ date: '2026-09-02', postedDate: '2026-09-03', amountCents: -2399 }); + expect(s.transactions[1]).toMatchObject({ amountCents: 1850, memo: 'Return' }); + }); +}); + +describe('importing OFX', () => { + let t: TestApp; + let admin: Client; + beforeEach(async () => { + t = await makeApp(); + admin = await setUpAdmin(t); + }); + afterEach(() => t.cleanup()); + + it('imports with no mapping, matches the account by its last digits, and skips repeats', async () => { + const checking = (await admin.post('/api/v1/accounts', { name: 'Checking', kind: 'checking', institution: 'Bank of America', last4: '1234' })).json.id; + await admin.post('/api/v1/accounts', { name: 'Prime Visa', kind: 'credit_card', institution: 'Chase', last4: '9876' }); + + const preview = await admin.upload('/api/v1/imports/files', 'export.qfx', SGML); + expect(preview.json).toMatchObject({ layout: { key: 'ofx' }, account: { id: checking, confidence: 'certain' }, counts: { new: 4 } }); + const done = await admin.post('/api/v1/imports', { stagingId: preview.json.stagingId, accountId: checking }); + expect(done.status).toBe(200); + + const page = (await admin.get(`/api/v1/transactions?accountId=${checking}`)).json; + expect(page.items.map((x: { merchant: string }) => x.merchant)).toContain('Kroger'); + const account = ((await admin.get('/api/v1/accounts')).json as { id: number; balanceCents: number; balanceConfirmedOn: string }[]).find((a) => a.id === checking)!; + expect(account).toMatchObject({ balanceCents: 463330, balanceConfirmedOn: '2026-09-20' }); + + const again = await admin.upload('/api/v1/imports/files', 'export.qfx', SGML); + expect(again.json.counts).toMatchObject({ new: 0, duplicates: 4 }); + + const card = await admin.upload('/api/v1/imports/files', 'Statement.qfx', XML); + expect(card.json.account.confidence).toBe('certain'); + expect(card.json.accountHint).toMatchObject({ kind: 'credit_card' }); + }); +}); diff --git a/server/test/restore.test.ts b/server/test/restore.test.ts new file mode 100644 index 0000000..6f29a8c --- /dev/null +++ b/server/test/restore.test.ts @@ -0,0 +1,128 @@ +import Database from 'better-sqlite3'; +import { join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { listBackups } from '../src/backups/backup'; +import { makeApp, setUpAdmin, type Client, type TestApp } from './helpers'; +import { discoverCsv } from './synthetic'; + +// The milestone 3 drill, automated: back up, change things, restore, and get +// everything back exactly. All data here is made up. + +let t: TestApp; +let admin: Client; + +const snapshot = async () => { + const page = (await admin.get('/api/v1/transactions?limit=500')).json; + return { + total: page.total, + sums: page.sums, + accounts: ((await admin.get('/api/v1/accounts')).json as { name: string; balanceCents: number }[]).map((a) => [a.name, a.balanceCents]), + budgets: (await admin.get('/api/v1/budgets?year=2026')).json, + subscriptions: ((await admin.get('/api/v1/subscriptions')).json as { name: string; status: string }[]).map((s) => [s.name, s.status]), + }; +}; + +beforeEach(async () => { + t = await makeApp(); + admin = await setUpAdmin(t); + const card = (await admin.post('/api/v1/accounts', { name: 'Discover', kind: 'credit_card', institution: 'Discover' })).json.id; + const preview = await admin.upload('/api/v1/imports/files', 'Discover-2026.csv', discoverCsv()); + await admin.post('/api/v1/imports', { stagingId: preview.json.stagingId, accountId: card }); + await admin.post('/api/v1/budgets/fill', { month: '2026-10', source: 'average3' }); +}); +afterEach(() => t.cleanup()); + +describe('restoring a backup', () => { + it('brings everything back, after saving a copy of how things were', async () => { + const before = await snapshot(); + const backup = (await admin.post('/api/v1/backups')).json.name as string; + + // Things go wrong: the import is undone and a budget is wiped. + const imports = (await admin.get('/api/v1/imports')).json as { id: number }[]; + await admin.del(`/api/v1/imports/${imports[0]!.id}`); + await admin.put('/api/v1/budgets', { categoryId: before.budgets.items[0].categoryId, year: 2026, month: 1, amountCents: 0, scope: 'year' }); + expect((await snapshot()).total).toBe(0); + + const check = await admin.get(`/api/v1/backups/${backup}/check`); + expect(check.json.counts).toMatchObject({ transactions: before.total, accounts: 1, people: 1 }); + + const restored = await admin.post(`/api/v1/backups/${backup}/restore`); + expect(restored.status).toBe(200); + expect(restored.json.safetyCopy).toMatch(/^spendlight-pre-restore-/); + expect(await snapshot()).toEqual(before); + + // The app keeps working on the restored data, and the restore can be undone. + expect((await admin.post('/api/v1/subscriptions/scan')).status).toBe(200); + const undo = await admin.post(`/api/v1/backups/${restored.json.safetyCopy}/restore`); + expect(undo.status).toBe(200); + expect((await snapshot()).total).toBe(0); + expect(listBackups(t.ctx.config.backupDir).filter((b) => b.kind === 'pre-restore')).toHaveLength(2); + }); + + it('keeps whoever restored signed in, and leaves only whole files in the backup folder', async () => { + const backup = (await admin.post('/api/v1/backups')).json.name as string; + // A sign-in newer than the backup. + const { Client } = await import('./helpers'); + const fresh = new Client(t); + await fresh.post('/api/v1/auth/login', { username: 'Chris', password: 'correct-horse' }); + await fresh.get(`/api/v1/backups/${backup}/check`); + expect((await fresh.post(`/api/v1/backups/${backup}/restore`)).status).toBe(200); + expect((await fresh.get('/api/v1/me')).status).toBe(200); + const names = (await import('node:fs')).readdirSync(t.ctx.config.backupDir); + expect(names.every((n) => /^spendlight-[a-z-]+-\d{8}-\d{6}\.db$/.test(n))).toBe(true); + }); + + it('accepts an uploaded backup, such as one from another machine', async () => { + const before = await snapshot(); + const backup = (await admin.post('/api/v1/backups')).json.name as string; + const imports = (await admin.get('/api/v1/imports')).json as { id: number }[]; + await admin.del(`/api/v1/imports/${imports[0]!.id}`); + + const form = new FormData(); + const bytes = (await import('node:fs')).readFileSync(join(t.ctx.config.backupDir, backup)); + form.append('file', new File([bytes], backup)); + const res = await t.app.request('http://localhost/api/v1/backups/restore-upload', { method: 'POST', body: form, headers: { Cookie: `sl_session=${admin.cookie('sl_session')}` } }); + expect(res.status).toBe(200); + expect(await snapshot()).toEqual(before); + }); + + it('refuses files that aren’t a Spendlight backup', async () => { + const dir = t.ctx.config.backupDir; + const upload = async (name: string, data: Buffer | string) => { + const form = new FormData(); + form.append('file', new File([data], name)); + const res = await t.app.request('http://localhost/api/v1/backups/restore-upload', { method: 'POST', body: form, headers: { Cookie: `sl_session=${admin.cookie('sl_session')}` } }); + return { status: res.status, json: (await res.json()) as { error: { code: string } } }; + }; + expect((await upload('notes.db', 'just some text')).json.error.code).toBe('not_sqlite'); + + // A SQLite file from some other app. + const other = join(dir, 'other.db'); + const db = new Database(other); + db.exec('CREATE TABLE things (id INTEGER)'); + db.close(); + expect((await upload('other.db', (await import('node:fs')).readFileSync(other))).json.error.code).toBe('not_spendlight'); + + // A Spendlight backup from a newer version, with a database upgrade this one doesn't know. + const backup = (await admin.post('/api/v1/backups')).json.name as string; + const newer = new Database(join(dir, backup)); + newer.prepare('INSERT INTO __drizzle_migrations (hash, created_at) VALUES (?, ?)').run('future', Date.now()); + newer.close(); + const tooNew = await admin.post(`/api/v1/backups/${backup}/restore`); + expect(tooNew.json.error.code).toBe('too_new'); + + // Nothing changed, and no safety copies were needed. + expect(listBackups(dir).filter((b) => b.kind === 'pre-restore')).toHaveLength(0); + expect((await admin.get('/api/v1/transactions?limit=1')).json.total).toBeGreaterThan(0); + }); + + it('is for admins only, and only for names in the backup folder', async () => { + await admin.post('/api/v1/people', { username: 'helper', displayName: 'Helper', access: 'full', isAdmin: false, password: 'helper-password-1' }); + const { Client } = await import('./helpers'); + const helper = new Client(t); + await helper.post('/api/v1/auth/login', { username: 'helper', password: 'helper-password-1' }); + const backup = (await admin.post('/api/v1/backups')).json.name as string; + expect((await helper.post(`/api/v1/backups/${backup}/restore`)).status).toBe(403); + expect((await admin.post('/api/v1/backups/..%2Fdata%2Fspendlight.db/restore')).status).toBe(404); + }); +}); diff --git a/shared/src/index.ts b/shared/src/index.ts index 0d4668f..304a004 100644 --- a/shared/src/index.ts +++ b/shared/src/index.ts @@ -158,6 +158,7 @@ export interface Split { categoryId: number; amountCents: number; note: string | null; + tags: string[]; } export interface Transaction { diff --git a/web/package.json b/web/package.json index ae029b5..26699ef 100644 --- a/web/package.json +++ b/web/package.json @@ -4,12 +4,11 @@ "type": "module", "scripts": { "dev": "vite", - "build": "tsc -p tsconfig.json && vite build", + "build": "tsc -p tsconfig.json && vite build && node scripts/compress.mjs", "typecheck": "tsc -p tsconfig.json", "icons": "node scripts/make-icons.mjs" }, "dependencies": { - "@fontsource-variable/inter": "^5.3.0", "@simplewebauthn/browser": "^14.0.0", "@spendlight/shared": "*", "@tanstack/react-query": "^5.104.1", @@ -22,7 +21,6 @@ "react-is": "^19.3.0", "react-router": "^8.4.0", "recharts": "^3.10.1", - "sonner": "^2.0.8", "tailwind-merge": "^3.7.0" }, "devDependencies": { diff --git a/web/scripts/compress.mjs b/web/scripts/compress.mjs new file mode 100644 index 0000000..fd5ffcc --- /dev/null +++ b/web/scripts/compress.mjs @@ -0,0 +1,25 @@ +// After the build: write brotli and gzip copies of every text file, so the +// server can send them compressed without compressing on each request. +import { readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs'; +import { extname, join } from 'node:path'; +import { brotliCompressSync, constants, gzipSync } from 'node:zlib'; + +const root = new URL('../dist/', import.meta.url).pathname; +const TEXT = new Set(['.js', '.mjs', '.css', '.html', '.svg', '.json', '.webmanifest', '.txt']); +let count = 0; + +function walk(dir) { + for (const name of readdirSync(dir)) { + const path = join(dir, name); + if (statSync(path).isDirectory()) walk(path); + else if (TEXT.has(extname(name)) && statSync(path).size > 1024) { + const data = readFileSync(path); + writeFileSync(`${path}.br`, brotliCompressSync(data, { params: { [constants.BROTLI_PARAM_QUALITY]: 11 } })); + writeFileSync(`${path}.gz`, gzipSync(data, { level: 9 })); + count++; + } + } +} + +walk(root); +console.log(`Compressed ${count} files`); diff --git a/web/src/assets/fonts/LICENSE-Inter.txt b/web/src/assets/fonts/LICENSE-Inter.txt new file mode 100644 index 0000000..40589da --- /dev/null +++ b/web/src/assets/fonts/LICENSE-Inter.txt @@ -0,0 +1,93 @@ +Copyright 2016 The Inter Project Authors (https://github.com/rsms/inter) Inter-Italic[opsz,wght].ttf: Copyright 2016 The Inter Project Authors (https://github.com/rsms/inter) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/web/src/assets/fonts/inter-latin-wght.woff2 b/web/src/assets/fonts/inter-latin-wght.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..7cd6c6479be620578bcc09f752676c30b06614d8 GIT binary patch literal 36156 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

*;`enaH&bJqY1N_=G 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 literal 0 HcmV?d00001 diff --git a/web/src/components/AccountButton.tsx b/web/src/components/AccountButton.tsx new file mode 100644 index 0000000..607e342 --- /dev/null +++ b/web/src/components/AccountButton.tsx @@ -0,0 +1,29 @@ +import { lazy, Suspense, useState } from 'react'; +import { useCurrentUser } from '@/lib/session'; +import { Avatar } from './Avatar'; + +const UserMenu = lazy(() => import('./UserMenu').then((m) => ({ default: m.UserMenu }))); + +/** The avatar at the top right. Its menu loads on first use, keeping the first visit light. */ +export function AccountButton() { + const me = useCurrentUser(); + const [used, setUsed] = useState(false); + const button = ( + + ); + if (!used) return button; + return ( + + + + ); +} diff --git a/web/src/components/AlertCard.tsx b/web/src/components/AlertCard.tsx index 9b9e5d7..fb0951e 100644 --- a/web/src/components/AlertCard.tsx +++ b/web/src/components/AlertCard.tsx @@ -6,9 +6,9 @@ import { api } from '@/lib/api'; import { cn } from '@/lib/utils'; const LOOK = { - bad: { icon: CircleAlert, tone: 'bg-bad/12 text-bad' }, - warn: { icon: TriangleAlert, tone: 'bg-warn/14 text-warn' }, - good: { icon: PartyPopper, tone: 'bg-good/12 text-good' }, + bad: { icon: CircleAlert, tone: 'bg-bad/12 text-bad-fg' }, + warn: { icon: TriangleAlert, tone: 'bg-warn/14 text-warn-fg' }, + good: { icon: PartyPopper, tone: 'bg-good/12 text-good-fg' }, info: { icon: Lightbulb, tone: 'bg-accent text-accent-foreground' }, } as const; diff --git a/web/src/components/AppShell.tsx b/web/src/components/AppShell.tsx index 7af51a2..23c00f3 100644 --- a/web/src/components/AppShell.tsx +++ b/web/src/components/AppShell.tsx @@ -1,24 +1,25 @@ import { Ellipsis, Upload } from 'lucide-react'; import { Suspense, useEffect, useState } from 'react'; import { Link, NavLink, Outlet, useLocation, useNavigate } from 'react-router'; -import { toast } from 'sonner'; +import { toast } from '@/components/toast'; import { useAlertCounts, useReviewCount } from '@/lib/data'; import { queueFiles } from '@/lib/pendingFiles'; import { useConfig, useCurrentUser } from '@/lib/session'; import { cn } from '@/lib/utils'; import { APP_VERSION } from '@/lib/version'; import { LogoMark, VersionTag } from './brand'; -import { IdeaButton } from './IdeaDialog'; -import { NAV, SETTINGS_NAV, type NavItem } from './nav'; +import { IdeaButton } from './IdeaButton'; +import { HELP_NAV, NAV, SETTINGS_NAV, type NavItem } from './nav'; import { Button } from './ui/button'; -import { Skeleton, Tip } from './ui/misc'; -import { UserMenu } from './UserMenu'; +import { Skeleton } from './ui/misc'; +import { AccountButton } from './AccountButton'; function pageTitle(pathname: string): string { if (pathname === '/') return 'Home'; if (pathname.startsWith('/settings')) return 'Settings'; if (pathname.startsWith('/more')) return 'More'; if (pathname.startsWith('/import')) return 'Import'; + if (pathname.startsWith('/help')) return 'Help'; return NAV.find((n) => n.to !== '/' && pathname.startsWith(n.to))?.label ?? 'Spendlight'; } @@ -75,6 +76,7 @@ function Sidebar() { ))}

+
@@ -87,7 +89,7 @@ function Sidebar() { function PhoneTabBar() { const { pathname } = useLocation(); const badges = useBadges(); - const inMore = pathname.startsWith('/more') || pathname.startsWith('/settings') || pathname.startsWith('/import') || NAV.some((n) => !n.phone && pathname.startsWith(n.to)); + const inMore = pathname.startsWith('/more') || pathname.startsWith('/settings') || pathname.startsWith('/import') || pathname.startsWith('/help') || NAV.some((n) => !n.phone && pathname.startsWith(n.to)); const tab = 'flex flex-col items-center gap-1 pt-2 pb-1 text-[11px] font-medium text-muted-foreground transition-colors'; return (
diff --git a/web/src/components/AuthLayout.tsx b/web/src/components/AuthLayout.tsx index b74587c..649d7ad 100644 --- a/web/src/components/AuthLayout.tsx +++ b/web/src/components/AuthLayout.tsx @@ -26,7 +26,7 @@ export function AuthLayout({ title, subtitle, children }: { title: string; subti export function FormError({ message }: { message: string | null }) { if (!message) return null; return ( -

+

{message}

); diff --git a/web/src/components/Avatar.tsx b/web/src/components/Avatar.tsx new file mode 100644 index 0000000..8306b6a --- /dev/null +++ b/web/src/components/Avatar.tsx @@ -0,0 +1,14 @@ +import { initials } from '@/lib/utils'; + +export function Avatar({ name, className = 'size-9 text-[13px]' }: { name: string; className?: string }) { + return ( + + + {initials(name)} + + + ); +} diff --git a/web/src/components/BudgetEditor.tsx b/web/src/components/BudgetEditor.tsx index 8124282..04413ed 100644 --- a/web/src/components/BudgetEditor.tsx +++ b/web/src/components/BudgetEditor.tsx @@ -1,7 +1,7 @@ import { monthName, type BudgetScope } from '@spendlight/shared'; import { useMutation, useQueryClient } from '@tanstack/react-query'; import { useState, type FormEvent } from 'react'; -import { toast } from 'sonner'; +import { toast } from '@/components/toast'; import { api, errorMessage } from '@/lib/api'; import { centsToInput, parseDollars } from '@/lib/money'; import { CategoryIcon } from './money'; diff --git a/web/src/components/IdeaButton.tsx b/web/src/components/IdeaButton.tsx new file mode 100644 index 0000000..e92e9b8 --- /dev/null +++ b/web/src/components/IdeaButton.tsx @@ -0,0 +1,34 @@ +import { Lightbulb } from 'lucide-react'; +import { lazy, Suspense, useState } from 'react'; +import { Button } from './ui/button'; + +const IdeaDialog = lazy(() => import('./IdeaDialog')); + +/** The lightbulb at the top right. Its pop-up loads on first use, keeping the first visit light. */ +export function IdeaButton() { + const [open, setOpen] = useState(false); + const [used, setUsed] = useState(false); + return ( + <> + + {used ? ( + + + + ) : null} + + ); +} diff --git a/web/src/components/IdeaDialog.tsx b/web/src/components/IdeaDialog.tsx index 571dc95..06e3875 100644 --- a/web/src/components/IdeaDialog.tsx +++ b/web/src/components/IdeaDialog.tsx @@ -1,18 +1,15 @@ import type { Idea } from '@spendlight/shared'; import { useMutation } from '@tanstack/react-query'; -import { Lightbulb } from 'lucide-react'; import { useState, type FormEvent } from 'react'; import { useLocation } from 'react-router'; -import { toast } from 'sonner'; +import { toast } from '@/components/toast'; import { api, errorMessage } from '@/lib/api'; import { Button } from './ui/button'; -import { Dialog, DialogContent, DialogTrigger } from './ui/dialog'; +import { Dialog, DialogContent } from './ui/dialog'; import { Field, Input, Textarea } from './ui/form'; -import { Tip } from './ui/misc'; -/** The lightbulb at the top right: share an idea in a few seconds. */ -export function IdeaButton() { - const [open, setOpen] = useState(false); +/** The idea pop-up, loaded the first time someone taps the lightbulb. */ +export default function IdeaDialog({ open, setOpen }: { open: boolean; setOpen: (open: boolean) => void }) { const [title, setTitle] = useState(''); const [description, setDescription] = useState(''); const location = useLocation(); @@ -36,13 +33,6 @@ export function IdeaButton() { return ( - - - - -
- +

Something went wrong

diff --git a/web/src/components/TransactionSheet.tsx b/web/src/components/TransactionSheet.tsx index 8904a55..d9187ba 100644 --- a/web/src/components/TransactionSheet.tsx +++ b/web/src/components/TransactionSheet.tsx @@ -1,9 +1,9 @@ import { formatCents, type Transaction } from '@spendlight/shared'; import { useMutation, useQueryClient } from '@tanstack/react-query'; import { ArrowLeftRight, ArrowRight, Plus, Repeat, Split as SplitIcon, Trash2, X } from 'lucide-react'; -import { useEffect, useMemo, useState } from 'react'; +import { useEffect, useId, useMemo, useState } from 'react'; import { Link } from 'react-router'; -import { toast } from 'sonner'; +import { toast } from '@/components/toast'; import { api, errorMessage } from '@/lib/api'; import { MONEY_KEYS, useAccounts, useCategories, useSubscriptions, useTags } from '@/lib/data'; import { useCurrentUser } from '@/lib/session'; @@ -26,9 +26,10 @@ export function useRefreshMoney() { return () => MONEY_KEYS.forEach((k) => void client.invalidateQueries({ queryKey: [...k] })); } -function TagEditor({ value, onChange }: { value: string[]; onChange: (tags: string[]) => void }) { +function TagEditor({ value, onChange, placeholder = 'Add a tag, like Vacation 2026', label = 'Add a tag' }: { value: string[]; onChange: (tags: string[]) => void; placeholder?: string; label?: string }) { const tags = useTags(); const [draft, setDraft] = useState(''); + const listId = useId(); const add = (name: string) => { const clean = name.trim(); if (clean && !value.some((t) => t.toLowerCase() === clean.toLowerCase())) onChange([...value, clean]); @@ -51,7 +52,7 @@ function TagEditor({ value, onChange }: { value: string[]; onChange: (tags: stri ) : null} setDraft(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ',') { @@ -60,10 +61,10 @@ function TagEditor({ value, onChange }: { value: string[]; onChange: (tags: stri } }} onBlur={() => draft && add(draft)} - placeholder="Add a tag, like Vacation 2026" - aria-label="Add a tag" + placeholder={placeholder} + aria-label={label} /> - + {suggestions.map((s) => (
update(p.key, { note: e.target.value })} placeholder="Note (optional), like Paper towels" aria-label={`Note for part ${i + 1}`} /> + update(p.key, { tags })} placeholder="Tags for this part (optional)" label={`Tags for part ${i + 1}`} />
))}
- -

+

{left === 0 ? 'All assigned' : left > 0 ? `${formatCents(left)} left to assign` : `${formatCents(-left)} too much`}

@@ -276,6 +279,7 @@ export function TransactionSheet({ {byId.get(s.categoryId)?.name} {s.note ? · {s.note} : null} + {s.tags.length ? · {s.tags.join(', ')} : null} {formatCents(s.amountCents)} @@ -347,7 +351,7 @@ export function TransactionSheet({ )} {t.source === 'manual' ? ( - ) : null} diff --git a/web/src/components/UserMenu.tsx b/web/src/components/UserMenu.tsx index 707d93a..56e3351 100644 --- a/web/src/components/UserMenu.tsx +++ b/web/src/components/UserMenu.tsx @@ -1,9 +1,9 @@ import type { Theme } from '@spendlight/shared'; -import { LogOut, Monitor, Moon, Settings, Sun } from 'lucide-react'; +import { CircleHelp, LogOut, Monitor, Moon, Settings, Sun } from 'lucide-react'; import { useNavigate } from 'react-router'; import { useSetTheme } from '@/lib/useSetTheme'; import { useCurrentUser, useSignOut } from '@/lib/session'; -import { initials } from '@/lib/utils'; +import { Avatar } from './Avatar'; import { DropdownMenu, DropdownMenuContent, @@ -13,33 +13,20 @@ import { DropdownMenuTrigger, } from './ui/menu'; -export function Avatar({ name, className = 'size-9 text-[13px]' }: { name: string; className?: string }) { - return ( - - - {initials(name)} - - - ); -} - const THEMES: { value: Theme; label: string; icon: typeof Sun }[] = [ { value: 'system', label: 'Match my device', icon: Monitor }, { value: 'light', label: 'Light', icon: Sun }, { value: 'dark', label: 'Dark', icon: Moon }, ]; -export function UserMenu() { +export function UserMenu({ defaultOpen }: { defaultOpen?: boolean }) { const me = useCurrentUser(); const navigate = useNavigate(); const signOut = useSignOut(); const setTheme = useSetTheme(); return ( - + @@ -61,6 +48,10 @@ export function UserMenu() { Settings + navigate('/help')}> + + How Spendlight works + signOut.mutate()}> Sign out diff --git a/web/src/components/brand.tsx b/web/src/components/brand.tsx index 0b3bb2d..18abc63 100644 --- a/web/src/components/brand.tsx +++ b/web/src/components/brand.tsx @@ -40,7 +40,7 @@ export function Wordmark({ className }: { className?: string }) { export function VersionTag({ className, version = APP_VERSION }: { className?: string; version?: string }) { return ( v{version} diff --git a/web/src/components/meter.tsx b/web/src/components/meter.tsx index 1bfdabd..dfd7b94 100644 --- a/web/src/components/meter.tsx +++ b/web/src/components/meter.tsx @@ -8,9 +8,9 @@ import { cn } from '@/lib/utils'; export const STATUS_TONE: Record = { none: { bar: 'bg-primary/70', text: 'text-muted-foreground', soft: 'bg-muted' }, - under: { bar: 'bg-good', text: 'text-good', soft: 'bg-good/12' }, - close: { bar: 'bg-warn', text: 'text-warn', soft: 'bg-warn/14' }, - over: { bar: 'bg-bad', text: 'text-bad', soft: 'bg-bad/12' }, + under: { bar: 'bg-good', text: 'text-good-fg', soft: 'bg-good/12' }, + close: { bar: 'bg-warn', text: 'text-warn-fg', soft: 'bg-warn/14' }, + over: { bar: 'bg-bad', text: 'text-bad-fg', soft: 'bg-bad/12' }, }; export function ProgressBar({ spent, budget, className, pace, scale }: { spent: number; budget: number; className?: string; pace?: number; scale?: number }) { @@ -34,7 +34,7 @@ export function BudgetNote({ spent, budget, className, income }: { spent: number if (income) { const short = budget - spent; return ( - + {short <= 0 ? : null} {short <= 0 ? `${whole(-short)} above plan` : `${whole(short)} still to come`} diff --git a/web/src/components/money.tsx b/web/src/components/money.tsx index 20190d4..5a6187c 100644 --- a/web/src/components/money.tsx +++ b/web/src/components/money.tsx @@ -199,7 +199,7 @@ export function CategoryIcon({ /** An amount with money in shown in green and a plus sign; money out plain. */ export function Amount({ cents, className, transfer }: { cents: number; className?: string; transfer?: boolean }) { return ( - 0 && !transfer && 'text-good', transfer && 'text-muted-foreground', className)}> + 0 && !transfer && 'text-good-fg', transfer && 'text-muted-foreground', className)}> {formatCents(cents, { sign: cents > 0 })} ); diff --git a/web/src/components/nav.ts b/web/src/components/nav.ts index e2c61a8..4819b1e 100644 --- a/web/src/components/nav.ts +++ b/web/src/components/nav.ts @@ -1,4 +1,4 @@ -import { ChartColumnBig, House, Inbox, Landmark, ListOrdered, Repeat, Settings, Target, type LucideIcon } from 'lucide-react'; +import { ChartColumnBig, CircleHelp, House, Inbox, Landmark, ListOrdered, Repeat, Settings, Target, type LucideIcon } from 'lucide-react'; export interface NavItem { to: string; @@ -19,3 +19,4 @@ export const NAV: NavItem[] = [ ]; export const SETTINGS_NAV: NavItem = { to: '/settings', label: 'Settings', icon: Settings }; +export const HELP_NAV: NavItem = { to: '/help', label: 'How Spendlight works', icon: CircleHelp }; diff --git a/web/src/components/toast.tsx b/web/src/components/toast.tsx new file mode 100644 index 0000000..757267b --- /dev/null +++ b/web/src/components/toast.tsx @@ -0,0 +1,115 @@ +import { CircleAlert, CircleCheck, Info, X } from 'lucide-react'; +import { useEffect, useRef, useState, useSyncExternalStore, type ReactNode } from 'react'; +import { cn } from '@/lib/utils'; + +// Small notices at the top of the screen: "Saved", "Filed 6 more", "Undo". +// A few lines of our own instead of a library, to keep a phone's first load light. + +type Kind = 'default' | 'success' | 'error' | 'info'; + +interface Options { + id?: string | number; + description?: ReactNode; + /** Milliseconds on screen; Infinity keeps it until dismissed. */ + duration?: number; + action?: { label: string; onClick: () => void }; +} + +interface Item extends Options { + id: string | number; + kind: Kind; + message: ReactNode; + duration: number; +} + +let items: Item[] = []; +let seq = 0; +const listeners = new Set<() => void>(); +const emit = () => listeners.forEach((l) => l()); + +function show(kind: Kind, message: ReactNode, options: Options = {}) { + const id = options.id ?? ++seq; + const duration = options.duration ?? (kind === 'error' ? 7000 : options.action ? 8000 : 4000); + items = [{ ...options, id, kind, message, duration }, ...items.filter((t) => t.id !== id)].slice(0, 4); + emit(); + return id; +} + +function dismiss(id: string | number) { + items = items.filter((t) => t.id !== id); + emit(); +} + +export const toast = Object.assign((message: ReactNode, options?: Options) => show('default', message, options), { + success: (message: ReactNode, options?: Options) => show('success', message, options), + error: (message: ReactNode, options?: Options) => show('error', message, options), + info: (message: ReactNode, options?: Options) => show('info', message, options), + dismiss, +}); + +const ICON = { + success: , + error: , + info: , + default: null, +}; + +function Toast({ t }: { t: Item }) { + const [paused, setPaused] = useState(false); + const left = useRef(t.duration); + useEffect(() => { + if (paused || !Number.isFinite(left.current)) return; + const started = Date.now(); + const timer = setTimeout(() => dismiss(t.id), left.current); + return () => { + clearTimeout(timer); + left.current -= Date.now() - started; + }; + }, [paused, t.id, t]); + + return ( +
setPaused(true)} + onPointerLeave={() => setPaused(false)} + className="pointer-events-auto flex w-full max-w-md animate-fade-up items-start gap-3 rounded-2xl border bg-popover px-4 py-3 text-popover-foreground shadow-lift" + > + {ICON[t.kind] ? {ICON[t.kind]} : null} +
+

{t.message}

+ {t.description ?

{t.description}

: null} +
+ {t.action ? ( + + ) : null} + +
+ ); +} + +export function Toaster() { + const list = useSyncExternalStore( + (l) => { + listeners.add(l); + return () => listeners.delete(l); + }, + () => items, + ); + return ( +
+ {list.map((t) => ( + + ))} +
+ ); +} diff --git a/web/src/components/ui/button.tsx b/web/src/components/ui/button.tsx index c389ceb..bf51147 100644 --- a/web/src/components/ui/button.tsx +++ b/web/src/components/ui/button.tsx @@ -18,9 +18,10 @@ export const buttonVariants = cva( }, size: { default: 'h-11 px-4 sm:h-10', - sm: 'h-9 px-3 text-[13px]', + // 44 px on touch screens (sm:), a little tighter where a mouse does the pointing. + sm: 'h-11 px-3.5 text-[13px] sm:h-9 sm:px-3', lg: 'h-12 px-6 text-base', - icon: 'size-10', + icon: 'size-11 sm:size-10', }, }, defaultVariants: { variant: 'default', size: 'default' }, diff --git a/web/src/components/ui/dialog.tsx b/web/src/components/ui/dialog.tsx index 0fddd15..68a6926 100644 --- a/web/src/components/ui/dialog.tsx +++ b/web/src/components/ui/dialog.tsx @@ -1,6 +1,6 @@ import { X } from 'lucide-react'; import { AlertDialog as AlertPrimitive, Dialog as DialogPrimitive } from 'radix-ui'; -import type { ComponentProps, ReactNode } from 'react'; +import { useState, type ComponentProps, type ReactNode } from 'react'; import { cn } from '@/lib/utils'; import { Button } from './button'; @@ -22,12 +22,26 @@ export function DialogContent({ children, title, description, + onCloseAutoFocus, ...props }: ComponentProps & { title: string; description?: ReactNode }) { + // Dialogs opened from a plain button (not a Dialog trigger) hand focus back + // to that button when they close, so keyboard users keep their place. + const [returnTo] = useState(() => (typeof document === 'undefined' ? null : document.activeElement)); return ( - + { + onCloseAutoFocus?.(e); + if (!e.defaultPrevented && returnTo instanceof HTMLElement && returnTo.isConnected && returnTo !== document.body) { + e.preventDefault(); + returnTo.focus(); + } + }} + {...props} + >
diff --git a/web/src/components/ui/menu.tsx b/web/src/components/ui/menu.tsx index f979110..6032c26 100644 --- a/web/src/components/ui/menu.tsx +++ b/web/src/components/ui/menu.tsx @@ -25,7 +25,7 @@ export function DropdownMenuItem({ className, destructive, ...props }: Component - {children} - - - {label} - - - - ); -} - export function Skeleton({ className, ...props }: ComponentProps<'div'>) { return
; } diff --git a/web/src/lib/useSetTheme.ts b/web/src/lib/useSetTheme.ts index 00725a2..3b73144 100644 --- a/web/src/lib/useSetTheme.ts +++ b/web/src/lib/useSetTheme.ts @@ -1,6 +1,6 @@ import type { Me, Theme } from '@spendlight/shared'; import { useQueryClient } from '@tanstack/react-query'; -import { toast } from 'sonner'; +import { toast } from '@/components/toast'; import { api, errorMessage } from './api'; import { applyTheme } from './theme'; diff --git a/web/src/main.tsx b/web/src/main.tsx index c7e645a..b22ad7e 100644 --- a/web/src/main.tsx +++ b/web/src/main.tsx @@ -1,12 +1,10 @@ -import '@fontsource-variable/inter'; import './styles.css'; import { QueryClientProvider } from '@tanstack/react-query'; -import { Tooltip } from 'radix-ui'; import { StrictMode, Suspense } from 'react'; import { createRoot } from 'react-dom/client'; import { RouterProvider } from 'react-router/dom'; -import { Toaster } from 'sonner'; import { Splash } from './components/RequireAuth'; +import { Toaster } from './components/toast'; import { queryClient } from './lib/session'; import { watchSystemTheme } from './lib/theme'; import { router } from './router'; @@ -16,12 +14,10 @@ watchSystemTheme(); createRoot(document.getElementById('root')!).render( - - }> - - - - + }> + + + , ); diff --git a/web/src/pages/AccountsPage.tsx b/web/src/pages/AccountsPage.tsx index 68adfdf..9dd264b 100644 --- a/web/src/pages/AccountsPage.tsx +++ b/web/src/pages/AccountsPage.tsx @@ -3,7 +3,7 @@ import { useMutation, useQueryClient } from '@tanstack/react-query'; import { Archive, ArchiveRestore, ChevronRight, CreditCard, Landmark, ListOrdered, Pencil, Plus, Scale, Trash2, Upload, Wallet } from 'lucide-react'; import { useState, type FormEvent } from 'react'; import { Link } from 'react-router'; -import { toast } from 'sonner'; +import { toast } from '@/components/toast'; import { AccountDot, PALETTE_CLASS } from '@/components/money'; import { EmptyState, PageIntro } from '@/components/page'; import { Button } from '@/components/ui/button'; @@ -427,7 +427,7 @@ export default function AccountsPage() { )} {selected.transactionCount === 0 ? ( - ) : null} diff --git a/web/src/pages/ActivityPage.tsx b/web/src/pages/ActivityPage.tsx index 8d50816..621e0d1 100644 --- a/web/src/pages/ActivityPage.tsx +++ b/web/src/pages/ActivityPage.tsx @@ -3,7 +3,7 @@ import { keepPreviousData, useMutation, useQuery } from '@tanstack/react-query'; import { ArrowLeftRight, Download, Plus, Search, SlidersHorizontal, Split as SplitIcon, Tag, X } from 'lucide-react'; import { useMemo, useState, type FormEvent } from 'react'; import { useSearchParams } from 'react-router'; -import { toast } from 'sonner'; +import { toast } from '@/components/toast'; import { CategoryButton, CategoryPicker } from '@/components/CategoryPicker'; import { AccountDot, Amount, CategoryIcon, formatDay } from '@/components/money'; import { EmptyState, PageIntro } from '@/components/page'; @@ -234,7 +234,7 @@ export default function ActivityPage() { <> - @@ -281,7 +281,7 @@ export default function ActivityPage() { type="search" /> - @@ -359,7 +359,7 @@ export default function ActivityPage() { {list.data.total} transaction{list.data.total === 1 ? '' : 's'} - In {formatCents(list.data.sums.inCents)} · Out{' '} + In {formatCents(list.data.sums.inCents)} · Out{' '} {formatCents(-list.data.sums.outCents)} · transfers not counted diff --git a/web/src/pages/BudgetPage.tsx b/web/src/pages/BudgetPage.tsx index 2a934a7..f3a2134 100644 --- a/web/src/pages/BudgetPage.tsx +++ b/web/src/pages/BudgetPage.tsx @@ -3,7 +3,7 @@ import { useMutation, useQueryClient } from '@tanstack/react-query'; import { CalendarRange, ChevronLeft, ChevronRight, Copy, Inbox, LayoutList, Sparkles, Table2, Wand2 } from 'lucide-react'; import { useEffect, useMemo, useState } from 'react'; import { Link, useSearchParams } from 'react-router'; -import { toast } from 'sonner'; +import { toast } from '@/components/toast'; import { BudgetEditor, type EditTarget } from '@/components/BudgetEditor'; import { BudgetNote, ProgressBar, STATUS_TONE } from '@/components/meter'; import { CategoryIcon } from '@/components/money'; @@ -227,14 +227,14 @@ function YearGrid({ year, sections, canEdit, onEdit, onMonth, through }: { {r.budget[i] ? whole(r.budget[i]!) : '–'} {own ? ● : null} - {past && r.actual[i] ? {whole(r.actual[i]!)} : null} + {past && r.actual[i] ? {whole(r.actual[i]!)} : null} ); })}
{yearBudget ? whole(yearBudget) : '–'}
- {yearActual ?
yearBudget ? 'font-medium text-bad' : 'text-muted-foreground')}>{whole(yearActual)}
: null} + {yearActual ?
yearBudget ? 'font-medium text-bad-fg' : 'text-muted-foreground')}>{whole(yearActual)}
: null} ); diff --git a/web/src/pages/HelpPage.tsx b/web/src/pages/HelpPage.tsx new file mode 100644 index 0000000..cc5a868 --- /dev/null +++ b/web/src/pages/HelpPage.tsx @@ -0,0 +1,174 @@ +import { ArrowRight, Download, Inbox, Upload } from 'lucide-react'; +import type { ReactNode } from 'react'; +import { Link } from 'react-router'; +import { PageIntro } from '@/components/page'; +import { Card } from '@/components/ui/card'; + +// A short guide inside the app: the monthly routine first, then a few +// sections to open when they're needed. Plain words, no accounting terms. + +function Step({ n, icon, title, children }: { n: number; icon: ReactNode; title: string; children: ReactNode }) { + return ( +
  • + {icon} +
    +

    + {n}. + {title} +

    +
    {children}
    +
    +
  • + ); +} + +function Section({ title, children, open }: { title: string; children: ReactNode; open?: boolean }) { + return ( +
    + + {title} + + +
    {children}
    +
    + ); +} + +const Key = ({ children }: { children: ReactNode }) => {children}; + +export default function HelpPage() { + return ( +
    + + + +

    The monthly routine

    +
      + } title="Download your statements"> + On each bank’s website, open the account’s activity and download it as a CSV file (sometimes + called “spreadsheet” or “Excel”). Include everything since your last import; overlapping dates are fine. + + } title="Drop them into Spendlight"> + Drag all the files onto any screen, or tap{' '} + + Import + {' '} + on a phone. Spendlight works out which account each file is for, skips anything already there, and matches card payments with your checking account. + + } title="Confirm what came in"> + In{' '} + + Review + + , most transactions already have a category. One tap confirms it; “Accept all” clears the lot. Then glance at Home to see where the month stands. + +
    +
    + +
    +
    +

    Banks move their buttons around from time to time, but the idea is always the same: find the account’s activity, then Download or Export, then CSV.

    +
      +
    • + Bank of America: open the checking or savings account, choose Download above the list of transactions, and pick the Microsoft + Excel format. The file is a CSV. +
    • +
    • + Chase, including the Amazon Prime Visa: open the card’s activity, choose the download icon, then CSV and the dates you want. +
    • +
    • + Discover: open Activity, choose Download, and pick the CSV or spreadsheet option. +
    • +
    • + Apple Card: in the Wallet app, open the card’s statements, choose a month, then Export Transactions as CSV. +
    • +
    • + Any other bank: Spendlight reads most CSV files. The first time it sees a new layout, it shows a few rows and asks which column is + which, then remembers. Quicken and Money files (OFX or QFX) work too. +
    • +
    +
    + +
    +
      +
    • The big button is Spendlight’s best guess. The smaller ones are the next most likely; “Something else” shows every category.
    • +
    • + After you file a purchase, Spendlight may offer to file the others from that shop the same way. Say yes and it remembers for future + imports too. Learned rules are listed in Settings → Rules. +
    • +
    • + A split divides one purchase between categories, such as groceries and household supplies from the same store. Tap a transaction in + Activity, then Split. +
    • +
    • + Tags (“Vacation 2026”, “Reimbursable”) and notes go on any transaction. Insights totals each tag. +
    • +
    • + Cash spending: in Activity, tap Cash entry. +
    • +
    • + On a computer, Review works from the keyboard: ↑ ↓ to move, Enter to accept the guess, 1–3 for a + suggestion, / for every category and S to split. +
    • +
    +
    + +
    +

    + Paying a card from checking, or moving money to savings, isn’t spending or income: the money just moves between your own accounts. Spendlight pairs the two + halves and shows them in italics, naming the other account, and leaves them out of every total. +

    +

    When only one half is in Spendlight, such as a payment to a card you don’t import, Review asks once whether it’s a transfer or spending.

    +
    + +
    +
      +
    • Each category gets one amount for every month. Any single month can be different, such as Gifts in December.
    • +
    • + Fill in on the Budget screen starts every category from your recent spending, rounded to friendly amounts. Adjust from there. +
    • +
    • + There’s no rollover: each month starts fresh. The year view keeps the running score, so a quiet month makes up for a busy one. +
    • +
    • + Green means on track, amber means close, and red means over. Each always comes with a word or an icon too. +
    • +
    • In December, the Budget screen offers to carry this year’s budgets into the next.
    • +
    +
    + +
    +
      +
    • + Spendlight spots charges that repeat on a steady rhythm, on any card or account, and asks once: “Is this a subscription?” Your answer is remembered. +
    • +
    • Bills that vary, like electric or insurance, have their own tab.
    • +
    • + It speaks up when a price changes, a charge doesn’t arrive, something is billed on two cards, a free trial is about to end, or a service you marked + cancelled charges again. +
    • +
    • Spendlight never cancels anything itself. Each subscription has a link to the place where you can manage or cancel it.
    • +
    +
    + +
    +
      +
    • + Add Spendlight to your home screen and it opens like an app. On iPhone: in Safari, tap Share, then “Add to Home Screen”. Sign in first, then add it. +
    • +
    • Turn on Face ID or fingerprint sign-in in Settings → Sign-in & devices, so a lapsed sign-in costs one glance, not a password.
    • +
    • Each device stays signed in for about a year, renewed every time you open the app.
    • +
    +
    + +
    +
      +
    • Everything stays on your own server. Spendlight makes no outside calls and never stores bank sign-ins.
    • +
    • A backup is made every night, and before every upgrade. An admin can restore any of them from Settings → Backups.
    • +
    • Export everything to Excel or CSV any time from Settings → Export.
    • +
    +
    +
    +
    + ); +} diff --git a/web/src/pages/HomePage.tsx b/web/src/pages/HomePage.tsx index a5bf87b..28c8a37 100644 --- a/web/src/pages/HomePage.tsx +++ b/web/src/pages/HomePage.tsx @@ -24,7 +24,7 @@ function Step({ done, icon, title, detail, action }: { done?: boolean; icon: Rea {done ? : icon} @@ -56,7 +56,12 @@ function GettingStarted({ hasAccounts, hasMoney, toReview, hasBudgets, onHide }:
    Getting started - A few steps now, and Spendlight does the bookkeeping from then on. + + A few steps now, and Spendlight does the bookkeeping from then on.{' '} + + How it works + +
    {onHide ? ( @@ -333,7 +333,7 @@ export default function ImportPage() { { diff --git a/web/src/pages/InsightsPage.tsx b/web/src/pages/InsightsPage.tsx index 3dd5350..9bde0bf 100644 --- a/web/src/pages/InsightsPage.tsx +++ b/web/src/pages/InsightsPage.tsx @@ -52,7 +52,7 @@ function Headline({ children, tone }: { children: ReactNode; tone: 'good' | 'war const Icon = tone === 'good' ? TrendingDown : tone === 'warn' ? TrendingUp : Sparkles; return (
    - +

    {children}

    @@ -174,7 +174,7 @@ function Overview({ report, year }: { report: MonthsReport; year: number }) {

    - What stands out + What stands out

    {alerts.data?.length ? (
    @@ -260,7 +260,7 @@ function BudgetVsActual({ report, year }: { report: MonthsReport; year: number }
    - {pct !== null && budgetStatus(spent, budget) === 'over' ? +{pct}% : null} + {pct !== null && budgetStatus(spent, budget) === 'over' ? +{pct}% : null}
    @@ -370,7 +370,7 @@ function Categories({ report, year }: { report: MonthsReport; year: number }) { {r.actual.map((v, i) => { const over = r.budget[i]! > 0 && v > r.budget[i]!; return ( - + {v ? whole(v) : '–'} ); diff --git a/web/src/pages/MorePage.tsx b/web/src/pages/MorePage.tsx index 5b10799..a7e12a8 100644 --- a/web/src/pages/MorePage.tsx +++ b/web/src/pages/MorePage.tsx @@ -1,7 +1,7 @@ import { ChevronRight, LogOut } from 'lucide-react'; import { Link } from 'react-router'; import { Wordmark, VersionTag } from '@/components/brand'; -import { NAV, SETTINGS_NAV } from '@/components/nav'; +import { HELP_NAV, NAV, SETTINGS_NAV } from '@/components/nav'; import { useAlertCounts } from '@/lib/data'; import { useConfig, useSignOut } from '@/lib/session'; @@ -10,7 +10,7 @@ export default function MorePage() { const signOut = useSignOut(); const { data: config } = useConfig(); const { data: alerts } = useAlertCounts(); - const items = [...NAV.filter((n) => !n.phone), SETTINGS_NAV]; + const items = [...NAV.filter((n) => !n.phone), SETTINGS_NAV, HELP_NAV]; return (
    @@ -30,7 +30,7 @@ export default function MorePage() { diff --git a/web/src/pages/ReviewPage.tsx b/web/src/pages/ReviewPage.tsx index 1c7dbbf..6d3b63c 100644 --- a/web/src/pages/ReviewPage.tsx +++ b/web/src/pages/ReviewPage.tsx @@ -3,7 +3,7 @@ import { useQuery } from '@tanstack/react-query'; import { ArrowLeftRight, Check, CheckCheck, ChevronRight, Inbox, PartyPopper, Search, SkipForward, Split as SplitIcon, Upload } from 'lucide-react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { Link } from 'react-router'; -import { toast } from 'sonner'; +import { toast } from '@/components/toast'; import { CategoryPicker } from '@/components/CategoryPicker'; import { AccountDot, Amount, CategoryIcon, formatDay } from '@/components/money'; import { EmptyState } from '@/components/page'; diff --git a/web/src/pages/SetupPage.tsx b/web/src/pages/SetupPage.tsx index 51a3a49..b72bc6c 100644 --- a/web/src/pages/SetupPage.tsx +++ b/web/src/pages/SetupPage.tsx @@ -2,7 +2,7 @@ import type { Me } from '@spendlight/shared'; import { useQueryClient } from '@tanstack/react-query'; import { useState, type FormEvent } from 'react'; import { Navigate, useNavigate } from 'react-router'; -import { toast } from 'sonner'; +import { toast } from '@/components/toast'; import { AuthLayout, FormError } from '@/components/AuthLayout'; import { Splash } from '@/components/RequireAuth'; import { Button } from '@/components/ui/button'; diff --git a/web/src/pages/SubscriptionsPage.tsx b/web/src/pages/SubscriptionsPage.tsx index 4deafdf..e3acfec 100644 --- a/web/src/pages/SubscriptionsPage.tsx +++ b/web/src/pages/SubscriptionsPage.tsx @@ -14,7 +14,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { Bell, BellOff, CalendarClock, Check, ExternalLink, Merge, Plus, Repeat, Trash2, X } from 'lucide-react'; import { useEffect, useMemo, useState, type FormEvent } from 'react'; import { Link, useSearchParams } from 'react-router'; -import { toast } from 'sonner'; +import { toast } from '@/components/toast'; import { AlertCard } from '@/components/AlertCard'; import { CategoryButton } from '@/components/CategoryPicker'; import { AccountDot, CategoryIcon, formatDay } from '@/components/money'; @@ -108,12 +108,15 @@ function Row({ s, onOpen }: { s: Subscription; onOpen: () => void }) { const category = s.categoryId ? byId.get(s.categoryId) : undefined; const live = LIVE.includes(s.status); return ( - {s.source === 'manual' ? ( - ) : ( diff --git a/web/src/pages/settings/AboutPage.tsx b/web/src/pages/settings/AboutPage.tsx index de0cb31..b9b1ec3 100644 --- a/web/src/pages/settings/AboutPage.tsx +++ b/web/src/pages/settings/AboutPage.tsx @@ -29,14 +29,14 @@ export default function AboutPage() { ) : (
    Development build
    )} - {version !== APP_VERSION ?
    These screens are {APP_VERSION}; reload to update.
    : null} + {version !== APP_VERSION ?
    These screens are {APP_VERSION}; reload to update.
    : null}

    - + Your data stays on your own server. Spendlight makes no calls to outside services.

    diff --git a/web/src/pages/settings/AiPage.tsx b/web/src/pages/settings/AiPage.tsx index 821f727..8c90bbe 100644 --- a/web/src/pages/settings/AiPage.tsx +++ b/web/src/pages/settings/AiPage.tsx @@ -1,7 +1,7 @@ import type { AiStatus } from '@spendlight/shared'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { ShieldCheck, Sparkles } from 'lucide-react'; -import { toast } from 'sonner'; +import { toast } from '@/components/toast'; import { PageIntro } from '@/components/page'; import { Card } from '@/components/ui/card'; import { Badge, Skeleton, Switch } from '@/components/ui/misc'; @@ -24,7 +24,7 @@ export default function AiPage() { ) : ( <>

    - +

    {s.providerConfigured ? ( <> diff --git a/web/src/pages/settings/AlertsPage.tsx b/web/src/pages/settings/AlertsPage.tsx index b41a960..475cd4a 100644 --- a/web/src/pages/settings/AlertsPage.tsx +++ b/web/src/pages/settings/AlertsPage.tsx @@ -1,7 +1,7 @@ import type { AlertSettings } from '@spendlight/shared'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useEffect, useState, type ChangeEvent, type FormEvent } from 'react'; -import { toast } from 'sonner'; +import { toast } from '@/components/toast'; import { PageIntro } from '@/components/page'; import { Button } from '@/components/ui/button'; import { Card, CardContent, CardFooter } from '@/components/ui/card'; diff --git a/web/src/pages/settings/BackupsPage.tsx b/web/src/pages/settings/BackupsPage.tsx index c8585c3..0ac8e03 100644 --- a/web/src/pages/settings/BackupsPage.tsx +++ b/web/src/pages/settings/BackupsPage.tsx @@ -1,12 +1,14 @@ import type { BackupInfo, BackupKind, BackupOverview } from '@spendlight/shared'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; -import { Archive, Download, FolderSync } from 'lucide-react'; -import { toast } from 'sonner'; +import { Archive, Download, FolderSync, History, Upload } from 'lucide-react'; +import { useRef, useState } from 'react'; +import { toast } from '@/components/toast'; import { PageIntro } from '@/components/page'; import { Button } from '@/components/ui/button'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { ConfirmDialog } from '@/components/ui/dialog'; import { Badge, Skeleton } from '@/components/ui/misc'; -import { api, errorMessage } from '@/lib/api'; +import { api, ApiError, errorMessage } from '@/lib/api'; import { formatBytes, formatDateTime } from '@/lib/utils'; const KIND: Record = { @@ -16,14 +18,36 @@ const KIND: Record `${n.toLocaleString()} ${word}${n === 1 ? '' : 's'}`; + +/** After a restore everything on screen is stale, and the session may belong to the old data. */ +function afterRestore(safetyCopy: string | null) { + toast.success('Restored', { description: safetyCopy ? 'How things were before is saved as a “Before restore” backup.' : undefined }); + setTimeout(() => window.location.assign('/'), 1200); +} + export default function BackupsPage() { const client = useQueryClient(); const overview = useQuery({ queryKey: ['backups'], queryFn: () => api('/backups') }); + const [restoring, setRestoring] = useState(null); + const [pendingFile, setPendingFile] = useState(null); + const fileInput = useRef(null); + const check = useQuery({ + queryKey: ['backups', 'check', restoring?.name], + queryFn: () => api(`/backups/${encodeURIComponent(restoring!.name)}/check`), + enabled: !!restoring, + retry: false, + }); const backUp = useMutation({ mutationFn: () => api('/backups', { method: 'POST' }), onSuccess: () => { @@ -32,6 +56,34 @@ export default function BackupsPage() { }, onError: (error) => toast.error(errorMessage(error)), }); + const restore = useMutation({ + mutationFn: (name: string) => api<{ safetyCopy: string | null }>(`/backups/${encodeURIComponent(name)}/restore`, { method: 'POST' }), + onSuccess: (r) => { + setRestoring(null); + afterRestore(r.safetyCopy); + }, + onError: (error) => toast.error(errorMessage(error)), + }); + const upload = useMutation({ + mutationFn: async (file: File) => { + const form = new FormData(); + form.append('file', file); + const res = await fetch('/api/v1/backups/restore-upload', { method: 'POST', body: form, credentials: 'same-origin' }); + const body = await res.json().catch(() => null); + if (!res.ok) throw new ApiError(res.status, body?.error?.code ?? 'error', body?.error?.message ?? 'That backup couldn’t be restored.'); + return body as { safetyCopy: string | null }; + }, + onSuccess: (r) => { + setPendingFile(null); + afterRestore(r.safetyCopy); + }, + onError: (error) => { + setPendingFile(null); + toast.error(errorMessage(error)); + }, + }); + + const counts = check.data?.counts; return (

    @@ -39,11 +91,27 @@ export default function BackupsPage() { title="Backups" description="Spendlight copies everything to the backup folder every night. Point your backup tool at that folder to keep a copy off this server." actions={ - + <> + + + } /> + { + const file = e.target.files?.[0]; + e.target.value = ''; + if (file) setPendingFile(file); + }} + /> {overview.data ? ( @@ -52,8 +120,8 @@ export default function BackupsPage() { Every night at {time12(overview.data.nightlyAt)} - Keeps the last {overview.data.keepDaily} nights and one copy from each of the last {overview.data.keepMonthly} months. Copies are - also taken before every upgrade. + Keeps the last {overview.data.keepDaily} nights and one copy from each of the last {overview.data.keepMonthly} months. Copies are also taken before every + upgrade and every restore, so a restore can be undone. @@ -67,12 +135,16 @@ export default function BackupsPage() { ) : null}
      {overview.data?.backups.map((b) => ( -
    • +
    • {formatDateTime(b.createdAt)}

      -

      {formatBytes(b.size)}

      +

      + {formatBytes(b.size)} {KIND[b.kind].label} +

      - {KIND[b.kind].label} +
    + + !open && setRestoring(null)} + title={`Restore the backup from ${restoring ? formatDateTime(restoring.createdAt) : ''}?`} + description={ + check.isError ? ( + errorMessage(check.error) + ) : counts ? ( + <> + Everything in Spendlight goes back to how it was then: {plural(counts.transactions, 'transaction')}, {plural(counts.accounts, 'account')},{' '} + {plural(counts.budgets, 'budget')} and {plural(counts.subscriptions, 'subscription')}. A copy of how things are right now is saved first, so you can + undo this. You stay signed in; anyone else may need to sign in again, with the password they had then. + + ) : ( + 'Checking the backup…' + ) + } + confirmLabel="Restore" + destructive + loading={restore.isPending} + onConfirm={() => restoring && counts && restore.mutate(restoring.name)} + /> + !open && !upload.isPending && setPendingFile(null)} + title="Restore from this file?" + description={`Everything in Spendlight is replaced with “${pendingFile?.name}”, after Spendlight checks it’s a valid backup. A copy of how things are right now is saved first, so you can undo this.`} + confirmLabel="Restore" + destructive + loading={upload.isPending} + onConfirm={() => pendingFile && upload.mutate(pendingFile)} + />
    ); } diff --git a/web/src/pages/settings/CategoriesPage.tsx b/web/src/pages/settings/CategoriesPage.tsx index 0f90c50..d4b1ab8 100644 --- a/web/src/pages/settings/CategoriesPage.tsx +++ b/web/src/pages/settings/CategoriesPage.tsx @@ -2,7 +2,7 @@ import { PALETTE, type Category, type CategoryGroup, type CategoryKind, type Pal import { useMutation, useQueryClient } from '@tanstack/react-query'; import { Archive, ArrowDown, ArrowUp, FolderPlus, Merge, Pencil, Plus, Trash2 } from 'lucide-react'; import { useState, type FormEvent } from 'react'; -import { toast } from 'sonner'; +import { toast } from '@/components/toast'; import { CategoryIcon, ICON_NAMES, PALETTE_CLASS } from '@/components/money'; import { PageIntro } from '@/components/page'; import { Button } from '@/components/ui/button'; @@ -149,7 +149,7 @@ function CategoryForm({ category, group, groups, onDone }: { category?: Category ) : null}
    {category ? ( - ) : ( @@ -251,7 +251,7 @@ export default function CategoriesPage() { {!g.categories.length ? ( - ) : null} @@ -262,9 +262,9 @@ export default function CategoriesPage() { {visible.map((c) => { const ci = g.categories.indexOf(c); return ( -
    +