Files
Hardings-service/DESIGN.md
T
2026-07-11 18:18:10 -04:00

229 lines
9.6 KiB
Markdown

# Harding's Clocks — Repair Tracker Design Doc
## 1. Purpose
A dead-simple web app for a one-person clock repair shop to:
- Track customers and the clocks they bring in
- Open service orders, log work performed, and list parts
- Generate a printable invoice matching the existing paper form
Design principle: **KISS**. One user (Dad), one machine, one job at a time. No accounts, no multi-tenancy, no plugin system, no build ceremony beyond what Vite needs.
## 2. Non-Goals
- No authentication in the app (handled by Caddy — see §9)
- No customer-facing portal
- No payment processing (just record what was paid)
- No job templates — a fixed `job_type` field instead
- No inventory management — parts are free-text lines on an order
- No email/SMS, no reporting dashboards (can add later)
## 3. Tech Stack
| Layer | Choice | Why |
|---------------|---------------------------|--------------------------------------------------|
| Database | SQLite (`better-sqlite3`) | Single file, zero-config, synchronous API |
| Runtime | Node.js (LTS) | Familiar, boring, reliable |
| Language | TypeScript | Type safety on the data model without heaviness |
| HTTP server | Express (or Fastify) | Minimal routing; serve HTML + handle form posts |
| Frontend | HTMX + server-rendered HTML | No SPA. Server returns HTML fragments. |
| Templating | Nunjucks (or EJS) | Plain HTML templates, easy to eyeball |
| Build/dev | Vite | Fast dev server + asset bundling for the bit of JS/CSS |
| Reverse proxy | Caddy | TLS + basic-auth in front |
**Why HTMX, not React:** the whole app is forms and lists. HTMX lets the server
return HTML fragments on `hx-post`/`hx-get`, so there's no client state to manage
and no API/JSON layer to maintain. TypeScript stays on the server where the logic is.
## 4. Data Model
Five tables plus one view. Money stored as **INTEGER cents** to avoid float rounding.
Dates stored as ISO-8601 TEXT (`YYYY-MM-DD` or full datetime).
```sql
CREATE TABLE customers (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
address_line1 TEXT,
address_line2 TEXT,
city TEXT,
state TEXT,
zip TEXT,
phone TEXT,
alt_phone TEXT,
email TEXT,
created_at TEXT DEFAULT (datetime('now'))
);
CREATE TABLE clocks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
customer_id INTEGER NOT NULL REFERENCES customers(id),
manufacturer TEXT,
model TEXT,
serial_number TEXT,
alternate_id TEXT,
reference TEXT,
description TEXT,
created_at TEXT DEFAULT (datetime('now'))
);
CREATE TABLE service_orders (
id INTEGER PRIMARY KEY AUTOINCREMENT,
order_number INTEGER UNIQUE,
customer_id INTEGER NOT NULL REFERENCES customers(id),
clock_id INTEGER REFERENCES clocks(id),
job_type TEXT CHECK (job_type IN (
'31 DAY','CLEANING','ESTIMATE','FIT UP/REPLACE',
'HOUSE CALL','MOVE CLOCK','OVERHAUL','SERVICE',
'WALK IN SERVICE')),
service_rep TEXT,
technician TEXT,
status TEXT DEFAULT 'OPEN', -- OPEN / IN PROGRESS / DONE / PAID
date_in TEXT DEFAULT (date('now')),
date_out TEXT,
due_date TEXT,
cust_po TEXT,
terms TEXT,
-- money summary (cents)
tax INTEGER DEFAULT 0,
shipping INTEGER DEFAULT 0,
pickup_delivery INTEGER DEFAULT 0,
service_call INTEGER DEFAULT 0,
disposal INTEGER DEFAULT 0,
amount_paid INTEGER DEFAULT 0,
created_at TEXT DEFAULT (datetime('now'))
);
CREATE TABLE line_items (
id INTEGER PRIMARY KEY AUTOINCREMENT,
service_order_id INTEGER NOT NULL REFERENCES service_orders(id) ON DELETE CASCADE,
kind TEXT NOT NULL DEFAULT 'labor', -- 'labor' | 'part' | 'sublet'
stock_code TEXT,
description TEXT,
quantity REAL DEFAULT 1,
unit_price INTEGER DEFAULT 0, -- cents
sort_order INTEGER DEFAULT 0
);
CREATE TABLE work_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
service_order_id INTEGER NOT NULL REFERENCES service_orders(id) ON DELETE CASCADE,
note TEXT NOT NULL,
logged_at TEXT DEFAULT (datetime('now'))
);
CREATE VIEW order_totals AS
SELECT
so.id AS service_order_id,
COALESCE(SUM(CASE WHEN li.kind='part' THEN li.quantity*li.unit_price END),0) AS total_parts,
COALESCE(SUM(CASE WHEN li.kind='labor' THEN li.quantity*li.unit_price END),0) AS total_labor,
COALESCE(SUM(CASE WHEN li.kind='sublet' THEN li.quantity*li.unit_price END),0) AS total_sublet,
COALESCE(SUM(li.quantity*li.unit_price),0)
+ so.tax + so.shipping + so.pickup_delivery + so.service_call + so.disposal AS grand_total
FROM service_orders so
LEFT JOIN line_items li ON li.service_order_id = so.id
GROUP BY so.id;
```
**Order numbers:** `order_number` starts at 8247 (next after the sample) and
increments. Simplest approach: `MAX(order_number)+1` at insert time inside a
transaction. `better-sqlite3` is synchronous so there's no race on a single-user app.
## 5. Application Structure
```
clock-tracker/
├─ src/
│ ├─ server.ts # Express app, route wiring
│ ├─ db.ts # better-sqlite3 connection + prepared statements
│ ├─ schema.sql # the DDL above (run on first boot if db missing)
│ ├─ money.ts # cents <-> "$1.75" helpers
│ ├─ routes/
│ │ ├─ customers.ts
│ │ ├─ clocks.ts
│ │ ├─ orders.ts # create/edit order, add line items, work log
│ │ └─ invoice.ts # printable invoice view
│ └─ views/ # Nunjucks templates + HTMX fragments
│ ├─ layout.njk
│ ├─ orders/
│ │ ├─ list.njk
│ │ ├─ detail.njk
│ │ └─ _line_item_row.njk # fragment returned to HTMX
│ └─ invoice.njk
├─ public/ # CSS, tiny JS, htmx.min.js
├─ data/app.db # the SQLite file (gitignored)
├─ vite.config.ts
├─ tsconfig.json
└─ package.json
```
## 6. Routes
| Method | Path | Returns | Purpose |
|--------|-----------------------------------|------------------|----------------------------------|
| GET | `/` | full page | Order list (open orders first) |
| GET | `/orders/new` | full page | New-order form |
| POST | `/orders` | redirect | Create order + customer/clock |
| GET | `/orders/:id` | full page | Order detail (work + parts) |
| POST | `/orders/:id/line-items` | HTML fragment | Add a labor/part line (HTMX) |
| DELETE | `/orders/:id/line-items/:lineId` | empty/200 | Remove a line (HTMX) |
| POST | `/orders/:id/work-log` | HTML fragment | Add a work-log note (HTMX) |
| POST | `/orders/:id` | HTML fragment | Update status/dates/totals |
| GET | `/orders/:id/invoice` | full page | Printable invoice |
| GET | `/customers` | full page | Customer list/search |
HTMX pattern: adding a part line does `hx-post="/orders/42/line-items"` with
`hx-target="#lines" hx-swap="beforeend"`; the server returns just the `<tr>` for
the new line plus an out-of-band swap updating the totals box.
## 7. The Invoice View
`GET /orders/:id/invoice` renders `invoice.njk` — a print-oriented HTML page
mirroring the paper layout: shop header block, Ship-To, manufacturer/clock block,
the line-item table (work performed + parts), and the totals box from
`order_totals`. A `@media print` stylesheet hides nav and fits one page.
"Print" is just the browser's print dialog → save as PDF or paper. No PDF library.
## 8. Local Dev & Build
- `npm run dev` — Vite dev server for assets + `tsx watch src/server.ts` for the API. (Vite proxies to the Node server, or run them side by side; keep it simple with `concurrently`.)
- `npm run build``vite build` for `public/` assets + `tsc` for server.
- `npm start``node dist/server.js`, serving built assets and listening on `127.0.0.1:3000`.
- DB bootstraps itself: on start, if `data/app.db` doesn't exist, run `schema.sql`.
## 9. Deployment & "Auth"
The app binds to localhost only. Caddy sits in front, terminates TLS, and does
HTTP Basic Auth so only Dad gets in. Example `Caddyfile`:
```
clocks.example.com {
basic_auth {
dad $2a$14$...bcrypt-hash...
}
reverse_proxy 127.0.0.1:3000
}
```
Generate the hash with `caddy hash-password`. That single credential is the whole
security model — acceptable because it's one private user on a small shop tool.
The app itself never sees or stores credentials.
## 10. Backups
The database is one file (`data/app.db`). A nightly `cron` job copies it somewhere
safe:
```
sqlite3 data/app.db ".backup '/backups/app-$(date +%F).db'"
```
Keep ~30 days. That's the entire disaster-recovery plan, and it's enough.
## 11. Future (explicitly deferred)
Only if actually needed: photo attachments per clock (the software's "Images" tab),
customer search-as-you-type, a simple monthly revenue total, quoted-values/estimate
tracking. None of this is built now.