5.0 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.
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.