commit d1cb472696d57de38aadc9848602266cb8611a99 Author: trav Date: Sat Aug 15 15:02:45 2026 -0400 Initial commit: repair cafe kanban board A per-event queue board for a repair cafe. Customers sign items in on a shared tablet; volunteers drag repairs through Queue, In progress and Done on a dashboard, then export the day's records as CSV. Zero npm dependencies: node:http plus JSON files on disk, no build step. Categories and house rules are set in config.json. Events are deleted 7 days after creation. Co-Authored-By: Claude Opus 5 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3bc74b0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +# Event data: real customers' names and what they brought in. Never commit it. +data/ + +# Editor backups +*~ +*.swp diff --git a/README.md b/README.md new file mode 100644 index 0000000..5aa485b --- /dev/null +++ b/README.md @@ -0,0 +1,123 @@ +# Repair Cafe Kanban + +A small queue board for a repair cafe. Volunteers create a board for one event, +customers sign their items in on a shared tablet, and volunteers drag repairs +through **Queue → In progress → Done**, then download the day's data as a CSV. + +No database, no build step, no npm dependencies. Just Node. + +## Running it + +```sh +git clone ssh://git@git.autonomic.zone:2222/trav/repair-cafe-kanban.git +cd repair-cafe-kanban +node server.js +``` + +Then open . Node 18 or newer is all it needs — there is +nothing to install. + +To use a different port: + +```sh +PORT=9000 node server.js +``` + +By default it listens on all interfaces, so other people on the same network can +reach it at `http://:8080`. Set `HOST=127.0.0.1` to keep it on +this machine only. + +## How it is used on the day + +1. On the main page press **Create repair cafe kanban** and name the event. + Tick *randomise the address* if you want a hard-to-guess URL. +2. You land on the **sign-in form** at `/`. Leave this open on a + tablet or laptop by the door — it clears itself after every submission. +3. Open the **dashboard** at `//dashboard` on the volunteers' laptop. + There is a link to it at the bottom of the sign-in form. +4. Drag cards between columns. Moving a card into *In progress* asks who is + repairing it; moving it into *Done* asks how it went. A card that reaches + *Done* without a result is outlined in red until someone fills it in. +5. Click any card's title to see and edit the full record, and to add notes. +6. Press **Download CSV** at the end of the day. + +On a touch screen, drag a card by its **⠿** grip in the top right corner — that +leaves the rest of the card free for scrolling. You can also change a card's +status from the detail overlay without dragging at all. + +## Configuration + +Everything adjustable lives in `config.json`. Restart the server after editing it. + +| Key | What it does | +| --- | --- | +| `port` | Port to listen on (the `PORT` environment variable wins). | +| `retentionDays` | Events are deleted this many days after creation. Set to `0` to keep them forever. | +| `categories` | The item categories offered on the sign-in form, each with the colour of its badge on the board. | +| `houseRules` | The text shown in the house rules popup. `\n` starts a new line. | + +Adding a category is a config edit and a restart — no code change. + +## Where the data lives + +One JSON file per event in `data/`, named after its URL. They are plain text, so +you can read, back up, or delete them by hand. `data/expired.json` remembers the +names of events that have been auto-deleted, so their URLs can say "expired" +instead of "not found". + +Each event's CSV is linked from its own dashboard. There is also a **combined CSV +of every event at `/export.csv`** — it is not linked from anywhere, since it is +for the person running the server rather than for volunteers on the day. + +**Events are permanently deleted after `retentionDays` days** (7 by default), +on startup and once a day after that. Download the CSV before then if you want +to keep the data. + +## Putting it on a real server + +Copy the folder up and run `node server.js`. To keep it running, a systemd unit +is enough: + +```ini +# /etc/systemd/system/repair-cafe.service +[Unit] +Description=Repair Cafe Kanban +After=network.target + +[Service] +ExecStart=/usr/bin/node /srv/repair-cafe-kanban/server.js +WorkingDirectory=/srv/repair-cafe-kanban +Environment=HOST=127.0.0.1 +Restart=always +User=www-data + +[Install] +WantedBy=multi-user.target +``` + +Then put nginx in front of it for TLS and a real domain name: + +```nginx +server { + server_name repair.example.org; + location / { + proxy_pass http://127.0.0.1:8080; + proxy_set_header Host $host; + } +} +``` + +There are no accounts and no passwords: anyone with a board's URL can view it, +sign items in, and move cards. That is fine for a public repair cafe, but do not +put anything on it you would not put on a whiteboard in the room. + +## Layout + +``` +server.js HTTP server: routing, JSON API, CSV export, retention +config.json categories, house rules, port, retention +lib/store.js reading and writing events on disk +lib/csv.js CSV formatting +public/ the pages, one stylesheet, two scripts +data/ one JSON file per event (created on first run, not in git) +``` diff --git a/config.json b/config.json new file mode 100644 index 0000000..63faf55 --- /dev/null +++ b/config.json @@ -0,0 +1,13 @@ +{ + "port": 8080, + "retentionDays": 7, + "categories": [ + { "name": "bicycle", "color": "#2f6f4f" }, + { "name": "appliance", "color": "#8a4b1f" }, + { "name": "sewing", "color": "#7a2f6a" }, + { "name": "electronics", "color": "#1f4f7a" }, + { "name": "computer", "color": "#4a4a8a" }, + { "name": "wood", "color": "#6b5230" } + ], + "houseRules": "House Rules\n\n1. Repairs are carried out by volunteers, free of charge. We do our best, but we cannot guarantee that your item will be fixed.\n\n2. You stay with your item while it is being repaired, and you help where you can. This is a repair cafe, not a drop-off service.\n\n3. Repairs are done at your own risk. Neither the volunteers nor the organisers are liable for any loss or damage to your item, or for any consequences of the repair.\n\n4. We do not have every spare part. If a part is needed, we will tell you what to buy so you can come back another time.\n\n5. One item per visit, please, so everyone gets a turn. If the queue is short you are welcome to rejoin it with a second item.\n\n6. Please take your item and any waste home with you at the end of the day." +} diff --git a/lib/csv.js b/lib/csv.js new file mode 100644 index 0000000..0282f56 --- /dev/null +++ b/lib/csv.js @@ -0,0 +1,66 @@ +'use strict'; + +const COLUMNS = [ + 'event', + 'date/time added', + 'customer', + 'item', + 'category', + 'problem', + 'status', + 'repairer', + 'outcome', + 'tools/consumables', + 'notes', +]; + +// Quote a field if it contains anything that would break the row, doubling any +// quotes inside it. Everything else goes out bare. +function escapeField(value) { + const text = value === null || value === undefined ? '' : String(value); + if (/[",\r\n]/.test(text)) return '"' + text.replace(/"/g, '""') + '"'; + return text; +} + +function row(fields) { + return fields.map(escapeField).join(','); +} + +// "2026-08-15 17:14" in the server's timezone — spreadsheets read that as a +// date, which a raw UTC ISO string does not. +function localDateTime(iso) { + const d = new Date(iso); + if (Number.isNaN(d.getTime())) return iso; + const pad = (n) => String(n).padStart(2, '0'); + return ( + `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ` + + `${pad(d.getHours())}:${pad(d.getMinutes())}` + ); +} + +function repairRows(event) { + return event.repairs.map((r) => + row([ + event.name, + localDateTime(r.createdAt), + r.customer, + r.item, + r.category, + r.problem, + r.status, + r.repairer, + r.outcome, + r.toolsUsed, + (r.notes || []).map((n) => n.text).join(' | '), + ]) + ); +} + +// A leading BOM so Excel reads it as UTF-8 rather than mangling accents. +function toCsv(events) { + const lines = [row(COLUMNS)]; + for (const event of events) lines.push(...repairRows(event)); + return '' + lines.join('\r\n') + '\r\n'; +} + +module.exports = { toCsv, escapeField, COLUMNS }; diff --git a/lib/store.js b/lib/store.js new file mode 100644 index 0000000..1508c29 --- /dev/null +++ b/lib/store.js @@ -0,0 +1,267 @@ +'use strict'; + +const fs = require('node:fs'); +const path = require('node:path'); +const crypto = require('node:crypto'); + +const ROOT = path.join(__dirname, '..'); +const DATA_DIR = path.join(ROOT, 'data'); +const CONFIG_PATH = path.join(ROOT, 'config.json'); + +// Slugs are stripped to [a-z0-9-], so no event file can ever be called this. +const EXPIRED_PATH = path.join(DATA_DIR, 'expired.json'); + +// URL segments the router needs for itself. +const RESERVED_SLUGS = new Set(['new', 'api', 'static', 'export.csv', 'dashboard', 'data', 'expired']); + +const STATUSES = ['queue', 'in-progress', 'done']; +const OUTCOMES = ['fixed', 'partially fixed', 'not fixed']; + +let config = null; + +function loadConfig() { + config = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf8')); + if (!Array.isArray(config.categories) || config.categories.length === 0) { + throw new Error('config.json must list at least one category'); + } + return config; +} + +function getConfig() { + if (!config) loadConfig(); + return config; +} + +function categoryNames() { + return getConfig().categories.map((c) => c.name); +} + +function ensureDataDir() { + fs.mkdirSync(DATA_DIR, { recursive: true }); +} + +// "Saturday Repair Day!" -> "saturday-repair-day" +function slugify(name) { + return String(name || '') + .toLowerCase() + .trim() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') + .slice(0, 60); +} + +function randomSuffix() { + // Base32-ish alphabet with no vowels or lookalikes, so a suffix read aloud + // over a noisy room doesn't turn into a different event. + const alphabet = '23456789bcdfghjkmnpqrstvwxz'; + let out = ''; + const bytes = crypto.randomBytes(4); + for (const b of bytes) out += alphabet[b % alphabet.length]; + return out; +} + +function eventPath(slug) { + return path.join(DATA_DIR, `${slug}.json`); +} + +function eventExists(slug) { + return fs.existsSync(eventPath(slug)); +} + +// Write to a temp file in the same directory, then rename over the target, so a +// crash mid-write can never leave a half-written event behind. +function writeJson(file, value) { + const tmp = `${file}.${process.pid}.tmp`; + fs.writeFileSync(tmp, JSON.stringify(value, null, 2)); + fs.renameSync(tmp, file); +} + +function readExpired() { + try { + return JSON.parse(fs.readFileSync(EXPIRED_PATH, 'utf8')); + } catch { + return {}; + } +} + +function isExpired(slug) { + return Object.prototype.hasOwnProperty.call(readExpired(), slug); +} + +function listSlugs() { + ensureDataDir(); + return fs + .readdirSync(DATA_DIR) + .filter((f) => f.endsWith('.json') && f !== 'expired.json') + .map((f) => f.slice(0, -5)) + .sort(); +} + +function loadEvent(slug) { + try { + return JSON.parse(fs.readFileSync(eventPath(slug), 'utf8')); + } catch { + return null; + } +} + +function saveEvent(event) { + ensureDataDir(); + writeJson(eventPath(event.slug), event); + return event; +} + +function listEvents() { + return listSlugs() + .map(loadEvent) + .filter(Boolean) + .sort((a, b) => a.createdAt.localeCompare(b.createdAt)); +} + +function createEvent(name, randomize) { + ensureDataDir(); + const base = slugify(name); + if (!base) throw new Error('Please give the event a name.'); + + let slug = randomize ? `${base}-${randomSuffix()}` : base; + // Always resolve a collision, even when the box was unchecked — two events + // sharing a URL would silently merge their queues. + while (RESERVED_SLUGS.has(slug) || eventExists(slug) || isExpired(slug)) { + slug = `${base}-${randomSuffix()}`; + } + + return saveEvent({ + slug, + name: String(name).trim(), + createdAt: new Date().toISOString(), + repairs: [], + }); +} + +function newId() { + return 'r_' + crypto.randomBytes(4).toString('hex'); +} + +function addRepair(event, input) { + const category = String(input.category || '').trim().toLowerCase(); + if (!categoryNames().includes(category)) { + throw new Error(`"${input.category}" is not one of the categories in config.json.`); + } + const customer = String(input.customer || '').trim(); + const item = String(input.item || '').trim(); + if (!customer) throw new Error('Please give the customer name.'); + if (!item) throw new Error('Please give the item name.'); + if (input.agreedRules !== true) throw new Error('The house rules must be agreed to.'); + + const repair = { + id: newId(), + createdAt: new Date().toISOString(), + customer, + item, + category, + problem: String(input.problem || '').trim(), + status: 'queue', + repairer: null, + outcome: null, + toolsUsed: '', + notes: [], + }; + event.repairs.push(repair); + saveEvent(event); + return repair; +} + +const EDITABLE_TEXT = ['customer', 'item', 'problem', 'repairer', 'toolsUsed']; + +function updateRepair(event, id, patch) { + const repair = event.repairs.find((r) => r.id === id); + if (!repair) return null; + + for (const field of EDITABLE_TEXT) { + if (patch[field] !== undefined) { + const value = patch[field] === null ? '' : String(patch[field]).trim(); + repair[field] = field === 'repairer' ? value || null : value; + } + } + + if (patch.category !== undefined) { + const category = String(patch.category).trim().toLowerCase(); + if (!categoryNames().includes(category)) { + throw new Error(`"${patch.category}" is not one of the categories in config.json.`); + } + repair.category = category; + } + + if (patch.status !== undefined) { + if (!STATUSES.includes(patch.status)) throw new Error(`Unknown status "${patch.status}".`); + repair.status = patch.status; + } + + if (patch.outcome !== undefined) { + const outcome = patch.outcome === null || patch.outcome === '' ? null : String(patch.outcome); + if (outcome !== null && !OUTCOMES.includes(outcome)) { + throw new Error(`Unknown outcome "${patch.outcome}".`); + } + repair.outcome = outcome; + } + + if (typeof patch.addNote === 'string' && patch.addNote.trim()) { + repair.notes.push({ at: new Date().toISOString(), text: patch.addNote.trim() }); + } + + saveEvent(event); + return repair; +} + +// Delete events older than retentionDays, remembering the slug so the URL can +// say "expired" rather than "not found". retentionDays: 0 disables pruning. +function pruneExpired() { + const days = Number(getConfig().retentionDays); + if (!days || days <= 0) return []; + + const cutoff = Date.now() - days * 24 * 60 * 60 * 1000; + const expired = readExpired(); + const pruned = []; + + for (const slug of listSlugs()) { + const event = loadEvent(slug); + if (!event) continue; + if (Date.parse(event.createdAt) < cutoff) { + fs.unlinkSync(eventPath(slug)); + expired[slug] = new Date().toISOString(); + pruned.push(slug); + } + } + + if (pruned.length) { + ensureDataDir(); + writeJson(EXPIRED_PATH, expired); + } + return pruned; +} + +function expiresAt(event) { + const days = Number(getConfig().retentionDays); + if (!days || days <= 0) return null; + return new Date(Date.parse(event.createdAt) + days * 24 * 60 * 60 * 1000).toISOString(); +} + +module.exports = { + DATA_DIR, + ROOT, + STATUSES, + OUTCOMES, + loadConfig, + getConfig, + categoryNames, + slugify, + createEvent, + loadEvent, + saveEvent, + listEvents, + addRepair, + updateRepair, + pruneExpired, + expiresAt, + isExpired, +}; diff --git a/public/app.css b/public/app.css new file mode 100644 index 0000000..69f91ad --- /dev/null +++ b/public/app.css @@ -0,0 +1,328 @@ +/* Deliberately plain: flat background, plain buttons, no decoration. */ + +* { box-sizing: border-box; } + +body { + margin: 0; + padding: 0; + background: #eceae5; + color: #1c1c1c; + font: 16px/1.5 system-ui, -apple-system, "Segoe UI", sans-serif; +} + +h1 { font-size: 24px; margin: 0 0 4px; } +h2 { font-size: 18px; margin: 0 0 12px; } +p { margin: 0 0 12px; } +a { color: #1f4f7a; } + +.wrap { + max-width: 620px; + margin: 0 auto; + padding: 32px 20px 60px; +} + +.subtitle { color: #555; margin-bottom: 28px; } + +/* ------------------------------------------------------------------ inputs */ + +label { + display: block; + margin: 0 0 4px; + font-weight: 600; +} + +input[type="text"], +input[type="search"], +textarea, +select { + width: 100%; + padding: 9px 10px; + border: 1px solid #9a958c; + border-radius: 4px; + background: #fff; + color: inherit; + font: inherit; +} + +textarea { resize: vertical; min-height: 90px; } + +input:focus-visible, +textarea:focus-visible, +select:focus-visible, +button:focus-visible, +a:focus-visible, +.card:focus-visible { + outline: 2px solid #1f4f7a; + outline-offset: 1px; +} + +.field { margin-bottom: 18px; } +.hint { font-weight: 400; color: #555; font-size: 14px; } + +.check { + display: flex; + align-items: flex-start; + gap: 9px; + font-weight: 400; +} +.check input { margin-top: 4px; width: 18px; height: 18px; flex: 0 0 auto; } + +button { + padding: 10px 18px; + border: 1px solid #6d6862; + border-radius: 4px; + background: #dcd8d1; + color: inherit; + font: inherit; + cursor: pointer; +} +button:hover { background: #d0ccc4; } +button:active { background: #c4bfb6; } + +button.primary { + background: #1f4f7a; + border-color: #163a5a; + color: #fff; + font-weight: 600; +} +button.primary:hover { background: #1a4468; } + +button.big { padding: 16px 26px; font-size: 18px; } +button.small { padding: 5px 10px; font-size: 14px; } + +.row { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; } + +/* ------------------------------------------------------------------ notices */ + +.notice { + padding: 10px 12px; + border-radius: 4px; + border: 1px solid #6d6862; + background: #dcd8d1; + margin-bottom: 16px; +} +.notice.ok { background: #d8e8d6; border-color: #4a7a46; } +.notice.error { background: #f2d7d5; border-color: #a33c33; } +.notice[hidden] { display: none; } + +.url-preview { + font-family: ui-monospace, "SFMono-Regular", Menlo, Consolas, monospace; + background: #fff; + border: 1px solid #cbc6bd; + border-radius: 4px; + padding: 8px 10px; + word-break: break-all; +} + +/* ---------------------------------------------------------------- dashboard */ + +body.dashboard { height: 100vh; display: flex; flex-direction: column; overflow: hidden; } + +.board-header { + flex: 0 0 auto; + display: flex; + align-items: baseline; + gap: 16px; + flex-wrap: wrap; + padding: 12px 18px; + background: #dcd8d1; + border-bottom: 1px solid #b8b3aa; +} +.board-header h1 { font-size: 20px; margin: 0; } +.board-header .spacer { flex: 1 1 auto; } +.board-header .meta { color: #555; font-size: 14px; } + +.board { + flex: 1 1 auto; + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 14px; + padding: 14px 18px 18px; + min-height: 0; +} + +.column { + display: flex; + flex-direction: column; + min-height: 0; + background: #e2dfd9; + border: 1px solid #b8b3aa; + border-radius: 6px; +} + +.column-header { + flex: 0 0 auto; + display: flex; + align-items: center; + gap: 8px; + padding: 8px 10px; + border-bottom: 1px solid #b8b3aa; +} +.column-header h2 { margin: 0; font-size: 16px; } +.column-header .count { color: #555; font-size: 14px; margin-right: auto; } +.column-header select { width: auto; padding: 3px 6px; font-size: 13px; } + +/* The scroll container the spec asks for: each column scrolls on its own. */ +.cards { + flex: 1 1 auto; + overflow-y: auto; + padding: 10px; + display: flex; + flex-direction: column; + gap: 10px; +} + +.card { + background: #fff; + border: 1px solid #b8b3aa; + border-left: 4px solid #b8b3aa; + border-radius: 6px; + padding: 9px 11px; + cursor: grab; + /* Let a finger scroll the column; the grip below opts out for dragging. */ + touch-action: pan-y; + user-select: none; +} +.card.incomplete { border-color: #c0392b; border-width: 2px; border-left-width: 4px; } + +.card-top { display: flex; align-items: flex-start; gap: 8px; } +.card-title { + flex: 1 1 auto; + font-weight: 600; + cursor: pointer; + text-decoration: underline; + text-decoration-color: #b8b3aa; + text-underline-offset: 2px; +} +.card-grip { + flex: 0 0 auto; + margin: -4px -4px 0 0; + padding: 4px 6px; + color: #9a958c; + line-height: 1; + cursor: grab; + touch-action: none; +} +.card-grip:hover { color: #555; } +.card-line { font-size: 14px; color: #444; margin-top: 3px; } +.card-line b { font-weight: 600; color: #222; } + +.chip { + display: inline-block; + margin-top: 7px; + padding: 2px 9px; + border-radius: 999px; + background: #6d6862; + color: #fff; + font-size: 12px; + font-weight: 600; + letter-spacing: 0.01em; +} + +.card.dragging { + position: fixed; + z-index: 50; + width: var(--drag-width); + pointer-events: none; + cursor: grabbing; + box-shadow: 0 6px 16px rgba(0, 0, 0, 0.25); + transform: rotate(1deg); +} + +/* Dashed box showing where the card would land. */ +.placeholder { + border: 2px dashed #6d6862; + border-radius: 6px; + background: rgba(255, 255, 255, 0.35); +} + +.empty-note { color: #6d6862; font-size: 14px; font-style: italic; } + +/* ----------------------------------------------------------------- dialogs */ + +dialog { + border: 1px solid #6d6862; + border-radius: 6px; + background: #eceae5; + color: inherit; + padding: 0; + max-width: 560px; + width: calc(100vw - 32px); +} +dialog::backdrop { background: rgba(0, 0, 0, 0.45); } +.dialog-body { padding: 20px; } +.dialog-body h2 { margin-bottom: 14px; } +.dialog-actions { + display: flex; + gap: 10px; + justify-content: flex-end; + margin-top: 18px; + flex-wrap: wrap; +} +.dialog-actions .left { margin-right: auto; } + +.rules-text { white-space: pre-wrap; max-height: 60vh; overflow-y: auto; } + +/* ---------------------------------------------------- detail overlay fields */ + +#detail { max-width: 640px; } +#detail .dialog-body { max-height: 85vh; overflow-y: auto; } + +/* The record is long enough to scroll, so keep Close on screen. */ +#detail .dialog-actions { + position: sticky; + bottom: -20px; + margin: 18px -20px -20px; + padding: 12px 20px; + background: #eceae5; + border-top: 1px solid #cbc6bd; +} + +/* Two columns for the short fields, so the notes are visible without scrolling. */ +#detail-fields { display: grid; grid-template-columns: 1fr 1fr; column-gap: 14px; } +.detail-field { margin-bottom: 10px; min-width: 0; } +.detail-field.wide { grid-column: 1 / -1; } +.detail-field > label { + font-size: 12px; + text-transform: uppercase; + letter-spacing: 0.04em; + color: #555; + margin-bottom: 2px; +} + +/* Click-to-edit: the value looks like text until you click it. */ +.editable { + display: block; + width: 100%; + text-align: left; + padding: 7px 9px; + border: 1px solid transparent; + border-radius: 4px; + background: #fff; + font: inherit; + color: inherit; + cursor: text; + white-space: pre-wrap; + min-height: 34px; +} +.editable:hover { border-color: #9a958c; } +.editable.empty { color: #8a857d; font-style: italic; } +.editable.readonly { background: transparent; cursor: default; color: #555; } +.editable.readonly:hover { border-color: transparent; } + +.notes-list { list-style: none; margin: 0 0 10px; padding: 0; } +.notes-list li { + background: #fff; + border: 1px solid #cbc6bd; + border-radius: 4px; + padding: 7px 9px; + margin-bottom: 7px; + white-space: pre-wrap; +} +.notes-list time { display: block; font-size: 12px; color: #666; margin-bottom: 2px; } + +@media (max-width: 800px) { + .board { grid-template-columns: 1fr; overflow-y: auto; } + body.dashboard { overflow: auto; height: auto; } + .column { min-height: 240px; } +} diff --git a/public/dashboard.html b/public/dashboard.html new file mode 100644 index 0000000..974a224 --- /dev/null +++ b/public/dashboard.html @@ -0,0 +1,104 @@ + + + + + +Dashboard + + + + +
+

 

+ + + + Sign-in form + Download CSV +
+ +
+ + + + + +
+

Who is repairing this?

+
+ + +
+
+ + +
+
+
+ + + +
+

How did it go?

+ +
+ + + + +
+ +
+ + +
+ +
+ + +
+ +
+ + + +
+
+
+ + + +
+

+
+ +
+ +
    + +
    +
    + +
    + +
    +
    +
    + + + + diff --git a/public/dashboard.js b/public/dashboard.js new file mode 100644 index 0000000..61ae42f --- /dev/null +++ b/public/dashboard.js @@ -0,0 +1,544 @@ +'use strict'; + +const slug = location.pathname.split('/').filter(Boolean)[0]; +const POLL_MS = 4000; + +const COLUMNS = [ + { status: 'queue', title: 'Queue' }, + { status: 'in-progress', title: 'In progress' }, + { status: 'done', title: 'Done' }, +]; + +const board = document.getElementById('board'); +const statusLine = document.getElementById('status-line'); + +let event = null; +let categories = []; +const colorOf = new Map(); +const columnEls = new Map(); // status -> { section, cards, count, sort } +let drag = null; + +/* --------------------------------------------------------------- utilities */ + +const byId = (id) => event.repairs.find((r) => r.id === id); +const isIncomplete = (r) => r.status === 'done' && !r.outcome; + +function el(tag, className, text) { + const node = document.createElement(tag); + if (className) node.className = className; + if (text !== undefined) node.textContent = text; + return node; +} + +function formatTime(iso) { + const d = new Date(iso); + return d.toLocaleString(undefined, { + day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit', + }); +} + +function sortKey(status) { + return localStorage.getItem(`sort:${slug}:${status}`) || 'time'; +} + +function setSortKey(status, value) { + localStorage.setItem(`sort:${slug}:${status}`, value); +} + +function sortRepairs(list, key) { + const byTime = (a, b) => a.createdAt.localeCompare(b.createdAt); + if (key === 'category') { + return list.slice().sort((a, b) => a.category.localeCompare(b.category) || byTime(a, b)); + } + return list.slice().sort(byTime); +} + +function showStatus(message, isError) { + statusLine.textContent = message || ''; + statusLine.style.color = isError ? '#a33c33' : '#555'; +} + +/* --------------------------------------------------------------- API calls */ + +async function api(path, options) { + const res = await fetch(path, options); + const data = await res.json().catch(() => ({})); + if (!res.ok) throw new Error(data.error || 'The server said no.'); + return data; +} + +async function patchRepair(id, patch) { + try { + const updated = await api(`/api/events/${slug}/repairs/${id}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(patch), + }); + const index = event.repairs.findIndex((r) => r.id === id); + if (index >= 0) event.repairs[index] = updated; + showStatus(''); + render(); + return updated; + } catch (err) { + showStatus(err.message, true); + await refresh(); // put the board back to whatever the server actually has + throw err; + } +} + +async function refresh() { + const data = await api(`/api/events/${slug}`); + event = data; + render(); +} + +/* ------------------------------------------------------------------ render */ + +function buildColumns() { + const template = document.getElementById('column-template'); + for (const column of COLUMNS) { + const section = template.content.firstElementChild.cloneNode(true); + section.dataset.status = column.status; + section.querySelector('h2').textContent = column.title; + + const select = section.querySelector('.sort'); + select.value = sortKey(column.status); + select.addEventListener('change', () => { + setSortKey(column.status, select.value); + render(); + }); + + board.append(section); + columnEls.set(column.status, { + section, + cards: section.querySelector('.cards'), + count: section.querySelector('.count'), + }); + } +} + +function buildCard(repair) { + const card = el('div', 'card'); + card.dataset.id = repair.id; + if (isIncomplete(repair)) card.classList.add('incomplete'); + + const top = el('div', 'card-top'); + const title = el('div', 'card-title', repair.item); + title.addEventListener('click', (e) => { + e.stopPropagation(); + openDetail(repair.id); + }); + const grip = el('span', 'card-grip', '⠿'); + grip.title = 'Drag to move'; + top.append(title, grip); + card.append(top); + + card.append(el('div', 'card-line', repair.customer)); + + if (repair.status === 'in-progress' && repair.repairer) { + const line = el('div', 'card-line'); + line.append(el('b', null, 'Repairer: '), document.createTextNode(repair.repairer)); + card.append(line); + } + + if (repair.status === 'done') { + const line = el('div', 'card-line'); + line.append(el('b', null, 'Result: '), document.createTextNode(repair.outcome || 'not recorded')); + card.append(line); + } + + const chip = el('span', 'chip', repair.category); + chip.style.background = colorOf.get(repair.category) || '#6d6862'; + card.append(chip); + + return card; +} + +function render() { + if (!event) return; + + document.getElementById('event-name').textContent = event.name; + document.title = `Dashboard — ${event.name}`; + + for (const column of COLUMNS) { + const { cards, count } = columnEls.get(column.status); + const list = sortRepairs(event.repairs.filter((r) => r.status === column.status), sortKey(column.status)); + + const scroll = cards.scrollTop; + cards.replaceChildren(); + for (const repair of list) cards.append(buildCard(repair)); + if (list.length === 0) cards.append(el('p', 'empty-note', 'Nothing here yet.')); + cards.scrollTop = scroll; + + count.textContent = String(list.length); + } + + // Keep an open detail overlay in step with whatever just changed. + if (detailId && detail.open) renderDetail(); +} + +/* ------------------------------------------------------------------ drag */ + +// Custom pointer dragging rather than HTML5 drag-and-drop, so this works on a +// tablet as well as a laptop. +function onPointerDown(e) { + if (e.button !== undefined && e.button !== 0) return; + const card = e.target.closest('.card'); + if (!card || !board.contains(card)) return; + if (e.target.closest('.card-title')) return; // the title opens the detail view + + // A mouse can grab the card anywhere. A finger has to use the grip, so that + // sliding a finger up the column still scrolls it. + if (e.pointerType !== 'mouse' && !e.target.closest('.card-grip')) return; + + drag = { + card, + id: card.dataset.id, + pointerId: e.pointerId, + startX: e.clientX, + startY: e.clientY, + offsetX: 0, + offsetY: 0, + started: false, + placeholder: null, + }; + + window.addEventListener('pointermove', onPointerMove); + window.addEventListener('pointerup', onPointerUp); + window.addEventListener('pointercancel', onPointerUp); +} + +function startDrag(e) { + const { card } = drag; + const rect = card.getBoundingClientRect(); + + const placeholder = el('div', 'placeholder'); + placeholder.style.height = `${rect.height}px`; + card.after(placeholder); + drag.placeholder = placeholder; + + drag.offsetX = drag.startX - rect.left; + drag.offsetY = drag.startY - rect.top; + card.style.setProperty('--drag-width', `${rect.width}px`); + card.classList.add('dragging'); + document.body.style.cursor = 'grabbing'; + drag.started = true; + moveCard(e); +} + +function moveCard(e) { + drag.card.style.left = `${e.clientX - drag.offsetX}px`; + drag.card.style.top = `${e.clientY - drag.offsetY}px`; +} + +// Nudge a column that the pointer is hovering near the edge of, so you can drag +// into a part of a long list that is off screen. +function autoScroll(container, y) { + const rect = container.getBoundingClientRect(); + const zone = 48; + if (y < rect.top + zone) container.scrollTop -= 12; + else if (y > rect.bottom - zone) container.scrollTop += 12; +} + +function placeholderTarget(x, y) { + const under = document.elementFromPoint(x, y); + const container = under && under.closest ? under.closest('.cards') : null; + if (!container) return null; + + autoScroll(container, y); + + const siblings = [...container.querySelectorAll('.card')].filter((c) => c !== drag.card); + let before = null; + for (const sibling of siblings) { + const rect = sibling.getBoundingClientRect(); + if (y < rect.top + rect.height / 2) { before = sibling; break; } + } + return { container, before }; +} + +function onPointerMove(e) { + if (!drag) return; + if (!drag.started) { + if (Math.hypot(e.clientX - drag.startX, e.clientY - drag.startY) < 6) return; + startDrag(e); + } + e.preventDefault(); + moveCard(e); + + const target = placeholderTarget(e.clientX, e.clientY); + if (target) { + const { container, before } = target; + if (before) container.insertBefore(drag.placeholder, before); + else container.append(drag.placeholder); + } +} + +function endDrag() { + const { card, placeholder } = drag; + card.classList.remove('dragging'); + card.style.left = card.style.top = ''; + card.style.removeProperty('--drag-width'); + if (placeholder) placeholder.remove(); + document.body.style.cursor = ''; + + window.removeEventListener('pointermove', onPointerMove); + window.removeEventListener('pointerup', onPointerUp); + window.removeEventListener('pointercancel', onPointerUp); +} + +async function onPointerUp(e) { + if (!drag) return; + const wasDragging = drag.started; + const id = drag.id; + const targetStatus = drag.placeholder + ? drag.placeholder.closest('.column').dataset.status + : null; + + endDrag(); + drag = null; + + if (!wasDragging) return; // a stray click on a card changes nothing + if (e.type === 'pointercancel') return render(); + + const repair = byId(id); + if (!repair || !targetStatus || targetStatus === repair.status) return render(); + + await applyMove(repair, targetStatus); +} + +async function applyMove(repair, targetStatus) { + if (targetStatus === 'in-progress') { + // Pulled back out of Done: whoever was repairing it still is, so don't ask + // again. Coming up from the queue we do ask, since that may be a hand-over. + if (repair.status === 'done' && repair.repairer) { + return patchRepair(repair.id, { status: targetStatus }).catch(() => {}); + } + const answer = await askRepairer(repair.repairer || ''); + if (answer === null) return render(); + return patchRepair(repair.id, { status: targetStatus, repairer: answer }).catch(() => {}); + } + + if (targetStatus === 'done') { + const answer = await askDone(repair); + if (answer === null) return render(); + const patch = { status: targetStatus }; + if (answer.filled) { + patch.outcome = answer.outcome; + patch.toolsUsed = answer.toolsUsed; + if (answer.note) patch.addNote = answer.note; + } + return patchRepair(repair.id, patch).catch(() => {}); + } + + return patchRepair(repair.id, { status: targetStatus }).catch(() => {}); +} + +board.addEventListener('pointerdown', onPointerDown); + +/* ---------------------------------------------------------------- dialogs */ + +// Cancel and Skip are plain buttons that close the dialog themselves, so the +// only submit button left is Save — which makes Save the one Enter triggers. +for (const button of document.querySelectorAll('dialog [data-close]')) { + button.addEventListener('click', () => button.closest('dialog').close(button.dataset.close)); +} + +function openDialog(dialog) { + return new Promise((resolve) => { + dialog.addEventListener('close', () => resolve(dialog.returnValue), { once: true }); + dialog.showModal(); + }); +} + +// Resolves with the volunteer's name, or null if the move was cancelled. +async function askRepairer(current) { + const dialog = document.getElementById('repairer-dialog'); + const input = document.getElementById('repairer-input'); + input.value = current; + const result = openDialog(dialog); + input.focus(); + input.select(); + return (await result) === 'save' ? input.value.trim() : null; +} + +// Resolves with the recorded result, {filled:false} if skipped, or null if cancelled. +async function askDone(repair) { + const dialog = document.getElementById('done-dialog'); + const tools = document.getElementById('tools-input'); + const note = document.getElementById('done-note'); + const radios = [...dialog.querySelectorAll('input[name="outcome"]')]; + + for (const radio of radios) radio.checked = radio.value === repair.outcome; + tools.value = repair.toolsUsed || ''; + note.value = ''; + + const result = openDialog(dialog); + const first = radios.find((r) => r.checked) || radios[0]; + first.focus(); + + const action = await result; + if (action === 'save') { + const chosen = radios.find((r) => r.checked); + return { + filled: true, + outcome: chosen ? chosen.value : null, + toolsUsed: tools.value.trim(), + note: note.value.trim(), + }; + } + if (action === 'skip') return { filled: false }; + return null; +} + +/* ---------------------------------------------------- detail overlay fields */ + +const detail = document.getElementById('detail'); +let detailId = null; + +// A value that looks like plain text until you click it, then becomes an input. +function editable(value, placeholder, makeEditor, onSave) { + const view = el('div', 'editable'); + const setText = (text) => { + view.textContent = text || placeholder; + view.classList.toggle('empty', !text); + }; + setText(value); + + view.addEventListener('click', () => { + const editor = makeEditor(value); + let done = false; + + const finish = async (save) => { + if (done) return; + done = true; + const next = editor.value.trim(); + editor.replaceWith(view); + if (save && next !== (value || '')) await onSave(next); + else setText(value); + }; + + editor.addEventListener('blur', () => finish(true)); + editor.addEventListener('keydown', (e) => { + if (e.key === 'Escape') { e.preventDefault(); finish(false); } + if (e.key === 'Enter' && editor.tagName !== 'TEXTAREA') { e.preventDefault(); finish(true); } + }); + if (editor.tagName === 'SELECT') editor.addEventListener('change', () => finish(true)); + + view.replaceWith(editor); + editor.focus(); + if (editor.select) editor.select(); + }); + + return view; +} + +function textEditor(value) { + const input = el('input'); + input.type = 'text'; + input.value = value || ''; + return input; +} + +function areaEditor(value) { + const area = el('textarea'); + area.value = value || ''; + area.rows = 4; + return area; +} + +function selectEditor(options, allowBlank, blankLabel) { + return (value) => { + const select = el('select'); + if (allowBlank) select.append(new Option(blankLabel, '')); + for (const option of options) select.append(new Option(option, option)); + select.value = value || ''; + return select; + }; +} + +// `wide` fields span both columns of the overlay grid. +function detailField(label, view, wide) { + const wrap = el('div', `detail-field${wide ? ' wide' : ''}`); + wrap.append(el('label', null, label), view); + return wrap; +} + +function renderDetail() { + const repair = byId(detailId); + if (!repair) return detail.close(); + + document.getElementById('detail-title').textContent = repair.item; + + const save = (field) => (value) => patchRepair(repair.id, { [field]: value }).catch(() => {}); + + const fields = document.getElementById('detail-fields'); + fields.replaceChildren( + detailField('Item', editable(repair.item, '—', textEditor, save('item'))), + detailField('Customer', editable(repair.customer, '—', textEditor, save('customer'))), + detailField('Problem', editable(repair.problem, 'No description given', areaEditor, save('problem')), true), + detailField('Category', editable(repair.category, '—', selectEditor(categories, false), save('category'))), + detailField('Status', editable(repair.status, '—', selectEditor(COLUMNS.map((c) => c.status), false), save('status'))), + detailField('Repairer', editable(repair.repairer, 'Nobody yet', textEditor, save('repairer'))), + detailField('Result', editable(repair.outcome, 'Not recorded', selectEditor(['fixed', 'partially fixed', 'not fixed'], true, 'Not recorded'), save('outcome'))), + detailField('Tools / consumables', editable(repair.toolsUsed, 'None recorded', textEditor, save('toolsUsed'))), + detailField('Signed in at', el('div', 'editable readonly', formatTime(repair.createdAt))) + ); + + const list = document.getElementById('notes-list'); + list.replaceChildren(); + for (const note of repair.notes) { + const li = el('li'); + li.append(el('time', null, formatTime(note.at)), document.createTextNode(note.text)); + list.append(li); + } + if (repair.notes.length === 0) list.append(el('li', 'empty-note', 'No notes yet.')); +} + +function openDetail(id) { + detailId = id; + renderDetail(); + detail.showModal(); +} + +document.getElementById('detail-close').addEventListener('click', () => detail.close()); +detail.addEventListener('close', () => { detailId = null; }); + +document.getElementById('add-note').addEventListener('click', async () => { + const box = document.getElementById('new-note'); + const text = box.value.trim(); + if (!text || !detailId) return; + await patchRepair(detailId, { addNote: text }).catch(() => {}); + box.value = ''; +}); + +/* ------------------------------------------------------------------- boot */ + +document.getElementById('intake-link').href = `/${slug}`; +document.getElementById('csv-link').href = `/${slug}/export.csv`; + +function busy() { + return drag !== null || document.querySelector('dialog[open]') !== null; +} + +async function init() { + const config = await api('/api/config'); + categories = config.categories.map((c) => c.name); + for (const category of config.categories) colorOf.set(category.name, category.color); + + buildColumns(); + await refresh(); + + if (event.expiresAt) { + document.getElementById('expiry').textContent = + `deleted after ${new Date(event.expiresAt).toLocaleDateString()}`; + } + + // Two volunteers on two laptops should see roughly the same board. Pause + // while someone is mid-drag or has a dialog open so nothing jumps. + setInterval(() => { + if (busy()) return; + refresh().catch(() => showStatus('Lost contact with the server.', true)); + }, POLL_MS); +} + +init().catch((err) => showStatus(err.message, true)); diff --git a/public/expired.html b/public/expired.html new file mode 100644 index 0000000..d56a91c --- /dev/null +++ b/public/expired.html @@ -0,0 +1,17 @@ + + + + + +Event expired — Repair Cafe Kanban + + + +
    +

    This event has expired

    +

    Repair cafe boards are kept for a short time and then deleted, along with their data. + This one is gone.

    +

    Start a new one

    +
    + + diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..cd902a0 --- /dev/null +++ b/public/index.html @@ -0,0 +1,17 @@ + + + + + +Repair Cafe Kanban + + + +
    +

    Repair Cafe Kanban

    +

    A queue board for a repair cafe event.

    + +

    +
    + + diff --git a/public/intake.html b/public/intake.html new file mode 100644 index 0000000..2bc3d51 --- /dev/null +++ b/public/intake.html @@ -0,0 +1,64 @@ + + + + + +Sign in a repair + + + +
    +

     

    +

    Fill this in and a volunteer will call you when it is your turn.

    + + + + +
    +
    + + +
    + +
    + + +
    + +
    + + + +
    + +
    + + +
    + +
    + +
    + + +
    + +

    Volunteer dashboard

    +
    + + +
    +

    House rules

    +
    +
    + +
    +
    +
    + + + + diff --git a/public/intake.js b/public/intake.js new file mode 100644 index 0000000..bcc2b2d --- /dev/null +++ b/public/intake.js @@ -0,0 +1,96 @@ +'use strict'; + +const slug = location.pathname.split('/').filter(Boolean)[0]; + +const form = document.getElementById('form'); +const customer = document.getElementById('customer'); +const item = document.getElementById('item'); +const category = document.getElementById('category'); +const problem = document.getElementById('problem'); +const agree = document.getElementById('agree'); +const submitBtn = document.getElementById('submit'); +const savedBox = document.getElementById('saved'); +const errorBox = document.getElementById('error'); +const rules = document.getElementById('rules'); + +let categories = []; +let savedTimer = null; + +document.getElementById('dashboard-link').href = `/${slug}/dashboard`; + +async function init() { + const [config, event] = await Promise.all([ + fetch('/api/config').then((r) => r.json()), + fetch(`/api/events/${slug}`).then((r) => r.json()), + ]); + + document.title = `Sign in a repair — ${event.name}`; + document.getElementById('event-name').textContent = event.name; + + categories = config.categories.map((c) => c.name); + const list = document.getElementById('categories'); + for (const name of categories) { + const option = document.createElement('option'); + option.value = name; + list.append(option); + } + + document.getElementById('rules-text').textContent = config.houseRules; + customer.focus(); +} + +/* house rules popup */ +document.getElementById('rules-link').addEventListener('click', (e) => { + e.preventDefault(); + rules.showModal(); +}); +document.getElementById('rules-ok').addEventListener('click', () => rules.close()); + +/* submit */ +function showError(message) { + errorBox.textContent = message; + errorBox.hidden = false; + savedBox.hidden = true; +} + +form.addEventListener('submit', async (e) => { + e.preventDefault(); + errorBox.hidden = true; + + const chosen = category.value.trim().toLowerCase(); + if (!categories.includes(chosen)) { + showError(`Please pick a category from the list: ${categories.join(', ')}.`); + category.focus(); + return; + } + + submitBtn.disabled = true; + try { + const res = await fetch(`/api/events/${slug}/repairs`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + customer: customer.value, + item: item.value, + category: chosen, + problem: problem.value, + agreedRules: agree.checked, + }), + }); + const data = await res.json(); + if (!res.ok) throw new Error(data.error || 'Could not submit.'); + + // Clear everything and hand the tablet back, ready for the next person. + form.reset(); + customer.focus(); + savedBox.hidden = false; + clearTimeout(savedTimer); + savedTimer = setTimeout(() => { savedBox.hidden = true; }, 6000); + } catch (err) { + showError(err.message); + } finally { + submitBtn.disabled = false; + } +}); + +init().catch(() => showError('Could not load this event. Try reloading the page.')); diff --git a/public/new.html b/public/new.html new file mode 100644 index 0000000..eb71dd5 --- /dev/null +++ b/public/new.html @@ -0,0 +1,86 @@ + + + + + +New event — Repair Cafe Kanban + + + +
    +

    Create repair cafe kanban

    +

    Set up a board for one event.

    + + + +
    +
    + + +
    + +
    + +
    + +
    + +
    +
    + + +

    Cancel

    +
    +
    + + + + diff --git a/public/notfound.html b/public/notfound.html new file mode 100644 index 0000000..9e09443 --- /dev/null +++ b/public/notfound.html @@ -0,0 +1,16 @@ + + + + + +Not found — Repair Cafe Kanban + + + +
    +

    Not found

    +

    There is no repair cafe at this address. Check the link, or start a new one.

    +

    Back to the start

    +
    + + diff --git a/repair cafe kanban spec.txt b/repair cafe kanban spec.txt new file mode 100644 index 0000000..a272a9d --- /dev/null +++ b/repair cafe kanban spec.txt @@ -0,0 +1,49 @@ +I'd like to make a little kanban web app for repair cafe. We have no existing code. I want you to make this and make it extremely basic. Plain color background, simple buttons. Nothing fancy/unnecessary. I'm not sure what language to use for this, your call. + +The main site page should just have 1 button "create repair cafe kanban". This takes them to a page where they can set parameters: +- name of event (this is what the url becomes [domain]/name-of-event-space-become-dashes) +- there should also be an option to randomize the name of the event for less possibility of collisions. These are temporary pages that only need to stay live for a week. +- a button that says 'create' + +'create' takes you to [domain]/name-of-event-space-become-dashes. This page has an input form with these fields: +- name of customer +- item name (free text field) +- item category (ideally free form text you can start typing in but it only lets you select certain categories. These categories should be able to set in a config file for the whole thing but there will be a bunch of defaults (bicycle, appliance, sewing, electronics, computer, wood) +- description of problem (freeform text multi-line) +- a checkbox next to some text that says "I agree to the house rules" where "house rules" is a link that pops up a box of text of the house rules (set in config I guess?) and people can click OK to go back to the form +- a 'submit' button + +the submit button submits the repair into the queue and clears the form for the next person to submit. + +there is also a dashboard. This is at the same [domain]/name-of-event-space-become-dashes plus 'dashboard'. Not sure the best format this url should be but it has 'dashboard' added somewhere. +this dashboard view should have 3 columns: +- queue +- in progress +- done + +queue is where repairs land after they've been submitted. Each repair should be a round-rec with the title, name of customer, and item category visible. Item category should be in it's own colored rounded rec so different categories can be visible at a glance by color. If you click on the title of a repair it pops up an overlay on top of the dashboard with the item information. Each field is editable if you click on it. There is also now a 'notes' field where new notes can be added. + +cards can be dragged between any status. If a card is dragged between queue and in-progress it pops up a dialog to ask the name of who is repairing it. This name should now also be visible on the repair card in the in-progress column. When a card is dragged from in-progress to done a dialog is popped up asking what the status of the repair was 'fixed', 'partially fixed' or 'not fixed', a field to fill in 'tools/consumables used', as well as any notes. These can be ignored. If ignored the card should be bordered in red indicating it has not been fully filled out. If it is clicked on you can fill it out and the red goes away. 'Tools/consumables used' does not cause it to be marked red, this is an entirely optional field. + +each of these columns should be individually scrollable if there are more cards than show up on the screen. When a card is being dragged it should show a dashed line box to show where it would drop when dropped. + +each column can be sorted by time added or item category. + +on the dashboard page and the main page there should be a link to download all the data as a csv. The csv should also contain a column for date and time. + +I'd like this whole thing to be really easy to host on a webserver. I don't have a server for us to test deploy to so we could try hosting it right on this machine for now. + + + + + + + + + + + + + + + diff --git a/server.js b/server.js new file mode 100644 index 0000000..c7c51ed --- /dev/null +++ b/server.js @@ -0,0 +1,238 @@ +'use strict'; + +const http = require('node:http'); +const fs = require('node:fs'); +const path = require('node:path'); + +const store = require('./lib/store'); +const { toCsv } = require('./lib/csv'); + +const PUBLIC_DIR = path.join(__dirname, 'public'); +const DAY_MS = 24 * 60 * 60 * 1000; + +const MIME = { + '.html': 'text/html; charset=utf-8', + '.css': 'text/css; charset=utf-8', + '.js': 'text/javascript; charset=utf-8', + '.svg': 'image/svg+xml', + '.ico': 'image/x-icon', +}; + +/* ------------------------------------------------------------------ replies */ + +function sendJson(res, status, value) { + const body = JSON.stringify(value); + res.writeHead(status, { + 'Content-Type': 'application/json; charset=utf-8', + 'Content-Length': Buffer.byteLength(body), + 'Cache-Control': 'no-store', + }); + res.end(body); +} + +function sendPage(res, file, status = 200) { + fs.readFile(path.join(PUBLIC_DIR, file), (err, body) => { + if (err) return sendText(res, 500, 'Could not read page.'); + res.writeHead(status, { + 'Content-Type': MIME['.html'], + 'Content-Length': body.length, + 'Cache-Control': 'no-store', + }); + res.end(body); + }); +} + +function sendText(res, status, message) { + const body = `${message}\n`; + res.writeHead(status, { + 'Content-Type': 'text/plain; charset=utf-8', + 'Content-Length': Buffer.byteLength(body), + }); + res.end(body); +} + +function sendCsv(res, filename, csv) { + const body = Buffer.from(csv, 'utf8'); + res.writeHead(200, { + 'Content-Type': 'text/csv; charset=utf-8', + 'Content-Length': body.length, + 'Content-Disposition': `attachment; filename="${filename}"`, + 'Cache-Control': 'no-store', + }); + res.end(body); +} + +function sendStatic(res, name) { + const file = path.join(PUBLIC_DIR, name); + // Never let a crafted path climb out of public/. + if (!file.startsWith(PUBLIC_DIR + path.sep)) return sendText(res, 403, 'Forbidden'); + + fs.readFile(file, (err, body) => { + if (err) return sendText(res, 404, 'Not found'); + res.writeHead(200, { + 'Content-Type': MIME[path.extname(file)] || 'application/octet-stream', + 'Content-Length': body.length, + 'Cache-Control': 'no-cache', + }); + res.end(body); + }); +} + +function readBody(req) { + return new Promise((resolve, reject) => { + let raw = ''; + req.on('data', (chunk) => { + raw += chunk; + // Nothing here should ever be large; refuse rather than buffer forever. + if (raw.length > 256 * 1024) reject(new Error('Request too large.')); + }); + req.on('error', reject); + req.on('end', () => { + if (!raw) return resolve({}); + try { + resolve(JSON.parse(raw)); + } catch { + reject(new Error('Expected JSON.')); + } + }); + }); +} + +/* ------------------------------------------------------------------- router */ + +// Resolve a slug to an event, replying for the caller if it is gone. +function requireEvent(res, slug, asJson) { + const event = store.loadEvent(slug); + if (event) return event; + + if (store.isExpired(slug)) { + if (asJson) sendJson(res, 410, { error: 'This event has expired and its data has been deleted.' }); + else sendPage(res, 'expired.html', 410); + } else if (asJson) { + sendJson(res, 404, { error: 'No such event.' }); + } else { + sendPage(res, 'notfound.html', 404); + } + return null; +} + +async function route(req, res) { + const url = new URL(req.url, 'http://localhost'); + const parts = url.pathname.split('/').filter(Boolean).map(decodeURIComponent); + const method = req.method; + + /* pages and assets */ + if (method === 'GET' && parts.length === 0) return sendPage(res, 'index.html'); + if (method === 'GET' && parts.length === 1 && parts[0] === 'new') return sendPage(res, 'new.html'); + if (method === 'GET' && parts[0] === 'static' && parts.length === 2) return sendStatic(res, parts[1]); + + /* all-events CSV */ + if (method === 'GET' && parts.length === 1 && parts[0] === 'export.csv') { + return sendCsv(res, 'repair-cafe-all-events.csv', toCsv(store.listEvents())); + } + + /* api */ + if (parts[0] === 'api') { + if (method === 'GET' && parts[1] === 'config' && parts.length === 2) { + const { categories, houseRules, retentionDays } = store.getConfig(); + return sendJson(res, 200, { categories, houseRules, retentionDays }); + } + + if (method === 'POST' && parts[1] === 'events' && parts.length === 2) { + const body = await readBody(req); + const event = store.createEvent(body.name, body.randomize === true); + return sendJson(res, 201, { slug: event.slug, name: event.name }); + } + + if (parts[1] === 'events' && parts.length >= 3) { + const slug = parts[2]; + + if (method === 'GET' && parts.length === 3) { + const event = requireEvent(res, slug, true); + if (!event) return; + return sendJson(res, 200, { ...event, expiresAt: store.expiresAt(event) }); + } + + if (method === 'POST' && parts[3] === 'repairs' && parts.length === 4) { + const event = requireEvent(res, slug, true); + if (!event) return; + return sendJson(res, 201, store.addRepair(event, await readBody(req))); + } + + if (method === 'PATCH' && parts[3] === 'repairs' && parts.length === 5) { + const event = requireEvent(res, slug, true); + if (!event) return; + const repair = store.updateRepair(event, parts[4], await readBody(req)); + if (!repair) return sendJson(res, 404, { error: 'No such repair.' }); + return sendJson(res, 200, repair); + } + } + + return sendJson(res, 404, { error: 'No such endpoint.' }); + } + + /* event pages */ + if (method === 'GET' && parts.length >= 1 && parts.length <= 2) { + const slug = parts[0]; + if (slug !== store.slugify(slug)) return sendPage(res, 'notfound.html', 404); + + if (parts.length === 1) { + if (!requireEvent(res, slug, false)) return; + return sendPage(res, 'intake.html'); + } + if (parts[1] === 'dashboard') { + if (!requireEvent(res, slug, false)) return; + return sendPage(res, 'dashboard.html'); + } + if (parts[1] === 'export.csv') { + const event = requireEvent(res, slug, false); + if (!event) return; + return sendCsv(res, `${slug}.csv`, toCsv([event])); + } + } + + return sendPage(res, 'notfound.html', 404); +} + +/* ------------------------------------------------------------------- server */ + +const server = http.createServer((req, res) => { + route(req, res).catch((err) => { + // Validation failures from the store are the user's to fix; anything else + // is ours, so log it. + const isValidation = err instanceof Error && !!err.message && err.message.length < 300; + if (!isValidation) console.error(err); + if (res.headersSent) return res.end(); + if (req.url.startsWith('/api/')) sendJson(res, 400, { error: err.message || 'Something went wrong.' }); + else sendText(res, 400, err.message || 'Something went wrong.'); + }); +}); + +function start() { + const config = store.loadConfig(); + + const pruned = store.pruneExpired(); + if (pruned.length) console.log(`Deleted ${pruned.length} expired event(s): ${pruned.join(', ')}`); + + // Re-check daily for long-running servers. unref() so this timer alone never + // keeps the process alive. + setInterval(() => { + const gone = store.pruneExpired(); + if (gone.length) console.log(`Deleted ${gone.length} expired event(s): ${gone.join(', ')}`); + }, DAY_MS).unref(); + + const port = Number(process.env.PORT) || config.port || 8080; + const host = process.env.HOST || '0.0.0.0'; + server.listen(port, host, () => { + console.log(`Repair Cafe Kanban listening on http://localhost:${port}`); + console.log( + config.retentionDays > 0 + ? `Events are deleted ${config.retentionDays} days after they are created.` + : 'Event retention is off — nothing is deleted automatically.' + ); + }); +} + +if (require.main === module) start(); + +module.exports = { server, start };