reimburse/CLAUDE.md
Claude 7d49759c75
Add user, admin, and developer documentation; expand CLAUDE.md
Creates docs/ with three audience-specific guides:
- docs/user-guide.md: step-by-step form-filling instructions, field
  descriptions, FX rate explanation, receipt handling, troubleshooting
- docs/admin-guide.md: deployment instructions and exhaustive config.yml
  key/value reference with types, defaults, and constraints
- docs/developer-guide.md: architecture overview, full state model, all
  CFG key mappings, PDF engine internals, column positions, four-pass build
  process, common modification checklists, known limitations

Rewrites CLAUDE.md as a dense agent reference covering the same ground
in a compact format: code section line ranges, full config key table with
JS access patterns, validation rules, PDF coordinate system, design
decisions, and modification checklists.

https://claude.ai/code/session_01Dad69NPna53u4hucCYnVNs
2026-05-19 09:50:11 +00:00

227 lines
9.7 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Reimbursement Form — Agent Reference
Static browser app that collects expense data via a form UI and generates a PDF with attached receipts. No server, no build step — one HTML file + config.yml + optional logo.
## Repository layout
```
.
├── CLAUDE.md # This file
├── README.md
├── LICENSE
├── docs/
│ ├── user-guide.md # End-user instructions
│ ├── admin-guide.md # Deployment and config.yml reference
│ └── developer-guide.md # Fork/extend instructions, design notes
└── app/
├── index.html # Entire application (~870 lines)
├── config.yml # Runtime configuration (loaded via fetch at startup)
└── assets/
└── logo.png # Optional logo (PNG or JPG)
```
## Architecture
Single-file app. CSS in `<style>`, JS in `<script>`. No framework, no build tools. Vanilla JS with DOM manipulation (no re-rendering — elements are created once, updated via event listeners). Two CDN dependencies loaded at runtime:
- **pdf-lib** 1.17.1 — PDF creation, image embedding, PDF merging (`unpkg.com`)
- **js-yaml** 4.1.0 — config.yml parsing (`unpkg.com`)
## Code sections (index.html)
| Banner | Approx lines | Contents |
|---|---|---|
| `UTILITIES` | 103131 | `uid()`, `el()`, `fmtAmt()`, `defaultPeriod()` |
| `CONFIG` | 136143 | `loadConfig()` — fetches and parses config.yml |
| `STATE` | 145155 | `state` object, `newItem()`, `newLine()` |
| `CALCULATIONS` | 157175 | `recalc()` |
| `CURRENCY DROPDOWN` | 177195 | `makeCDD()` |
| `SELECT HELPER` | 197208 | `makeSelect()` |
| `FORM RENDERING` | 210469 | `render()`, `renderItem()`, `renderLine()`, `buildReceiptArea()` |
| `VALIDATION` | 471500 | `validate()` |
| `PDF ENGINE` | 502786 | `generatePDF()` and helpers |
| `GENERATE HANDLER` | 831854 | `onGenerate()` |
| `INIT` | 856869 | `init()` |
## State model
```js
state = {
staff: string, // persisted to localStorage('reimb-staff')
periodFrom: string, // YYYY-MM-DD
periodTo: string, // YYYY-MM-DD
baseCurrency: string, // ISO code; from CFG['currency-base']
fxRateMemory: {}, // { [code]: '00.00000' } session cache
items: Item[],
_grandTotal: number // computed by recalc()
}
Item = {
id: string,
name: string,
lines: Line[],
_subtotal: number // computed by recalc()
}
Line = {
id: string,
date: string, // YYYY-MM-DD
description: string,
currency: string, // ISO code
fxRate: string, // '0.00000'; units of line currency per 1 base currency
vendor: string,
hasReceipt: boolean,
receipts: Receipt[],
noReceiptExplanation: string,
amount: string, // in line currency
account: string, // from CFG.accounts[]
program: string, // from CFG.programs[]
programOther: string // used when program === 'Other'
}
Receipt = {
name: string,
type: string, // 'application/pdf' | 'image/png' | 'image/jpeg'
data: ArrayBuffer
}
```
State is mutated directly. After any change that affects totals, call `recalc()` explicitly.
## Config keys — full reference
All config is accessed as `CFG['key']` after `jsyaml.load()`. Keys with hyphens must use bracket notation.
| JS access | YAML key | Type | Default | Notes |
|---|---|---|---|---|
| `CFG.organization` | `organization` | string | — | Org name; PDF header and logo fallback |
| `CFG.logo` | `logo` | `yes`/`no` | — | Checked as `=== true \|\| === 'yes'`; loads `assets/logo.png` then `assets/logo.jpg` |
| `CFG['logo-maxwidth']` | `logo-maxwidth` | number (cm) | unconstrained | Converted to pt by `× 28.3465` for PDF; applied as `maxWidth` CSS in UI |
| `CFG['page-size']` | `page-size` | `'A4'`/`'letter'` | `'A4'` | A4: 595.28×841.89 pt; letter: 612×792 pt |
| `CFG['font-body']` | `font-body` | string | `'Helvetica'` | Parsed but currently unused (hardcoded to StandardFonts) |
| `CFG['font-heading']` | `font-heading` | string | `'Helvetica'` | Parsed but currently unused |
| `CFG['font-monospace']` | `font-monospace` | string | `'Courier'` | Parsed but currently unused |
| `CFG['font-size']` | `font-size` | number (pt) | `10` | PDF base size; `szSm = sz-1`, `szLg = sz+4`, `lh = sz+4` |
| `CFG['accent-colour']` | `accent-colour` | hex string | `'#1a3a5c'` | Set as CSS `--accent`; converted via `parseHex()` for PDF |
| `CFG.intro` | `intro` | string | `''` | Rendered on PDF page 1 before header fields; omitted if empty |
| `CFG.footer` | `footer` | string | `''` | Bottom of every PDF page |
| `CFG['currency-base']` | `currency-base` | ISO code | — | Initial `state.baseCurrency`; must be in `currencies` list |
| `CFG.currencies` | `currencies` | `{code, name}[]` | — | Options for per-line currency dropdown |
| `CFG.accounts` | `accounts` | `string[]` | — | Options for account dropdown; selection required per line |
| `CFG.programs` | `programs` | `string[]` | — | Options for program dropdown; literal `'Other'` triggers text input |
## Validation rules
`validate()` returns `string[]`. Empty array means valid.
- `state.staff` — required (non-empty after trim)
- `state.periodFrom`, `state.periodTo` — both required
- `state.items.length > 0` — at least one item
- Per item: `name` required; `lines.length > 0`
- Per line:
- `date` — required
- `description` — required
- `vendor` — required
- `amount` — required; must parse as positive float
- `fxRate` — required positive float if `currency !== baseCurrency`
- `account` — required
- `program` — required
- if `program === 'Other'`: `programOther` required
- if `hasReceipt === true`: `receipts.length > 0`
- if `hasReceipt === false`: `noReceiptExplanation` required
## PDF layout
**Coordinate system:** origin bottom-left, Y increases upward.
**Margins:** top 50, bottom 65, left 50, right 50 (pt).
**Usable width:** `W = pageW - 100`.
**Column positions** (inside line blocks):
```
c1 = 0 // Date
c2 = W * 0.22 // Vendor
c3 = W * 0.68 // Currency, Receipt label
c4 = W * 0.82 // FX rate, Amount label
```
Program: `W * 0.5`. Account: left edge.
**Header columns:** Staff at left, Period at `W * 0.5`, Currency at `W * 0.8`.
**Page break:** `needSpace(h)` checks `y - h < marginBottom`; if so, calls `addPage()` which draws a continuation header (staff name + period + divider).
**Four-pass PDF build:**
1. Form pages + receipt placeholder recording (`receiptRefs[]`)
2. Receipt pages — PDF merging and image embedding (`receiptPageMap{}`)
3. Backfill receipt references ("See page N for receipt")
4. Footers on all pages (page X/Y, printed timestamp, staff name)
**Downloaded filename:** `reimbursement_[staff]_[from]_[to].pdf` (spaces replaced with `_`).
## FX rate convention
Rate = units of line currency per 1 base currency.
```
base_amount = line_amount / fxRate
```
Example: 1000 THB at rate 34.25000 → 1000/34.25 = 29.20 USD.
The `fxRate` field is locked to `'1.00000'` (readonly) when `line.currency === state.baseCurrency`. FX rates are cached in `state.fxRateMemory[code]` while the page is open.
## Key design decisions
**`makeCDD` — custom currency dropdown:** native `<select>` cannot show code+name two-line options that collapse to code-only. Built as a positioned div with click-outside-to-close via a document-level listener.
**Receipt storage as ArrayBuffer:** files are stored raw in state, ready for pdf-lib without re-reading. Memory is proportional to total file size.
**No re-rendering:** DOM is mutated in-place. Exception: `buildReceiptArea()` replaces the receipt area div when file list or toggle changes.
**Period auto-fill:** `defaultPeriod()` returns the previous calendar month. Exception: if today is the last day of the month, returns the current month.
**localStorage key:** `reimb-staff` — staff name only; no other state is persisted.
## Common tasks (checklist)
**Add a field to an expense line:**
1. `newLine()` — add field with default
2. `renderLine()` — create DOM, wire event listener, call `recalc()` if it affects amounts
3. `validate()` — add error if required
4. `generatePDF()` — render value at appropriate position
**Add a config option:**
1. Add to `config.yml`
2. Read as `CFG['your-key']` in JS
3. No other registration needed
**Change PDF column layout:**
Edit `c1``c4` constants inside the `item.lines.forEach` block in `generatePDF()`.
**Implement custom TTF fonts:**
1. `const bytes = await fetch('assets/Font.ttf').then(r => r.arrayBuffer())`
2. `const font = await doc.embedFont(bytes)`
3. Replace `StandardFonts.Helvetica` references with the embedded font
## Known limitations
| Limitation | Detail |
|---|---|
| Custom fonts not embedded | `font-body`/`font-heading`/`font-monospace` config keys parsed but ignored; hardcoded to Helvetica/Courier |
| Password-protected PDFs fail | pdf-lib cannot decrypt; error page inserted instead |
| No offline support | CDN dependencies require network on first load |
| Large receipts stress memory | All ArrayBuffers held in JS heap |
| Long text truncated with `…` | Most PDF fields truncate to fit column width; only `intro` and `noReceiptExplanation` wrap |
## Deployment
Static files served from any HTTP server. No build step. Place `app/` at the target path. The server must serve `.yml` files (Caddy does this by default; for Nginx add `text/yaml yml` to `types {}`).
Local development: `python3 -m http.server 8080` from the `app/` directory. Do not open `index.html` via `file://``fetch('config.yml')` is blocked on that origin.
## Human-readable docs
- `docs/user-guide.md` — filling in the form (staff audience)
- `docs/admin-guide.md` — deployment and all config.yml keys (admin audience)
- `docs/developer-guide.md` — architecture, state, PDF engine, modification patterns (developer audience)