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
web/public/favicon.svg Normal file
View File

@@ -0,0 +1,18 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<defs>
<linearGradient id="beam" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#FBBF24" stop-opacity="0.95"/>
<stop offset="1" stop-color="#F97362" stop-opacity="0.18"/>
</linearGradient>
<radialGradient id="pool" cx="0.5" cy="0.5" r="0.5">
<stop offset="0" stop-color="#FCD34D"/>
<stop offset="0.55" stop-color="#F97362" stop-opacity="0.6"/>
<stop offset="1" stop-color="#F97362" stop-opacity="0"/>
</radialGradient>
</defs>
<rect width="512" height="512" rx="116" fill="#0B1020"/>
<path d="M222 140h68l110 240a144 40 0 0 1-288 0z" fill="url(#beam)"/>
<ellipse cx="256" cy="380" rx="168" ry="50" fill="url(#pool)"/>
<rect x="204" y="96" width="104" height="52" rx="20" fill="#818CF8"/>
<rect x="230" y="78" width="52" height="24" rx="10" fill="#6366F1"/>
</svg>

After

Width:  |  Height:  |  Size: 898 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

View File

@@ -0,0 +1,16 @@
{
"name": "Spendlight",
"short_name": "Spendlight",
"description": "Your household's money, in the spotlight.",
"start_url": "/",
"scope": "/",
"display": "standalone",
"orientation": "any",
"background_color": "#0B1020",
"theme_color": "#0B1020",
"icons": [
{ "src": "/icons/icon-192.png", "sizes": "192x192", "type": "image/png" },
{ "src": "/icons/icon-512.png", "sizes": "512x512", "type": "image/png" },
{ "src": "/icons/icon-maskable-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
]
}

57
web/public/sw.js Normal file
View File

@@ -0,0 +1,57 @@
// Spendlight service worker. Makes the home-screen app open instantly and
// show its shell offline. It never touches /api: household data is always
// fetched fresh and never stored here.
const SHELL = 'spendlight-shell';
const ASSETS = 'spendlight-assets';
const MAX_ASSETS = 80;
self.addEventListener('install', () => self.skipWaiting());
self.addEventListener('activate', (event) => {
event.waitUntil(
caches
.keys()
.then((keys) => Promise.all(keys.filter((k) => k !== SHELL && k !== ASSETS).map((k) => caches.delete(k))))
.then(() => self.clients.claim()),
);
});
self.addEventListener('fetch', (event) => {
const request = event.request;
const url = new URL(request.url);
if (request.method !== 'GET' || url.origin !== self.location.origin || url.pathname.startsWith('/api/')) return;
// Pages: always try the network first so a new version shows up at once.
if (request.mode === 'navigate') {
event.respondWith(
fetch(request)
.then((response) => {
if (response.ok) {
const copy = response.clone();
caches.open(SHELL).then((cache) => cache.put('/', copy));
}
return response;
})
.catch(() => caches.match('/').then((cached) => cached || Response.error())),
);
return;
}
// Versioned files never change, so a cached copy is always right.
if (url.pathname.startsWith('/assets/')) {
event.respondWith(
caches.open(ASSETS).then(async (cache) => {
const cached = await cache.match(request);
if (cached) return cached;
const response = await fetch(request);
if (response.ok) {
await cache.put(request, response.clone());
const keys = await cache.keys();
if (keys.length > MAX_ASSETS) await Promise.all(keys.slice(0, keys.length - MAX_ASSETS).map((k) => cache.delete(k)));
}
return response;
}),
);
}
});

9
web/public/theme-init.js Normal file
View File

@@ -0,0 +1,9 @@
// Runs before the page paints, so dark mode never flashes white.
(function () {
try {
var choice = localStorage.getItem('sl-theme') || 'system';
var dark = choice === 'dark' || (choice === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches);
document.documentElement.classList.toggle('dark', dark);
document.documentElement.style.colorScheme = dark ? 'dark' : 'light';
} catch (e) {}
})();