Add roaster (machine) management, mobile header-tools redesign, and profile pictures
Test and deploy / test-and-deploy (push) Successful in 1m0s

Machines:
- roasters table + actual_roasts.roaster_id (migrations 009): per-user
  machines with a default; uploads attach to the default roaster and are
  reassignable from the roast detail view
- Learning is now per machine: /api/roaster-profile scopes to a roaster
  (default when unspecified) and applies the user's override tweaks
  (milestone temps, TP time, pace factor) on top of learned medians
- /roasters page: machine list + analysis report showing learned vs
  applied values with editable tweaks, typical RoR, and a plain-language
  list of the adjustments applied to plans; planner honors a manual pace
  override; evaluations use the roast's own machine profile

Mobile header redesign:
- The planner's header tools collapse behind a single 'Tools ▾'
  disclosure (dropdown card) at ≤720px, keeping the fixed three-row
  mobile header with no wrapped or scrolling button rows; desktop
  renders inline via display:contents (same DOM, same handlers)

Profile pictures:
- users.avatar (migration 010) + PUT/GET/DELETE /api/account/avatar;
  client-side centre-crop resize to 128px JPEG; avatar shows in the nav
  chip on every page; included in full backups

Co-Authored-By: Claude Fable 5 <[email protected]>
This commit is contained in:
Shane Maynard
2026-08-09 08:17:10 -04:00
co-authored by Claude Fable 5
parent e62b9601a2
commit a341d67467
15 changed files with 1291 additions and 62 deletions
+351 -21
View File
@@ -49,6 +49,7 @@ const PUBLIC_SHELL_FILES = new Set([
"/beans.html",
"/brews.html",
"/gear.html",
"/roasters.html",
"/api-docs.html",
]);
@@ -144,6 +145,7 @@ export function createApp({
req.path === "/beans" ||
req.path === "/brews" ||
req.path === "/gear" ||
req.path === "/roasters" ||
req.path === "/api-docs"
)
res.set("Cache-Control", "no-store, private");
@@ -227,7 +229,7 @@ export function createApp({
const raw = cookie(req, "rp_session");
if (!raw) return null;
const r = await db.query(
"SELECT s.csrf_hash,u.id,u.email,u.role,u.created_at FROM sessions s JOIN users u ON u.id=s.user_id WHERE s.token_hash=$1 AND s.expires_at>now() AND u.disabled_at IS NULL",
"SELECT s.csrf_hash,u.id,u.email,u.role,u.created_at,(u.avatar IS NOT NULL) AS has_avatar FROM sessions s JOIN users u ON u.id=s.user_id WHERE s.token_hash=$1 AND s.expires_at>now() AND u.disabled_at IS NULL",
[hash(raw)],
);
const row = r.rows[0];
@@ -425,6 +427,7 @@ export function createApp({
["/beans", "beans.html"],
["/brews", "brews.html"],
["/gear", "gear.html"],
["/roasters", "roasters.html"],
["/api-docs", "api-docs.html"],
]) {
app.get(route, async (req, res, next) => {
@@ -611,6 +614,7 @@ export function createApp({
email: req.user.email,
role: req.user.role,
createdAt: req.user.created_at,
hasAvatar: Boolean(req.user.has_avatar),
},
}),
);
@@ -1183,17 +1187,234 @@ export function createApp({
await db.query("SELECT value FROM app_settings WHERE key='llm_model'")
).rows[0]?.value || "";
// ─── Roasters (per-user machines) ──────────────────────────────────────
// Numeric override keys the user may tweak on a roaster — these replace the corresponding
// LEARNED values wherever the profile is applied (plan curve fallbacks, pace).
const OVERRIDE_KEYS = {
chargeTempC: [0, 400],
turningPointS: [10, 300],
turningPointTempC: [0, 400],
yellowTempC: [0, 400],
firstCrackTempC: [0, 400],
dropTempC: [0, 400],
paceFactor: [0.5, 2],
};
function parseOverrides(raw, existing = {}) {
if (raw === undefined) return { value: existing };
if (!raw || typeof raw !== "object") return { error: "overrides must be an object" };
const out = {};
for (const [key, [lo, hi]] of Object.entries(OVERRIDE_KEYS)) {
const v = raw[key];
if (v === undefined || v === null || v === "") continue;
if (typeof v !== "number" || !Number.isFinite(v) || v < lo || v > hi)
return { error: `${key} must be a number between ${lo} and ${hi}` };
out[key] = v;
}
return { value: out };
}
const toRoasterRow = (row) => ({
id: row.id,
name: row.name,
model: row.model,
notes: row.notes,
isDefault: row.is_default,
overrides: row.overrides ?? {},
roastCount: row.roast_count == null ? undefined : Number(row.roast_count),
createdAt: row.created_at,
updatedAt: row.updated_at,
});
app.get("/api/roasters", requireAuth, async (req, res, next) => {
try {
const [rows, counts] = await Promise.all([
db
.query(
"SELECT * FROM roasters WHERE user_id=$1 ORDER BY created_at",
[req.user.id],
)
.then((r) => r.rows),
db
.query(
"SELECT roaster_id, COUNT(*) AS roast_count FROM actual_roasts WHERE user_id=$1 AND roaster_id IS NOT NULL GROUP BY roaster_id",
[req.user.id],
)
.then((r) => new Map(r.rows.map((x) => [x.roaster_id, x.roast_count]))),
]);
res.json({
ok: true,
roasters: rows.map((row) =>
toRoasterRow({ ...row, roast_count: counts.get(row.id) ?? 0 }),
),
});
} catch (e) {
next(e);
}
});
app.post("/api/roasters", requireAuth, csrf, async (req, res, next) => {
try {
const name = String(req.body.name || "").trim();
if (!name) return res.status(400).json({ ok: false, code: "bad_roaster" });
const overrides = parseOverrides(req.body.overrides, {});
if (overrides.error)
return res
.status(400)
.json({ ok: false, code: "bad_roaster", error: overrides.error });
// The user's first roaster becomes the default automatically.
const hasAny = (
await db.query("SELECT 1 FROM roasters WHERE user_id=$1 LIMIT 1", [req.user.id])
).rowCount;
const makeDefault = !hasAny || Boolean(req.body.isDefault);
if (makeDefault && hasAny)
await db.query(
"UPDATE roasters SET is_default=false WHERE user_id=$1",
[req.user.id],
);
const row = (
await db.query(
"INSERT INTO roasters(user_id,name,model,notes,is_default,overrides) VALUES($1,$2,$3,$4,$5,$6) RETURNING *",
[
req.user.id,
name,
String(req.body.model || ""),
String(req.body.notes || ""),
makeDefault,
JSON.stringify(overrides.value),
],
)
).rows[0];
res.status(201).json({ ok: true, roaster: toRoasterRow(row) });
} catch (e) {
next(e);
}
});
app.put(
"/api/roasters/:id",
requireAuth,
csrf,
requireUuidParam("id"),
async (req, res, next) => {
try {
const existing = (
await db.query("SELECT * FROM roasters WHERE id=$1 AND user_id=$2", [
req.params.id,
req.user.id,
])
).rows[0];
if (!existing)
return res.status(404).json({ ok: false, code: "not_found" });
const b = req.body;
const name =
b.name === undefined ? existing.name : String(b.name || "").trim();
if (!name) return res.status(400).json({ ok: false, code: "bad_roaster" });
const overrides = parseOverrides(b.overrides, existing.overrides ?? {});
if (overrides.error)
return res
.status(400)
.json({ ok: false, code: "bad_roaster", error: overrides.error });
const makeDefault =
b.isDefault === undefined ? existing.is_default : Boolean(b.isDefault);
if (makeDefault && !existing.is_default)
await db.query(
"UPDATE roasters SET is_default=false WHERE user_id=$1",
[req.user.id],
);
const row = (
await db.query(
"UPDATE roasters SET name=$1, model=$2, notes=$3, is_default=$4, overrides=$5, updated_at=now() WHERE id=$6 AND user_id=$7 RETURNING *",
[
name,
b.model === undefined ? existing.model : String(b.model || ""),
b.notes === undefined ? existing.notes : String(b.notes || ""),
makeDefault,
JSON.stringify(overrides.value),
req.params.id,
req.user.id,
],
)
).rows[0];
res.json({ ok: true, roaster: toRoasterRow(row) });
} catch (e) {
next(e);
}
},
);
app.delete(
"/api/roasters/:id",
requireAuth,
csrf,
requireUuidParam("id"),
async (req, res, next) => {
try {
const r = await db.query(
"DELETE FROM roasters WHERE id=$1 AND user_id=$2",
[req.params.id, req.user.id],
);
if (!r.rowCount)
return res.status(404).json({ ok: false, code: "not_found" });
res.json({ ok: true });
} catch (e) {
next(e);
}
},
);
// Learned roaster behavior aggregated from this user's uploaded .alogs — grounds the plan
// chat, the roast evaluations, and the planner's suggested curve temps.
const userRoasterProfile = async (userId) =>
computeRoasterProfile(
(
await db.query("SELECT parsed FROM actual_roasts WHERE user_id=$1", [userId])
).rows.map((r) => r.parsed),
);
// chat, the roast evaluations, and the planner's suggested curve temps. Scoped to one
// machine: an explicit roasterId, else the user's default roaster, else all uploads
// (covers pre-roaster history and users with a single unmanaged machine). The returned
// medians already have that roaster's user overrides applied; the raw learned values ride
// along so the report page can show learned-vs-applied.
async function userRoasterProfile(userId, roasterId = null) {
let roaster = null;
if (roasterId) {
roaster = (
await db.query("SELECT * FROM roasters WHERE id=$1 AND user_id=$2", [
roasterId,
userId,
])
).rows[0];
if (!roaster) return null;
} else {
roaster =
(
await db.query(
"SELECT * FROM roasters WHERE user_id=$1 AND is_default=true",
[userId],
)
).rows[0] ?? null;
}
const rows = roaster
? (
await db.query(
"SELECT parsed FROM actual_roasts WHERE user_id=$1 AND roaster_id=$2",
[userId, roaster.id],
)
).rows
: (
await db.query("SELECT parsed FROM actual_roasts WHERE user_id=$1", [userId])
).rows;
const learned = computeRoasterProfile(rows.map((r) => r.parsed));
const overrides = roaster?.overrides ?? {};
const { paceFactor, ...tempOverrides } = overrides;
return {
...learned,
medians: { ...(learned.medians ?? {}), ...tempOverrides },
learnedMedians: learned.medians ?? null,
overrides,
paceFactorOverride: Number.isFinite(paceFactor) ? paceFactor : null,
roaster: roaster
? { id: roaster.id, name: roaster.name, isDefault: roaster.is_default }
: null,
};
}
app.get("/api/roaster-profile", requireAuth, async (req, res, next) => {
try {
res.json({ ok: true, profile: await userRoasterProfile(req.user.id) });
const roasterId =
typeof req.query.roaster === "string" && UUID_RE.test(req.query.roaster)
? req.query.roaster
: null;
const profile = await userRoasterProfile(req.user.id, roasterId);
if (!profile) return res.status(404).json({ ok: false, code: "not_found" });
res.json({ ok: true, profile });
} catch (e) {
next(e);
}
@@ -1239,6 +1460,8 @@ export function createApp({
id: row.id,
roastPlanId: row.roast_plan_id,
planTitle: row.plan_title ?? null,
roasterId: row.roaster_id ?? null,
roasterName: row.roaster_name ?? null,
filename: row.filename,
roast: parsed.roast ?? null,
derived: parsed.derived ?? null,
@@ -1258,7 +1481,7 @@ export function createApp({
const run = (async () => {
const row = (
await db.query(
`SELECT a.parsed, p.plan FROM actual_roasts a
`SELECT a.parsed, a.roaster_id, p.plan FROM actual_roasts a
LEFT JOIN roast_plans p ON p.id=a.roast_plan_id AND p.user_id=a.user_id
WHERE a.id=$1 AND a.user_id=$2`,
[roastId, userId],
@@ -1270,7 +1493,7 @@ export function createApp({
row.parsed,
row.plan ?? null,
await llmModelSetting(),
await userRoasterProfile(userId),
await userRoasterProfile(userId, row.roaster_id ?? null),
);
await db.query(
"UPDATE actual_roasts SET evaluation=$1, evaluation_status='done', evaluation_error=NULL, updated_at=now() WHERE id=$2",
@@ -1313,11 +1536,33 @@ export function createApp({
.status(422)
.json({ ok: false, code: "unparseable_alog", error: err.message });
}
// Attach to the requested roaster (must be the user's own), else the default one.
let roasterId = null;
if (req.body.roasterId) {
if (!UUID_RE.test(req.body.roasterId))
return res.status(404).json({ ok: false, code: "not_found" });
const owns = (
await db.query("SELECT 1 FROM roasters WHERE id=$1 AND user_id=$2", [
req.body.roasterId,
req.user.id,
])
).rowCount;
if (!owns) return res.status(404).json({ ok: false, code: "not_found" });
roasterId = req.body.roasterId;
} else {
roasterId =
(
await db.query(
"SELECT id FROM roasters WHERE user_id=$1 AND is_default=true",
[req.user.id],
)
).rows[0]?.id ?? null;
}
const row = (
await db.query(
`INSERT INTO actual_roasts(user_id,roast_plan_id,filename,original_content,parsed)
VALUES($1,$2,$3,$4,$5) RETURNING *`,
[req.user.id, roastPlanId, filename, content, parsed],
`INSERT INTO actual_roasts(user_id,roast_plan_id,roaster_id,filename,original_content,parsed)
VALUES($1,$2,$3,$4,$5,$6) RETURNING *`,
[req.user.id, roastPlanId, roasterId, filename, content, parsed],
)
).rows[0];
startEvaluation(row.id, req.user.id);
@@ -1334,9 +1579,11 @@ export function createApp({
: null;
const rows = (
await db.query(
`SELECT a.id,a.roast_plan_id,a.filename,a.parsed,a.evaluation,a.evaluation_status,a.evaluation_error,a.created_at,
p.plan->'fields'->>'0.1' AS plan_title
FROM actual_roasts a LEFT JOIN roast_plans p ON p.id=a.roast_plan_id AND p.user_id=a.user_id
`SELECT a.id,a.roast_plan_id,a.roaster_id,a.filename,a.parsed,a.evaluation,a.evaluation_status,a.evaluation_error,a.created_at,
p.plan->'fields'->>'0.1' AS plan_title, m.name AS roaster_name
FROM actual_roasts a
LEFT JOIN roast_plans p ON p.id=a.roast_plan_id AND p.user_id=a.user_id
LEFT JOIN roasters m ON m.id=a.roaster_id AND m.user_id=a.user_id
WHERE a.user_id=$1 AND ($2::uuid IS NULL OR a.roast_plan_id=$2)
ORDER BY a.created_at DESC`,
[req.user.id, planFilter],
@@ -1355,8 +1602,10 @@ export function createApp({
try {
const row = (
await db.query(
`SELECT a.*, p.plan->'fields'->>'0.1' AS plan_title, p.plan
FROM actual_roasts a LEFT JOIN roast_plans p ON p.id=a.roast_plan_id AND p.user_id=a.user_id
`SELECT a.*, p.plan->'fields'->>'0.1' AS plan_title, p.plan, m.name AS roaster_name
FROM actual_roasts a
LEFT JOIN roast_plans p ON p.id=a.roast_plan_id AND p.user_id=a.user_id
LEFT JOIN roasters m ON m.id=a.roaster_id AND m.user_id=a.user_id
WHERE a.id=$1 AND a.user_id=$2`,
[req.params.id, req.user.id],
)
@@ -1393,6 +1642,41 @@ export function createApp({
}
},
);
// Reassign a roast to another of the user's machines (the only mutable field on an upload —
// the file and its parse are immutable history).
app.put(
"/api/roasts/:id",
requireAuth,
csrf,
requireUuidParam("id"),
async (req, res, next) => {
try {
let roasterId = null;
if (req.body.roasterId !== undefined && req.body.roasterId !== null && req.body.roasterId !== "") {
if (!UUID_RE.test(req.body.roasterId))
return res.status(404).json({ ok: false, code: "not_found" });
const owns = (
await db.query("SELECT 1 FROM roasters WHERE id=$1 AND user_id=$2", [
req.body.roasterId,
req.user.id,
])
).rowCount;
if (!owns)
return res.status(404).json({ ok: false, code: "not_found" });
roasterId = req.body.roasterId;
}
const r = await db.query(
"UPDATE actual_roasts SET roaster_id=$1, updated_at=now() WHERE id=$2 AND user_id=$3",
[roasterId, req.params.id, req.user.id],
);
if (!r.rowCount)
return res.status(404).json({ ok: false, code: "not_found" });
res.json({ ok: true, roasterId });
} catch (e) {
next(e);
}
},
);
app.post(
"/api/roasts/:id/evaluate",
requireAuth,
@@ -2157,6 +2441,51 @@ export function createApp({
}
},
);
// Profile picture: a small data URL, resized client-side before upload. Served back as a
// real image so <img>/background-image can use it without shipping base64 in every page.
const AVATAR_RE = /^data:image\/(png|jpeg|webp);base64,([A-Za-z0-9+/=]+)$/;
app.put("/api/account/avatar", requireAuth, csrf, async (req, res, next) => {
try {
const dataUrl = req.body.dataUrl;
if (
typeof dataUrl !== "string" ||
dataUrl.length > 300_000 ||
!AVATAR_RE.test(dataUrl)
)
return res.status(400).json({ ok: false, code: "bad_avatar" });
await db.query("UPDATE users SET avatar=$1 WHERE id=$2", [
dataUrl,
req.user.id,
]);
res.json({ ok: true });
} catch (e) {
next(e);
}
});
app.delete("/api/account/avatar", requireAuth, csrf, async (req, res, next) => {
try {
await db.query("UPDATE users SET avatar=NULL WHERE id=$1", [req.user.id]);
res.json({ ok: true });
} catch (e) {
next(e);
}
});
app.get("/api/account/avatar", requireAuth, async (req, res, next) => {
try {
const avatar = (
await db.query("SELECT avatar FROM users WHERE id=$1", [req.user.id])
).rows[0]?.avatar;
const match = avatar ? avatar.match(AVATAR_RE) : null;
if (!match) return res.status(404).json({ ok: false, code: "not_found" });
res.set({
"Content-Type": `image/${match[1]}`,
"Cache-Control": "private, max-age=300",
});
res.send(Buffer.from(match[2], "base64"));
} catch (e) {
next(e);
}
});
app.get("/api/account/sessions", requireAuth, async (req, res, next) => {
try {
const currentHash = hash(cookie(req, "rp_session"));
@@ -2356,7 +2685,7 @@ export function createApp({
// part of a backup: they are short-lived secrets, and restoring them would resurrect revoked
// access. Deleting users cascades both away on import anyway.
const BACKUP_TABLES = [
["users", ["id", "email", "password_hash", "role", "created_at", "disabled_at"]],
["users", ["id", "email", "password_hash", "role", "created_at", "disabled_at", "avatar"]],
["app_settings", ["key", "value"]],
["roast_plans", ["id", "user_id", "plan", "created_at", "updated_at"]],
[
@@ -2369,10 +2698,11 @@ export function createApp({
],
["bean_consumption", ["id", "lot_id", "user_id", "roast_plan_id", "weight_g", "created_at"]],
["cupping_sessions", ["id", "user_id", "roast_plan_id", "data", "total_score", "created_at", "updated_at"]],
["roasters", ["id", "user_id", "name", "model", "notes", "is_default", "overrides", "created_at", "updated_at"]],
[
"actual_roasts",
[
"id", "user_id", "roast_plan_id", "filename", "original_content", "parsed",
"id", "user_id", "roast_plan_id", "roaster_id", "filename", "original_content", "parsed",
"evaluation", "evaluation_status", "evaluation_error", "created_at", "updated_at",
],
],