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

11
shared/package.json Normal file
View File

@@ -0,0 +1,11 @@
{
"name": "@spendlight/shared",
"private": true,
"type": "module",
"exports": {
".": "./src/index.ts"
},
"scripts": {
"typecheck": "tsc -p tsconfig.json"
}
}

255
shared/src/index.ts Normal file
View File

@@ -0,0 +1,255 @@
// Types and small helpers shared by the server and the web app.
export type Access = 'read' | 'full';
export type Theme = 'system' | 'light' | 'dark';
export type IdeaStatus = 'new' | 'planned' | 'done' | 'declined';
export type BackupKind = 'nightly' | 'manual' | 'pre-upgrade' | 'pre-restore';
export const IDEA_STATUSES: readonly IdeaStatus[] = ['new', 'planned', 'done', 'declined'];
export interface PublicConfig {
version: string;
ideasEnabled: boolean;
needsSetup: boolean;
}
export interface Me {
id: number;
username: string;
displayName: string;
access: Access;
isAdmin: boolean;
theme: Theme;
mustChangePassword: boolean;
hasPasskey: boolean;
}
export interface Person {
id: number;
username: string;
displayName: string;
access: Access;
isAdmin: boolean;
mustChangePassword: boolean;
createdAt: string;
lastActiveAt: string | null;
}
export interface Device {
id: string;
deviceLabel: string;
createdAt: string;
lastSeenAt: string;
current: boolean;
}
export interface PasskeyInfo {
id: string;
name: string;
createdAt: string;
lastUsedAt: string | null;
}
export interface BackupInfo {
name: string;
kind: BackupKind;
size: number;
createdAt: string;
}
export interface BackupOverview {
backups: BackupInfo[];
nightlyAt: string;
keepDaily: number;
keepMonthly: number;
}
export interface Idea {
id: number;
title: string;
description: string;
status: IdeaStatus;
adminNote: string | null;
path: string | null;
appVersion: string | null;
userAgent: string | null;
author: string | null;
createdAt: string;
updatedAt: string;
}
export interface ApiErrorBody {
error: { code: string; message: string };
}
/** Versions look like 20261007.01: the build date, then that day's build counter. */
export const VERSION_PATTERN = /^(\d{4})(\d{2})(\d{2})\.(\d{2,})$/;
/** The calendar date a version was built, or null for development builds. */
export function versionDate(version: string): Date | null {
const m = VERSION_PATTERN.exec(version);
if (!m) return null;
return new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3]));
}
export const ACCESS_LABEL: Record<Access, string> = {
read: 'Read-only',
full: 'Full access',
};
// ---------------------------------------------------------------------------
// Milestone 1: money in. Amounts are whole cents; money out is negative.
export type AccountKind = 'checking' | 'savings' | 'credit_card' | 'cash' | 'loan' | 'mortgage' | 'heloc';
export type CategoryKind = 'expense' | 'income' | 'transfer';
export const ACCOUNT_KIND_LABEL: Record<AccountKind, string> = {
checking: 'Checking',
savings: 'Savings',
credit_card: 'Credit card',
cash: 'Cash',
loan: 'Loan',
mortgage: 'Mortgage',
heloc: 'Home equity line',
};
/** Account kinds where the balance is money owed rather than money held. */
export const LIABILITY_KINDS: readonly AccountKind[] = ['credit_card', 'loan', 'mortgage', 'heloc'];
/** Twelve hues tuned to read well in both light and dark themes. */
export const PALETTE = ['indigo', 'sky', 'teal', 'emerald', 'lime', 'amber', 'orange', 'rose', 'pink', 'violet', 'cyan', 'slate'] as const;
export type PaletteColor = (typeof PALETTE)[number];
export interface Account {
id: number;
name: string;
kind: AccountKind;
institution: string | null;
last4: string | null;
color: PaletteColor;
status: 'open' | 'retired';
/** Current balance: money held is positive, money owed is negative. */
balanceCents: number;
balanceConfirmedOn: string | null;
transactionCount: number;
lastImportAt: string | null;
lastTransactionDate: string | null;
}
export interface Category {
id: number;
groupId: number;
groupName: string;
name: string;
kind: CategoryKind;
icon: string;
color: PaletteColor;
archived: boolean;
}
export interface CategoryGroup {
id: number;
name: string;
categories: Category[];
}
export interface Split {
id: number;
categoryId: number;
amountCents: number;
note: string | null;
}
export interface Transaction {
id: number;
accountId: number;
date: string;
amountCents: number;
description: string;
merchant: string;
categoryId: number | null;
needsReview: boolean;
suggestedBy: string | null;
isSplit: boolean;
splits: Split[];
note: string | null;
tags: string[];
transferPeer: { id: number; accountId: number; accountName: string } | null;
source: 'import' | 'manual';
bankCategory: string | null;
importId: number | null;
}
export interface ReviewItem extends Transaction {
/** Up to three likely categories, best first. */
suggestions: number[];
/** True when Spendlight is sure: a rule, or the last 3 picks agreed. */
confident: boolean;
/** Set for card payments and transfers with no matching half in another account. */
unpairedTransfer: boolean;
}
export interface TransactionPage {
items: Transaction[];
total: number;
sums: { inCents: number; outCents: number };
}
export interface ImportColumnMap {
date: number;
description: number;
/** One signed amount column... */
amount?: number;
/** ...or separate columns for money out and money in. */
debit?: number;
credit?: number;
postedDate?: number;
category?: number;
balance?: number;
/** Whether a positive number in `amount` is money spent (most credit cards) or money received. */
positiveIs: 'out' | 'in';
dateOrder: 'MDY' | 'DMY' | 'YMD';
}
export interface ImportPreview {
stagingId: string;
fileName: string;
layout: { key: string; name: string } | null;
/** When the layout is unknown: the first rows, for the mapping pop-up. */
sampleRows: string[][];
headerRow: string[] | null;
suggestedMap: ImportColumnMap | null;
account: { id: number; confidence: 'certain' | 'likely'; reason: string } | null;
dateFrom: string | null;
dateTo: string | null;
counts: { rows: number; new: number; duplicates: number; possibleDuplicates: number; skipped: number };
newRows: { date: string; merchant: string; amountCents: number }[];
possibleDuplicates: { key: string; date: string; merchant: string; amountCents: number; existingDate: string }[];
/** Account details the file itself suggests, for creating a new account. */
accountHint: { kind: AccountKind; institution: string | null; last4: string | null; name: string } | null;
}
export interface ImportSummary {
id: number;
accountId: number;
accountName: string;
fileName: string;
layoutKey: string;
dateFrom: string | null;
dateTo: string | null;
addedCount: number;
duplicateCount: number;
skippedCount: number;
transferCount: number;
toReview: number;
status: 'committed' | 'undone';
createdAt: string;
}
export function formatCents(cents: number, options: { sign?: boolean; whole?: boolean } = {}): string {
const value = Math.abs(cents) / 100;
const digits = options.whole ? 0 : 2;
const text = value.toLocaleString('en-US', { style: 'currency', currency: 'USD', minimumFractionDigits: digits, maximumFractionDigits: digits });
if (cents < 0) return `−${text}`;
return options.sign && cents > 0 ? `+${text}` : text;
}

7
shared/tsconfig.json Normal file
View File

@@ -0,0 +1,7 @@
{
"extends": "../tsconfig.base.json",
"compilerOptions": {
"lib": ["ES2023"]
},
"include": ["src"]
}