Users were confused by the "X foreign = 1 local" convention, which is the inverse of how exchange rates are normally quoted (e.g. "1 USD = 35 THB"). Flip to the market-standard "1 foreign = X local" direction, updating the rate label, the price calculation (per * rate instead of per / rate), and the PDF note. Note: existing localStorage data using the old convention will produce incorrect prices until users re-enter their exchange rates. https://claude.ai/code/session_0151QtsUhzXmgzEhSvXG2SDt
4.8 KiB
CLAUDE.md — Freelance Invoicing App
Project overview
Single-file browser app for generating freelance invoices as PDF or print preview. No backend, no build step. All app logic lives in app/index.html (~1600 lines: HTML + embedded CSS + embedded JS). Runtime config is loaded from app/config.yml via fetch().
Running locally
Must be served over HTTP — fetch() for config.yml fails on file://.
npx serve app # recommended
python3 -m http.server 8000 # run from app/
No package.json, no bundler, no install step.
Architecture
app/
index.html — entire application
config.yml — runtime config (recipients, products, translations, etc.)
docs/
devdocs/Specs.md
user-guide.md / admin-guide.md / dev-guide.md
CDN dependencies (loaded via <script> tags in index.html):
js-yaml 4.1.0(cdnjs) — parsesconfig.ymljsPDF 2.5.1(cdnjs) — PDF generation
Boot sequence: loadCfg() → boot() → buildLangBar(), buildForm(), restoreStorage(), loadLines() || addLine()
Key globals:
cfg— parsed config objectlang— active language codelid/lines— line ID counter + map of active invoice line IDstlid/tLines— tax-line ID counter + map_loading— bool;truewhileloadLines()restores from localStorage; blockssaveLines()writes
localStorage keys:
inv_data_v1— form field values (all[data-ls]elements)inv_lines_v1— serialised line arrayinv_generated_v1— bump flag; invoice number increments on next load after GeneratezoomIdx— font-size index
PDF / preview: gatherData() feeds both buildPreviewHTML() (overlay) and buildPDF() (file download). Uses jsPDF with Helvetica, mm units; paper size from config (a4 or letter).
i18n: t(key) looks up cfg.translations[key][lang], falls back to default-code. relabel() re-applies all translatable labels on language switch.
Critical state & invariants
Do not break these behaviours:
- Charge-to: value
""→ all CT fields locked. Predefined entry → fill + lock."other"→ unlock. - Line description: value
""→ QTY/UOM/Price disabled. Predefined → UOM locked, Price editable."other"→ all three editable. - FX: FX Yes → Price locked, calculated as
per_item_foreign / exchange_rate. FX No → Price re-enabled. _loadingguard:saveLines()must no-op when_loading === true; set_loading = truefor the entire duration ofloadLines().addLine()mustreturn i—loadLines()depends on the returned ID.saveStorage()is triggered bychangeevents on all[data-ls]elements; do not remove that listener.- Invoice number bump: occurs on the next page load after Generate via the
inv_generated_v1flag; do not bump on Generate itself. - FX rate convention: 1 foreign unit = X local units (market-standard quote).
price_local = price_foreign * exchange_rate.
Config structure (config.yml)
| Section | Purpose |
|---|---|
default-code / languages |
Available UI languages and default |
hide-payment-info |
Bool; omits payment block from PDF |
date-format / paper-format |
Output formatting |
tax-types |
Tax rows available in the tax table |
uom |
Units of measure for line items |
charge-to |
Recipients; each may have project-codes and currency |
project-codes |
Global fallback project code list |
products |
Predefined line items; multilingual description/uom/price |
currencies |
Currency codes and exchange rates |
translations |
All UI strings keyed by language code |
Config changes (new recipients, products, translations) never require JS changes.
Common tasks
Add a persisted form field
- Add the element to
buildForm()with adata-ls="your_key"attribute. - Nothing else —
saveStorage()andrestoreStorage()handle it automatically. - If the label is translatable, add the key to
cfg.translationsinconfig.ymland updaterelabel()to set it.
Add a new line-level field
- Add the input to the line row HTML in
addLine(). - Add the field to the object written in
saveLines(). - Read it back in
loadLines()when reconstructing each line. - If translatable, update
relabel().
Add a new language
- Add the language code to
languagesinconfig.yml. - Add a translation entry for every key in
cfg.translationsusing the new code. - Set
default-codeto the new code if it should be the default. - No JS changes needed;
buildLangBar()andt()pick it up automatically.
Add a new product
Add an entry under products in config.yml:
products:
- id: my_product
description:
en: "Widget"
fr: "Gadget"
uom:
en: "ea"
price: 99.00
No JS changes needed.