Add roaster (machine) management, mobile header-tools redesign, and profile pictures
Test and deploy / test-and-deploy (push) Successful in 1m0s
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:
co-authored by
Claude Fable 5
parent
e62b9601a2
commit
a341d67467
+58
-1
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
@@ -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
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user