Test and deploy / test-and-deploy (push) Successful in 59s
Co-Authored-By: Claude Fable 5 <[email protected]>
1323 lines
46 KiB
JavaScript
1323 lines
46 KiB
JavaScript
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 } 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 { 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 plans = [];
|
||
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
|
||
|
||
// Learned per-user machine profile (pace factor + temperature bands) from shared/learn.js — see
|
||
// its header comment. Cached to localStorage per user so it's available offline, same pattern as
|
||
// the plan draft cache below.
|
||
const MACHINE_PROFILE_KEY_PREFIX = "roastPlannerMachineProfile.v1";
|
||
let machineProfile = null;
|
||
// Learned roaster behavior from uploaded .alogs (server-aggregated) — used as the plan
|
||
// curve's fallback milestone temps so the suggested curve matches this user's machine.
|
||
let roasterProfile = null;
|
||
|
||
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 */
|
||
}
|
||
}
|
||
|
||
async function loadMachineProfile(userId) {
|
||
const cacheKey = `${MACHINE_PROFILE_KEY_PREFIX}:${userId}`;
|
||
try {
|
||
const response = await fetch("/api/machine-profile");
|
||
if (response.ok) {
|
||
machineProfile = (await response.json()).profile;
|
||
localStorage.setItem(cacheKey, JSON.stringify(machineProfile));
|
||
return;
|
||
}
|
||
} catch {
|
||
/* offline — fall through to the cached profile, if any */
|
||
}
|
||
try {
|
||
const cached = localStorage.getItem(cacheKey);
|
||
if (cached) machineProfile = JSON.parse(cached);
|
||
} catch {
|
||
/* no cached profile available; ledger falls back to pace 1.0 (reference) */
|
||
}
|
||
}
|
||
|
||
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.";
|
||
return;
|
||
}
|
||
const paceText =
|
||
Math.abs(pace.value - 1) < 0.01
|
||
? "running close to reference pace"
|
||
: `running ${pace.value > 1 ? `${Math.round((pace.value - 1) * 100)}% slower` : `${Math.round((1 - pace.value) * 100)}% faster`} than reference`;
|
||
el.textContent = `Learned from ${pace.n} of your own roasts — ${paceText}.`;
|
||
}
|
||
|
||
// 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) === "F" ? "F" : "C";
|
||
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 renderActuatorPrintRows() {
|
||
const tbody = document.getElementById("actuator-rows");
|
||
tbody.replaceChildren();
|
||
state.plan.actuators.forEach((_, i) => {
|
||
const tr = document.createElement("tr");
|
||
tr.append(
|
||
document.createRange().createContextualFragment(`
|
||
<td class="num"><input class="f n ws-input" name="actuators.${i}.time"><span class="pv"></span></td>
|
||
<td class="num"><input class="f n ws-input" name="actuators.${i}.heatPct"><span class="pv"></span></td>
|
||
<td class="num"><input class="f n ws-input" name="actuators.${i}.fanPct"><span class="pv"></span></td>
|
||
<td class="num"><input class="f n ws-input" name="actuators.${i}.expectedBt"><span class="pv"></span></td>
|
||
<td><input class="f ws-input" style="min-width:78mm" name="actuators.${i}.why"><span class="pv"></span></td>
|
||
`),
|
||
);
|
||
tbody.appendChild(tr);
|
||
});
|
||
}
|
||
|
||
function renderActuatorTimeline() {
|
||
const wrap = document.getElementById("actuator-timeline");
|
||
wrap.replaceChildren();
|
||
state.plan.actuators.forEach((_, i) => {
|
||
const step = document.createElement("div");
|
||
step.className = "actuator-step";
|
||
step.append(
|
||
document.createRange().createContextualFragment(`
|
||
<div class="actuator-rail"><div class="actuator-dot"></div><div class="actuator-line"></div></div>
|
||
<div class="actuator-card">
|
||
<label class="field"><span class="field-label">Time</span><input class="field-input sm" name="actuators.${i}.time" placeholder="m:ss"></label>
|
||
<label class="field"><span class="field-label">Heat %</span><input class="field-input sm" name="actuators.${i}.heatPct"></label>
|
||
<label class="field"><span class="field-label">Fan %</span><input class="field-input sm" name="actuators.${i}.fanPct"></label>
|
||
<button type="button" class="actuator-remove" data-remove-actuator="${i}" aria-label="Remove step" ${state.plan.actuators.length <= 1 ? "disabled" : ""}>✕</button>
|
||
<label class="field"><span class="field-label">Expected BT</span><input class="field-input sm" name="actuators.${i}.expectedBt"></label>
|
||
<label class="field actuator-why"><span class="field-label">Why this change</span><input class="field-input" name="actuators.${i}.why" placeholder="What you're watching for"></label>
|
||
</div>
|
||
`),
|
||
);
|
||
wrap.appendChild(step);
|
||
});
|
||
for (const btn of wrap.querySelectorAll("[data-remove-actuator]")) {
|
||
btn.addEventListener("click", () => {
|
||
const i = Number(btn.dataset.removeActuator);
|
||
if (state.plan.actuators.length <= 1) return;
|
||
state.plan.actuators.splice(i, 1);
|
||
renderActuators();
|
||
renderFormFromPlan();
|
||
recompute();
|
||
});
|
||
}
|
||
}
|
||
|
||
function renderActuators() {
|
||
renderActuatorPrintRows();
|
||
renderActuatorTimeline();
|
||
updateTempUnitUI(); // new actuator rows need their expectedBt placeholder set too
|
||
}
|
||
|
||
// ---- 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));
|
||
|
||
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, roasterProfile);
|
||
const ref = state.plan.reference;
|
||
|
||
paintCurveInto(
|
||
document.getElementById("plan-curve"),
|
||
document.getElementById("ref-curve"),
|
||
planPoints,
|
||
ref,
|
||
);
|
||
paintCurveInto(
|
||
document.getElementById("plan-curve-live"),
|
||
document.getElementById("ref-curve-live"),
|
||
planPoints,
|
||
ref,
|
||
);
|
||
}
|
||
|
||
// 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");
|
||
await loadPlans();
|
||
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("[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-plans", "panel-plans"],
|
||
["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 };
|
||
}
|
||
|
||
// The custom `name` column wins over worksheet field 0.1 wherever a plan's title is shown —
|
||
// mirrors the server's COALESCE(NULLIF(name,''), plan->'fields'->>'0.1') fallback.
|
||
function planDisplayTitle(plan) {
|
||
return plan.name || plan.plan?.fields?.["0.1"] || "Untitled plan";
|
||
}
|
||
async function loadPlans() {
|
||
if (!navigator.onLine) return;
|
||
const response = await fetch("/api/plans");
|
||
if (!response.ok) return;
|
||
plans = (await response.json()).plans || [];
|
||
const list = document.getElementById("plan-list");
|
||
list.replaceChildren(
|
||
...plans.map((plan) => {
|
||
const item = document.createElement("li");
|
||
item.className = "plan-list-row";
|
||
const button = document.createElement("button");
|
||
button.type = "button";
|
||
button.className = "plan-list-item";
|
||
button.classList.toggle("active", plan.id === remotePlanId);
|
||
const title = document.createElement("strong");
|
||
title.textContent = planDisplayTitle(plan);
|
||
const updated = document.createElement("span");
|
||
updated.textContent = `Updated ${new Date(plan.updated_at).toLocaleDateString()}`;
|
||
button.append(title, updated);
|
||
button.addEventListener("click", () => selectPlan(plan));
|
||
|
||
const renameBtn = document.createElement("button");
|
||
renameBtn.type = "button";
|
||
renameBtn.className = "icon-btn plan-list-rename";
|
||
renameBtn.style.flexShrink = "0";
|
||
renameBtn.style.alignSelf = "center";
|
||
renameBtn.setAttribute("aria-label", "Rename plan");
|
||
renameBtn.textContent = "✎";
|
||
// A sibling of the select button, not nested inside it — stopPropagation alone
|
||
// wouldn't be enough to keep a click here from also selecting the plan otherwise.
|
||
renameBtn.addEventListener("click", async (event) => {
|
||
event.stopPropagation();
|
||
const next = prompt("Plan name", plan.name || "");
|
||
if (next === null) return;
|
||
try {
|
||
await api(`/api/plans/${plan.id}`, {
|
||
method: "PUT",
|
||
body: JSON.stringify({ name: next.trim() }),
|
||
});
|
||
} catch (error) {
|
||
showToast(error.message, "fail");
|
||
return;
|
||
}
|
||
if (plan.id === remotePlanId) {
|
||
currentPlanName = next.trim();
|
||
renderFormFromPlan();
|
||
renderLedger();
|
||
}
|
||
await loadPlans();
|
||
});
|
||
|
||
const deleteBtn = document.createElement("button");
|
||
deleteBtn.type = "button";
|
||
deleteBtn.className = "icon-btn plan-list-delete";
|
||
deleteBtn.setAttribute("aria-label", `Delete ${title.textContent}`);
|
||
deleteBtn.textContent = "✕";
|
||
// A sibling of the select button, not nested inside it — stopPropagation alone
|
||
// wouldn't be enough to keep a click here from also selecting the plan otherwise.
|
||
deleteBtn.addEventListener("click", (event) => {
|
||
event.stopPropagation();
|
||
deletePlan(plan);
|
||
});
|
||
|
||
item.append(button, renameBtn, deleteBtn);
|
||
return item;
|
||
}),
|
||
);
|
||
}
|
||
async function selectPlan(plan) {
|
||
if (!(await flushCurrentPlan())) return;
|
||
remotePlanId = plan.id;
|
||
draftSyncedAt = plan.updated_at;
|
||
currentPlanName = plan.name || "";
|
||
state.plan = { ...blankPlan(), ...plan.plan };
|
||
history.replaceState(
|
||
null,
|
||
"",
|
||
`/app?plan=${encodeURIComponent(remotePlanId)}`,
|
||
);
|
||
renderBlend();
|
||
renderActuators();
|
||
renderFormFromPlan();
|
||
recompute();
|
||
// renderFormFromPlan() sets the lot <select>'s value, but that only sticks if the option is
|
||
// already in the DOM — re-render the picker's own option list against the newly-loaded
|
||
// plan's inventory.lotId so switching to a plan referencing a different (or no) lot doesn't
|
||
// leave the dropdown showing the previous plan's selection or a blank state.
|
||
lotPicker?.renderOptions();
|
||
lotPicker?.refreshRefineSuggestion();
|
||
document.querySelector("[data-close-drawer]")?.click();
|
||
}
|
||
// Shared by newPlan() and deletePlan() (when the deleted plan was the one on screen) — resets
|
||
// the whole app to a fresh, unsynced plan.
|
||
function resetToBlankPlan() {
|
||
remotePlanId = null;
|
||
draftSyncedAt = null;
|
||
currentPlanName = "";
|
||
state.plan = blankPlan();
|
||
history.replaceState(null, "", "/app");
|
||
renderBlend();
|
||
renderActuators();
|
||
renderFormFromPlan();
|
||
recompute();
|
||
lotPicker?.renderOptions();
|
||
lotPicker?.refreshRefineSuggestion();
|
||
}
|
||
async function newPlan() {
|
||
if (!confirm("Start a new plan? Your current plan is already saved locally."))
|
||
return;
|
||
if (!(await flushCurrentPlan())) return;
|
||
resetToBlankPlan();
|
||
// Without this, the Plans drawer stays open over the now-blank form — the reset happens but
|
||
// is invisible, so it doesn't read as "starting something new" until the user closes it themselves.
|
||
document.querySelector("[data-close-drawer]")?.click();
|
||
}
|
||
async function deletePlan(plan) {
|
||
const name = planDisplayTitle(plan);
|
||
if (!confirm(`Delete "${name}"? This cannot be undone.`)) return;
|
||
try {
|
||
await api(`/api/plans/${plan.id}`, { method: "DELETE" });
|
||
} catch (error) {
|
||
showToast(error.message, "fail");
|
||
return;
|
||
}
|
||
showToast("Plan deleted.");
|
||
if (plan.id === remotePlanId) resetToBlankPlan();
|
||
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");
|
||
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("btn-new-plan").addEventListener("click", newPlan);
|
||
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("/");
|
||
}
|
||
});
|
||
document.getElementById("btn-add-actuator").addEventListener("click", () => {
|
||
state.plan.actuators.push({
|
||
time: "",
|
||
heatPct: "",
|
||
fanPct: "",
|
||
expectedBt: "",
|
||
why: "",
|
||
});
|
||
renderActuators();
|
||
renderFormFromPlan();
|
||
recompute();
|
||
});
|
||
}
|
||
|
||
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")];
|
||
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");
|
||
const localDraft = loadFromStorage(user.id);
|
||
state.plan = localDraft ?? blankPlan();
|
||
const draftRemoteId = remotePlanId;
|
||
const draftSyncedAtAtLoad = draftSyncedAt;
|
||
// 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
|
||
// synced at or after the server's own updated_at — otherwise another device's newer
|
||
// edit (or a draft from before sync tracking existed) would be silently discarded.
|
||
const draftIsCurrent =
|
||
selected &&
|
||
draftRemoteId === selected.id &&
|
||
draftSyncedAtAtLoad &&
|
||
new Date(selected.updated_at) <= new Date(draftSyncedAtAtLoad);
|
||
if (selected && !draftIsCurrent) {
|
||
state.plan = { ...blankPlan(), ...selected.plan };
|
||
remotePlanId = selected.id;
|
||
draftSyncedAt = selected.updated_at;
|
||
} else if (selected) {
|
||
remotePlanId = selected.id;
|
||
} else if (!localDraft && !requestedId && plans[0]) {
|
||
state.plan = { ...blankPlan(), ...plans[0].plan };
|
||
remotePlanId = plans[0].id;
|
||
draftSyncedAt = plans[0].updated_at;
|
||
}
|
||
} 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();
|
||
await loadMachineProfile(offlineUserId);
|
||
}
|
||
} 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({ beforePrint: renderFormFromPlan });
|
||
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();
|