Milestones 0 and 1: foundation and money in (20261007.10)

Foundation: single arm64 container (Hono + SQLite via Drizzle, React + Vite),
local sign-in with long-lived sessions and passkeys, roles (read-only, full
access, admin), nightly and pre-upgrade backups, light and dark themes,
date-based version numbers, and the ideas lightbulb behind IDEAS_ENABLED.

Money in: accounts with balances, CSV import with bank detection, a column
mapping pop-up for unknown layouts, duplicate checks, transfer pairing and
undo; one-tap review with learned rules; activity with search, splits, tags,
notes and cash entries. Statement files are blocked from commits by a
pre-commit hook; tests use synthetic fixtures only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-07 19:52:07 -04:00
commit c3cf0f3eb8
147 changed files with 21713 additions and 0 deletions

18
scripts/build.sh Executable file
View File

@@ -0,0 +1,18 @@
#!/usr/bin/env bash
# Build the Spendlight container image and stamp it with the next version.
# The VERSION file is only updated once the build succeeds.
set -euo pipefail
cd "$(dirname "$0")/.."
version="$(scripts/next-version.sh)"
echo "Building Spendlight ${version}"
docker build \
--build-arg APP_VERSION="${version}" \
--label org.opencontainers.image.version="${version}" \
-t "spendlight:${version}" \
-t spendlight:latest \
.
echo "${version}" > VERSION
echo "Built spendlight:${version}"

18
scripts/deploy.sh Executable file
View File

@@ -0,0 +1,18 @@
#!/usr/bin/env bash
# Build a new version and (re)start Spendlight with it.
set -euo pipefail
cd "$(dirname "$0")/.."
if [ ! -f .env ]; then
cp .env.example .env
echo "Created .env from .env.example; review it, then run this again if needed."
fi
# Read the folder settings so the bind mounts exist and belong to this user
# (otherwise Docker creates them as root and the app can't write).
set -a; . ./.env; set +a
mkdir -p "${DATA_PATH:-./data}" "${BACKUP_PATH:-./backups}"
scripts/build.sh
docker compose up -d
echo "Spendlight $(cat VERSION) is starting on port ${HOST_PORT:-8095}."

24
scripts/dev.sh Executable file
View File

@@ -0,0 +1,24 @@
#!/usr/bin/env bash
# Run a command with the project's Node 24 toolchain, inside the official
# Node image, so the host needs nothing but Docker.
#
# scripts/dev.sh npm install
# scripts/dev.sh npm test
# scripts/dev.sh npm run dev:server
set -euo pipefail
cd "$(dirname "$0")/.."
tty_flag=()
if [ -t 0 ] && [ -t 1 ]; then tty_flag=(-t); fi
exec docker run --rm -i "${tty_flag[@]}" \
--user "$(id -u):$(id -g)" \
--network host \
-e HOME=/tmp \
-e npm_config_cache=/app/.npm-cache \
-e npm_config_update_notifier=false \
-e npm_config_fund=false \
-e npm_config_audit=false \
-e TZ="${TZ:-America/New_York}" \
-v "$PWD":/app -w /app \
node:24-bookworm-slim "$@"

19
scripts/next-version.sh Executable file
View File

@@ -0,0 +1,19 @@
#!/usr/bin/env bash
# Print the next build version, YYYYMMDD.NN: today's date in the household's
# time zone, then a counter that starts at .01 each day.
#
# Reads the last version from the VERSION file (or $1). Set TODAY=YYYYMMDD to
# pretend it's another day, which the tests use.
set -euo pipefail
cd "$(dirname "$0")/.."
previous="${1:-$(cat VERSION 2>/dev/null || true)}"
today="${TODAY:-$(TZ="${TZ:-America/New_York}" date +%Y%m%d)}"
if [[ "$previous" =~ ^${today}\.([0-9]+)$ ]]; then
counter=$((10#${BASH_REMATCH[1]} + 1))
else
counter=1
fi
printf '%s.%02d\n' "$today" "$counter"

155
scripts/profile-samples.mjs Normal file
View File

@@ -0,0 +1,155 @@
#!/usr/bin/env node
// Describe statement files without revealing their contents.
//
// node scripts/profile-samples.mjs [folder] (default: samples)
//
// Prints, for each file: its layout, header labels, and value *shapes* only.
// Every letter becomes A/a and every digit 9, so "KROGER #123" reads
// "AAAAAA #999" and "-54.20" reads "-99.99". Rows made only of labels (no
// digits at all, such as a CSV header) are shown as they are. This is what
// Claude reads to build importers; the raw files never leave this server.
import { readdirSync, readFileSync, statSync } from 'node:fs';
import { basename, extname, join, relative } from 'node:path';
const root = process.argv[2] ?? 'samples';
const SKIP = new Set(['README.md', '.DS_Store']);
const mask = (s) => s.replace(/[A-Z]/g, 'A').replace(/[a-z]/g, 'a').replace(/[0-9]/g, '9').replace(/[^\x20-\x7e]/g, '?');
const shape = (s) =>
s.trim() === ''
? '(empty)'
: s.trim().replace(/[0-9]+/g, '9').replace(/[A-Z]+/g, 'A').replace(/[a-z]+/g, 'a').replace(/[^\x20-\x7e]/g, '?');
const maskName = (name) => name.replace(/[0-9]{3,}/g, (d) => '9'.repeat(d.length));
function files(dir) {
return readdirSync(dir).flatMap((name) => {
if (SKIP.has(name) || name.startsWith('_') || name.startsWith('.')) return [];
const path = join(dir, name);
return statSync(path).isDirectory() ? files(path) : [path];
});
}
/** A small CSV reader that understands quoted fields and embedded commas or newlines. */
function parseCsv(text, delimiter) {
const rows = [];
let row = [];
let field = '';
let quoted = false;
for (let i = 0; i < text.length; i++) {
const ch = text[i];
if (quoted) {
if (ch === '"' && text[i + 1] === '"') {
field += '"';
i++;
} else if (ch === '"') quoted = false;
else field += ch;
} else if (ch === '"') quoted = true;
else if (ch === delimiter) {
row.push(field);
field = '';
} else if (ch === '\n' || ch === '\r') {
if (ch === '\r' && text[i + 1] === '\n') i++;
row.push(field);
rows.push(row);
row = [];
field = '';
} else field += ch;
}
if (field !== '' || row.length) {
row.push(field);
rows.push(row);
}
return rows;
}
const isLabelRow = (cells) => cells.filter((c) => c.trim()).length >= 2 && cells.every((c) => !/[0-9]/.test(c));
const showRow = (cells) => (isLabelRow(cells) ? cells.map((c) => c.trim()) : cells.map(mask));
const numeric = (s) => /^\s*[-+(]?\s*\$?\s*[0-9][0-9,]*(\.[0-9]+)?\s*\)?\s*(CR|DR)?\s*$/i.test(s);
function profileCsv(text) {
const firstLines = text.split(/\r?\n/).slice(0, 20).join('\n');
const counts = { ',': 0, '\t': 0, ';': 0, '|': 0 };
for (const ch of firstLines) if (ch in counts) counts[ch]++;
const delimiter = Object.entries(counts).sort((a, b) => b[1] - a[1])[0][0];
const rows = parseCsv(text, delimiter).filter((r) => r.some((c) => c.trim() !== ''));
const out = [];
out.push(`- Delimiter: ${JSON.stringify(delimiter)} · rows (non-blank): ${rows.length}`);
const widths = {};
for (const r of rows) widths[r.length] = (widths[r.length] ?? 0) + 1;
out.push(`- Columns per row: ${Object.entries(widths).map(([w, n]) => `${w} cols × ${n}`).join(', ')}`);
const headerIndex = rows.findIndex((r, i) => isLabelRow(r) && rows.slice(i + 1, i + 4).some((n) => n.length === r.length));
out.push(`- Header row: ${headerIndex >= 0 ? `row ${headerIndex + 1}` : 'none found'}`);
out.push('', '```text');
rows.slice(0, 12).forEach((r, i) => out.push(`${String(i + 1).padStart(3)}: ${showRow(r).join(' | ')}`));
if (rows.length > 15) out.push(' …');
rows.slice(Math.max(12, rows.length - 3)).forEach((r, i, arr) => out.push(`${String(rows.length - arr.length + i + 1).padStart(3)}: ${showRow(r).join(' | ')}`));
out.push('```');
if (headerIndex >= 0) {
const header = rows[headerIndex];
const data = rows.slice(headerIndex + 1).filter((r) => r.length === header.length);
out.push('', `Columns (${data.length} data rows):`, '');
header.forEach((name, col) => {
const values = data.map((r) => r[col] ?? '');
const shapes = {};
for (const v of values) shapes[shape(v)] = (shapes[shape(v)] ?? 0) + 1;
const top = Object.entries(shapes)
.sort((a, b) => b[1] - a[1])
.slice(0, 4)
.map(([s, n]) => `\`${s}\` ×${n}`)
.join(', ');
let extra = '';
const nums = values.filter((v) => numeric(v));
if (nums.length >= values.length * 0.6 && nums.length > 0) {
const neg = nums.filter((v) => /^\s*-|\(|DR/i.test(v)).length;
extra = ` · numeric: ${nums.length - neg} positive, ${neg} negative`;
} else {
const distinct = new Set(values.map((v) => v.trim()).filter(Boolean)).size;
extra = ` · ${distinct} distinct values`;
if (/^\d{1,2}\/\d{1,2}\/\d{2,4}$/.test(values.find((v) => v.trim()) ?? '')) {
const firsts = values.map((v) => Number(v.split('/')[0])).filter(Number.isFinite);
const seconds = values.map((v) => Number(v.split('/')[1])).filter(Number.isFinite);
extra += ` · first part max ${Math.max(...firsts)}, second part max ${Math.max(...seconds)}`;
}
}
out.push(`- **${name.trim() || `(column ${col + 1})`}**: ${top}${extra}`);
});
}
return out;
}
function profileOfx(text) {
const out = [];
const headerLines = text.split(/\r?\n/).filter((l) => /^[A-Z]+:[A-Z0-9]+$/.test(l.trim()));
if (headerLines.length) out.push(`- OFX header: ${headerLines.join(' ')}`);
const tags = {};
const examples = {};
for (const m of text.matchAll(/<([A-Z0-9.]+)>([^<\r\n]*)/g)) {
tags[m[1]] = (tags[m[1]] ?? 0) + 1;
if (m[2].trim() && !examples[m[1]]) examples[m[1]] = mask(m[2].trim());
}
out.push(`- Transactions (STMTTRN): ${tags.STMTTRN ?? 0}`);
out.push('', 'Tags (count, masked example):', '');
for (const [tag, n] of Object.entries(tags)) out.push(`- ${tag} ×${n}${examples[tag] ? ` · \`${examples[tag]}\`` : ''}`);
return out;
}
const list = files(root);
console.log(`# Sample profiles\n\n${list.length} file(s) under ${root}/. Values are masked: letters → A/a, digits → 9.\n`);
for (const path of list) {
const buffer = readFileSync(path);
const bom = buffer[0] === 0xef && buffer[1] === 0xbb && buffer[2] === 0xbf;
const text = buffer.toString('utf8').replace(/^/, '');
const ext = extname(path).toLowerCase();
const rel = relative(root, path);
console.log(`## ${maskName(rel)}\n`);
console.log(`- Size: ${buffer.length} bytes · extension: ${ext || '(none)'} · BOM: ${bom ? 'yes' : 'no'} · line endings: ${text.includes('\r\n') ? 'CRLF' : 'LF'} · non-ASCII bytes: ${/[^\x00-\x7f]/.test(text) ? 'yes' : 'no'}`);
const looksOfx = /<OFX>|OFXHEADER/i.test(text.slice(0, 2000));
const lines = looksOfx ? profileOfx(text) : profileCsv(text);
console.log(lines.join('\n') + '\n');
}
void basename;