5.3 KiB
Harding's Clocks — Repair Tracker
A repair-order tracker for a one-person clock repair shop. Full spec lives in
DESIGN.md — read it for the data model, route table, and design rationale.
This file covers what's useful for day-to-day development that isn't already
in the design doc.
Goals & constraints
- One user (Dad), one machine, one job at a time. Optimize for simplicity over flexibility — this is explicitly not meant to grow into a multi-tenant or general-purpose product.
- No client-side app framework: the server renders HTML, HTMX does partial updates. Don't introduce React/Vue/a JSON API layer for this app.
- No auth in the app itself — that's Caddy's job in front of it (see DESIGN.md §9). Don't add login/session code here.
- Money is always integer cents in the DB; format at the view layer via the
moneyNunjucks filter (src/money.ts). Never store floats for currency.
Tech stack
Express + TypeScript (ESM/NodeNext) + better-sqlite3 + Nunjucks + HTMX.
Package manager is pnpm (not npm — a package-lock.json should not
reappear; pnpm-lock.yaml is the one committed).
Deviation from DESIGN.md §3/§8: the doc lists Vite for asset bundling.
There's no client JS/TS to bundle — just vendored htmx.min.js and one plain
CSS file — so Vite was dropped as ceremony with no payoff. Express serves
public/ and src/views/*.njk directly. tsc compiles the server; the
build script then copies schema.sql and views/ into dist/ since tsc
only touches .ts files.
pnpm + better-sqlite3 gotcha
better-sqlite3 needs a native addon compiled on install. pnpm blocks
postinstall/install scripts by default for security, which leaves
better_sqlite3.node missing and the app fails at runtime with a bindings
error (not at pnpm install time — it only surfaces when new Database()
runs). This is handled by pnpm.onlyBuiltDependencies: ["better-sqlite3"] in
package.json, which allows pnpm to run its build script non-interactively.
If native-binding errors show up after adding new deps, check whether they
need the same treatment (pnpm approve-builds for one-off interactive
approval, or add to onlyBuiltDependencies for it to be automatic).
Commands
pnpm install # first-time setup (compiles better-sqlite3's native binding)
pnpm dev # tsx watch src/server.ts — reads views/schema straight from src/
pnpm build # tsc + copy schema.sql/views into dist/
pnpm start # node dist/server.js — run this after pnpm build
pnpm backup # scripts/backup.ts — see DESIGN.md §10
Nunjucks template changes are not hot-reloaded (chokidar isn't a dependency,
to avoid the extra install); restart pnpm dev after editing .njk files.
The dev server and dist/server.js both bind to 127.0.0.1:3000. The SQLite
file lives at data/app.db (gitignored) and bootstraps itself from
schema.sql on first run if missing.
Structure
src/
├─ server.ts # Express app, Nunjucks config, route mounting
├─ db.ts # DB connection, schema bootstrap, order-number allocation
├─ money.ts # cents <-> string helpers ("money", "lineTotal" filters)
├─ schema.sql # DDL — source of truth for the data model
├─ routes/
│ ├─ orders.ts # order list/detail/create/update/delete, line items, work log
│ ├─ customers.ts # customer list/search
│ ├─ clocks.ts # HTMX fragment: clock <option> list for a given customer
│ └─ invoice.ts # printable invoice page
└─ views/ # Nunjucks templates; `_`-prefixed files are HTMX fragments
public/css/style.css # all styling — design tokens live in :root, see DESIGN.md §12
scripts/backup.ts # nightly SQLite backup via better-sqlite3's backup API
HTMX patterns used here
- Adding/removing a line item returns the changed
<tr>(or nothing, for a delete) plus an out-of-band swap of#totals-summaryso the totals box stays in sync without a full page reload. Seesrc/views/orders/_line_item_added.njkand_totals.njk(theoobflag controls whetherhx-swap-oob="true"is emitted). - The order-summary edit form (status, dates, tax/shipping/etc.) posts to
POST /orders/:idand swaps the whole#order-summarycard viahx-swap="outerHTML", which naturally refreshes its embedded totals too. - Deleting an order uses
hx-deleteand anHX-Redirectresponse header rather than a plain 3xx, since HTMX needs that header to force a full-page navigation after an XHR delete.
Verifying UI changes
Don't spin up Playwright/chromium-cli/headless-browser automation to verify frontend changes in this repo. Make the code change, explain what you did and why it should work, and ask the user (Dad's the only one who runs this app) to check it in their own browser.
Data model notes worth remembering
order_numberstarts at 8247, is allocated viaMAX(order_number) + 1innextOrderNumber()(src/db.ts), and is never reused — gaps from soft-deleted orders are expected and fine.- Orders are soft-deleted (
deleted_at); every query that lists/reads orders filters ondeleted_at IS NULL(baked into theorder_totalsview too). order_totalsis a SQL view, not computed in application code — if you add a new money field toservice_orders, update the view inschema.sql.