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:
@@ -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));
|
||||
Reference in New Issue
Block a user