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