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
+17
View File
@@ -0,0 +1,17 @@
-- Additive only. Per-user roasting machines: uploaded .alogs attach to one, learning is
-- computed per roaster, and `overrides` stores the user's manual tweaks to what the learned
-- profile would otherwise apply to their plans (milestone temps, TP time, pace factor).
CREATE TABLE roasters (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name text NOT NULL,
model text NOT NULL DEFAULT '',
notes text NOT NULL DEFAULT '',
is_default boolean NOT NULL DEFAULT false,
overrides jsonb NOT NULL DEFAULT '{}',
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX roasters_user ON roasters(user_id, created_at);
ALTER TABLE actual_roasts ADD COLUMN roaster_id uuid REFERENCES roasters(id) ON DELETE SET NULL;
CREATE INDEX actual_roasts_roaster ON actual_roasts(roaster_id);
+2
View File
@@ -0,0 +1,2 @@
-- Additive only. Account profile picture, stored as a small client-side-resized data URL.
ALTER TABLE users ADD COLUMN avatar text;
+23
View File
@@ -65,6 +65,29 @@
<h2>Profile</h2>
</div>
<div class="panel-body">
<div style="display:flex;align-items:center;gap:14px;margin-bottom:8px">
<div
class="nav-user-avatar"
id="profile-avatar"
style="width:64px;height:64px;font-size:24px;flex:none"
aria-hidden="true"
></div>
<div style="display:flex;gap:8px;flex-wrap:wrap">
<label class="filebtn ghost-btn small"
>Upload picture…<input
type="file"
id="avatar-file"
accept="image/png,image/jpeg,image/webp"
/></label>
<button
class="ghost-btn small hidden"
type="button"
id="avatar-remove"
>
Remove
</button>
</div>
</div>
<p>
<strong id="profile-email"></strong>
<span class="badge badge-admin hidden" id="profile-role-admin"
+57 -1
View File
@@ -3001,10 +3001,66 @@ select.f {
min-height: 40px;
}
/* ── Planner header tools (page-scoped actions moved out of the left nav) ── */
/* ── Planner header tools (page-scoped actions moved out of the left nav) ──
Desktop: the tools render inline in the header actions row (display: contents).
≤720px: they collapse behind a single "Tools ▾" disclosure that opens a dropdown card, so
the mobile header keeps its fixed three-row height (--header-h) with no wrapped or
horizontally scrolling button rows. */
.header-tools {
flex-wrap: wrap;
justify-content: flex-end;
row-gap: 6px;
max-width: 100%;
position: relative;
}
.header-tools-toggle {
display: none;
}
.header-tools-group {
display: contents;
}
@media (max-width: 720px) {
.header-tools {
flex-wrap: nowrap;
}
.header-tools-toggle {
display: inline-flex;
align-items: center;
flex: 0 0 auto;
}
.header-tools-toggle[aria-expanded="true"] {
background: var(--ember-soft);
border-color: var(--ember);
color: var(--ember-strong);
}
.header-tools-group {
display: none;
}
.header-tools-group.open {
display: flex;
flex-direction: column;
align-items: stretch;
gap: 6px;
position: absolute;
right: 0;
top: calc(100% + 6px);
z-index: 70;
min-width: 220px;
padding: 10px;
background: var(--surface-raised);
border: 1px solid var(--line);
border-radius: var(--radius-md);
box-shadow: var(--shadow-pop);
}
.header-tools-group .ghost-btn {
justify-content: flex-start;
text-align: left;
width: 100%;
}
.header-tools-group .header-divider {
display: block;
width: 100%;
height: 1px;
margin: 2px 0;
}
}
+38 -27
View File
@@ -75,37 +75,48 @@
><span class="dot"></span
><span class="autosave-text">Not saved yet</span></span
>
<button id="nav-plans" type="button" class="ghost-btn small">
▤ Plans
</button>
<button id="nav-chat" type="button" class="ghost-btn small">
✳ Ask the LLM
</button>
<button id="nav-prefill" type="button" class="ghost-btn small">
↗ Prefill
</button>
<button id="nav-alog" type="button" class="ghost-btn small">
∿ Curves
</button>
<button id="nav-import-export" type="button" class="ghost-btn small">
⇅ Import / export
</button>
<button
id="btn-toggle-fids"
id="btn-tools-menu"
type="button"
class="ghost-btn small"
aria-pressed="false"
title="Show the worksheet reference codes (e.g. 1.4) on every field"
class="ghost-btn small header-tools-toggle"
aria-expanded="false"
aria-controls="header-tools-group"
>
# IDs
</button>
<button id="nav-settings" type="button" class="ghost-btn small">
◎ Settings
</button>
<span class="header-divider" aria-hidden="true"></span>
<button id="btn-expand-why" type="button" class="ghost-btn small">
Expand "why" notes
⚙ Tools ▾
</button>
<div class="header-tools-group" id="header-tools-group">
<button id="nav-plans" type="button" class="ghost-btn small">
▤ Plans
</button>
<button id="nav-chat" type="button" class="ghost-btn small">
✳ Ask the LLM
</button>
<button id="nav-prefill" type="button" class="ghost-btn small">
↗ Prefill
</button>
<button id="nav-alog" type="button" class="ghost-btn small">
∿ Curves
</button>
<button id="nav-import-export" type="button" class="ghost-btn small">
⇅ Import / export
</button>
<button
id="btn-toggle-fids"
type="button"
class="ghost-btn small"
aria-pressed="false"
title="Show the worksheet reference codes (e.g. 1.4) on every field"
>
# IDs
</button>
<button id="nav-settings" type="button" class="ghost-btn small">
◎ Settings
</button>
<span class="header-divider" aria-hidden="true"></span>
<button id="btn-expand-why" type="button" class="ghost-btn small">
Expand "why" notes
</button>
</div>
<button id="btn-print" type="button" class="primary-btn">
Print
</button>
+58 -1
View File
@@ -1,5 +1,5 @@
import { api, protectedFetch } from "./api.js?v=__ASSET_VERSION__";
import { initSideNav, loadNavUser } from "./nav.js?v=__ASSET_VERSION__";
import { applyAvatar, initSideNav, loadNavUser } from "./nav.js?v=__ASSET_VERSION__";
import { showToast } from "./toast.js?v=__ASSET_VERSION__";
function fmtDate(value) {
@@ -331,6 +331,62 @@ function wirePwaButtons() {
});
}
// Profile picture: resized client-side to a small square JPEG data URL before upload, so the
// server only ever stores a few tens of KB per user.
function wireAvatar(user) {
const refresh = (hasAvatar) => {
const shownUser = { ...user, hasAvatar };
applyAvatar(document.getElementById("profile-avatar"), shownUser, true);
applyAvatar(document.getElementById("nav-user-avatar"), shownUser, true);
document.getElementById("avatar-remove").classList.toggle("hidden", !hasAvatar);
};
refresh(user.hasAvatar);
document.getElementById("avatar-file").addEventListener("change", async (event) => {
const file = event.target.files?.[0];
event.target.value = "";
if (!file) return;
try {
const bitmap = await createImageBitmap(file);
const size = 128;
const canvas = document.createElement("canvas");
canvas.width = size;
canvas.height = size;
const context = canvas.getContext("2d");
// Cover-crop the centre square so faces aren't squished.
const side = Math.min(bitmap.width, bitmap.height);
context.drawImage(
bitmap,
(bitmap.width - side) / 2,
(bitmap.height - side) / 2,
side,
side,
0,
0,
size,
size,
);
const dataUrl = canvas.toDataURL("image/jpeg", 0.85);
await api("/api/account/avatar", {
method: "PUT",
body: JSON.stringify({ dataUrl }),
});
showToast("Profile picture updated.");
refresh(true);
} catch (error) {
showToast(error.message || "Could not read that image.", "fail");
}
});
document.getElementById("avatar-remove").addEventListener("click", async () => {
try {
await api("/api/account/avatar", { method: "DELETE" });
showToast("Profile picture removed.");
refresh(false);
} catch (error) {
showToast(error.message, "fail");
}
});
}
async function loadTokens() {
const body = document.getElementById("tokens-body");
try {
@@ -413,6 +469,7 @@ async function init() {
wireForms(user);
wirePwaButtons();
wireTokenForm();
wireAvatar(user);
await Promise.all([loadSessions(), loadPlans(), loadTokens()]);
}
+49 -5
View File
@@ -17,7 +17,7 @@ import { initAlogPanel } from "./alog-ui.js?v=__ASSET_VERSION__";
import { initPrint } from "./print.js?v=__ASSET_VERSION__";
import { initLotPicker } from "./lot-picker.js?v=__ASSET_VERSION__";
import { api, protectedFetch, csrfToken } from "./api.js?v=__ASSET_VERSION__";
import { initSideNav } from "./nav.js?v=__ASSET_VERSION__";
import { applyAvatar, initSideNav } from "./nav.js?v=__ASSET_VERSION__";
import { wireWhyPanels } from "./why-panels.js?v=__ASSET_VERSION__";
import { initFieldHelp } from "./field-help.js?v=__ASSET_VERSION__";
import { initPlanChat } from "./plan-chat-ui.js?v=__ASSET_VERSION__";
@@ -47,6 +47,17 @@ async function loadRoasterProfile() {
try {
const response = await fetch("/api/roaster-profile");
if (response.ok) roasterProfile = (await response.json()).profile;
// A manual pace tweak on the default machine (Machines page) beats the plan-log
// learned pace in the ledger.
if (Number.isFinite(roasterProfile?.paceFactorOverride))
machineProfile = {
...(machineProfile ?? {}),
pace: {
value: roasterProfile.paceFactorOverride,
source: "override",
roasterName: roasterProfile.roaster?.name ?? null,
},
};
} catch {
/* offline — curve falls back to reference machine temps */
}
@@ -76,6 +87,10 @@ function renderMachineProfileNote() {
const el = document.getElementById("machine-profile-note");
if (!el) return;
const pace = machineProfile?.pace;
if (pace?.source === "override") {
el.textContent = `Pace ×${pace.value.toFixed(2)} — your manual tweak${pace.roasterName ? ` for ${pace.roasterName}` : ""} (Machines page).`;
return;
}
if (!pace || pace.source !== "learned") {
el.textContent =
"Using reference timing (one operator's Hottop KN-8828B-2K+) — recalibrates to your own machine as you log roasts with an actual first-crack time.";
@@ -926,6 +941,33 @@ async function deletePlan(plan) {
await loadPlans();
}
// Mobile-only "Tools ▾" disclosure in the header; a no-op on desktop where the group renders
// inline (display: contents). Any action inside closes it — every tool opens a drawer anyway.
function wireToolsMenu() {
const toggle = document.getElementById("btn-tools-menu");
const group = document.getElementById("header-tools-group");
if (!toggle || !group) return;
const close = () => {
group.classList.remove("open");
toggle.setAttribute("aria-expanded", "false");
};
toggle.addEventListener("click", (event) => {
event.stopPropagation();
const open = group.classList.toggle("open");
toggle.setAttribute("aria-expanded", String(open));
});
group.addEventListener("click", (event) => {
if (event.target.closest("button")) close();
});
document.addEventListener("click", (event) => {
if (!group.classList.contains("open")) return;
if (!group.contains(event.target) && event.target !== toggle) close();
});
document.addEventListener("keydown", (event) => {
if (event.key === "Escape") close();
});
}
function wireToolbar() {
document.getElementById("btn-toggle-fids").addEventListener("click", (e) => {
const on = document.body.classList.toggle("show-fids");
@@ -1094,16 +1136,17 @@ async function init() {
const { user } = await meResponse.json();
document.getElementById("account-email").textContent = user.email;
document.getElementById("settings-email").textContent = user.email;
document.getElementById("nav-user-avatar").textContent = user.email
.charAt(0)
.toUpperCase();
applyAvatar(document.getElementById("nav-user-avatar"), user);
if (user.role === "admin")
document.getElementById("nav-admin").classList.remove("hidden");
const localDraft = loadFromStorage(user.id);
state.plan = localDraft ?? blankPlan();
const draftRemoteId = remotePlanId;
const draftSyncedAtAtLoad = draftSyncedAt;
await Promise.all([loadPlans(), loadMachineProfile(user.id), loadRoasterProfile()]);
// Roaster profile loads after the machine profile on purpose: its pace override (if
// any) must be applied on top of the freshly loaded machine profile, not raced with it.
await Promise.all([loadPlans(), loadMachineProfile(user.id)]);
await loadRoasterProfile();
const requestedId = new URLSearchParams(location.search).get("plan");
const selected = plans.find((plan) => plan.id === requestedId);
// Prefer the local draft only when it targets this exact plan AND was last confirmed
@@ -1153,6 +1196,7 @@ async function init() {
initSideNav();
wireDrawers();
wireToolbar();
wireToolsMenu();
wireTempUnitToggle();
updateTempUnitUI();
wirePwa();
+15 -1
View File
@@ -10,6 +10,7 @@ const NAV_GROUPS = [
items: [
{ href: "/app", icon: "◐", label: "Planner" },
{ href: "/roasts", icon: "∿", label: "Roasts" },
{ href: "/roasters", icon: "♨", label: "Machines" },
{ href: "/inventory", icon: "▥", label: "Green lots" },
{ href: "/cupping", icon: "◒", label: "Cupping" },
],
@@ -112,6 +113,19 @@ export function initSideNav() {
return { closeMobileNav };
}
/** Renders a user's avatar into an element: their profile picture when set, else initial. */
export function applyAvatar(el, user, cacheBust = false) {
if (user.hasAvatar) {
el.textContent = "";
el.style.backgroundImage = `url("/api/account/avatar${cacheBust ? `?t=${Date.now()}` : ""}")`;
el.style.backgroundSize = "cover";
el.style.backgroundPosition = "center";
} else {
el.style.backgroundImage = "";
el.textContent = user.email.charAt(0).toUpperCase();
}
}
/** Populates the nav's user chip and admin link; redirects to /login when signed out. */
export async function loadNavUser() {
const response = await fetch("/api/auth/me");
@@ -123,7 +137,7 @@ export async function loadNavUser() {
const emailEl = document.getElementById("account-email");
if (emailEl) emailEl.textContent = user.email;
const avatarEl = document.getElementById("nav-user-avatar");
if (avatarEl) avatarEl.textContent = user.email.charAt(0).toUpperCase();
if (avatarEl) applyAvatar(avatarEl, user);
if (user.role === "admin")
document.getElementById("nav-admin")?.classList.remove("hidden");
return user;
+288
View File
@@ -0,0 +1,288 @@
import { api, protectedFetch } from "./api.js?v=__ASSET_VERSION__";
import { initSideNav, loadNavUser } from "./nav.js?v=__ASSET_VERSION__";
import { showToast } from "./toast.js?v=__ASSET_VERSION__";
let roasters = [];
let selectedId = null;
let profile = null;
const fmtTime = (s) =>
s == null
? "—"
: `${Math.floor(Math.round(s) / 60)}:${String(Math.round(s) % 60).padStart(2, "0")}`;
// Metric rows of the report: what the planner actually consumes from a machine's profile.
// `fmt` renders read-only values; overrides are edited in the raw unit (°C / seconds / ×).
const METRICS = [
{ key: "chargeTempC", label: "Charge temp (curve fallback)", unit: "°C" },
{ key: "turningPointS", label: "Turning point time (thermal lag)", unit: "s", fmt: fmtTime },
{ key: "turningPointTempC", label: "Turning point temp", unit: "°C" },
{ key: "yellowTempC", label: "Yellow temp (curve fallback)", unit: "°C" },
{ key: "firstCrackTempC", label: "First crack temp (curve fallback)", unit: "°C" },
{ key: "dropTempC", label: "Drop temp (curve fallback)", unit: "°C" },
{ key: "paceFactor", label: "Pace factor (multiplies first-crack timing)", unit: "×" },
];
function renderRoasters() {
const body = document.getElementById("roasters-body");
if (!roasters.length) {
body.innerHTML = `<tr><td colspan="4" class="empty-state">No roasters yet — add your machine below. Your first one becomes the default.</td></tr>`;
return;
}
body.replaceChildren(
...roasters.map((roaster) => {
const tr = document.createElement("tr");
tr.style.cursor = "pointer";
if (roaster.id === selectedId) tr.style.background = "var(--surface-sunken)";
const nameCell = document.createElement("td");
const strong = document.createElement("strong");
strong.textContent = roaster.name;
nameCell.append(strong);
if (roaster.model) {
const sub = document.createElement("div");
sub.className = "muted";
sub.style.fontSize = "11.5px";
sub.textContent = roaster.model;
nameCell.append(sub);
}
const defaultCell = document.createElement("td");
defaultCell.textContent = roaster.isDefault ? "✓ default" : "";
const countCell = document.createElement("td");
countCell.className = "num";
countCell.textContent = roaster.roastCount ?? 0;
const actions = document.createElement("td");
actions.className = "data-table-actions";
if (!roaster.isDefault) {
const makeDefault = document.createElement("button");
makeDefault.className = "ghost-btn small";
makeDefault.type = "button";
makeDefault.textContent = "Make default";
makeDefault.addEventListener("click", async (event) => {
event.stopPropagation();
try {
await api(`/api/roasters/${roaster.id}`, {
method: "PUT",
body: JSON.stringify({ isDefault: true }),
});
showToast(`${roaster.name} is now the default.`);
await loadRoasters();
} catch (error) {
showToast(error.message, "fail");
}
});
actions.append(makeDefault);
}
const deleteBtn = document.createElement("button");
deleteBtn.className = "ghost-btn small";
deleteBtn.type = "button";
deleteBtn.textContent = "Delete";
deleteBtn.addEventListener("click", async (event) => {
event.stopPropagation();
if (
!confirm(
`Delete "${roaster.name}"? Its logged roasts are kept but detach from it.`,
)
)
return;
try {
await api(`/api/roasters/${roaster.id}`, { method: "DELETE" });
showToast("Roaster deleted.");
if (selectedId === roaster.id) selectedId = null;
await loadRoasters();
} catch (error) {
showToast(error.message, "fail");
}
});
actions.append(deleteBtn);
tr.append(nameCell, defaultCell, countCell, actions);
tr.addEventListener("click", () => selectRoaster(roaster.id));
return tr;
}),
);
}
function overrideInputs() {
return [...document.querySelectorAll("#report-rows input[data-key]")];
}
function renderReport() {
const card = document.getElementById("report-card");
if (!selectedId || !profile) {
card.classList.add("hidden");
return;
}
card.classList.remove("hidden");
const roaster = roasters.find((r) => r.id === selectedId);
document.getElementById("report-title").textContent =
`Analysis — ${roaster?.name ?? ""}`;
document.getElementById("report-count").textContent =
`${profile.n} roast${profile.n === 1 ? "" : "s"} analyzed`;
document.getElementById("report-empty").hidden = profile.n > 0;
const rows = document.getElementById("report-rows");
rows.replaceChildren(
...METRICS.map((metric) => {
const tr = document.createElement("tr");
const label = document.createElement("td");
label.textContent = metric.label;
const learnedCell = document.createElement("td");
const learnedValue =
metric.key === "paceFactor" ? null : profile.learnedMedians?.[metric.key];
learnedCell.textContent =
learnedValue == null
? metric.key === "paceFactor"
? "learned from plan logs"
: "—"
: `${metric.fmt ? metric.fmt(learnedValue) : learnedValue} ${metric.fmt ? "" : metric.unit}`.trim();
const overrideCell = document.createElement("td");
const input = document.createElement("input");
input.className = "field-input sm";
input.type = "number";
input.step = "any";
input.style.maxWidth = "110px";
input.dataset.key = metric.key;
input.placeholder = metric.unit;
const overrideValue = profile.overrides?.[metric.key];
if (overrideValue != null) input.value = overrideValue;
overrideCell.append(input);
const appliedCell = document.createElement("td");
const applied =
overrideValue ??
(metric.key === "paceFactor" ? null : profile.learnedMedians?.[metric.key]);
appliedCell.textContent =
applied == null
? metric.key === "paceFactor"
? "plan-log pace"
: "reference default"
: `${metric.fmt ? metric.fmt(applied) : applied} ${metric.fmt ? "" : metric.unit}${overrideValue != null ? " (your tweak)" : " (learned)"}`.trim();
tr.append(label, learnedCell, overrideCell, appliedCell);
return tr;
}),
);
const ror = profile.rorCPerMin ?? {};
document.getElementById("report-ror").textContent =
profile.n > 0
? `Typical rate of rise on this machine — drying ${ror.drying ?? "—"} °C/min, Maillard ${ror.maillard ?? "—"} °C/min, development ${ror.development ?? "—"} °C/min.`
: "";
const adjustments = document.getElementById("report-adjustments");
adjustments.replaceChildren();
const list = document.createElement("ul");
list.style.paddingLeft = "18px";
list.style.fontSize = "13px";
const items = [];
if (profile.n > 0)
items.push(
"Blank milestone temps on the planner's curve use this machine's learned medians instead of the reference machine.",
`New plans assume the turning point around ${fmtTime(profile.medians?.turningPointS)} — this machine's measured thermal lag.`,
);
if (profile.paceFactorOverride != null)
items.push(
`First-crack timing is multiplied by your pace tweak ×${profile.paceFactorOverride} instead of the plan-log learned pace.`,
);
items.push(
"Roast reviews compare each new roast against this machine's own typical behavior, not a generic one.",
);
if (!(roaster?.isDefault ?? false))
items.push(
"Note: the planner uses the DEFAULT machine's profile — make this one default to plan with it.",
);
for (const text of items) {
const li = document.createElement("li");
li.textContent = text;
list.append(li);
}
adjustments.append(list);
}
async function selectRoaster(id) {
selectedId = id;
try {
profile = (await api(`/api/roaster-profile?roaster=${encodeURIComponent(id)}`)).profile;
} catch (error) {
showToast(error.message, "fail");
profile = null;
}
renderRoasters();
renderReport();
}
async function saveOverrides(clear = false) {
if (!selectedId) return;
const overrides = {};
if (!clear)
for (const input of overrideInputs()) {
if (input.value === "") continue;
const value = Number(input.value);
if (!Number.isFinite(value)) {
showToast(`"${input.value}" is not a number.`, "fail");
return;
}
overrides[input.dataset.key] = value;
}
try {
await api(`/api/roasters/${selectedId}`, {
method: "PUT",
body: JSON.stringify({ overrides }),
});
showToast(clear ? "Tweaks cleared — using learned values." : "Tweaks saved.");
await loadRoasters();
await selectRoaster(selectedId);
} catch (error) {
showToast(error.message, "fail");
}
}
async function loadRoasters() {
try {
roasters = (await api("/api/roasters")).roasters;
renderRoasters();
} catch {
document.getElementById("roasters-body").innerHTML =
`<tr><td colspan="4" class="empty-state">Could not load roasters.</td></tr>`;
}
}
document.getElementById("roaster-form").addEventListener("submit", async (event) => {
event.preventDefault();
const form = event.target;
try {
const created = await api("/api/roasters", {
method: "POST",
body: JSON.stringify({ name: form.name.value, model: form.model.value }),
});
form.reset();
showToast("Roaster added.");
await loadRoasters();
await selectRoaster(created.roaster.id);
} catch (error) {
showToast(error.message, "fail");
}
});
document.getElementById("save-overrides").addEventListener("click", () => saveOverrides(false));
document.getElementById("clear-overrides").addEventListener("click", () => saveOverrides(true));
document.getElementById("btn-logout").addEventListener("click", async () => {
await protectedFetch("/api/auth/logout", { method: "POST" });
location.assign("/");
});
async function init() {
initSideNav();
const user = await loadNavUser();
if (!user) return;
await loadRoasters();
const preferred = roasters.find((r) => r.isDefault) ?? roasters[0];
if (preferred) await selectRoaster(preferred.id);
}
init();
+51 -2
View File
@@ -8,6 +8,7 @@ let roasts = [];
let openId = null;
let pollTimer = null;
let machineProfile = null;
let roasterList = [];
const SVG_NS = "http://www.w3.org/2000/svg";
@@ -42,7 +43,13 @@ function renderTable() {
const sub = document.createElement("div");
sub.className = "muted";
sub.style.fontSize = "11.5px";
sub.textContent = `${roast.roast?.roastDate || fmtDate(roast.createdAt)} · ${roast.filename}`;
sub.textContent = [
roast.roast?.roastDate || fmtDate(roast.createdAt),
roast.roasterName,
roast.filename,
]
.filter(Boolean)
.join(" · ");
roastCell.append(strong, sub);
const planCell = document.createElement("td");
@@ -305,7 +312,8 @@ async function openDetail(id, { keepScroll = false } = {}) {
card.classList.remove("hidden");
document.getElementById("detail-title").textContent =
detail.roast?.title || detail.filename;
document.getElementById("detail-meta").textContent = [
const meta = document.getElementById("detail-meta");
meta.textContent = [
detail.roast?.roastDate || fmtDate(detail.createdAt),
detail.roast?.roasterType,
detail.planTitle ? `Plan: ${detail.planTitle}` : "No plan attached",
@@ -313,6 +321,42 @@ async function openDetail(id, { keepScroll = false } = {}) {
]
.filter(Boolean)
.join(" · ");
// Machine assignment: which of the user's roasters this roast trains.
if (roasterList.length) {
const wrap = document.createElement("span");
wrap.append(" · Machine: ");
const select = document.createElement("select");
select.className = "field-input sm";
select.style.display = "inline-block";
select.style.width = "auto";
select.append(
Object.assign(document.createElement("option"), {
value: "",
textContent: "— none —",
}),
...roasterList.map((r) =>
Object.assign(document.createElement("option"), {
value: r.id,
textContent: r.name,
}),
),
);
select.value = detail.roasterId ?? "";
select.addEventListener("change", async () => {
try {
await api(`/api/roasts/${detail.id}`, {
method: "PUT",
body: JSON.stringify({ roasterId: select.value || null }),
});
showToast("Machine updated — future learning uses this assignment.");
await loadRoasts();
} catch (error) {
showToast(error.message, "fail");
}
});
wrap.append(select);
meta.append(wrap);
}
document.getElementById("detail-download").href =
`/api/roasts/${encodeURIComponent(id)}/download`;
renderGraph(detail);
@@ -406,6 +450,11 @@ async function init() {
} catch {
machineProfile = null;
}
try {
roasterList = (await api("/api/roasters")).roasters;
} catch {
roasterList = [];
}
await loadRoasts();
const requested = new URLSearchParams(location.search).get("roast");
if (requested && roasts.some((roast) => roast.id === requested))
+150
View File
@@ -0,0 +1,150 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>Machines — Roast Planner</title>
<link rel="stylesheet" href="/app.css?v=__ASSET_VERSION__" />
<link rel="icon" href="/icon.svg?v=__ASSET_VERSION__" type="image/svg+xml" />
<meta name="theme-color" content="#A8481A" />
</head>
<body>
<div class="app-shell">
<nav class="side-nav" id="side-nav" aria-label="Main navigation">
<div class="side-nav-brand">
<span class="brand-mark" aria-hidden="true"></span>
<span class="brand-name">Roast Planner</span>
</div>
<div id="nav-links"></div>
<div class="nav-spacer"></div>
<button
class="icon-btn nav-collapse-toggle"
type="button"
id="nav-collapse"
aria-label="Collapse navigation"
title="Collapse navigation"
>
«
</button>
<div class="nav-user">
<div class="nav-user-avatar" id="nav-user-avatar" aria-hidden="true"></div>
<div class="nav-user-detail">
<span class="nav-user-email" id="account-email"></span>
<button class="nav-user-logout" type="button" id="btn-logout">
Log out
</button>
</div>
</div>
</nav>
<div class="app-workspace" id="app-workspace">
<header class="app-header">
<div class="header-row-top">
<button
class="icon-btn nav-hamburger"
type="button"
id="nav-hamburger"
aria-label="Open navigation"
aria-expanded="false"
>
</button>
<div class="brand">
<div class="brand-text">
<h1>Machines</h1>
<p class="brand-sub">
Your roasters — what the app has learned from each, and your
tweaks to it
</p>
</div>
</div>
</div>
</header>
<div class="drawer-overlay hidden" id="drawer-overlay"></div>
<main class="page-content">
<section class="panel-card" id="roasters-card">
<div class="panel-head"><h2>Your roasters</h2></div>
<div class="panel-body">
<div class="table-wrap">
<table class="data-table" id="roasters-table">
<thead>
<tr>
<th>Roaster</th>
<th>Default</th>
<th class="num">Logged roasts</th>
<th></th>
</tr>
</thead>
<tbody id="roasters-body"></tbody>
</table>
</div>
<form
id="roaster-form"
style="display:flex;gap:8px;flex-wrap:wrap;align-items:center;margin-top:12px"
>
<input
class="text-input"
name="name"
placeholder="Name (e.g. Hottop)"
required
style="max-width:200px"
/>
<input
class="text-input"
name="model"
placeholder="Model (e.g. KN-8828B-2K+)"
style="max-width:220px"
/>
<button class="ghost-btn" type="submit">Add roaster</button>
</form>
<p class="field-note">
Uploaded .alogs attach to the default roaster (you can reassign
any roast from its detail view on the Roasts page). Learning,
the planner's suggested curve, and roast reviews all use the
selected machine's own history.
</p>
</div>
</section>
<section class="panel-card hidden" id="report-card">
<div class="panel-head">
<h2 id="report-title">Analysis</h2>
<span class="muted" id="report-count"></span>
</div>
<div class="panel-body">
<p class="field-note" id="report-empty" hidden>
No roasts logged against this machine yet — upload finished
.alogs on the Roasts page and the analysis fills in.
</p>
<div id="report-body">
<div class="table-wrap">
<table class="data-table" id="report-table">
<thead>
<tr>
<th>What the plans use</th>
<th>Learned from your roasts</th>
<th>Your tweak</th>
<th>Applied</th>
</tr>
</thead>
<tbody id="report-rows"></tbody>
</table>
</div>
<p class="field-note" id="report-ror"></p>
<div id="report-adjustments" style="margin-top:8px"></div>
<button class="primary-btn" type="button" id="save-overrides" style="margin-top:10px">
Save tweaks
</button>
<button class="ghost-btn" type="button" id="clear-overrides" style="margin-top:10px">
Clear all tweaks (use learned values)
</button>
</div>
</div>
</section>
</main>
</div>
</div>
<script type="module" src="/js/roasters.js?v=__ASSET_VERSION__"></script>
</body>
</html>
+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",
],
],
+16 -2
View File
@@ -108,6 +108,7 @@ export function buildOpenApiSpec({ origin = "" } = {}) {
},
"/api/roasts/{id}": {
get: { tags: ["actual roasts"], summary: "Roast detail incl. curve, LLM review, linked plan", parameters: [idParam], responses: { 200: ok("Detail"), 404: err("Not found") } },
put: { tags: ["actual roasts"], summary: "Reassign the roast to another of your machines", parameters: [idParam], requestBody: jsonBody(obj({ roasterId: { ...str, nullable: true } })), responses: { 200: ok("Reassigned"), 404: err("Not found") } },
delete: { tags: ["actual roasts"], summary: "Delete an uploaded roast", parameters: [idParam], responses: { 200: ok("Deleted"), 404: err("Not found") } },
},
"/api/roasts/{id}/download": { get: { tags: ["actual roasts"], summary: "Download the original .alog", parameters: [idParam], responses: { 200: { description: "Original file as attachment" }, 404: err("Not found") } } },
@@ -164,7 +165,15 @@ export function buildOpenApiSpec({ origin = "" } = {}) {
// ── LLM helpers ──
"/api/plan-chat": { post: { tags: ["llm"], summary: "Chat with the LLM about a roast plan (stateless; send the full visible conversation)", requestBody: jsonBody(obj({ plan: { type: "object" }, messages: arr(obj({ role: { ...str, enum: ["user", "assistant"] }, content: str }, ["role", "content"])) }, ["plan", "messages"])), responses: { 200: ok("Reply", obj({ ok: bool, reply: str })), 400: err("Bad plan/messages"), 503: err("No LLM model configured") } } },
"/api/roaster-profile": { get: { tags: ["llm"], summary: "Learned roaster behavior aggregated from your uploaded .alogs", responses: { 200: ok("Profile", obj({ ok: bool, profile: { type: "object" } })) } } },
"/api/roaster-profile": { get: { tags: ["llm"], summary: "Learned behavior for a machine (?roaster=id; default roaster otherwise), with user overrides applied", parameters: [{ name: "roaster", in: "query", schema: { type: "string", format: "uuid" } }], responses: { 200: ok("Profile", obj({ ok: bool, profile: { type: "object" } })) } } },
"/api/roasters": {
get: { tags: ["roasters"], summary: "List your roasting machines", responses: { 200: ok("Roasters") } },
post: { tags: ["roasters"], summary: "Add a machine (first one becomes default)", requestBody: jsonBody(obj({ name: str, model: str, notes: str, isDefault: bool }, ["name"])), responses: { 201: ok("Created") } },
},
"/api/roasters/{id}": {
put: { tags: ["roasters"], summary: "Update a machine (name/model/notes/default/override tweaks)", parameters: [idParam], requestBody: jsonBody(obj({ name: str, model: str, notes: str, isDefault: bool, overrides: { type: "object", description: "chargeTempC/turningPointS/turningPointTempC/yellowTempC/firstCrackTempC/dropTempC/paceFactor" } }), false), responses: { 200: ok("Updated"), 400: err("Bad override") } },
delete: { tags: ["roasters"], summary: "Delete a machine (its roasts detach)", parameters: [idParam], responses: { 200: ok("Deleted") } },
},
"/api/prefill": { post: { tags: ["llm"], summary: "Extract coffee facts from a product URL (used by roast planner and bean form)", requestBody: jsonBody(obj({ url: str }, ["url"])), responses: { 200: ok("Extraction + derived worksheet fields"), 422: err("Extraction failed"), 503: err("No LLM model configured") } } },
"/api/alog": { post: { tags: ["llm"], summary: "Parse an Artisan .alog for the reference-curve overlay (no storage)", requestBody: jsonBody(obj({ filename: str, content: str }, ["content"])), responses: { 200: ok("Parsed"), 422: err("Unparseable") } } },
@@ -175,6 +184,11 @@ export function buildOpenApiSpec({ origin = "" } = {}) {
"/api/account/sessions/{id}": { delete: { tags: ["account"], summary: "Revoke a session", parameters: [{ ...idParam, schema: str }], responses: { 200: ok("Revoked") } } },
"/api/account/sessions/revoke-others": { post: { tags: ["account"], summary: "Revoke all other sessions", responses: { 200: ok("Revoked") } } },
"/api/account/export": { get: { tags: ["account"], summary: "Download all of your own data as JSON", responses: { 200: ok("Personal data export") } } },
"/api/account/avatar": {
get: { tags: ["account"], summary: "Your profile picture (image response)", responses: { 200: { description: "Image" }, 404: err("No picture set") } },
put: { tags: ["account"], summary: "Set your profile picture (small data URL)", requestBody: jsonBody(obj({ dataUrl: { ...str, description: "data:image/png|jpeg|webp;base64,… (≤300KB)" } }, ["dataUrl"])), responses: { 200: ok("Saved"), 400: err("Bad image") } },
delete: { tags: ["account"], summary: "Remove your profile picture", responses: { 200: ok("Removed") } },
},
"/api/account": { delete: { tags: ["account"], summary: "Delete your account", requestBody: jsonBody(obj({ password: str }, ["password"])), responses: { 200: ok("Deleted") } } },
// ── Admin ──
@@ -205,7 +219,7 @@ export function buildOpenApiSpec({ origin = "" } = {}) {
},
servers: [{ url: origin || "/" }],
tags: [
{ name: "auth" }, { name: "tokens" }, { name: "roast plans" }, { name: "actual roasts" },
{ name: "auth" }, { name: "tokens" }, { name: "roast plans" }, { name: "actual roasts" }, { name: "roasters" },
{ name: "green inventory" }, { name: "cupping" }, { name: "beans" }, { name: "brews" },
{ name: "llm" }, { name: "account" }, { name: "admin" },
],
+3 -2
View File
@@ -22,7 +22,7 @@ export async function setup(env = {}, appOptions = {}) {
const pg = mem.adapters.createPg();
const db = new pg.Pool();
await db.query(
`CREATE TABLE users(id uuid PRIMARY KEY DEFAULT gen_random_uuid(),email text UNIQUE NOT NULL,password_hash text NOT NULL,role text NOT NULL DEFAULT 'user',created_at timestamptz DEFAULT now(),disabled_at timestamptz);
`CREATE TABLE users(id uuid PRIMARY KEY DEFAULT gen_random_uuid(),email text UNIQUE NOT NULL,password_hash text NOT NULL,role text NOT NULL DEFAULT 'user',created_at timestamptz DEFAULT now(),disabled_at timestamptz,avatar text);
CREATE TABLE sessions(token_hash text PRIMARY KEY,user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,csrf_hash text NOT NULL,expires_at timestamptz NOT NULL,created_at timestamptz DEFAULT now(),user_agent text,ip text,last_seen_at timestamptz);
CREATE TABLE roast_plans(id uuid PRIMARY KEY DEFAULT gen_random_uuid(),user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,plan jsonb NOT NULL,created_at timestamptz DEFAULT now(),updated_at timestamptz DEFAULT now());
CREATE TABLE app_settings(key text PRIMARY KEY,value text NOT NULL);
@@ -34,7 +34,8 @@ export async function setup(env = {}, appOptions = {}) {
CREATE TABLE bean_consumption(id uuid PRIMARY KEY DEFAULT gen_random_uuid(),lot_id uuid NOT NULL REFERENCES green_bean_lots(id) ON DELETE CASCADE,user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,roast_plan_id uuid REFERENCES roast_plans(id) ON DELETE SET NULL,weight_g numeric NOT NULL CHECK (weight_g > 0),created_at timestamptz DEFAULT now());
CREATE UNIQUE INDEX bean_consumption_one_per_plan ON bean_consumption(roast_plan_id);
CREATE TABLE cupping_sessions(id uuid PRIMARY KEY DEFAULT gen_random_uuid(),user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,roast_plan_id uuid REFERENCES roast_plans(id) ON DELETE SET NULL,data jsonb NOT NULL,total_score numeric NOT NULL DEFAULT 0,created_at timestamptz DEFAULT now(),updated_at timestamptz DEFAULT now());
CREATE TABLE actual_roasts(id uuid PRIMARY KEY DEFAULT gen_random_uuid(),user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,roast_plan_id uuid REFERENCES roast_plans(id) ON DELETE SET NULL,filename text NOT NULL,original_content text NOT NULL,parsed jsonb NOT NULL,evaluation jsonb,evaluation_status text NOT NULL DEFAULT 'pending',evaluation_error text,created_at timestamptz DEFAULT now(),updated_at timestamptz DEFAULT now());
CREATE TABLE roasters(id uuid PRIMARY KEY DEFAULT gen_random_uuid(),user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,name text NOT NULL,model text NOT NULL DEFAULT '',notes text NOT NULL DEFAULT '',is_default boolean NOT NULL DEFAULT false,overrides jsonb NOT NULL DEFAULT '{}',created_at timestamptz DEFAULT now(),updated_at timestamptz DEFAULT now());
CREATE TABLE actual_roasts(id uuid PRIMARY KEY DEFAULT gen_random_uuid(),user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,roast_plan_id uuid REFERENCES roast_plans(id) ON DELETE SET NULL,roaster_id uuid REFERENCES roasters(id) ON DELETE SET NULL,filename text NOT NULL,original_content text NOT NULL,parsed jsonb NOT NULL,evaluation jsonb,evaluation_status text NOT NULL DEFAULT 'pending',evaluation_error text,created_at timestamptz DEFAULT now(),updated_at timestamptz DEFAULT now());
CREATE TABLE roasted_beans(id uuid PRIMARY KEY DEFAULT gen_random_uuid(),user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,name text NOT NULL,roaster text NOT NULL DEFAULT '',origin text NOT NULL DEFAULT '',process text NOT NULL DEFAULT '',variety text NOT NULL DEFAULT '',roast_level text NOT NULL DEFAULT '',roast_date date,initial_weight_g numeric,url text NOT NULL DEFAULT '',tasting_notes text NOT NULL DEFAULT '',notes text NOT NULL DEFAULT '',roast_plan_id uuid REFERENCES roast_plans(id) ON DELETE SET NULL,archived boolean NOT NULL DEFAULT false,created_at timestamptz DEFAULT now(),updated_at timestamptz DEFAULT now());
CREATE TABLE brews(id uuid PRIMARY KEY DEFAULT gen_random_uuid(),user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,bean_id uuid REFERENCES roasted_beans(id) ON DELETE SET NULL,method text NOT NULL,dose_g numeric,water_g numeric,yield_g numeric,grinder text NOT NULL DEFAULT '',grind_setting text NOT NULL DEFAULT '',water_temp_c numeric,brew_time_s integer,bloom_time_s integer,rating numeric,recipe text NOT NULL DEFAULT '',tasting_notes text NOT NULL DEFAULT '',notes text NOT NULL DEFAULT '',brewed_at timestamptz DEFAULT now(),created_at timestamptz DEFAULT now(),updated_at timestamptz DEFAULT now());
CREATE TABLE api_tokens(token_hash text PRIMARY KEY,user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,name text NOT NULL DEFAULT '',created_at timestamptz DEFAULT now(),last_used_at timestamptz);
+173
View File
@@ -0,0 +1,173 @@
import test from "node:test";
import assert from "node:assert/strict";
import request from "supertest";
import { setup, signup } from "./helpers.js";
function makeAlog(title, fcIdx = 14) {
const timex = [], temp1 = [], temp2 = [];
for (let i = 0; i <= 20; i++) {
timex.push(i * 30);
temp1.push(200 + i);
temp2.push(i < 3 ? 180 - i * 30 : 90 + (i - 3) * 7);
}
return JSON.stringify({ title, mode: "C", weight: [250, 212, "g"], timex, temp1, temp2, timeindex: [1, 8, fcIdx, 0, 0, 0, 20, 0] });
}
const stubEval = async () => ({ summary: "ok", grade: "good", highlights: [], concerns: [], suggestions: [], planComparison: null });
test("profile picture: set, serve, remove, validate", async () => {
const { agent } = await setup();
const { csrf } = await signup(agent, "[email protected]");
assert.equal((await agent.get("/api/auth/me")).body.user.hasAvatar, false);
assert.equal((await agent.get("/api/account/avatar")).status, 404);
// 1x1 PNG
const png =
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==";
assert.equal(
(await agent.put("/api/account/avatar").set("x-csrf-token", csrf).send({ dataUrl: png })).status,
200,
);
assert.equal((await agent.get("/api/auth/me")).body.user.hasAvatar, true);
const served = await agent.get("/api/account/avatar");
assert.equal(served.status, 200);
assert.equal(served.headers["content-type"], "image/png");
// Junk rejected
for (const dataUrl of ["not-an-image", "data:image/svg+xml;base64,PHN2Zz4=", 5])
assert.equal(
(await agent.put("/api/account/avatar").set("x-csrf-token", csrf).send({ dataUrl })).status,
400,
);
assert.equal(
(await agent.delete("/api/account/avatar").set("x-csrf-token", csrf)).status,
200,
);
assert.equal((await agent.get("/api/auth/me")).body.user.hasAvatar, false);
assert.equal((await agent.get("/api/account/avatar")).status, 404);
});
test("roasters: CRUD, default handling, uploads attach and reassign, per-roaster profiles", async () => {
const { app, agent } = await setup({}, { evaluateRoast: stubEval });
const { csrf } = await signup(agent, "[email protected]");
// First roaster auto-defaults; second doesn't
const hottop = (
await agent.post("/api/roasters").set("x-csrf-token", csrf).send({ name: "Hottop", model: "KN-8828B-2K+" })
).body.roaster;
assert.equal(hottop.isDefault, true);
const aillio = (
await agent.post("/api/roasters").set("x-csrf-token", csrf).send({ name: "Aillio" })
).body.roaster;
assert.equal(aillio.isDefault, false);
assert.equal(
(await agent.post("/api/roasters").set("x-csrf-token", csrf).send({ name: "" })).status,
400,
);
// Uploads attach to the default roaster unless told otherwise
const upDefault = await agent
.post("/api/roasts")
.set("x-csrf-token", csrf)
.send({ filename: "d.alog", content: makeAlog("On default") });
assert.equal(upDefault.body.roast.roasterId, hottop.id);
const upExplicit = await agent
.post("/api/roasts")
.set("x-csrf-token", csrf)
.send({ filename: "e.alog", content: makeAlog("On aillio", 15), roasterId: aillio.id });
assert.equal(upExplicit.body.roast.roasterId, aillio.id);
const list = await agent.get("/api/roasts");
assert.equal(list.body.roasts.find((r) => r.filename === "d.alog").roasterName, "Hottop");
// Roaster list counts its roasts
const roasters = (await agent.get("/api/roasters")).body.roasters;
assert.equal(roasters.find((r) => r.id === hottop.id).roastCount, 1);
assert.equal(roasters.find((r) => r.id === aillio.id).roastCount, 1);
// Per-roaster profile only sees that machine's roasts; default profile = default roaster
const hottopProfile = (
await agent.get(`/api/roaster-profile?roaster=${hottop.id}`)
).body.profile;
assert.equal(hottopProfile.n, 1);
assert.equal(hottopProfile.roaster.name, "Hottop");
const defaultProfile = (await agent.get("/api/roaster-profile")).body.profile;
assert.equal(defaultProfile.roaster.id, hottop.id);
assert.equal(defaultProfile.n, 1);
// Overrides: applied on top of learned medians; bad values rejected
const put = await agent
.put(`/api/roasters/${hottop.id}`)
.set("x-csrf-token", csrf)
.send({ overrides: { firstCrackTempC: 196, paceFactor: 1.1 } });
assert.equal(put.status, 200);
const tweaked = (
await agent.get(`/api/roaster-profile?roaster=${hottop.id}`)
).body.profile;
assert.equal(tweaked.medians.firstCrackTempC, 196);
assert.notEqual(tweaked.learnedMedians.firstCrackTempC, 196);
assert.equal(tweaked.paceFactorOverride, 1.1);
assert.equal(
(
await agent
.put(`/api/roasters/${hottop.id}`)
.set("x-csrf-token", csrf)
.send({ overrides: { paceFactor: 9 } })
).status,
400,
);
// Reassign a roast between machines
const reassign = await agent
.put(`/api/roasts/${upDefault.body.roast.id}`)
.set("x-csrf-token", csrf)
.send({ roasterId: aillio.id });
assert.equal(reassign.status, 200);
assert.equal(
(await agent.get(`/api/roaster-profile?roaster=${aillio.id}`)).body.profile.n,
2,
);
// Default transfer + delete keeps roasts (detached)
await agent.put(`/api/roasters/${aillio.id}`).set("x-csrf-token", csrf).send({ isDefault: true });
const after = (await agent.get("/api/roasters")).body.roasters;
assert.equal(after.find((r) => r.id === hottop.id).isDefault, false);
assert.equal(after.find((r) => r.id === aillio.id).isDefault, true);
assert.equal(
(await agent.delete(`/api/roasters/${aillio.id}`).set("x-csrf-token", csrf)).status,
200,
);
const survivors = await agent.get("/api/roasts");
assert.equal(survivors.body.roasts.length, 2);
assert.equal(
survivors.body.roasts.every((r) => r.roasterId === null || r.roasterId === hottop.id),
true,
);
// Ownership: a stranger sees nothing and can't touch anything
const stranger = request.agent(app);
const { csrf: strangerCsrf } = await signup(stranger, "[email protected]");
assert.equal((await stranger.get("/api/roasters")).body.roasters.length, 0);
assert.equal(
(await stranger.get(`/api/roaster-profile?roaster=${hottop.id}`)).status,
404,
);
assert.equal(
(
await stranger
.put(`/api/roasters/${hottop.id}`)
.set("x-csrf-token", strangerCsrf)
.send({ name: "hijack" })
).status,
404,
);
assert.equal(
(
await stranger
.post("/api/roasts")
.set("x-csrf-token", strangerCsrf)
.send({ filename: "x.alog", content: makeAlog("x"), roasterId: hottop.id })
).status,
404,
);
});