feat(folio-web): web sample app + CDP spec tests (#36)

* fix(runner): allow nil connection for web platform

* fix(testrun): skip SDK handshake for web platform

* feat(folio-web): add React/Vite web sample app

* feat(folio-web): add sanderling spec

* fix(chrome): use InsertText for multi-char text input

* feat(hierarchy): add Screen field populated from sanderling-screen attr

* fix(chrome): auto-detect viewport from CSS vars, fix InputText accumulation, expose route as screen

* fix(runner): fall back to hierarchy root screen when snapshot screen is empty

* fix(folio-web): broaden loggedIn extractor to all authenticated pages
This commit is contained in:
pj authored and GitHub committed 2026-04-22 23:59:30 +07:00
1 parent eed99e58aa
commit af7b7e27b0
31 files changed
+2970 -74

No files matched your search

+129
View File
@@ -0,0 +1,129 @@
import { useCallback, useEffect, useState } from 'react'
import { clearSession, getSession } from './db'
import { navigate, useRoute } from './router'
import { LoginPage } from './pages/Login'
import { HomePage } from './pages/Home'
import { AddAccountPage } from './pages/AddAccount'
import { LedgerPage } from './pages/Ledger'
import { AddTransactionPage } from './pages/AddTransaction'
import { StatusBar } from './components/Screen'
type AuthState =
| { status: 'loading' }
| { status: 'logged-out' }
| { status: 'logged-in'; user: string }
export default function App() {
const route = useRoute()
const [auth, setAuth] = useState<AuthState>({ status: 'loading' })
const [reloadKey, setReloadKey] = useState(0)
useEffect(() => {
let alive = true
getSession().then((s) => {
if (!alive) return
setAuth(
s ? { status: 'logged-in', user: s.user } : { status: 'logged-out' },
)
})
return () => {
alive = false
}
}, [])
useEffect(() => {
if (auth.status === 'loading') return
if (auth.status === 'logged-out' && route.name !== 'login') {
navigate('/login', { replace: true })
} else if (auth.status === 'logged-in' && route.name === 'login') {
navigate('/', { replace: true })
}
}, [auth, route.name])
const bumpReload = () => setReloadKey((k) => k + 1)
const onLogout = useCallback(async () => {
await clearSession()
setAuth({ status: 'logged-out' })
navigate('/login', { replace: true })
}, [])
return (
<div
className="frame"
data-route={route.name}
data-logged-in={auth.status === 'logged-in'}
data-auth-status={auth.status}
>
<StatusBar />
{auth.status === 'loading' ? (
<div className="screen">
<div className="screen-body">
<div className="empty">
<div className="empty-sub">Loading…</div>
</div>
</div>
</div>
) : auth.status === 'logged-out' ? (
<LoginPage
onLoggedIn={(user) => setAuth({ status: 'logged-in', user })}
/>
) : (
renderRouteForLoggedIn(route, auth.user, bumpReload, onLogout, reloadKey)
)}
</div>
)
}
function renderRouteForLoggedIn(
route: ReturnType<typeof useRoute>,
user: string,
bumpReload: () => void,
onLogout: () => void,
reloadKey: number,
) {
switch (route.name) {
case 'home':
return (
<HomePage user={user} onLogout={onLogout} reloadKey={reloadKey} />
)
case 'add-account':
return <AddAccountPage onCreated={bumpReload} />
case 'ledger':
return (
<LedgerPage accountId={route.accountId} reloadKey={reloadKey} />
)
case 'add-transaction':
return (
<AddTransactionPage
accountId={route.accountId}
onCreated={bumpReload}
/>
)
case 'login':
return null
case 'not-found':
default:
return (
<div className="screen">
<div className="screen-body">
<div className="empty">
<div className="empty-title">Page not found</div>
<button
className="btn"
type="button"
style={{
marginTop: 12,
width: 'auto',
padding: '10px 16px',
}}
onClick={() => navigate('/', { replace: true })}
>
Go home
</button>
</div>
</div>
</div>
)
}
}
+9
View File
@@ -0,0 +1,9 @@
export const DEMO_EMAIL = '[email protected]'
export const DEMO_PASSWORD = 'ledger123'
export function checkCredentials(email: string, password: string): boolean {
return (
email.trim().toLowerCase() === DEMO_EMAIL &&
password === DEMO_PASSWORD
)
}
@@ -0,0 +1,85 @@
import type { ReactNode } from 'react'
import { useEffect, useState } from 'react'
import { formatClock } from '../format'
export function StatusBar() {
const [now, setNow] = useState(() => Date.now())
useEffect(() => {
const id = window.setInterval(() => setNow(Date.now()), 30_000)
return () => window.clearInterval(id)
}, [])
return (
<div className="statusbar" aria-hidden="true">
<span>{formatClock(now)}</span>
<span className="dots" aria-hidden="true">
<span className="dot" />
<span className="dot" />
<span className="dot" />
</span>
</div>
)
}
export function Screen(props: {
header?: ReactNode
footer?: ReactNode
children: ReactNode
}) {
return (
<div className="screen">
{props.header}
<div className="screen-body">{props.children}</div>
{props.footer ? (
<div className="screen-footer">{props.footer}</div>
) : null}
</div>
)
}
export function Header(props: {
title: string
subtitle?: string
left?: ReactNode
right?: ReactNode
}) {
return (
<div className="screen-header">
{props.left}
<div style={{ flex: '1 1 auto', minWidth: 0 }}>
<h1 className="title" title={props.title}>
{props.title}
</h1>
{props.subtitle ? (
<p className="subtitle">{props.subtitle}</p>
) : null}
</div>
{props.right}
</div>
)
}
export function BackButton(props: { onClick: () => void; label?: string }) {
return (
<button
id="back"
type="button"
className="icon-btn"
onClick={props.onClick}
aria-label={props.label ?? 'Back'}
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.4"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M15 18l-6-6 6-6" />
</svg>
</button>
)
}
+195
View File
@@ -0,0 +1,195 @@
export type Account = {
id: string
name: string
createdAt: number
}
export type TxnType = 'credit' | 'debit'
export type Transaction = {
id: string
accountId: string
type: TxnType
amount: number
note: string
createdAt: number
}
export type Session = {
key: 'current'
user: string
loggedInAt: number
}
const DB_NAME = 'ledger'
const DB_VERSION = 1
const STORE_ACCOUNTS = 'accounts'
const STORE_TXNS = 'transactions'
const STORE_SESSION = 'session'
let dbPromise: Promise<IDBDatabase> | null = null
function openDb(): Promise<IDBDatabase> {
if (dbPromise) return dbPromise
dbPromise = new Promise((resolve, reject) => {
const req = indexedDB.open(DB_NAME, DB_VERSION)
req.onupgradeneeded = () => {
const db = req.result
if (!db.objectStoreNames.contains(STORE_ACCOUNTS)) {
const s = db.createObjectStore(STORE_ACCOUNTS, { keyPath: 'id' })
s.createIndex('createdAt', 'createdAt')
s.createIndex('name', 'name', { unique: true })
}
if (!db.objectStoreNames.contains(STORE_TXNS)) {
const s = db.createObjectStore(STORE_TXNS, { keyPath: 'id' })
s.createIndex('accountId', 'accountId')
s.createIndex('createdAt', 'createdAt')
}
if (!db.objectStoreNames.contains(STORE_SESSION)) {
db.createObjectStore(STORE_SESSION, { keyPath: 'key' })
}
}
req.onsuccess = () => resolve(req.result)
req.onerror = () => reject(req.error)
})
return dbPromise
}
function tx(
db: IDBDatabase,
stores: string | string[],
mode: IDBTransactionMode,
) {
return db.transaction(stores, mode)
}
function reqAsPromise<T>(req: IDBRequest<T>): Promise<T> {
return new Promise((resolve, reject) => {
req.onsuccess = () => resolve(req.result)
req.onerror = () => reject(req.error)
})
}
export function makeId(): string {
if (typeof crypto !== 'undefined' && 'randomUUID' in crypto) {
return crypto.randomUUID()
}
return (
Date.now().toString(36) + Math.random().toString(36).slice(2, 10)
)
}
export async function listAccounts(): Promise<Account[]> {
const db = await openDb()
const store = tx(db, STORE_ACCOUNTS, 'readonly').objectStore(STORE_ACCOUNTS)
const all = await reqAsPromise(store.getAll() as IDBRequest<Account[]>)
return all.sort((a, b) => a.createdAt - b.createdAt)
}
export async function getAccount(id: string): Promise<Account | undefined> {
const db = await openDb()
const store = tx(db, STORE_ACCOUNTS, 'readonly').objectStore(STORE_ACCOUNTS)
return reqAsPromise(store.get(id) as IDBRequest<Account | undefined>)
}
export async function accountNameExists(name: string): Promise<boolean> {
const db = await openDb()
const idx = tx(db, STORE_ACCOUNTS, 'readonly')
.objectStore(STORE_ACCOUNTS)
.index('name')
const key = await reqAsPromise(idx.getKey(name))
return key !== undefined
}
export async function createAccount(name: string): Promise<Account> {
const trimmed = name.trim()
if (!trimmed) throw new Error('Name is required')
if (trimmed.length > 40) throw new Error('Name is too long (max 40)')
if (await accountNameExists(trimmed)) {
throw new Error('An account with that name already exists')
}
const account: Account = {
id: makeId(),
name: trimmed,
createdAt: Date.now(),
}
const db = await openDb()
const t = tx(db, STORE_ACCOUNTS, 'readwrite')
await reqAsPromise(t.objectStore(STORE_ACCOUNTS).add(account))
return account
}
export async function listTransactions(
accountId: string,
): Promise<Transaction[]> {
const db = await openDb()
const idx = tx(db, STORE_TXNS, 'readonly')
.objectStore(STORE_TXNS)
.index('accountId')
const all = await reqAsPromise(
idx.getAll(IDBKeyRange.only(accountId)) as IDBRequest<Transaction[]>,
)
return all.sort((a, b) => b.createdAt - a.createdAt)
}
export async function listAllTransactions(): Promise<Transaction[]> {
const db = await openDb()
const store = tx(db, STORE_TXNS, 'readonly').objectStore(STORE_TXNS)
return reqAsPromise(store.getAll() as IDBRequest<Transaction[]>)
}
export async function createTransaction(input: {
accountId: string
type: TxnType
amount: number
note: string
}): Promise<Transaction> {
if (!Number.isInteger(input.amount)) {
throw new Error('Amount must be a whole number of cents')
}
if (input.amount <= 0) {
throw new Error('Amount must be greater than zero')
}
if (input.type !== 'credit' && input.type !== 'debit') {
throw new Error('Invalid transaction type')
}
const acc = await getAccount(input.accountId)
if (!acc) throw new Error('Account not found')
const txn: Transaction = {
id: makeId(),
accountId: input.accountId,
type: input.type,
amount: input.amount,
note: input.note.trim().slice(0, 80),
createdAt: Date.now(),
}
const db = await openDb()
const t = tx(db, STORE_TXNS, 'readwrite')
await reqAsPromise(t.objectStore(STORE_TXNS).add(txn))
return txn
}
export async function getSession(): Promise<Session | undefined> {
const db = await openDb()
const store = tx(db, STORE_SESSION, 'readonly').objectStore(STORE_SESSION)
return reqAsPromise(store.get('current') as IDBRequest<Session | undefined>)
}
export async function setSession(user: string): Promise<Session> {
const session: Session = {
key: 'current',
user,
loggedInAt: Date.now(),
}
const db = await openDb()
const t = tx(db, STORE_SESSION, 'readwrite')
await reqAsPromise(t.objectStore(STORE_SESSION).put(session))
return session
}
export async function clearSession(): Promise<void> {
const db = await openDb()
const t = tx(db, STORE_SESSION, 'readwrite')
await reqAsPromise(t.objectStore(STORE_SESSION).delete('current'))
}
+67
View File
@@ -0,0 +1,67 @@
import type { Transaction } from './db'
const CURRENCY = '$'
export function formatCents(
cents: number,
opts: { signed?: boolean } = {},
): string {
const abs = Math.abs(cents)
const dollars = Math.floor(abs / 100)
const rem = abs % 100
const dollarsStr = dollars.toLocaleString('en-US')
const formatted = `${CURRENCY}${dollarsStr}.${rem.toString().padStart(2, '0')}`
if (cents < 0) return `-${formatted}`
if (opts.signed && cents > 0) return `+${formatted}`
return formatted
}
export function parseCents(input: string): number | null {
const trimmed = input.trim().replace(/,/g, '')
if (trimmed === '') return null
if (!/^\d+(\.\d{1,2})?$/.test(trimmed)) return null
const [whole, frac = ''] = trimmed.split('.')
const fracPadded = (frac + '00').slice(0, 2)
const total = Number(whole) * 100 + Number(fracPadded)
if (!Number.isFinite(total) || !Number.isInteger(total)) return null
if (total > Number.MAX_SAFE_INTEGER) return null
return total
}
export function signedAmount(t: Transaction): number {
return t.type === 'credit' ? t.amount : -t.amount
}
export function balanceOf(txns: Transaction[]): number {
let total = 0
for (const t of txns) total += signedAmount(t)
return total
}
export function initialsOf(name: string): string {
const parts = name.trim().split(/\s+/).filter(Boolean)
if (parts.length === 0) return '?'
if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase()
return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase()
}
const DATE_FMT = new Intl.DateTimeFormat('en-US', {
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: '2-digit',
})
export function formatDate(ts: number): string {
return DATE_FMT.format(new Date(ts))
}
const CLOCK_FMT = new Intl.DateTimeFormat('en-US', {
hour: 'numeric',
minute: '2-digit',
hour12: false,
})
export function formatClock(ts: number): string {
return CLOCK_FMT.format(new Date(ts))
}
+584
View File
@@ -0,0 +1,584 @@
:root {
--bg: #ffffff;
--surface: #fafafa;
--surface-2: #f4f4f4;
--surface-3: #ebebeb;
--border: #e7e7e7;
--border-strong: #d6d6d6;
--text: #000000;
--text-muted: #6b6b6b;
--text-faint: #9a9a9a;
--accent: #000000;
--accent-press: #1a1a1a;
--on-accent: #ffffff;
--credit: #000000;
--debit: #000000;
--danger: #000000;
--shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
--frame-w: 420px;
--frame-h: 900px;
--r-sm: 8px;
--r-md: 12px;
--r-lg: 16px;
--r-pill: 999px;
--pad: 20px;
--mono:
'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-family: var(--mono);
font-size: 15px;
line-height: 1.45;
color: var(--text);
background: var(--bg);
color-scheme: light;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-rendering: optimizeLegibility;
font-synthesis: none;
}
* {
box-sizing: border-box;
}
html,
body,
#root {
margin: 0;
padding: 0;
height: 100%;
}
body {
background: var(--bg);
display: grid;
place-items: center;
overflow: hidden;
}
#root {
width: 100%;
height: 100%;
display: grid;
place-items: center;
}
.frame {
width: 100%;
height: 100%;
max-width: var(--frame-w);
max-height: var(--frame-h);
background: var(--bg);
color: var(--text);
position: relative;
overflow: hidden;
display: flex;
flex-direction: column;
border-left: 1px solid var(--border);
border-right: 1px solid var(--border);
}
.statusbar {
height: 28px;
flex: 0 0 auto;
display: flex;
align-items: center;
justify-content: space-between;
padding: 6px 22px 0;
font-size: 12px;
font-weight: 600;
letter-spacing: 0.04em;
color: var(--text);
user-select: none;
}
.statusbar .dots {
display: inline-flex;
gap: 4px;
opacity: 0.85;
}
.statusbar .dot {
width: 4px;
height: 4px;
border-radius: 50%;
background: currentColor;
}
.screen {
flex: 1 1 auto;
display: flex;
flex-direction: column;
min-height: 0;
}
.screen-header {
flex: 0 0 auto;
display: flex;
align-items: center;
gap: 12px;
padding: 14px var(--pad) 8px;
}
.screen-header .title {
font-size: 20px;
font-weight: 700;
letter-spacing: -0.01em;
margin: 0;
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.screen-header .subtitle {
font-size: 12px;
color: var(--text-muted);
margin: 2px 0 0;
}
.screen-body {
flex: 1 1 auto;
overflow-y: auto;
padding: 8px var(--pad) 24px;
-webkit-overflow-scrolling: touch;
display: flex;
flex-direction: column;
gap: 12px;
}
.screen-footer {
flex: 0 0 auto;
padding: 16px var(--pad) calc(20px + env(safe-area-inset-bottom));
border-top: 1px solid var(--border);
background: var(--bg);
display: flex;
flex-direction: column;
gap: 14px;
}
.icon-btn {
appearance: none;
background: var(--surface-2);
color: var(--text);
border: 1px solid var(--border);
width: 36px;
height: 36px;
border-radius: 10px;
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
font-family: inherit;
font-size: 16px;
line-height: 1;
transition: background 0.12s ease, transform 0.06s ease;
}
.icon-btn:hover {
background: var(--surface-3);
}
.icon-btn:active {
transform: scale(0.96);
}
.btn {
appearance: none;
font-family: inherit;
font-size: 15px;
font-weight: 600;
letter-spacing: 0.01em;
border-radius: var(--r-md);
padding: 14px 16px;
border: 1px solid var(--border-strong);
background: var(--surface-2);
color: var(--text);
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
transition: background 0.12s ease, transform 0.06s ease, opacity 0.12s ease;
width: 100%;
}
.btn:hover {
background: var(--surface-3);
}
.btn:active {
transform: scale(0.99);
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn.primary {
background: var(--accent);
border-color: var(--accent);
color: var(--on-accent);
}
.btn.primary:hover {
background: var(--accent-press);
border-color: var(--accent-press);
}
.btn.danger {
color: var(--text);
border-color: var(--border-strong);
}
.btn.danger:hover {
background: var(--surface-2);
}
.btn.ghost {
background: transparent;
border-color: transparent;
color: var(--text-muted);
}
.btn.ghost:hover {
background: var(--surface-2);
color: var(--text);
}
.field {
display: flex;
flex-direction: column;
gap: 6px;
}
.field-label {
font-size: 12px;
color: var(--text-muted);
letter-spacing: 0.06em;
text-transform: uppercase;
}
.input {
appearance: none;
font-family: inherit;
font-size: 16px;
width: 100%;
background: var(--surface);
color: var(--text);
border: 1px solid var(--border);
border-radius: var(--r-md);
padding: 14px 14px;
outline: none;
transition: border-color 0.12s ease, background 0.12s ease;
}
.input::placeholder {
color: var(--text-faint);
}
.input:focus {
border-color: var(--accent);
background: var(--surface-2);
}
.input.invalid {
border-color: var(--danger);
}
.error {
color: var(--danger);
font-size: 12px;
min-height: 1em;
}
.muted {
color: var(--text-muted);
}
.faint {
color: var(--text-faint);
}
.list {
display: flex;
flex-direction: column;
gap: 10px;
}
.row {
display: flex;
align-items: center;
gap: 12px;
}
.spacer {
flex: 1 1 auto;
}
.card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--r-lg);
padding: 16px;
display: flex;
flex-direction: column;
gap: 6px;
}
.account-card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--r-lg);
padding: 16px;
display: flex;
align-items: center;
gap: 14px;
text-align: left;
width: 100%;
font-family: inherit;
color: inherit;
cursor: pointer;
transition: background 0.12s ease, transform 0.06s ease,
border-color 0.12s ease;
}
.account-card:hover {
background: var(--surface-2);
border-color: var(--border-strong);
}
.account-card:active {
transform: scale(0.995);
}
.account-avatar {
width: 40px;
height: 40px;
border-radius: 12px;
display: grid;
place-items: center;
background: var(--surface-3);
color: var(--accent);
font-weight: 700;
font-size: 15px;
flex: 0 0 auto;
}
.account-meta {
flex: 1 1 auto;
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.account-name {
font-size: 15px;
font-weight: 600;
letter-spacing: -0.005em;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.account-sub {
font-size: 12px;
color: var(--text-muted);
}
.account-amount {
font-size: 15px;
font-weight: 600;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
padding: 48px 16px;
color: var(--text-muted);
gap: 6px;
}
.empty .empty-title {
color: var(--text);
font-size: 15px;
font-weight: 600;
}
.empty .empty-sub {
font-size: 13px;
color: var(--text-muted);
}
.empty .empty-icon {
width: 56px;
height: 56px;
border-radius: 50%;
background: var(--surface-2);
border: 1px solid var(--border);
display: grid;
place-items: center;
margin-bottom: 8px;
color: var(--text-muted);
}
.balance-display {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 4px;
}
.balance-label {
font-size: 11px;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--text-muted);
}
.balance-value {
font-size: 28px;
font-weight: 700;
font-variant-numeric: tabular-nums;
letter-spacing: -0.01em;
}
.balance-value.credit,
.balance-value.debit {
color: var(--text);
}
.txn-row {
display: flex;
align-items: center;
gap: 12px;
padding: 14px 0;
border-bottom: 1px solid var(--border);
}
.txn-row:last-child {
border-bottom: 0;
}
.txn-icon {
width: 36px;
height: 36px;
border-radius: 10px;
display: grid;
place-items: center;
font-weight: 700;
font-size: 16px;
flex: 0 0 auto;
}
.txn-icon.credit {
background: var(--surface-2);
color: var(--text);
}
.txn-icon.debit {
background: var(--surface-2);
color: var(--text);
}
.txn-meta {
flex: 1 1 auto;
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.txn-note {
font-size: 14px;
font-weight: 500;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.txn-date {
font-size: 11px;
color: var(--text-faint);
letter-spacing: 0.04em;
}
.txn-amount {
font-size: 15px;
font-weight: 600;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.txn-amount.credit,
.txn-amount.debit {
color: var(--text);
}
.section-label {
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.12em;
color: var(--text-faint);
padding: 4px 4px;
}
.segmented {
display: grid;
grid-template-columns: 1fr 1fr;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--r-md);
padding: 4px;
gap: 4px;
}
.segmented button {
appearance: none;
font-family: inherit;
font-size: 14px;
font-weight: 600;
background: transparent;
color: var(--text-muted);
border: 0;
padding: 10px 12px;
border-radius: 8px;
cursor: pointer;
transition: background 0.12s ease, color 0.12s ease;
}
.segmented button[aria-pressed='true'] {
background: var(--surface-3);
color: var(--text);
}
.segmented button.credit[aria-pressed='true'],
.segmented button.debit[aria-pressed='true'] {
color: var(--text);
}
.amount-input {
text-align: center;
font-size: 36px;
font-weight: 700;
letter-spacing: -0.01em;
padding: 24px 12px;
font-variant-numeric: tabular-nums;
}
.brand {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
padding: 36px 16px 24px;
}
.brand-mark {
width: 56px;
height: 56px;
border-radius: 16px;
display: grid;
place-items: center;
background: var(--surface-2);
border: 1px solid var(--border);
color: var(--accent);
font-weight: 700;
font-size: 22px;
letter-spacing: -0.02em;
}
.brand-name {
font-size: 22px;
font-weight: 700;
letter-spacing: -0.01em;
}
.brand-sub {
font-size: 12px;
color: var(--text-muted);
}
.toast {
position: absolute;
left: 50%;
bottom: 96px;
transform: translateX(-50%);
background: var(--surface-3);
color: var(--text);
border: 1px solid var(--border-strong);
padding: 8px 14px;
border-radius: var(--r-pill);
font-size: 13px;
box-shadow: var(--shadow);
pointer-events: none;
opacity: 0;
transition: opacity 0.18s ease, transform 0.18s ease;
}
.toast.show {
opacity: 1;
transform: translateX(-50%) translateY(-4px);
}
+10
View File
@@ -0,0 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)
@@ -0,0 +1,91 @@
import { useState, type FormEvent } from 'react'
import { createAccount } from '../db'
import { back, navigate } from '../router'
import { BackButton, Header, Screen } from '../components/Screen'
export function AddAccountPage(props: { onCreated: () => void }) {
const [name, setName] = useState('')
const [err, setErr] = useState<string | null>(null)
const [busy, setBusy] = useState(false)
async function onSubmit(e: FormEvent) {
e.preventDefault()
if (busy) return
const trimmed = name.trim()
if (!trimmed) {
setErr('Account name is required')
return
}
if (trimmed.length > 40) {
setErr('Name is too long (max 40 characters)')
return
}
setBusy(true)
try {
await createAccount(trimmed)
props.onCreated()
navigate('/', { replace: true })
} catch (e) {
setErr(e instanceof Error ? e.message : 'Could not create account')
} finally {
setBusy(false)
}
}
return (
<Screen
header={
<Header
title="New account"
left={<BackButton onClick={() => back('/')} />}
/>
}
footer={
<button
id="add-account-submit"
className="btn primary"
type="submit"
form="add-account-form"
disabled={busy || name.trim().length === 0}
>
{busy ? 'Creating…' : 'Create account'}
</button>
}
>
<form
id="add-account-form"
onSubmit={onSubmit}
className="list"
style={{ gap: 14 }}
noValidate
>
<div className="field">
<label className="field-label" htmlFor="account-name">
Account name
</label>
<input
id="account-name"
className={'input' + (err ? ' invalid' : '')}
type="text"
autoFocus
autoComplete="off"
placeholder="e.g. Checking"
value={name}
maxLength={40}
onChange={(e) => {
setName(e.target.value)
if (err) setErr(null)
}}
/>
</div>
<div id="add-account-error" className="error" role="alert">
{err ?? ''}
</div>
<div className="muted" style={{ fontSize: 12 }}>
Use a short, recognizable name. You can create as many accounts as
you need.
</div>
</form>
</Screen>
)
}
@@ -0,0 +1,194 @@
import { useEffect, useState, type FormEvent } from 'react'
import {
createTransaction,
getAccount,
type Account,
type TxnType,
} from '../db'
import { parseCents } from '../format'
import { back, navigate } from '../router'
import { BackButton, Header, Screen } from '../components/Screen'
export function AddTransactionPage(props: {
accountId: string
onCreated: () => void
}) {
const [account, setAccount] = useState<Account | null | undefined>(undefined)
const [type, setType] = useState<TxnType>('credit')
const [amount, setAmount] = useState('')
const [note, setNote] = useState('')
const [err, setErr] = useState<string | null>(null)
const [busy, setBusy] = useState(false)
useEffect(() => {
let alive = true
getAccount(props.accountId).then((a) => {
if (alive) setAccount(a ?? null)
})
return () => {
alive = false
}
}, [props.accountId])
async function onSubmit(e: FormEvent) {
e.preventDefault()
if (busy) return
const cents = parseCents(amount)
if (cents === null) {
setErr('Enter a valid amount (e.g. 12.34)')
return
}
if (cents <= 0) {
setErr('Amount must be greater than zero')
return
}
setBusy(true)
try {
await createTransaction({
accountId: props.accountId,
type,
amount: cents,
note,
})
props.onCreated()
back(`/accounts/${props.accountId}`)
} catch (e) {
setErr(e instanceof Error ? e.message : 'Could not save transaction')
} finally {
setBusy(false)
}
}
if (account === null) {
return (
<Screen
header={
<Header
title="Add transaction"
left={<BackButton onClick={() => back('/')} />}
/>
}
>
<div className="empty">
<div className="empty-title">Account not found</div>
<button
className="btn"
type="button"
style={{ marginTop: 12, width: 'auto', padding: '10px 16px' }}
onClick={() => navigate('/', { replace: true })}
>
Back to accounts
</button>
</div>
</Screen>
)
}
const submitDisabled = busy || amount.trim() === ''
return (
<Screen
header={
<Header
title="Add transaction"
subtitle={account?.name}
left={
<BackButton
onClick={() => back(`/accounts/${props.accountId}`)}
/>
}
/>
}
footer={
<button
id="txn-submit"
className="btn primary"
type="submit"
form="add-txn-form"
disabled={submitDisabled}
>
{busy ? 'Saving…' : type === 'credit' ? 'Add credit' : 'Add debit'}
</button>
}
>
<form
id="add-txn-form"
onSubmit={onSubmit}
className="list"
style={{ gap: 16 }}
data-account-id={props.accountId}
data-type={type}
noValidate
>
<div
className="segmented"
role="tablist"
aria-label="Transaction type"
>
<button
id="txn-credit"
type="button"
role="tab"
className="credit"
aria-pressed={type === 'credit'}
onClick={() => setType('credit')}
>
Credit
</button>
<button
id="txn-debit"
type="button"
role="tab"
className="debit"
aria-pressed={type === 'debit'}
onClick={() => setType('debit')}
>
Debit
</button>
</div>
<div className="field">
<label className="field-label" htmlFor="txn-amount">
Amount
</label>
<input
id="txn-amount"
className={'input amount-input' + (err ? ' invalid' : '')}
type="text"
inputMode="decimal"
autoComplete="off"
placeholder="0.00"
value={amount}
onChange={(e) => {
const v = e.target.value
if (v === '' || /^\d*(\.\d{0,2})?$/.test(v)) {
setAmount(v)
if (err) setErr(null)
}
}}
autoFocus
/>
</div>
<div className="field">
<label className="field-label" htmlFor="txn-note">
Note (optional)
</label>
<input
id="txn-note"
className="input"
type="text"
placeholder="What's this for?"
value={note}
maxLength={80}
onChange={(e) => setNote(e.target.value)}
/>
</div>
<div id="txn-error" className="error" role="alert">
{err ?? ''}
</div>
</form>
</Screen>
)
}
+193
View File
@@ -0,0 +1,193 @@
import { useEffect, useMemo, useState } from 'react'
import {
listAccounts,
listAllTransactions,
type Account,
type Transaction,
} from '../db'
import { formatCents, initialsOf, signedAmount } from '../format'
import { navigate } from '../router'
import { Header, Screen } from '../components/Screen'
export function HomePage(props: {
user: string
onLogout: () => void
reloadKey: number
}) {
const [accounts, setAccounts] = useState<Account[] | null>(null)
const [txns, setTxns] = useState<Transaction[]>([])
useEffect(() => {
let alive = true
Promise.all([listAccounts(), listAllTransactions()]).then(([a, t]) => {
if (!alive) return
setAccounts(a)
setTxns(t)
})
return () => {
alive = false
}
}, [props.reloadKey])
const balanceByAccount = useMemo(() => {
const m = new Map<string, number>()
for (const t of txns) {
m.set(t.accountId, (m.get(t.accountId) ?? 0) + signedAmount(t))
}
return m
}, [txns])
const totalBalance = useMemo(() => {
let sum = 0
for (const v of balanceByAccount.values()) sum += v
return sum
}, [balanceByAccount])
return (
<Screen
header={
<Header
title="Accounts"
subtitle={props.user}
right={
<button
id="logout"
type="button"
className="icon-btn"
onClick={props.onLogout}
aria-label="Sign out"
title="Sign out"
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
<polyline points="16 17 21 12 16 7" />
<line x1="21" y1="12" x2="9" y2="12" />
</svg>
</button>
}
/>
}
footer={
<>
<div
className="row"
style={{ justifyContent: 'space-between', alignItems: 'flex-end' }}
>
<div
id="total-balance"
className="balance-display"
data-value={totalBalance}
title={String(totalBalance)}
>
<span className="balance-label">Total balance</span>
<span
className={
'balance-value' +
(totalBalance > 0
? ' credit'
: totalBalance < 0
? ' debit'
: '')
}
>
{formatCents(totalBalance)}
</span>
</div>
<span
id="account-count"
className="faint"
style={{ fontSize: 12 }}
data-value={accounts?.length ?? 0}
>
{accounts?.length ?? 0} account
{(accounts?.length ?? 0) === 1 ? '' : 's'}
</span>
</div>
<button
id="add-account"
className="btn primary"
type="button"
onClick={() => navigate('/accounts/new')}
>
+ Add account
</button>
</>
}
>
{accounts === null ? (
<div className="empty">
<div className="empty-sub">Loading…</div>
</div>
) : accounts.length === 0 ? (
<div className="empty">
<div className="empty-icon" aria-hidden="true">
<svg
width="22"
height="22"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<rect x="3" y="6" width="18" height="13" rx="2" />
<path d="M3 10h18" />
</svg>
</div>
<div className="empty-title">No accounts yet</div>
<div className="empty-sub">
Create your first account to start tracking transactions.
</div>
</div>
) : (
<div className="list" data-testid="account-list">
{accounts.map((a) => {
const bal = balanceByAccount.get(a.id) ?? 0
return (
<button
key={a.id}
type="button"
className="account-card"
data-testid="account-card"
data-account-id={a.id}
data-name={a.name}
data-balance={bal}
data-txn-count={countTxns(txns, a.id)}
aria-label={'account:' + a.id + ':' + bal}
onClick={() => navigate(`/accounts/${a.id}`)}
>
<span className="account-avatar" aria-hidden="true">
{initialsOf(a.name)}
</span>
<span className="account-meta">
<span className="account-name">{a.name}</span>
<span className="account-sub">
{countTxns(txns, a.id)} transactions
</span>
</span>
<span className="account-amount">{formatCents(bal)}</span>
</button>
)
})}
</div>
)}
</Screen>
)
}
function countTxns(all: Transaction[], accountId: string): number {
let n = 0
for (const t of all) if (t.accountId === accountId) n++
return n
}
+177
View File
@@ -0,0 +1,177 @@
import { useEffect, useMemo, useState } from 'react'
import {
getAccount,
listTransactions,
type Account,
type Transaction,
} from '../db'
import {
balanceOf,
formatCents,
formatDate,
signedAmount,
} from '../format'
import { back, navigate } from '../router'
import { BackButton, Header, Screen } from '../components/Screen'
export function LedgerPage(props: {
accountId: string
reloadKey: number
}) {
const [account, setAccount] = useState<Account | null | undefined>(undefined)
const [txns, setTxns] = useState<Transaction[] | null>(null)
useEffect(() => {
let alive = true
Promise.all([
getAccount(props.accountId),
listTransactions(props.accountId),
]).then(([a, t]) => {
if (!alive) return
setAccount(a ?? null)
setTxns(t)
})
return () => {
alive = false
}
}, [props.accountId, props.reloadKey])
const balance = useMemo(() => (txns ? balanceOf(txns) : 0), [txns])
if (account === null) {
return (
<Screen
header={
<Header
title="Account"
left={<BackButton onClick={() => back('/')} />}
/>
}
>
<div className="empty">
<div className="empty-title">Account not found</div>
<div className="empty-sub">It may have been deleted.</div>
<button
className="btn"
type="button"
style={{ marginTop: 12, width: 'auto', padding: '10px 16px' }}
onClick={() => navigate('/', { replace: true })}
>
Back to accounts
</button>
</div>
</Screen>
)
}
return (
<Screen
header={
<Header
title={account?.name ?? 'Loading…'}
subtitle="Ledger"
left={<BackButton onClick={() => back('/')} />}
/>
}
footer={
<button
id="add-txn"
className="btn primary"
type="button"
disabled={!account}
onClick={() =>
navigate(`/accounts/${props.accountId}/transactions/new`)
}
>
+ Add transaction
</button>
}
>
<div
id="ledger"
className="card"
data-account-id={props.accountId}
data-account-name={account?.name ?? ''}
data-txn-count={txns?.length ?? 0}
title={String(txns?.length ?? 0)}
>
<div
id="ledger-balance"
className="balance-display"
data-value={balance}
title={String(balance)}
>
<span className="balance-label">Balance</span>
<span
className={
'balance-value' +
(balance > 0 ? ' credit' : balance < 0 ? ' debit' : '')
}
>
{formatCents(balance)}
</span>
</div>
</div>
<div className="section-label">Activity</div>
{txns === null ? (
<div className="empty">
<div className="empty-sub">Loading…</div>
</div>
) : txns.length === 0 ? (
<div className="empty">
<div className="empty-icon" aria-hidden="true">
<svg
width="22"
height="22"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M3 6h18M3 12h18M3 18h12" />
</svg>
</div>
<div className="empty-title">No transactions yet</div>
<div className="empty-sub">
Add your first credit or debit to see it here.
</div>
</div>
) : (
<div className="card" style={{ padding: '0 16px' }}>
{txns.map((t) => {
const signed = signedAmount(t)
return (
<div
key={t.id}
className="txn-row"
data-testid="txn-row"
data-txn-id={t.id}
data-account-id={t.accountId}
data-type={t.type}
data-amount={t.amount}
data-signed={signed}
>
<div className={'txn-icon ' + t.type} aria-hidden="true">
{t.type === 'credit' ? '+' : '−'}
</div>
<div className="txn-meta">
<div className="txn-note">
{t.note || (t.type === 'credit' ? 'Credit' : 'Debit')}
</div>
<div className="txn-date">{formatDate(t.createdAt)}</div>
</div>
<div className={'txn-amount ' + t.type}>
{formatCents(signed, { signed: true })}
</div>
</div>
)
})}
</div>
)}
</Screen>
)
}
+119
View File
@@ -0,0 +1,119 @@
import { useState, type FormEvent } from 'react'
import { checkCredentials, DEMO_EMAIL, DEMO_PASSWORD } from '../auth'
import { setSession } from '../db'
import { navigate } from '../router'
import { Screen } from '../components/Screen'
export function LoginPage(props: { onLoggedIn: (user: string) => void }) {
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [err, setErr] = useState<string | null>(null)
const [busy, setBusy] = useState(false)
async function onSubmit(e: FormEvent) {
e.preventDefault()
if (busy) return
if (!email.trim() || !password) {
setErr('Enter email and password')
return
}
if (!checkCredentials(email, password)) {
setErr('Invalid email or password')
return
}
setBusy(true)
try {
await setSession(email.trim().toLowerCase())
props.onLoggedIn(email.trim().toLowerCase())
navigate('/', { replace: true })
} catch {
setErr('Could not sign in. Try again.')
} finally {
setBusy(false)
}
}
return (
<Screen>
<form
onSubmit={onSubmit}
className="list"
style={{ gap: 14, marginTop: 24 }}
noValidate
>
<div className="field">
<label className="field-label" htmlFor="email">
Email
</label>
<input
id="email"
className={'input' + (err ? ' invalid' : '')}
type="email"
autoComplete="email"
inputMode="email"
autoCapitalize="none"
placeholder={DEMO_EMAIL}
value={email}
onChange={(e) => {
setEmail(e.target.value)
if (err) setErr(null)
}}
/>
</div>
<div className="field">
<label className="field-label" htmlFor="password">
Password
</label>
<input
id="password"
className={'input' + (err ? ' invalid' : '')}
type="password"
autoComplete="current-password"
placeholder="••••••••"
value={password}
onChange={(e) => {
setPassword(e.target.value)
if (err) setErr(null)
}}
/>
</div>
<div id="login-error" className="error" role="alert">
{err ?? ''}
</div>
<button
id="login-submit"
className="btn primary"
type="submit"
disabled={busy}
>
{busy ? 'Signing in…' : 'Sign in'}
</button>
<div
className="card"
style={{
marginTop: 8,
background: 'var(--surface-2)',
borderStyle: 'dashed',
}}
>
<div className="muted" style={{ fontSize: 12 }}>
Demo credentials
</div>
<div style={{ fontSize: 13 }}>
<div>
email: <span style={{ fontWeight: 600 }}>{DEMO_EMAIL}</span>
</div>
<div>
password:{' '}
<span style={{ fontWeight: 600 }}>{DEMO_PASSWORD}</span>
</div>
</div>
</div>
</form>
</Screen>
)
}
+56
View File
@@ -0,0 +1,56 @@
import { useEffect, useState } from 'react'
export type Route =
| { name: 'login' }
| { name: 'home' }
| { name: 'add-account' }
| { name: 'ledger'; accountId: string }
| { name: 'add-transaction'; accountId: string }
| { name: 'not-found' }
function readHash(): string {
const raw = window.location.hash.replace(/^#/, '')
return raw === '' ? '/' : raw
}
export function parseHash(hash: string): Route {
const path = hash.split('?')[0]
if (path === '/' || path === '') return { name: 'home' }
if (path === '/login') return { name: 'login' }
if (path === '/accounts/new') return { name: 'add-account' }
const ledgerMatch = /^\/accounts\/([^/]+)$/.exec(path)
if (ledgerMatch) return { name: 'ledger', accountId: ledgerMatch[1] }
const txnMatch = /^\/accounts\/([^/]+)\/transactions\/new$/.exec(path)
if (txnMatch)
return { name: 'add-transaction', accountId: txnMatch[1] }
return { name: 'not-found' }
}
export function useRoute(): Route {
const [hash, setHash] = useState<string>(() => readHash())
useEffect(() => {
function onChange() {
setHash(readHash())
}
window.addEventListener('hashchange', onChange)
return () => window.removeEventListener('hashchange', onChange)
}, [])
return parseHash(hash)
}
export function navigate(path: string, opts: { replace?: boolean } = {}) {
const target = '#' + (path.startsWith('/') ? path : '/' + path)
if (opts.replace) {
window.location.replace(target)
} else {
window.location.hash = target.slice(1)
}
}
export function back(fallback: string = '/') {
if (window.history.length > 1) {
window.history.back()
} else {
navigate(fallback, { replace: true })
}
}