Add design doc
This commit is contained in:
@@ -0,0 +1,228 @@
|
||||
# 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.
|
||||
Reference in New Issue
Block a user