- 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>
283 lines
16 KiB
JavaScript
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");
|
|
});
|