mirror of
https://github.com/priyanshujain/sanderling.git
synced 2026-10-02 19:17:10 +00:00
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:
31 files changed
+2970
-74
No files matched your search
@@ -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>
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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'))
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user