Files
roast_command_center/public/js/main.js
T
snowspeeder 5f3a015ea2
Test and deploy / test-and-deploy (push) Successful in 1m43s
Fix report pagination and rebuild saved plan navigation
2026-09-05 15:47:44 -04:00

1066 lines
37 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { FIELD_IDS, blankPlan } from "/shared/fields.js?v=__ASSET_VERSION__";
import { computeLedger } from "/shared/ledger.js?v=__ASSET_VERSION__";
import {
formatDuration,
formatSigned,
parseRangeMidpoint,
} from "/shared/time.js?v=__ASSET_VERSION__";
import {
CULTIVARS,
findCultivar,
findProcess,
findRoastLevel,
} from "/shared/reference-data.js?v=__ASSET_VERSION__";
import { buildPlanCurve, pointsToPathD, tToX, tempToY, curveWarnings } from "/shared/curve.js?v=__ASSET_VERSION__";
import { initPrefillPanel } from "./prefill-ui.js?v=__ASSET_VERSION__";
import { initAlogPanel } from "./alog-ui.js?v=__ASSET_VERSION__";
import { renderReportCurve } from "/shared/report.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 { 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__";
import { showToast } from "./toast.js?v=__ASSET_VERSION__";
const FIELD_ID_SET = new Set(FIELD_IDS);
const STORAGE_PREFIX = "roastPlannerPlan.v2";
let storageKey = null;
let remotePlanId = null;
let draftSyncedAt = null;
// The custom name of the currently loaded plan (server column, not part of the plan JSONB —
// see roast_plans.name). Header display prefers this over worksheet field 0.1 when set.
let currentPlanName = "";
let lastDrawerOpener = null;
let deferredInstallPrompt = null;
let lotPicker = null;
const BAND_DOMAIN = [60, 220]; // shared °C domain for every band-track in the Machine Plan section
const MACHINE_PROFILE_KEY_PREFIX = "roastPlannerMachineProfile.v1";
const machineProfile = null;
function renderMachineProfileNote() {
document.getElementById("machine-profile-note").textContent = "Plans use your inputs. Roaster selection and roast history do not change timing or temperature suggestions.";
}
// Temperature fields are always stored in state.plan as canonical °C — this is purely a
// display-layer preference. tempC/actualBt/expectedBt cover Machine Plan, Roast Log, and
// the actuator schedule respectively; every other numeric field (%, g, days) is untouched.
const TEMP_UNIT_KEY = "roastPlannerTempUnit.v1";
const TEMP_FIELD_RE = /\.(tempC|actualBt|expectedBt)$/;
let tempUnit = localStorage.getItem(TEMP_UNIT_KEY) === "C" ? "C" : "F";
const cToF = (c) => (c * 9) / 5 + 32;
const fToC = (f) => ((f - 32) * 5) / 9;
function isTempField(name) {
return TEMP_FIELD_RE.test(name);
}
// Canonical °C string -> what the field should display right now.
function tempToDisplay(canonical) {
const n = Number.parseFloat(canonical);
if (!Number.isFinite(n)) return canonical ?? "";
return String(Math.round(tempUnit === "F" ? cToF(n) : n));
}
// What the user typed (in the current display unit) -> canonical °C string.
function tempToCanonical(display) {
const n = Number.parseFloat(display);
if (!Number.isFinite(n)) return display;
return String(Math.round((tempUnit === "F" ? fToC(n) : n) * 10) / 10);
}
export const state = { plan: blankPlan() };
const form = document.getElementById("plan-form");
// The print worksheet (#print-sheet) sits outside #plan-form on purpose — see index.html —
// so its radios don't fight the screen form's identically-named radios for exclusivity.
// Every sync pass therefore has to reach both containers explicitly.
function allNamedInputs() {
return document.querySelectorAll(
"#plan-form input, #plan-form select, #plan-form textarea, " +
"#print-sheet input, #print-sheet select, #print-sheet textarea",
);
}
// ---- nested path get/set for names like "temps.charge.tempC" or "blendComponents.0.cultivar"
function getPath(obj, path) {
return path.split(".").reduce((o, k) => (o == null ? undefined : o[k]), obj);
}
function setPath(obj, path, value) {
const parts = path.split(".");
let node = obj;
for (let i = 0; i < parts.length - 1; i++) {
if (node[parts[i]] === undefined || node[parts[i]] === null)
node[parts[i]] = {};
node = node[parts[i]];
}
node[parts[parts.length - 1]] = value;
}
function valueForName(name) {
if (FIELD_ID_SET.has(name)) return state.plan.fields[name] ?? "";
const v = getPath(state.plan, name);
return v ?? "";
}
function setValueForName(name, value) {
if (FIELD_ID_SET.has(name)) {
state.plan.fields[name] = value;
} else {
setPath(state.plan, name, value);
}
}
// ---- dynamic rows: blend components + actuator schedule
// Each renders into TWO places that share `name` attributes: the interactive screen
// component, and the hidden print-only worksheet table (#blend-rows / #actuator-rows),
// which is kept in sync by renderFormFromPlan() and only needs to look right on paper.
function renderBlendPrintRows() {
const tbody = document.getElementById("blend-rows");
tbody.replaceChildren();
state.plan.blendComponents.forEach((_, i) => {
const tr = document.createElement("tr");
tr.append(
document.createRange().createContextualFragment(`
<td><input class="f w ws-input" name="blendComponents.${i}.cultivar"><span class="pv"></span></td>
<td><input class="f ws-input" style="min-width:18mm" name="blendComponents.${i}.group"><span class="pv"></span></td>
<td><input class="f ws-input" style="min-width:20mm" name="blendComponents.${i}.process"><span class="pv"></span></td>
<td class="num"><input class="f n ws-input" name="blendComponents.${i}.sharePct"><span class="pv"></span></td>
<td class="num"><input class="f n ws-input" name="blendComponents.${i}.fcAnchor"><span class="pv"></span></td>
`),
);
tbody.appendChild(tr);
});
}
function renderBlendCards() {
const wrap = document.getElementById("blend-cards");
wrap.replaceChildren();
state.plan.blendComponents.forEach((_, i) => {
const card = document.createElement("div");
card.className = "blend-card";
card.append(
document.createRange().createContextualFragment(`
<label class="field"><span class="field-label">Cultivar</span><input class="field-input" name="blendComponents.${i}.cultivar" placeholder="e.g. Caturra"></label>
<label class="field"><span class="field-label">Group</span><input class="field-input" name="blendComponents.${i}.group"></label>
<label class="field"><span class="field-label">Process</span><input class="field-input" name="blendComponents.${i}.process"></label>
<label class="field"><span class="field-label">Share %</span><input class="field-input sm" name="blendComponents.${i}.sharePct"></label>
<label class="field"><span class="field-label">FC anchor</span><input class="field-input sm" name="blendComponents.${i}.fcAnchor"></label>
<button type="button" class="blend-remove" data-remove-blend="${i}" aria-label="Remove component" ${state.plan.blendComponents.length <= 1 ? "disabled" : ""}>✕</button>
`),
);
wrap.appendChild(card);
});
for (const btn of wrap.querySelectorAll("[data-remove-blend]")) {
btn.addEventListener("click", () => {
const i = Number(btn.dataset.removeBlend);
if (state.plan.blendComponents.length <= 1) return;
state.plan.blendComponents.splice(i, 1);
renderBlend();
renderFormFromPlan();
recompute();
});
}
}
function updateBlendTotal() {
const total = state.plan.blendComponents.reduce(
(sum, c) => sum + (Number.parseFloat(c.sharePct) || 0),
0,
);
const fill = document.getElementById("blend-total-fill");
const label = document.getElementById("blend-total-label");
if (!fill || !label) return;
fill.style.width = `${Math.min(100, total)}%`;
fill.classList.toggle("over", total > 100);
fill.classList.toggle("under", total > 0 && total < 100);
label.textContent = `${Math.round(total * 10) / 10}% of 100%`;
}
function updateBlendVisibility(mode) {
document
.getElementById("blend-body")
.classList.toggle("collapsed", mode !== "blend");
}
function renderBlend() {
renderBlendPrintRows();
renderBlendCards();
updateBlendTotal();
}
function renderActuators() {} // Older plans retain their saved schedule for report export.
// ---- populate every control in the form from state.plan
export function renderFormFromPlan() {
for (const el of allNamedInputs()) {
const name = el.name;
if (!name) continue;
const value = valueForName(name);
if (el.type === "radio") {
el.checked = el.value === value;
} else if (el.type === "checkbox") {
el.checked = Boolean(value);
} else {
el.value = isTempField(name) ? tempToDisplay(value) : (value ?? "");
}
}
updateBlendVisibility(state.plan.fields["2.1"]);
document.getElementById("header-coffee-name").textContent =
currentPlanName || state.plan.fields["0.1"] || "New plan";
}
function fmtOut(id, text) {
for (const el of document.querySelectorAll(`[data-out="${id}"]`))
el.textContent = text ?? "—";
}
function renderLedger() {
const ledger = computeLedger(state.plan, machineProfile);
const d = (s) => (s === null || s === undefined ? "—" : formatDuration(s));
const ds = (s) => (s === null || s === undefined ? "—" : formatSigned(s));
fmtOut("l1", d(ledger.lines.l1));
fmtOut("l2", ds(ledger.lines.l2));
fmtOut("l3", ds(ledger.lines.l3));
fmtOut("subtotalA", d(ledger.subtotalA));
fmtOut("pace-factor", `×${ledger.pace.toFixed(2)}`);
fmtOut("A", d(ledger.A));
fmtOut("yellow", d(ledger.yellow));
fmtOut("maillard", d(ledger.maillard));
fmtOut("l7", d(ledger.lines.l7));
fmtOut("l8", ds(ledger.lines.l8));
fmtOut("l9", ds(ledger.lines.l9));
fmtOut("C", d(ledger.C));
fmtOut("D", d(ledger.D));
const pct = (v) => (v === null || v === undefined ? "—" : `${v.toFixed(1)}%`);
fmtOut("check-drying", pct(ledger.checks.drying.pct));
fmtOut("check-maillard", pct(ledger.checks.maillard.pct));
fmtOut("check-dtr", pct(ledger.checks.dtr.pct));
fmtOut("check-ceiling", d(ledger.checks.ceiling.valueS));
ledger.warnings.push(...curveWarnings(buildPlanCurve(state.plan, ledger)));
const warnings = document.getElementById("ledger-warnings");
warnings.replaceChildren(
...ledger.warnings.map((warning) => {
const item = document.createElement("p");
item.textContent = warning;
return item;
}),
);
warnings.classList.toggle("hidden", ledger.warnings.length === 0);
for (const [key, check] of Object.entries(ledger.checks)) {
for (const cell of document.querySelectorAll(`[data-pass="${key}"]`)) {
cell.classList.remove("pass", "fail", "unknown", "informational");
// Drying/Maillard are mathematically derived from DTR (yellow = 0.56 × A), so they can
// never fail independently of it — shown as plain figures, not a pass/fail verdict.
if (check.informational) cell.classList.add("informational");
else
cell.classList.add(
check.pass === null ? "unknown" : check.pass ? "pass" : "fail",
);
}
}
// Box 8 / box 11 derived time columns
fmtOut("t-charge", "0:00");
fmtOut("t-yellow", d(ledger.yellow));
fmtOut("t-fc", d(ledger.A));
fmtOut("t-drop", d(ledger.D));
fmtOut("pa-charge", "0:00");
fmtOut("pa-tp", state.plan.temps.tp.time || "—");
fmtOut("pa-yellow", d(ledger.yellow));
fmtOut("pa-fc", d(ledger.A));
fmtOut("pa-drop", d(ledger.D));
document.getElementById("back-coffee-name").textContent =
currentPlanName || state.plan.fields["0.1"] || "";
document.getElementById("header-coffee-name").textContent =
currentPlanName || state.plan.fields["0.1"] || "New plan";
return ledger;
}
function domainPct(v) {
const [lo, hi] = BAND_DOMAIN;
return Math.max(0, Math.min(100, ((v - lo) / (hi - lo)) * 100));
}
function renderBandRanges() {
for (const row of document.querySelectorAll(".band-row")) {
const lo = Number(row.dataset.bandLo);
const hi = Number(row.dataset.bandHi);
const range = row.querySelector(".band-range");
if (!range) continue;
const loPct = domainPct(lo);
range.style.left = `${loPct}%`;
range.style.width = `${domainPct(hi) - loPct}%`;
}
}
function renderBandMarkers() {
for (const row of document.querySelectorAll(".band-row")) {
const input = row.querySelector('input[name$=".tempC"]');
const marker = row.querySelector(".band-marker");
if (!input || !marker) continue;
const raw = Number.parseFloat(input.value);
const v = Number.isFinite(raw) ? (tempUnit === "F" ? fToC(raw) : raw) : NaN;
if (!Number.isFinite(v)) {
marker.style.display = "none";
continue;
}
const lo = Number(row.dataset.bandLo);
const hi = Number(row.dataset.bandHi);
marker.style.display = "block";
marker.style.left = `${domainPct(v)}%`;
marker.classList.toggle("out-of-band", v < lo || v > hi);
}
}
function paintCurveInto(planGroup, refGroup, planPoints, ref) {
planGroup.replaceChildren();
if (planPoints.length > 0) {
const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
path.setAttribute("d", pointsToPathD(planPoints));
path.setAttribute("fill", "none");
path.setAttribute("stroke", "currentColor");
path.setAttribute("stroke-width", "1.6");
path.style.color = "var(--ink, #1a1512)";
planGroup.appendChild(path);
for (const p of planPoints) {
const c = document.createElementNS(
"http://www.w3.org/2000/svg",
"circle",
);
c.setAttribute("cx", tToX(p.timeS).toFixed(1));
c.setAttribute("cy", tempToY(p.tempC).toFixed(1));
c.setAttribute("r", "3.2");
c.setAttribute("fill", "currentColor");
c.style.color = "var(--ink, #1a1512)";
planGroup.appendChild(c);
}
}
refGroup.replaceChildren();
if (ref?.curve?.length) {
const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
const d = ref.curve
.map(
(p, i) =>
`${i === 0 ? "M" : "L"}${tToX(p.t).toFixed(1)},${tempToY(p.bt).toFixed(1)}`,
)
.join(" ");
path.setAttribute("d", d);
path.setAttribute("fill", "none");
path.setAttribute("stroke", "currentColor");
path.setAttribute("stroke-width", "1.3");
path.setAttribute("stroke-dasharray", "4 3");
path.setAttribute("opacity", ".65");
path.style.color = "var(--ink-2, #5a5048)";
refGroup.appendChild(path);
for (const m of ref.milestones ?? []) {
if (m.tempC === null) continue;
const c = document.createElementNS(
"http://www.w3.org/2000/svg",
"circle",
);
c.setAttribute("cx", tToX(m.timeS).toFixed(1));
c.setAttribute("cy", tempToY(m.tempC).toFixed(1));
c.setAttribute("r", "2.6");
c.setAttribute("fill", "none");
c.setAttribute("stroke", "currentColor");
c.setAttribute("stroke-width", "1.2");
c.style.color = "var(--ink-2, #5a5048)";
refGroup.appendChild(c);
}
}
}
function renderCurve(ledger) {
const planPoints = buildPlanCurve(state.plan, ledger);
const ref = state.plan.reference;
paintCurveInto(
document.getElementById("plan-curve"),
document.getElementById("ref-curve"),
planPoints,
ref,
);
const live = document.getElementById("curve-svg-live");
live.setAttribute("viewBox", "0 0 900 440");
live.innerHTML = renderReportCurve(planPoints, ledger, ref).replace(/^<svg[^>]*>/, "").replace(/<\/svg>$/, "");
}
// The blank shape has some non-empty defaults of its own (e.g. planActual.charge.actualTime
// is "0:00", since charge is always t=0) — compare against those defaults, not against "",
// so a fresh plan doesn't read as already having roast-day data.
const BLANK_PLAN = blankPlan();
// True once any value in `obj` differs from the same path in `blank` — used to lift the
// "phase-later" muting off Roast Log / After the Roast the moment the user has actually started
// using them, without ever hiding the plan column they exist to compare against.
function hasVal(obj, blank) {
if (obj == null) return false;
return Object.keys(obj).some((key) => {
const v = obj[key];
return typeof v === "object" && v !== null
? hasVal(v, blank?.[key])
: v !== "" && v != null && v !== blank?.[key];
});
}
export function recompute() {
const ledger = renderLedger();
renderCurve(ledger);
renderBandMarkers();
document
.getElementById("sec-roastlog")
?.classList.toggle(
"has-data",
hasVal(state.plan.planActual, BLANK_PLAN.planActual),
);
lotPicker?.updateConsumeRow();
autosave();
}
function setSaveStatus(status) {
const chip = document.getElementById("autosave-status");
const text = chip.querySelector(".autosave-text");
chip.classList.remove("saving", "saved", "failed");
chip.classList.add(status);
text.textContent =
{
saving: "Saving locally…",
saved: "Synced",
failed: "Saved locally — sync failed",
local: "Saved locally — waiting to sync",
}[status] || "Not saved yet";
}
let syncQueue = Promise.resolve();
// Resolves true once the server sync attempt has settled (succeeded, failed, or was correctly
// deferred because we're offline/unauthenticated) — offline is not a failure here, the local
// write it's paired with in flushCurrentPlan already guarantees the draft isn't lost, and
// newPlan()/selectPlan() rely on that to let plan-switching keep working offline. Callers that
// specifically need a confirmed server-side plan id (attaching a lot draw or a cupping session)
// must check remotePlanId themselves afterward, which they already do.
function syncPlan(snapshot = structuredClone(state.plan)) {
if (!navigator.onLine || !csrfToken()) {
setSaveStatus("local");
return Promise.resolve(true);
}
syncQueue = syncQueue.then(async () => {
try {
const response = await protectedFetch(
remotePlanId ? `/api/plans/${remotePlanId}` : "/api/plans",
{
method: remotePlanId ? "PUT" : "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ plan: snapshot }),
},
);
if (!response.ok) throw new Error("sync_failed");
const body = await response.json();
remotePlanId = body.plan.id;
draftSyncedAt = body.plan.updated_at;
currentPlanName = body.plan.name ?? currentPlanName;
if (storageKey)
localStorage.setItem(
storageKey,
JSON.stringify({ plan: snapshot, remotePlanId, syncedAt: draftSyncedAt }),
);
history.replaceState(
null,
"",
`/app?plan=${encodeURIComponent(remotePlanId)}`,
);
setSaveStatus("saved");
return true;
} catch {
setSaveStatus("failed");
return false;
}
});
return syncQueue;
}
async function flushCurrentPlan() {
clearTimeout(autosave._t);
const snapshot = structuredClone(state.plan);
try {
if (storageKey)
localStorage.setItem(
storageKey,
JSON.stringify({ plan: snapshot, remotePlanId, syncedAt: draftSyncedAt }),
);
} catch {
setSaveStatus("failed");
return false;
}
return await syncPlan(snapshot);
}
function autosave() {
setSaveStatus("saving");
clearTimeout(autosave._t);
autosave._t = setTimeout(() => {
flushCurrentPlan();
}, 400);
}
function loadFromStorage(userId) {
try {
storageKey = `${STORAGE_PREFIX}:${userId}`;
localStorage.setItem(`${STORAGE_PREFIX}:last-user`, userId);
const machineProfileKey = `${MACHINE_PROFILE_KEY_PREFIX}:${userId}`;
// A shared browser must never retain a previous account's local-only draft or cache.
for (let i = localStorage.length - 1; i >= 0; i--) {
const key = localStorage.key(i);
if (
(key?.startsWith(`${STORAGE_PREFIX}:`) &&
key !== storageKey &&
key !== `${STORAGE_PREFIX}:last-user`) ||
key === "roastPlannerPlan.v1" ||
(key?.startsWith(`${MACHINE_PROFILE_KEY_PREFIX}:`) && key !== machineProfileKey)
)
localStorage.removeItem(key);
}
const raw = localStorage.getItem(storageKey);
if (!raw) return null;
const draft = JSON.parse(raw);
if (draft?.plan) {
remotePlanId = draft.remotePlanId || null;
draftSyncedAt = draft.syncedAt || null;
return draft.plan;
}
return draft; // legacy v2 draft: retain it once, then upgrade on next save
} catch {
return null;
}
}
function clearDraft() {
if (storageKey) localStorage.removeItem(storageKey);
try {
localStorage.removeItem(`${STORAGE_PREFIX}:last-user`);
for (let i = localStorage.length - 1; i >= 0; i--) {
const key = localStorage.key(i);
if (key?.startsWith(`${MACHINE_PROFILE_KEY_PREFIX}:`)) localStorage.removeItem(key);
}
} catch {
/* unavailable storage */
}
storageKey = null;
remotePlanId = null;
draftSyncedAt = null;
}
function cultivarAutofill(name) {
const row = findCultivar(name);
if (!row) return;
setValueForName("1.2", row.group);
setValueForName("1.4", formatDuration(parseRangeMidpoint(row.fcAnchor)));
setValueForName("1.5", row.profile.join("|"));
setValueForName("1.7", formatSigned(row.devModS));
renderFormFromPlan();
}
// Mirrors cultivarAutofill(): picking a process/roast level should feed the same reference
// numbers into the ledger that selecting a cultivar does, not just record the choice as text.
function processAutofill(key) {
const row = findProcess(key);
if (!row) return;
setValueForName("3.2", formatSigned(row.devModS));
renderFormFromPlan();
}
function roastLevelAutofill(key) {
const row = findRoastLevel(key);
if (!row) return;
setValueForName("4.3", formatDuration(parseRangeMidpoint(row.devBand)));
renderFormFromPlan();
}
function wireCultivarDatalist() {
const list = document.getElementById("cultivar-list");
list.replaceChildren(
...CULTIVARS.map((c) => {
const option = document.createElement("option");
option.value = c.name;
return option;
}),
);
}
function wireForm() {
form.addEventListener("input", (e) => {
const el = e.target;
if (!el.name) return;
if (el.type === "radio" && !el.checked) return;
setValueForName(
el.name,
isTempField(el.name) ? tempToCanonical(el.value) : el.value,
);
if (el.name === "1.1") cultivarAutofill(el.value);
if (el.name === "2.1") updateBlendVisibility(el.value);
if (el.name === "3.1") processAutofill(el.value);
if (el.name === "4.1") roastLevelAutofill(el.value);
if (el.name.startsWith("blendComponents.")) updateBlendTotal();
recompute();
});
}
// Refreshes every unit-facing bit of chrome (labels, placeholders, the toggle itself) after
// tempUnit changes. Field *values* are handled separately by renderFormFromPlan().
function updateTempUnitUI() {
const label = tempUnit === "F" ? "°F" : "°C";
for (const input of document.querySelectorAll(
'input[name$=".tempC"], input[name$=".actualBt"], input[name$=".expectedBt"]',
))
input.placeholder = label;
for (const span of document.querySelectorAll("#sec-machine .unit-input .unit"))
span.textContent = label;
const printHeader = document.getElementById("print-machine-temp-th");
if (printHeader) printHeader.textContent = label;
for (const input of document.querySelectorAll('#temp-unit-toggle input[name="tempUnit"]'))
input.checked = input.value === tempUnit;
// Static reference numbers that live outside the plan (chart axes, band captions, the
// print worksheet's "Your logged band" column) — these are fixed constants, not plan
// data, so they're driven off data-tempc/data-band-lo/hi rather than valueForName().
const conv = (c) => Math.round(tempUnit === "F" ? cToF(c) : c);
for (const el of document.querySelectorAll("[data-tempc]"))
el.textContent = String(conv(Number(el.dataset.tempc)));
for (const el of document.querySelectorAll("td[data-band-lo], text[data-band-lo]")) {
const lo = conv(Number(el.dataset.bandLo));
const hi = conv(Number(el.dataset.bandHi));
el.textContent = `${el.dataset.bandPrefix ?? ""}${lo}–${hi} ${label}${el.dataset.bandSuffix ?? ""}`;
}
const axisTitle = document.getElementById("print-curve-axis-title");
if (axisTitle) axisTitle.textContent = `BEAN TEMP ${label}`;
}
function setTempUnit(unit) {
tempUnit = unit === "F" ? "F" : "C";
localStorage.setItem(TEMP_UNIT_KEY, tempUnit);
updateTempUnitUI();
renderFormFromPlan();
recompute();
}
function wireTempUnitToggle() {
const toggle = document.getElementById("temp-unit-toggle");
if (!toggle) return;
toggle.addEventListener("change", (e) => {
if (e.target.name === "tempUnit") setTempUnit(e.target.value);
});
}
function wireDrawers() {
const overlay = document.getElementById("drawer-overlay");
const drawers = [...document.querySelectorAll(".drawer")];
function closeAll() {
for (const panel of drawers) {
panel.classList.add("hidden");
panel.setAttribute("aria-hidden", "true");
}
overlay.classList.add("hidden");
lastDrawerOpener?.focus();
}
function open(panel, opener) {
lastDrawerOpener = opener;
for (const p of drawers) {
p.classList.add("hidden");
p.setAttribute("aria-hidden", "true");
}
panel.classList.remove("hidden");
panel.setAttribute("aria-hidden", "false");
overlay.classList.remove("hidden");
panel.querySelector("button, input, [href]")?.focus();
}
for (const [buttonId, panelId] of [
["nav-prefill", "panel-prefill"],
["nav-alog", "panel-alog"],
["nav-chat", "panel-chat"],
["nav-settings", "panel-settings"],
["nav-import-export", "panel-import-export"],
])
document
.getElementById(buttonId)
.addEventListener("click", (event) => {
document.getElementById("side-nav")?.classList.remove("mobile-open");
open(document.getElementById(panelId), event.currentTarget);
});
overlay.addEventListener("click", closeAll);
for (const btn of document.querySelectorAll("[data-close-drawer]"))
btn.addEventListener("click", closeAll);
document.addEventListener("keydown", (event) => {
const activeDrawer = drawers.find(
(drawer) => !drawer.classList.contains("hidden"),
);
if (!activeDrawer) return;
if (event.key === "Escape") {
closeAll();
return;
}
if (event.key !== "Tab") return;
const focusable = [
...activeDrawer.querySelectorAll(
"button:not([disabled]), input:not([disabled]), [href]",
),
];
const first = focusable[0],
last = focusable.at(-1);
if (event.shiftKey && document.activeElement === first) {
event.preventDefault();
last?.focus();
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault();
first?.focus();
}
});
return { open, closeAll };
}
async function leaveForPlans(event) {
event.preventDefault();
if (!(await flushCurrentPlan())) {
showToast("Your changes are saved on this device, but sync failed. Retry before switching plans.", "fail");
return;
}
location.assign("/plans");
}
// 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");
e.currentTarget.setAttribute("aria-pressed", String(on));
});
document.getElementById("btn-save").addEventListener("click", () => {
const blob = new Blob([JSON.stringify(state.plan, null, 2)], {
type: "application/json",
});
const a = document.createElement("a");
a.href = URL.createObjectURL(blob);
a.download = `${(state.plan.fields["0.1"] || "roast-plan").replace(/[^\w-]+/g, "_")}.json`;
a.click();
URL.revokeObjectURL(a.href);
});
document
.getElementById("btn-load")
.addEventListener("click", () =>
document.getElementById("file-load").click(),
);
document.getElementById("file-load").addEventListener("change", async (e) => {
const file = e.target.files?.[0];
if (!file) return;
try {
const loaded = JSON.parse(await file.text());
state.plan = { ...blankPlan(), ...loaded };
renderBlend();
renderActuators();
renderFormFromPlan();
recompute();
} catch (err) {
alert(`Could not load plan: ${err.message}`);
}
});
document.getElementById("nav-plans").addEventListener("click", leaveForPlans);
document.querySelector('#nav-links a[href="/plans"]')?.addEventListener("click", leaveForPlans);
document.getElementById("btn-add-blend").addEventListener("click", () => {
state.plan.blendComponents.push({
cultivar: "",
group: "",
process: "",
sharePct: "",
fcAnchor: "",
});
renderBlend();
renderFormFromPlan();
recompute();
});
document.getElementById("btn-logout").addEventListener("click", async () => {
try {
await protectedFetch("/api/auth/logout", { method: "POST" });
} finally {
clearDraft();
location.replace("/");
}
});
}
function wirePwa() {
const status = document.getElementById("connection-status");
const renderConnection = () => {
const offline = !navigator.onLine;
status.classList.toggle("hidden", !offline);
status.textContent = offline
? "Offline — changes continue saving on this device."
: "";
};
window.addEventListener("online", () => {
renderConnection();
syncPlan();
});
window.addEventListener("offline", renderConnection);
window.addEventListener("beforeinstallprompt", (event) => {
event.preventDefault();
deferredInstallPrompt = event;
document.getElementById("btn-install").classList.remove("hidden");
});
document.getElementById("btn-install").addEventListener("click", async () => {
if (!deferredInstallPrompt) return;
deferredInstallPrompt.prompt();
await deferredInstallPrompt.userChoice;
deferredInstallPrompt = null;
document.getElementById("btn-install").classList.add("hidden");
});
renderConnection();
if ("serviceWorker" in navigator) {
// A brand-new visitor has no controller yet, so the *first* activation firing
// "controllerchange" is not an update — only a page that was already controlled by a
// previous service worker should reload when a new one takes over.
const hadController = !!navigator.serviceWorker.controller;
window.addEventListener("load", () => {
navigator.serviceWorker
.register("/sw.js?v=__ASSET_VERSION__")
.then((registration) => {
const showUpdate = () =>
document.getElementById("btn-refresh").classList.remove("hidden");
if (registration.waiting) showUpdate();
registration.addEventListener("updatefound", () =>
registration.installing?.addEventListener("statechange", () => {
if (registration.waiting && navigator.serviceWorker.controller)
showUpdate();
}),
);
})
.catch((error) =>
console.warn("Service worker registration failed:", error),
);
});
navigator.serviceWorker.addEventListener("controllerchange", () => {
if (hadController) location.reload();
});
}
document.getElementById("btn-refresh").addEventListener("click", async () => {
if (!(await flushCurrentPlan())) return;
const registration = await navigator.serviceWorker.getRegistration();
registration?.waiting?.postMessage("SKIP_WAITING");
});
}
function wireSectionNav() {
const links = [...document.querySelectorAll('.section-nav a[href^="#"]')];
const sections = links
.map((a) => document.querySelector(a.getAttribute("href")))
.filter(Boolean);
if (sections.length === 0) return;
const observer = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (!entry.isIntersecting) continue;
const id = `#${entry.target.id}`;
for (const a of links) {
const active = a.getAttribute("href") === id;
a.classList.toggle("active", active);
a.toggleAttribute("aria-current", active);
}
}
},
{ rootMargin: "-20% 0px -70% 0px" },
);
for (const s of sections) observer.observe(s);
}
async function init() {
// The side nav is generated — render it before anything touches its elements (the admin
// link reveal below crashed init for admin accounts when this ran later).
initSideNav();
try {
const meResponse = await fetch("/api/auth/me");
if (!meResponse.ok) {
location.replace("/");
return;
}
const { user } = await meResponse.json();
document.getElementById("account-email").textContent = user.email;
document.getElementById("settings-email").textContent = user.email;
applyAvatar(document.getElementById("nav-user-avatar"), user);
if (user.role === "admin")
document.getElementById("nav-admin")?.classList.remove("hidden");
document.getElementById("alog-library-refresh").hidden = user.role !== "admin";
const localDraft = loadFromStorage(user.id);
state.plan = localDraft ?? blankPlan();
const draftRemoteId = remotePlanId;
const draftSyncedAtAtLoad = draftSyncedAt;
const requestedId = new URLSearchParams(location.search).get("plan");
let selected = null;
if (requestedId) {
const response = await fetch(`/api/plans/${encodeURIComponent(requestedId)}`);
if (!response.ok) {
showToast("That plan could not be opened. Returning to your plans.", "fail");
location.replace("/plans");
return;
}
selected = (await response.json()).plan;
} else if (!localDraft) {
const recent = await api("/api/plans?view=summary&limit=1");
if (recent.plans[0]) selected = (await api(`/api/plans/${recent.plans[0].id}`)).plan;
}
const draftIsCurrent = selected && draftRemoteId === selected.id && draftSyncedAtAtLoad && new Date(selected.updated_at) <= new Date(draftSyncedAtAtLoad);
if (selected) {
if (!draftIsCurrent) state.plan = { ...blankPlan(), ...selected.plan, fields: {...blankPlan().fields,...selected.plan.fields} };
remotePlanId = selected.id;
draftSyncedAt = selected.updated_at;
currentPlanName = selected.name || "";
}
} catch {
// The cached app shell contains no user data. A successful online sign-in records the
// last account only until logout, allowing that account's local draft to reopen offline.
try {
const offlineUserId = localStorage.getItem(`${STORAGE_PREFIX}:last-user`);
if (offlineUserId && csrfToken()) {
state.plan = loadFromStorage(offlineUserId) ?? blankPlan();
}
} catch {
/* no local draft is available */
}
}
renderBlend();
renderActuators();
wireCultivarDatalist();
wireWhyPanels();
initFieldHelp({
getPlan: () => state.plan,
getTempUnit: () => tempUnit,
getMachineProfile: () => machineProfile,
});
renderBandRanges();
renderFormFromPlan();
wireForm();
wireDrawers();
wireToolbar();
wireToolsMenu();
wireTempUnitToggle();
updateTempUnitUI();
wirePwa();
wireSectionNav();
initPrefillPanel({
state,
renderBlendRows: renderBlend,
renderActuatorRows: renderActuators,
renderFormFromPlan,
recompute,
});
initAlogPanel({
state,
recompute,
getRemotePlanId: () => remotePlanId,
flushCurrentPlan,
});
initPrint({ getPlan: () => state.plan, getName: () => currentPlanName });
lotPicker = initLotPicker({
state,
recompute,
flushCurrentPlan,
getRemotePlanId: () => remotePlanId,
renderFormFromPlan,
});
wireCuppingLink();
initPlanChat({ getPlan: () => state.plan });
renderMachineProfileNote();
recompute();
}
function wireCuppingLink() {
const button = document.getElementById("btn-open-cupping");
const note = document.getElementById("cupping-link-note");
// Defensive: this UI lives in the Roast Log section; a missing element must degrade to
// "no cupping shortcut", never crash init (that failure mode silently killed autosave).
if (!button || !note) return;
button.addEventListener("click", async () => {
button.disabled = true;
try {
if (!(await flushCurrentPlan())) {
note.textContent = "Could not sync the plan. Try again.";
return;
}
if (!remotePlanId) {
note.textContent = "Sync the plan first, then open a cupping session.";
return;
}
const existing = await protectedFetch(
`/api/cupping?plan=${encodeURIComponent(remotePlanId)}`,
)
.then((r) => (r.ok ? r.json() : { sessions: [] }))
.catch(() => ({ sessions: [] }));
if (existing.sessions?.length) {
location.assign(`/cupping?session=${existing.sessions[0].id}`);
return;
}
let created;
try {
created = await protectedFetch("/api/cupping", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ roastPlanId: remotePlanId }),
}).then((r) => r.json());
} catch {
note.textContent = "Could not reach the server. Try again.";
return;
}
if (created.session) location.assign(`/cupping?session=${created.session.id}`);
else note.textContent = "Could not start a cupping session.";
} finally {
button.disabled = false;
}
});
// Non-blocking: show whether a session already exists without forcing a sync first.
if (remotePlanId)
protectedFetch(`/api/cupping?plan=${encodeURIComponent(remotePlanId)}`)
.then((r) => (r.ok ? r.json() : null))
.then((body) => {
if (body?.sessions?.length)
note.textContent = `Session exists — ${body.sessions[0].totalScore.toFixed(2)}`;
})
.catch(() => {});
}
init();