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

9.7 KiB
Raw Permalink Blame History

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

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 c1c4 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)