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

268 lines
7.0 KiB
JavaScript

'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,
};