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:
11
shared/package.json
Normal file
11
shared/package.json
Normal 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
255
shared/src/index.ts
Normal 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
7
shared/tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"extends": "../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"lib": ["ES2023"]
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
Reference in New Issue
Block a user