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

View File

@@ -0,0 +1,74 @@
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 { AuthLayout, FormError } from '@/components/AuthLayout';
import { Splash } from '@/components/RequireAuth';
import { Button } from '@/components/ui/button';
import { Field, Input, PasswordInput } from '@/components/ui/form';
import { api, errorMessage } from '@/lib/api';
import { useConfig } from '@/lib/session';
export default function SetupPage() {
const config = useConfig();
const client = useQueryClient();
const navigate = useNavigate();
const [displayName, setDisplayName] = useState('');
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
if (config.isPending) return <Splash />;
if (config.data && !config.data.needsSetup) return <Navigate to="/" replace />;
const submit = async (e: FormEvent) => {
e.preventDefault();
setBusy(true);
setError(null);
try {
const me = await api<Me>('/setup', { method: 'POST', body: { displayName, username, password } });
client.setQueryData(['me'], me);
await client.invalidateQueries({ queryKey: ['config'] });
toast.success(`Welcome to Spendlight, ${me.displayName}!`);
navigate('/', { replace: true });
} catch (err) {
setError(errorMessage(err));
} finally {
setBusy(false);
}
};
return (
<AuthLayout title="Welcome to Spendlight" subtitle="Let’s create your sign-in. You’ll be the admin, so you can invite the rest of the household later.">
<form onSubmit={submit} className="grid gap-4">
<FormError message={error} />
<Field label="Your name">
{(id) => <Input id={id} value={displayName} onChange={(e) => setDisplayName(e.target.value)} autoComplete="name" placeholder="Chris" required />}
</Field>
<Field label="Username" hint="What you’ll type to sign in. Letters and numbers work best.">
{(id) => (
<Input
id={id}
value={username}
onChange={(e) => setUsername(e.target.value)}
autoComplete="username"
autoCapitalize="none"
autoCorrect="off"
spellCheck={false}
placeholder="chris"
required
/>
)}
</Field>
<Field label="Password" hint="At least 8 characters. A short phrase is easy to remember.">
{(id) => <PasswordInput id={id} value={password} onChange={(e) => setPassword(e.target.value)} autoComplete="new-password" minLength={8} required />}
</Field>
<Button type="submit" size="lg" loading={busy} className="mt-1 w-full">
Create and continue
</Button>
</form>
</AuthLayout>
);
}