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
+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>