reimburse/docs/developer-guide.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

276 lines
11 KiB
Markdown
Raw 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.

# Developer Guide — Reimbursement Form
This guide is for developers who want to fork, extend, or modify the reimbursement form.
---
## Repository layout
```
.
├── CLAUDE.md # Agent-oriented reference (architecture, state, tasks)
├── README.md
├── LICENSE
├── docs/
│ ├── user-guide.md
│ ├── admin-guide.md
│ └── developer-guide.md # this file
└── app/
├── index.html # Entire application (~870 lines)
├── config.yml # Runtime configuration
└── assets/
└── logo.png # Optional organisation logo
```
---
## Technology
| Concern | Technology |
|---|---|
| UI | Vanilla JS — DOM manipulation, no framework, no virtual DOM |
| PDF generation | [pdf-lib](https://pdf-lib.js.org/) 1.17.1 via CDN |
| Config parsing | [js-yaml](https://github.com/nodeca/js-yaml) 4.1.0 via CDN |
| Build | None — single HTML file served as-is |
There is no `package.json`, no bundler, and no transpilation step. The file runs directly in any modern browser.
---
## Code structure inside `index.html`
The JS is one immediately-invoked async function. Sections are separated by `// ===` banners:
| Section | Line range | Purpose |
|---|---|---|
| UTILITIES | ~103131 | `uid()`, `el()`, `fmtAmt()`, `defaultPeriod()` |
| CONFIG | ~136143 | `loadConfig()` — fetches and parses `config.yml` |
| STATE | ~145155 | `state` object, `newItem()`, `newLine()` |
| CALCULATIONS | ~157175 | `recalc()` — recomputes subtotals and grand total |
| CURRENCY DROPDOWN | ~177195 | `makeCDD()` — custom two-line currency picker |
| SELECT HELPER | ~197208 | `makeSelect()` — standard `<select>` builder |
| FORM RENDERING | ~210469 | `render()`, `renderItem()`, `renderLine()`, `buildReceiptArea()` |
| VALIDATION | ~471500 | `validate()` — returns array of error strings |
| PDF ENGINE | ~502786 | `generatePDF()` and helpers |
| GENERATE HANDLER | ~831854 | `onGenerate()` — wires validation to PDF generation |
| INIT | ~856869 | `init()` — entry point |
---
## State model
```js
state = {
staff: string, // Full name, persisted in localStorage('reimb-staff')
periodFrom: string, // YYYY-MM-DD
periodTo: string, // YYYY-MM-DD
baseCurrency: string, // ISO code from CFG['currency-base']
fxRateMemory: {}, // { [currencyCode]: '00.00000' } — session cache
items: Item[],
_grandTotal: number // computed by recalc()
}
Item = {
id: string, // uid()
name: string,
lines: Line[],
_subtotal: number // computed by recalc()
}
Line = {
id: string, // uid()
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, // one of CFG.accounts[]
program: string, // one of CFG.programs[]
programOther: string // used when program === 'Other'
}
Receipt = {
name: string, // original filename
type: string, // MIME type: 'application/pdf', 'image/png', 'image/jpeg'
data: ArrayBuffer // raw file bytes
}
```
State is mutated directly — there is no reactivity system. After any change that affects totals, call `recalc()` explicitly.
---
## Config keys read by the JS
| JS expression | Config key | Type | Notes |
|---|---|---|---|
| `CFG['accent-colour']` | `accent-colour` | string | Hex colour applied via CSS variable and used in PDF |
| `CFG['page-size']` | `page-size` | string | `'A4'` or `'letter'` |
| `CFG['font-size']` | `font-size` | number | Base pt size; `sz` in PDF engine |
| `CFG['font-body']` | `font-body` | string | Parsed but currently unused (hardcoded to Helvetica) |
| `CFG['font-heading']` | `font-heading` | string | Parsed but currently unused |
| `CFG['font-monospace']` | `font-monospace` | string | Parsed but currently unused |
| `CFG.logo` | `logo` | `true` / `'yes'` / `false` / `'no'` | Checked with `=== true \|\| === 'yes'` |
| `CFG['logo-maxwidth']` | `logo-maxwidth` | number | cm; converted to pt by `× 28.3465` |
| `CFG.organization` | `organization` | string | Fallback text and PDF org header |
| `CFG['currency-base']` | `currency-base` | string | Initial value for `state.baseCurrency` |
| `CFG.currencies` | `currencies` | `{code, name}[]` | Currency dropdown options |
| `CFG.accounts` | `accounts` | `string[]` | Account dropdown options |
| `CFG.programs` | `programs` | `string[]` | Program dropdown options |
| `CFG.intro` | `intro` | string | Rendered on first PDF page; empty string omits it |
| `CFG.footer` | `footer` | string | Printed at bottom of every PDF page |
---
## PDF engine
### Coordinate system
pdf-lib uses a bottom-left origin. Y increases upward.
| Page size | Width (pt) | Height (pt) |
|---|---|---|
| A4 | 595.28 | 841.89 |
| letter | 612 | 792 |
Margins: top 50 pt, bottom 65 pt, left 50 pt, right 50 pt.
The cursor variable `y` starts at `pageH - marginTop` and decrements as content is drawn. `needSpace(h)` checks `y - h < marginBottom` and calls `addPage()` if a page break is needed.
### Column positions
Within each expense line the usable width `W = pageW - 100` is divided using fixed proportions:
```js
const c1 = 0; // Date column — left edge
const c2 = W * 0.22; // Vendor column
const c3 = W * 0.68; // Currency / Receipt columns
const c4 = W * 0.82; // FX rate / Amount columns
```
Program label is at `W * 0.5`. Account starts at `c1`.
### Two-pass receipt page reference
The PDF is built in four passes:
1. **Form pages:** draw all item/line content. For each receipt, record `{ pageIdx, x, y, key }` in `receiptRefs[]` — placeholder positions.
2. **Receipt pages:** append embedded PDFs (page by page) and images (scaled to fit). Build `receiptPageMap[key] = pageNumber`.
3. **Reference backfill:** go back to each recorded position and draw `"See page N for receipt"`.
4. **Footers:** iterate all pages and draw the footer line with page X/Y and printed timestamp.
### Fonts
Three fonts are embedded from the standard PDF font set:
```js
const fontBody = await doc.embedFont(StandardFonts.Helvetica);
const fontBold = await doc.embedFont(StandardFonts.HelveticaBold);
const fontMono = await doc.embedFont(StandardFonts.Courier);
```
Custom TTF embedding is not yet wired despite the config keys being present.
### Font sizes
```js
const sz = CFG['font-size'] || 10; // body
const szSm = sz - 1; // labels
const szLg = sz + 4; // title, org name
const lh = sz + 4; // line height
```
---
## Common modification tasks
### Add a new field to an expense line
Follow this checklist in order:
1. **`newLine()`** — add the field with its default value.
2. **`renderLine()`** — create and wire the DOM element; update state in an event listener; call `recalc()` if the field affects amounts.
3. **`validate()`** — add an error string if the field is required.
4. **`generatePDF()`** — render the field value at the appropriate position.
### Add a new config option
1. Add the key and value to `config.yml`.
2. Read it in JS as `CFG['your-key']` (or `CFG.yourKey` if it has no hyphens).
3. No other registration is needed — `jsyaml.load()` makes all keys available.
### Change PDF column proportions
Edit the `c1``c4` constants inside the `item.lines.forEach` block in `generatePDF()`. They are local to that scope. The header layout (Staff/Period/Currency) uses separate constants `col2 = W * 0.5` and `col3 = W * 0.8`.
### Implement custom TTF fonts
The config keys `font-body`, `font-heading`, `font-monospace` are parsed but not used. To implement:
1. Fetch the TTF file: `const fontBytes = await fetch('assets/MyFont.ttf').then(r => r.arrayBuffer())`.
2. Embed it: `const fontBody = await doc.embedFont(fontBytes)`.
3. Replace the `StandardFonts.Helvetica` call with the embedded font.
4. Do the same for bold and monospace variants.
Note: pdf-lib requires the font to be a valid OpenType/TrueType file. Subset embedding is done automatically.
### Add a new top-level form field (not per-line)
1. Add the field to `state` in the module-level `state` declaration.
2. Render it in `render()`, between the header and the divider.
3. Add validation in `validate()`.
4. Render it in `generatePDF()` in the header section (around the Staff/Period/Currency row).
---
## Key design decisions
**Custom currency dropdown (`makeCDD`):** The native `<select>` element cannot display two-line options (code on line 1, name on line 2) that collapse to code-only in the closed state. The custom dropdown is a positioned `div` with click-outside-to-close via a document-level listener.
**FX rate direction:** The rate is "units of line currency per 1 base currency". This means `base_amount = line_amount / fx_rate`. Example: if base is USD and line is THB at rate 34.25, then 1000 THB = 1000/34.25 = 29.20 USD.
**Receipt storage as ArrayBuffer:** Files are stored as `ArrayBuffer` in state (not as object URLs or base64). This keeps them ready for pdf-lib without re-reading. Memory use is proportional to total receipt file size.
**No re-rendering:** DOM elements are created once by `render()`/`renderItem()`/`renderLine()` and mutated in-place by event listeners. The only exception is the receipt area (`buildReceiptArea`), which is replaced when the file list or receipt toggle changes.
**`localStorage` for staff name:** Keyed as `reimb-staff`. This persists the most frequently retyped field across sessions without requiring a server.
**Period default logic:** `defaultPeriod()` returns the previous calendar month in most cases. Exception: if today is the last day of the month, it returns the current month (on the assumption that the user is filing for the month just ending).
---
## Known limitations and planned work
| Limitation | Notes |
|---|---|
| Custom TTF fonts not embedded | Config keys present but code uses StandardFonts only |
| Password-protected PDF receipts fail | pdf-lib cannot decrypt; an error page is inserted instead |
| No offline support | CDN-loaded libraries require internet on first load |
| Large receipts stress browser memory | ArrayBuffer approach stores all bytes in RAM |
| PDF text truncated with `…` | Long field values are cut to fit the fixed column width |
| No multi-line PDF text in most fields | Only `intro` and `noReceiptExplanation` wrap; other fields truncate |
---
## Testing
There is no automated test suite. To test manually:
1. Open `app/index.html` via a local HTTP server (e.g. `python3 -m http.server 8080` from the `app/` directory, then open `http://localhost:8080`).
2. Opening `index.html` as a `file://` URL will fail — `fetch('config.yml')` is blocked by browser security on `file://` origins.
3. Exercise the form: add items and lines, attach receipts (PDF, PNG, JPG), toggle the receipt Yes/No, use non-base currencies, and generate a PDF.
4. Verify the generated PDF: check totals, receipt embedding, page references, footers, and column layout.
---
## Deployment checklist
- [ ] `config.yml` is updated for the target organisation
- [ ] `assets/logo.png` placed if `logo: yes`
- [ ] Web server serves `.yml` with a valid MIME type (`text/yaml`)
- [ ] URL is accessible to intended users
- [ ] Browser test: open form, fill in one claim, generate PDF, verify output