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 <noreply@anthropic.com>
This commit is contained in:
2026-08-15 15:02:45 -04:00
co-authored by Claude Opus 5
commit d1cb472696
16 changed files with 2034 additions and 0 deletions
+6
View File
@@ -0,0 +1,6 @@
# Event data: real customers' names and what they brought in. Never commit it.
data/
# Editor backups
*~
*.swp
+123
View File
@@ -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 <http://localhost:8080>. 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://<this-machine's-ip>: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 `/<event-name>`. Leave this open on a
tablet or laptop by the door — it clears itself after every submission.
3. Open the **dashboard** at `/<event-name>/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)
```
+13
View File
@@ -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."
}
+66
View File
@@ -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 };
+267
View File
@@ -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,
};
+328
View File
@@ -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; }
}
+104
View File
@@ -0,0 +1,104 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Dashboard</title>
<link rel="stylesheet" href="/static/app.css">
</head>
<body class="dashboard">
<header class="board-header">
<h1 id="event-name">&nbsp;</h1>
<span class="meta" id="expiry"></span>
<span class="spacer"></span>
<span class="meta" id="status-line"></span>
<a id="intake-link" href="">Sign-in form</a>
<a id="csv-link" href="">Download CSV</a>
</header>
<main class="board" id="board"></main>
<template id="column-template">
<section class="column">
<div class="column-header">
<h2></h2>
<span class="count"></span>
<label class="hint" style="font-weight:400">sort
<select class="sort">
<option value="time">time added</option>
<option value="category">item category</option>
</select>
</label>
</div>
<div class="cards"></div>
</section>
</template>
<!-- queue -> in progress -->
<dialog id="repairer-dialog">
<form method="dialog" class="dialog-body">
<h2>Who is repairing this?</h2>
<div class="field">
<label for="repairer-input">Volunteer name</label>
<input type="text" id="repairer-input" maxlength="80" autocomplete="off">
</div>
<div class="dialog-actions">
<button type="button" data-close="cancel">Cancel</button>
<button value="save" class="primary">Save</button>
</div>
</form>
</dialog>
<!-- in progress -> done -->
<dialog id="done-dialog">
<form method="dialog" class="dialog-body">
<h2>How did it go?</h2>
<div class="field">
<label>Result</label>
<label class="check"><input type="radio" name="outcome" value="fixed"> <span>Fixed</span></label>
<label class="check"><input type="radio" name="outcome" value="partially fixed"> <span>Partially fixed</span></label>
<label class="check"><input type="radio" name="outcome" value="not fixed"> <span>Not fixed</span></label>
</div>
<div class="field">
<label for="tools-input">Tools / consumables used <span class="hint">— optional</span></label>
<input type="text" id="tools-input" maxlength="300" autocomplete="off">
</div>
<div class="field">
<label for="done-note">Notes <span class="hint">— optional</span></label>
<textarea id="done-note" maxlength="2000"></textarea>
</div>
<div class="dialog-actions">
<button type="button" data-close="cancel" class="left">Cancel</button>
<button type="button" data-close="skip">Skip for now</button>
<button value="save" class="primary">Save</button>
</div>
</form>
</dialog>
<!-- card detail -->
<dialog id="detail">
<div class="dialog-body">
<h2 id="detail-title"></h2>
<div id="detail-fields"></div>
<div class="detail-field">
<label>Notes</label>
<ul class="notes-list" id="notes-list"></ul>
<textarea id="new-note" placeholder="Add a note…" maxlength="2000" rows="2"></textarea>
<div style="margin-top:8px"><button type="button" class="small" id="add-note">Add note</button></div>
</div>
<div class="dialog-actions">
<button type="button" id="detail-close" class="primary">Close</button>
</div>
</div>
</dialog>
<script src="/static/dashboard.js"></script>
</body>
</html>
+544
View File
@@ -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));
+17
View File
@@ -0,0 +1,17 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Event expired — Repair Cafe Kanban</title>
<link rel="stylesheet" href="/static/app.css">
</head>
<body>
<div class="wrap">
<h1>This event has expired</h1>
<p>Repair cafe boards are kept for a short time and then deleted, along with their data.
This one is gone.</p>
<p><a href="/">Start a new one</a></p>
</div>
</body>
</html>
+17
View File
@@ -0,0 +1,17 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Repair Cafe Kanban</title>
<link rel="stylesheet" href="/static/app.css">
</head>
<body>
<div class="wrap">
<h1>Repair Cafe Kanban</h1>
<p class="subtitle">A queue board for a repair cafe event.</p>
<p><button class="primary big" onclick="location.href='/new'">Create repair cafe kanban</button></p>
</div>
</body>
</html>
+64
View File
@@ -0,0 +1,64 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Sign in a repair</title>
<link rel="stylesheet" href="/static/app.css">
</head>
<body>
<div class="wrap">
<h1 id="event-name">&nbsp;</h1>
<p class="subtitle">Fill this in and a volunteer will call you when it is your turn.</p>
<div id="saved" class="notice ok" hidden>Added to the queue. Thank you!</div>
<div id="error" class="notice error" hidden></div>
<form id="form" autocomplete="off">
<div class="field">
<label for="customer">Your name</label>
<input type="text" id="customer" required maxlength="80">
</div>
<div class="field">
<label for="item">What have you brought?</label>
<input type="text" id="item" required maxlength="120" placeholder="Toaster, table lamp, bike…">
</div>
<div class="field">
<label for="category">Category <span class="hint">— start typing, then pick one from the list</span></label>
<input type="text" id="category" list="categories" required autocapitalize="none">
<datalist id="categories"></datalist>
</div>
<div class="field">
<label for="problem">What is wrong with it?</label>
<textarea id="problem" maxlength="2000" placeholder="Describe the problem, and anything you have already tried."></textarea>
</div>
<div class="field">
<label class="check">
<input type="checkbox" id="agree" required>
<span>I agree to the <a href="#" id="rules-link">house rules</a></span>
</label>
</div>
<button type="submit" class="primary big" id="submit">Submit</button>
</form>
<p style="margin-top:40px"><a id="dashboard-link" href="">Volunteer dashboard</a></p>
</div>
<dialog id="rules">
<div class="dialog-body">
<h2>House rules</h2>
<div class="rules-text" id="rules-text"></div>
<div class="dialog-actions">
<button type="button" id="rules-ok" class="primary">OK</button>
</div>
</div>
</dialog>
<script src="/static/intake.js"></script>
</body>
</html>
+96
View File
@@ -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.'));
+86
View File
@@ -0,0 +1,86 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>New event — Repair Cafe Kanban</title>
<link rel="stylesheet" href="/static/app.css">
</head>
<body>
<div class="wrap">
<h1>Create repair cafe kanban</h1>
<p class="subtitle">Set up a board for one event.</p>
<div id="error" class="notice error" hidden></div>
<form id="form" autocomplete="off">
<div class="field">
<label for="name">Name of event</label>
<input type="text" id="name" name="name" required maxlength="80" placeholder="Saturday Repair Day">
</div>
<div class="field">
<label class="check">
<input type="checkbox" id="randomize">
<span>Randomise the address<br>
<span class="hint">Adds a few random characters to the end so the address is hard to
guess or collide with. Useful if you run an event with the same name often.</span></span>
</label>
</div>
<div class="field">
<label>The board will live at</label>
<div class="url-preview" id="preview">&hellip;</div>
</div>
<button type="submit" class="primary big" id="create">Create</button>
<p style="margin-top:24px"><a href="/">Cancel</a></p>
</form>
</div>
<script>
const nameInput = document.getElementById('name');
const randomize = document.getElementById('randomize');
const preview = document.getElementById('preview');
const errorBox = document.getElementById('error');
const createBtn = document.getElementById('create');
// Must match slugify() in lib/store.js.
function slugify(name) {
return name.toLowerCase().trim().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 60);
}
function updatePreview() {
const slug = slugify(nameInput.value);
const suffix = randomize.checked ? '-xxxx' : '';
preview.textContent = slug ? `${location.origin}/${slug}${suffix}` : '…';
}
nameInput.addEventListener('input', updatePreview);
randomize.addEventListener('change', updatePreview);
updatePreview();
document.getElementById('form').addEventListener('submit', async (e) => {
e.preventDefault();
errorBox.hidden = true;
createBtn.disabled = true;
try {
const res = await fetch('/api/events', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: nameInput.value, randomize: randomize.checked }),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Could not create the event.');
location.href = '/' + data.slug;
} catch (err) {
errorBox.textContent = err.message;
errorBox.hidden = false;
createBtn.disabled = false;
}
});
nameInput.focus();
</script>
</body>
</html>
+16
View File
@@ -0,0 +1,16 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Not found — Repair Cafe Kanban</title>
<link rel="stylesheet" href="/static/app.css">
</head>
<body>
<div class="wrap">
<h1>Not found</h1>
<p>There is no repair cafe at this address. Check the link, or start a new one.</p>
<p><a href="/">Back to the start</a></p>
</div>
</body>
</html>
+49
View File
@@ -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.
+238
View File
@@ -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 };