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:
+66
@@ -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
@@ -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,
|
||||
};
|
||||
Reference in New Issue
Block a user