Files
travandClaude Opus 5 d1cb472696 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>
2026-08-15 15:02:45 -04:00

545 lines
17 KiB
JavaScript

'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));