Files
Bonna-Moon-Studio/prototype/server.mjs
Bonna cd83fd61cb Studio Notebook: vision, Phase 1 designs, and working prototype
- VISION.md: the full studio-backbone product vision (craft core, operations,
  selling, daily driver), Bonna's approved design decisions, and phasing
- design/: two clickable Phase 1 mockups (moonlit palette, CSS-only tabs)
- prototype/: zero-dependency local app (Node built-ins + SQLite) — Today with
  jot-first capture + subtask outliner, Clay & Glazes library, fill-and-go
  Firing Log with emoji unload ratings feeding evidence tables, Pieces with
  core line/tiles/one-of-a-kind + money card, Files with per-piece attachment,
  Sourcing with price ledger + live supplier search links (9 suppliers seeded)
- CLAUDE.md: access notes updated for the provisioned site + this repo's state

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-06 21:46:54 -05:00

283 lines
16 KiB
JavaScript

// Studio Notebook — Phase 1 prototype server
// Zero dependencies: Node's built-in http + sqlite. Run: node prototype/server.mjs
import { createServer } from "node:http";
import { DatabaseSync } from "node:sqlite";
import { readFileSync, existsSync, mkdirSync, writeFileSync, readdirSync, unlinkSync } from "node:fs";
import { join, dirname, extname } from "node:path";
import { fileURLToPath } from "node:url";
import { randomUUID } from "node:crypto";
const ROOT = dirname(fileURLToPath(import.meta.url));
const DATA = join(ROOT, "data");
const PHOTOS = join(DATA, "photos");
mkdirSync(PHOTOS, { recursive: true });
const db = new DatabaseSync(join(DATA, "studio.db"));
db.exec(`
PRAGMA journal_mode = WAL;
CREATE TABLE IF NOT EXISTS notes (
id INTEGER PRIMARY KEY, text TEXT NOT NULL, tags TEXT DEFAULT '',
created TEXT DEFAULT (datetime('now','localtime')));
CREATE TABLE IF NOT EXISTS todos (
id INTEGER PRIMARY KEY, text TEXT NOT NULL, done INTEGER DEFAULT 0,
created TEXT DEFAULT (datetime('now','localtime')));
CREATE TABLE IF NOT EXISTS shopping (
id INTEGER PRIMARY KEY, text TEXT NOT NULL, done INTEGER DEFAULT 0,
created TEXT DEFAULT (datetime('now','localtime')));
CREATE TABLE IF NOT EXISTS glazes (
id INTEGER PRIMARY KEY, name TEXT NOT NULL, kind TEXT DEFAULT 'glaze',
cone TEXT DEFAULT '', atmosphere TEXT DEFAULT '', surface TEXT DEFAULT '',
source TEXT DEFAULT 'mine', sg TEXT DEFAULT '', swatch TEXT DEFAULT '',
created TEXT DEFAULT (datetime('now','localtime')));
CREATE TABLE IF NOT EXISTS glaze_materials (
id INTEGER PRIMARY KEY, glaze_id INTEGER NOT NULL, name TEXT NOT NULL,
pct REAL NOT NULL, addition INTEGER DEFAULT 0);
CREATE TABLE IF NOT EXISTS glaze_journal (
id INTEGER PRIMARY KEY, glaze_id INTEGER NOT NULL, text TEXT NOT NULL,
created TEXT DEFAULT (datetime('now','localtime')));
CREATE TABLE IF NOT EXISTS firings (
id INTEGER PRIMARY KEY, type TEXT DEFAULT 'glaze', cone TEXT DEFAULT '∆6',
schedule TEXT DEFAULT '', status TEXT DEFAULT 'firing',
result_notes TEXT DEFAULT '',
started TEXT DEFAULT (datetime('now','localtime')), unloaded TEXT);
CREATE TABLE IF NOT EXISTS firing_items (
id INTEGER PRIMARY KEY, firing_id INTEGER NOT NULL, piece TEXT NOT NULL,
clay TEXT DEFAULT '', glaze TEXT DEFAULT '', rating TEXT DEFAULT '', note TEXT DEFAULT '');
CREATE TABLE IF NOT EXISTS products (
id INTEGER PRIMARY KEY, name TEXT NOT NULL, kind TEXT DEFAULT 'core line',
category TEXT DEFAULT '', price REAL DEFAULT 0, cost REAL DEFAULT 0,
qty INTEGER DEFAULT 0, status TEXT DEFAULT 'in progress',
created TEXT DEFAULT (datetime('now','localtime')));
CREATE TABLE IF NOT EXISTS sales (
id INTEGER PRIMARY KEY, product_id INTEGER NOT NULL, qty INTEGER DEFAULT 1,
price REAL DEFAULT 0, cost REAL DEFAULT 0,
created TEXT DEFAULT (datetime('now','localtime')));
CREATE TABLE IF NOT EXISTS photos (
id INTEGER PRIMARY KEY, entity TEXT NOT NULL, entity_id INTEGER NOT NULL,
filename TEXT NOT NULL, created TEXT DEFAULT (datetime('now','localtime')));
CREATE TABLE IF NOT EXISTS suppliers (
id INTEGER PRIMARY KEY, name TEXT NOT NULL, kind TEXT DEFAULT 'online',
distance TEXT DEFAULT '', url TEXT DEFAULT '', notes TEXT DEFAULT '',
created TEXT DEFAULT (datetime('now','localtime')));
CREATE TABLE IF NOT EXISTS price_entries (
id INTEGER PRIMARY KEY, item TEXT NOT NULL, supplier_id INTEGER NOT NULL,
price REAL NOT NULL, unit TEXT DEFAULT '', shipping REAL DEFAULT 0,
note TEXT DEFAULT '', created TEXT DEFAULT (datetime('now','localtime')));
CREATE TABLE IF NOT EXISTS files (
id INTEGER PRIMARY KEY, entity TEXT DEFAULT '', entity_id INTEGER DEFAULT 0,
stored TEXT NOT NULL, name TEXT NOT NULL, tag TEXT DEFAULT '',
size INTEGER DEFAULT 0, created TEXT DEFAULT (datetime('now','localtime')));
`);
const FILES = join(DATA, "files");
mkdirSync(FILES, { recursive: true });
try { db.exec("ALTER TABLE todos ADD COLUMN parent_id INTEGER"); } catch { /* column exists */ }
try { db.exec("ALTER TABLE products ADD COLUMN notes TEXT DEFAULT ''"); } catch { /* column exists */ }
try { db.exec("ALTER TABLE suppliers ADD COLUMN search_url TEXT DEFAULT ''"); } catch { /* column exists */ }
const all = (sql, ...p) => db.prepare(sql).all(...p);
const get = (sql, ...p) => db.prepare(sql).get(...p);
const run = (sql, ...p) => db.prepare(sql).run(...p);
// ── the "plays well with" evidence table, built from unload ratings only ──
function compatibility(glazeName) {
return all(
`SELECT fi.clay, fi.rating, COUNT(*) n, GROUP_CONCAT(NULLIF(fi.note,''), ' · ') notes
FROM firing_items fi JOIN firings f ON f.id = fi.firing_id
WHERE fi.glaze = ? AND fi.rating != '' AND fi.clay != '' AND f.status = 'unloaded'
GROUP BY fi.clay, fi.rating ORDER BY fi.clay, n DESC`, glazeName);
}
const routes = {
"GET /api/summary": () => ({
todos: all("SELECT * FROM todos ORDER BY id DESC LIMIT 60"),
shopping: all("SELECT * FROM shopping ORDER BY done, id DESC LIMIT 12"),
notes: all("SELECT * FROM notes ORDER BY id DESC LIMIT 6"),
activeFiring: get("SELECT * FROM firings WHERE status='firing' ORDER BY id DESC LIMIT 1") ?? null,
lastUnload: get("SELECT * FROM firings WHERE status='unloaded' ORDER BY unloaded DESC LIMIT 1") ?? null,
}),
"POST /api/jot": (b) => {
const kind = b.kind || "note";
const lines = String(b.text).split("\n").map((l) => l.replace(/^[\s\-•↳]+/, "").trim()).filter(Boolean);
if (kind === "todo") {
// first line = the task, following lines = its subtasks
const r = run("INSERT INTO todos (text) VALUES (?)", lines[0]);
for (const sub of lines.slice(1)) run("INSERT INTO todos (text, parent_id) VALUES (?, ?)", sub, r.lastInsertRowid);
return { ok: true };
}
if (kind === "shopping") {
for (const l of lines) run("INSERT INTO shopping (text) VALUES (?)", l);
return { ok: true };
}
return run("INSERT INTO notes (text, tags) VALUES (?, ?)", b.text, b.tags || (kind !== "note" ? kind : ""));
},
"POST /api/todos/add": (b) => {
const r = run("INSERT INTO todos (text, parent_id) VALUES (?, ?)", b.text, b.parent_id ?? null);
return { id: Number(r.lastInsertRowid) };
},
"POST /api/todos/delete": (b) => {
run("DELETE FROM todos WHERE parent_id = ?", b.id);
return run("DELETE FROM todos WHERE id = ?", b.id);
},
"POST /api/todos/toggle": (b) => run("UPDATE todos SET done = 1 - done WHERE id = ?", b.id),
"POST /api/shopping/toggle": (b) => run("UPDATE shopping SET done = 1 - done WHERE id = ?", b.id),
"GET /api/notes": () => all("SELECT * FROM notes ORDER BY id DESC"),
"GET /api/glazes": () => all("SELECT * FROM glazes ORDER BY name").map(g => ({
...g, materials: all("SELECT * FROM glaze_materials WHERE glaze_id = ? ORDER BY addition, pct DESC", g.id),
})),
"GET /api/glaze": (_, q) => {
const g = get("SELECT * FROM glazes WHERE id = ?", Number(q.get("id")));
if (!g) return null;
return { ...g,
materials: all("SELECT * FROM glaze_materials WHERE glaze_id = ? ORDER BY addition, pct DESC", g.id),
journal: all("SELECT * FROM glaze_journal WHERE glaze_id = ? ORDER BY id DESC", g.id),
compatibility: compatibility(g.name),
photos: all("SELECT * FROM photos WHERE entity='glaze' AND entity_id = ?", g.id) };
},
"POST /api/glazes": (b) => {
const r = run(
"INSERT INTO glazes (name, kind, cone, atmosphere, surface, source, sg, swatch) VALUES (?,?,?,?,?,?,?,?)",
b.name, b.kind || "glaze", b.cone || "", b.atmosphere || "", b.surface || "", b.source || "mine", b.sg || "", b.swatch || "");
for (const m of b.materials || [])
run("INSERT INTO glaze_materials (glaze_id, name, pct, addition) VALUES (?,?,?,?)",
r.lastInsertRowid, m.name, m.pct, m.addition ? 1 : 0);
return { id: Number(r.lastInsertRowid) };
},
"POST /api/glaze/journal": (b) => run("INSERT INTO glaze_journal (glaze_id, text) VALUES (?,?)", b.glaze_id, b.text),
"GET /api/firings": () => all("SELECT * FROM firings ORDER BY id DESC").map(f => ({
...f, items: all("SELECT * FROM firing_items WHERE firing_id = ?", f.id) })),
"POST /api/firings": (b) => {
const r = run("INSERT INTO firings (type, cone, schedule) VALUES (?,?,?)", b.type || "glaze", b.cone || "∆6", b.schedule || "");
for (const it of b.items || [])
run("INSERT INTO firing_items (firing_id, piece, clay, glaze) VALUES (?,?,?,?)", r.lastInsertRowid, it.piece, it.clay || "", it.glaze || "");
return { id: Number(r.lastInsertRowid) };
},
"POST /api/firing/items": (b) => run("INSERT INTO firing_items (firing_id, piece, clay, glaze) VALUES (?,?,?,?)", b.firing_id, b.piece, b.clay || "", b.glaze || ""),
"POST /api/firing/rate": (b) => run("UPDATE firing_items SET rating = ?, note = ? WHERE id = ?", b.rating, b.note || "", b.id),
"POST /api/firing/unload": (b) => run("UPDATE firings SET status='unloaded', unloaded=datetime('now','localtime'), result_notes=? WHERE id=?", b.result_notes || "", b.id),
"GET /api/products": () => ({
products: all("SELECT * FROM products ORDER BY kind, category, name"),
money: {
revenue: get("SELECT COALESCE(SUM(qty*price),0) v FROM sales").v,
cogs: get("SELECT COALESCE(SUM(qty*cost),0) v FROM sales").v,
monthRevenue: get("SELECT COALESCE(SUM(qty*price),0) v FROM sales WHERE created >= date('now','start of month')").v,
salesCount: get("SELECT COALESCE(SUM(qty),0) v FROM sales").v,
},
}),
"POST /api/products": (b) => {
const r = run("INSERT INTO products (name, kind, category, price, cost, qty) VALUES (?,?,?,?,?,?)",
b.name, b.kind || "core line", b.category || "", Number(b.price) || 0, Number(b.cost) || 0, Number(b.qty) || 0);
return { id: Number(r.lastInsertRowid) };
},
"GET /api/product": (_, q) => {
const p = get("SELECT * FROM products WHERE id = ?", Number(q.get("id")));
if (!p) return null;
return { ...p,
photos: all("SELECT * FROM photos WHERE entity='product' AND entity_id = ?", p.id),
files: all("SELECT * FROM files WHERE entity='product' AND entity_id = ? ORDER BY id DESC", p.id),
sales: all("SELECT * FROM sales WHERE product_id = ? ORDER BY id DESC LIMIT 10", p.id) };
},
"POST /api/products/notes": (b) => run("UPDATE products SET notes = ? WHERE id = ?", b.notes, b.id),
"GET /api/files": () => all(`SELECT f.*, p.name AS product_name FROM files f
LEFT JOIN products p ON f.entity = 'product' AND p.id = f.entity_id ORDER BY f.id DESC`),
"POST /api/files/delete": (b) => {
const f = get("SELECT * FROM files WHERE id = ?", b.id);
if (f) { try { unlinkSync(join(FILES, f.stored)); } catch { /* already gone */ } run("DELETE FROM files WHERE id = ?", b.id); }
return { ok: true };
},
"GET /api/sourcing": () => ({
suppliers: all("SELECT * FROM suppliers ORDER BY name"),
entries: all(`SELECT pe.*, s.name AS supplier, s.kind AS supplier_kind, s.distance
FROM price_entries pe JOIN suppliers s ON s.id = pe.supplier_id ORDER BY pe.item, pe.id DESC`),
}),
"POST /api/suppliers": (b) => {
const r = run("INSERT INTO suppliers (name, kind, distance, url, notes, search_url) VALUES (?,?,?,?,?,?)",
b.name, b.kind || "online", b.distance || "", b.url || "", b.notes || "", b.search_url || "");
return { id: Number(r.lastInsertRowid) };
},
"POST /api/price-entries": (b) => run(
"INSERT INTO price_entries (item, supplier_id, price, unit, shipping, note) VALUES (?,?,?,?,?,?)",
b.item, b.supplier_id, Number(b.price) || 0, b.unit || "", Number(b.shipping) || 0, b.note || ""),
"POST /api/products/qty": (b) => run("UPDATE products SET qty = MAX(0, qty + ?) WHERE id = ?", b.delta, b.id),
"POST /api/products/status": (b) => run("UPDATE products SET status = ? WHERE id = ?", b.status, b.id),
"POST /api/sales": (b) => {
const p = get("SELECT * FROM products WHERE id = ?", b.product_id);
if (!p) return { error: "no such product" };
run("INSERT INTO sales (product_id, qty, price, cost) VALUES (?,?,?,?)", p.id, b.qty || 1, b.price ?? p.price, p.cost);
if (p.kind === "core line" || p.kind === "tiles") run("UPDATE products SET qty = MAX(0, qty - ?) WHERE id = ?", b.qty || 1, p.id);
else run("UPDATE products SET status = 'sold' WHERE id = ?", p.id);
return { ok: true };
},
};
const MIME = { ".html": "text/html", ".js": "text/javascript", ".css": "text/css",
".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".webp": "image/webp", ".heic": "image/heic" };
createServer(async (req, res) => {
const url = new URL(req.url, "http://localhost");
const send = (code, body, type = "application/json") => {
res.writeHead(code, { "content-type": type });
res.end(type === "application/json" ? JSON.stringify(body) : body);
};
try {
// photo upload: POST /api/photos?entity=note&id=3 (raw image body)
if (req.method === "POST" && url.pathname === "/api/photos") {
const chunks = [];
for await (const c of req) chunks.push(c);
const ext = (MIME[extname(url.searchParams.get("name") || "").toLowerCase()] ? extname(url.searchParams.get("name")) : ".jpg").toLowerCase();
const filename = randomUUID() + ext;
writeFileSync(join(PHOTOS, filename), Buffer.concat(chunks));
run("INSERT INTO photos (entity, entity_id, filename) VALUES (?,?,?)",
url.searchParams.get("entity") || "note", Number(url.searchParams.get("id") || 0), filename);
return send(200, { ok: true, filename });
}
// file upload: POST /api/files?name=mold.stl&tag=master+molds[&entity=product&id=3]
if (req.method === "POST" && url.pathname === "/api/files") {
const chunks = [];
for await (const c of req) chunks.push(c);
const buf = Buffer.concat(chunks);
const orig = (url.searchParams.get("name") || "file").replace(/[\/\\]/g, "_");
const stored = randomUUID() + extname(orig).toLowerCase();
writeFileSync(join(FILES, stored), buf);
run("INSERT INTO files (entity, entity_id, stored, name, tag, size) VALUES (?,?,?,?,?,?)",
url.searchParams.get("entity") || "", Number(url.searchParams.get("id") || 0),
stored, orig, url.searchParams.get("tag") || "", buf.length);
return send(200, { ok: true });
}
if (url.pathname.startsWith("/file/")) {
const stored = url.pathname.slice(6).replace(/[^A-Za-z0-9.\-]/g, "");
const f = get("SELECT * FROM files WHERE stored = ?", stored);
if (!f || !existsSync(join(FILES, stored))) return send(404, { error: "not found" });
res.writeHead(200, {
"content-type": MIME[extname(stored)] || "application/octet-stream",
"content-disposition": `attachment; filename="${f.name.replace(/"/g, "")}"`,
});
return res.end(readFileSync(join(FILES, stored)));
}
if (url.pathname.startsWith("/photos/")) {
const f = join(PHOTOS, url.pathname.slice(8).replace(/[^A-Za-z0-9.\-]/g, ""));
if (existsSync(f)) return send(200, readFileSync(f), MIME[extname(f).toLowerCase()] || "application/octet-stream");
return send(404, { error: "not found" });
}
const handler = routes[`${req.method} ${url.pathname}`];
if (handler) {
let body = {};
if (req.method === "POST") {
const chunks = [];
for await (const c of req) chunks.push(c);
body = chunks.length ? JSON.parse(Buffer.concat(chunks).toString()) : {};
}
return send(200, handler(body, url.searchParams) ?? { ok: true });
}
// static app
const file = url.pathname === "/" ? "index.html" : url.pathname.slice(1).replace(/[^A-Za-z0-9.\-\/]/g, "");
const path = join(ROOT, "public", file);
if (existsSync(path)) return send(200, readFileSync(path), MIME[extname(path).toLowerCase()] || "text/plain");
send(404, { error: "not found" });
} catch (e) {
send(500, { error: String(e.message || e) });
}
}).listen(6161, "127.0.0.1", () => {
console.log("🌙 Studio Notebook running at http://localhost:6161");
});