invoice/docs/dev-guide.md
Claude d0c5249bcb
Add user, admin, dev documentation and CLAUDE.md
docs/user-guide.md  — end-user guide covering the full invoice workflow
docs/admin-guide.md — config.yml reference for setting up the app
docs/dev-guide.md   — codebase guide: architecture, state, functions,
                      localStorage, i18n, FX convention, PDF generation
CLAUDE.md           — project briefing for Claude Code sessions
2026-05-24 18:25:19 +00:00

9 KiB
Raw Permalink Blame History

Developer Guide

This is a pure static web app with no build step, no npm, and no bundler. The entire application lives in app/index.html — approximately 1600 lines of HTML, embedded CSS, and embedded JavaScript. Runtime configuration is loaded from app/config.yml via fetch().

Running Locally

Because the app uses fetch() to load config.yml, it cannot be opened directly as a file:// URL. Serve it from any local HTTP server:

npx serve app
# or
python3 -m http.server 8000  # run from the app/ directory

There is no install step. The two external dependencies are loaded from CDN via <script> tags:

  • js-yaml 4.1.0 — parses config.yml at runtime
  • jsPDF 2.5.1 — generates the PDF download

Architecture

app/
  index.html    — entire application
  config.yml    — runtime configuration
docs/
  devdocs/
    Specs.md    — original specification
  user-guide.md
  admin-guide.md
  dev-guide.md
CLAUDE.md
README.md

Everything runs in a single browser context. There is no component framework, no module system, and no state management library. State is held in plain global variables, the DOM is built by injecting HTML strings, and persistence is handled via localStorage.

Boot Sequence

  1. loadCfg() fetches and parses config.yml, sets the cfg and lang globals, then calls boot().
  2. boot() calls buildLangBar(), buildForm(), and restoreStorage() in sequence, then calls loadLines().
  3. If loadLines() finds no saved lines in localStorage it falls back to addLine() to produce a single fresh empty line.
  4. The loading indicator is hidden once boot() completes.

buildForm() injects the entire form as an HTML string into #form-root and wires all event listeners. This means the DOM for the form does not exist before boot() runs — any code that queries form elements must run after boot().

Key Global State

let cfg        // parsed config object (set once in loadCfg)
let lang       // active language code, e.g. "en"
let lid        // line ID counter; auto-increments on each addLine() call
const lines    // map: line ID → {} (key presence = line exists)
let tlid       // tax-line ID counter
const tLines   // map: tax-line ID → {}
let _loading   // true while loadLines() is restoring from localStorage

_loading is the most important guard to understand. saveLines() is a no-op while _loading is true. This prevents partial state from being written to localStorage during restoration — without this guard, each addLine() call during restore would overwrite the full saved state with progressively incomplete data.

Core Functions

Configuration and i18n

t(key) is the i18n lookup function. It returns cfg.translations[key][lang], falling back to the default language code defined in config if the active language has no entry for that key. All UI strings are defined in config.yml under translations:, keyed by string identifier with one entry per language code.

relabel() re-applies translations to the existing DOM when the user switches language. Switching language updates lang and calls relabel() — it does not rebuild the form.

buildLangBar() constructs the language switcher from the languages: list in config.

Form and Charge-To

buildForm() injects the full form HTML and attaches all event listeners. It is called once during boot.

fillChargeTo(v) responds to the charge-to dropdown:

  • "" — locks all charge-to fields
  • "__other__" — unlocks all fields for free-text entry
  • A numeric index — fills fields from the matching config entry and locks them, then calls updateProjectCodes() with that client's project code list

updateProjectCodes(codes) rebuilds the project code dropdown. If codes is null it falls back to the global list from config.

Invoice Lines

addLine() adds a row to the line-item table. All cells (QTY, UOM, Price) start disabled until a product is selected. Returns the new line ID.

pickProduct(i) handles the description dropdown for line i:

  • "" — locks all cells
  • "__other__" — unlocks QTY, UOM, and Price for free entry
  • A predefined product — fills UOM and Price from config, locks UOM, unlocks QTY and Price

It calls saveLines() after updating state.

toggleFx(i) handles the foreign currency toggle for line i:

  • "yes" — locks Price and inserts the FX sub-row containing exchange rate and per-item inputs
  • "no" — removes the sub-row and re-enables Price

It calls saveLines() after the DOM change.

resetLines() asks for confirmation, clears all line DOM nodes and the inv_lines_v1 key in localStorage, then adds one fresh empty line.

Calculation

calcFxFromPer(i) recomputes the local-currency unit price for line i using the FX inputs, then calls calcLine(i) and saveLines().

calcLine(i) updates the line total display: qty × price. Calls calcTotals().

calcTotals() sums all line totals to produce the subtotal, applies each tax line, and computes the final amount to pay.

Persistence

saveLines() serialises the state of every active line to localStorage key inv_lines_v1 as a JSON array. Each entry holds: dsel, dtxt, qty, uom, price, fx, fcur, frate, fper. It is a no-op when _loading is true.

loadLines() reads inv_lines_v1, recreates the DOM for each saved line with field states matching the saved values, and calls saveLines() at the end. It sets _loading = true for the duration and resets it before the final saveLines() call. Returns true if lines were restored, false otherwise.

saveStorage() writes every field marked [data-ls] to inv_data_v1 as JSON. This covers sender details, currency, project code, invoice number, and payment fields.

restoreStorage() reads inv_data_v1 and repopulates [data-ls] fields. If the inv_generated_v1 flag is set it also bumps the invoice number, then clears the flag.

Preview and PDF

gatherData() collects all current form values into a plain object. Both buildPreviewHTML() and buildPDF() call this function — it is the single source of truth for what ends up in the output.

buildPreviewHTML() returns an HTML string rendered in an overlay so the user can review the invoice before downloading.

buildPDF() draws the invoice onto a jsPDF document using Helvetica, in the page format specified by config (a4 or letter), with units in mm. Layout order: 2×2 header grid (Sender | Invoice meta, then Charge-to | Payment), line-items table, totals block. The function manages its own Y cursor and calls doc.addPage() when content would overflow the current page.

localStorage Keys

Key Contents
inv_data_v1 JSON: all [data-ls] field values
inv_lines_v1 JSON array: one object per line
inv_generated_v1 "true" after Generate is clicked; triggers invoice number bump on next load
zoomIdx Integer 07 index into the ZOOMS array for accessibility font size

FX Calculation

Exchange rate is expressed as "X units of foreign currency = 1 unit of local currency". The local-currency unit price is therefore:

price (local) = per-item (foreign) ÷ exchange rate

updateFxLabels(i) keeps the exchange-rate and per-item input labels in sync with the actual currency codes shown in the FX sub-row.

When the user changes either the per-item amount or the exchange rate, calcFxFromPer(i) recomputes price and propagates it through calcLine(i).

i18n

All translatable strings live in config.yml under translations:. Each key maps to an object whose properties are language codes:

translations:
  invoice-label:
    en: Invoice
    fr: Facture

t(key) resolves the string for the active lang, falling back to cfg["default-code"] if the key is missing for that language.

To add a new language:

  1. Add an entry to languages: in config with a direction value (ltr or rtl).
  2. Add a translation entry for every existing key under translations:.
  3. No code changes are needed — buildLangBar() and t() are driven entirely by config.

Extending the App

Adding a config field to the PDF

  1. Add the field to config.yml.
  2. Read it in loadCfg() if it is needed globally, or read it inside gatherData() via g("field-id") if it is per-invoice.
  3. Add the corresponding HTML input inside buildForm(). Give it a data-ls attribute if the value should persist across sessions.
  4. Render the value in buildPreviewHTML() and buildPDF().

Adding a new invoice line field

  1. Add the cell to the row HTML template inside addLine().
  2. Serialise it in saveLines() and deserialise it in loadLines(), matching the field name used in the saved object.
  3. Update gatherData() to collect the value and add rendering logic in buildPreviewHTML() and buildPDF().

Changing PDF layout

buildPDF() owns its Y cursor as a local variable. Find the section you want to change and adjust the coordinate arithmetic. Page overflow is handled by a guard that calls doc.addPage() and resets the cursor — update this threshold if you add content that changes page height.