981 lines
32 KiB
JavaScript
981 lines
32 KiB
JavaScript
import { FIELD_IDS, blankPlan } from "/shared/fields.js";
|
|
import { computeLedger } from "/shared/ledger.js";
|
|
import {
|
|
formatDuration,
|
|
formatSigned,
|
|
parseRangeMidpoint,
|
|
} from "/shared/time.js";
|
|
import { CULTIVARS, findCultivar } from "/shared/reference-data.js";
|
|
import { buildPlanCurve, pointsToPathD, tToX, tempToY } from "/shared/curve.js";
|
|
import { initPrefillPanel } from "./prefill-ui.js";
|
|
import { initAlogPanel } from "./alog-ui.js";
|
|
import { initPrint } from "./print.js";
|
|
|
|
const FIELD_ID_SET = new Set(FIELD_IDS);
|
|
const STORAGE_PREFIX = "roastPlannerPlan.v2";
|
|
let storageKey = null;
|
|
let remotePlanId = null;
|
|
let plans = [];
|
|
let lastDrawerOpener = null;
|
|
let deferredInstallPrompt = null;
|
|
const csrfToken = () =>
|
|
document.cookie
|
|
.split("; ")
|
|
.find((v) => v.startsWith("rp_csrf="))
|
|
?.split("=")[1] || "";
|
|
export const protectedFetch = (url, options = {}) =>
|
|
fetch(url, {
|
|
...options,
|
|
headers: { ...options.headers, "x-csrf-token": csrfToken() },
|
|
});
|
|
const BAND_DOMAIN = [60, 220]; // shared °C domain for every band-track in the Machine Plan section
|
|
|
|
export const state = { plan: blankPlan() };
|
|
|
|
const form = document.getElementById("plan-form");
|
|
|
|
const FIELD_HELP = {
|
|
5.1: "Optional moisture percentage. Find it on a supplier certificate of analysis (COA) or measure it with a calibrated meter. Leave it blank when unknown: it does not automatically change your roast timing.",
|
|
5.2: "Optional green-bean density in g/L. Get it from a supplier COA or measure a known volume. Leave it blank when unknown: it does not automatically change your roast timing.",
|
|
5.6: "A documented timing adjustment in m:ss, normally no more than ±0:15. Use only after an observation or comparison roast; moisture and density never create this value automatically.",
|
|
1.4: "Your first-crack anchor in m:ss. It is the starting point for the Time Ledger; use a cultivar reference or a previous comparable roast.",
|
|
4.3: "Development base in m:ss. Together with the processing and cultivar modifiers it determines development and drop time.",
|
|
};
|
|
function helpText(input) {
|
|
if (FIELD_HELP[input.name]) return FIELD_HELP[input.name];
|
|
const unit = input
|
|
.closest(".unit-input")
|
|
?.querySelector(".unit")?.textContent;
|
|
const label =
|
|
input
|
|
.closest(".field")
|
|
?.querySelector(".field-label")
|
|
?.textContent?.trim() ||
|
|
input.getAttribute("aria-label") ||
|
|
input.placeholder ||
|
|
"This value";
|
|
return `${label} records your plan or roast observation${unit ? ` in ${unit}` : ""}. Use the format shown; leave it blank when you do not know it, then refine it from a supplier record or your next roast.`;
|
|
}
|
|
function wireFieldHelp(root = document) {
|
|
for (const input of root.querySelectorAll(
|
|
"input[name], select[name], textarea[name], #prefill-url",
|
|
)) {
|
|
if (!input.closest("#plan-form") && input.id !== "prefill-url") continue;
|
|
const field = input.closest(".field");
|
|
if (
|
|
input.dataset.helpWired ||
|
|
(input.type === "radio" && field?.dataset.radioHelp === input.name)
|
|
)
|
|
continue;
|
|
input.dataset.helpWired = "true";
|
|
if (input.type === "radio" && field) field.dataset.radioHelp = input.name;
|
|
const id = `field-help-${Math.random().toString(36).slice(2)}`;
|
|
const help = document.createElement("span");
|
|
help.id = id;
|
|
help.className = "field-help-text";
|
|
help.hidden = true;
|
|
help.textContent = helpText(input);
|
|
const button = document.createElement("button");
|
|
button.type = "button";
|
|
button.className = "field-help";
|
|
button.setAttribute("aria-expanded", "false");
|
|
button.setAttribute("aria-controls", id);
|
|
button.setAttribute("aria-label", `Learn about ${input.name}`);
|
|
button.textContent = "?";
|
|
button.addEventListener("click", () => {
|
|
const open = help.hidden;
|
|
help.hidden = !open;
|
|
button.setAttribute("aria-expanded", String(open));
|
|
});
|
|
const milestone = input
|
|
.closest(".milestone-row")
|
|
?.querySelector(".milestone-label")
|
|
?.textContent?.trim();
|
|
const fieldLabel = field
|
|
?.querySelector(".field-label")
|
|
?.textContent?.trim();
|
|
input.setAttribute(
|
|
"aria-label",
|
|
input.getAttribute("aria-label") ||
|
|
milestone ||
|
|
fieldLabel ||
|
|
input.placeholder ||
|
|
"Plan value",
|
|
);
|
|
input.setAttribute(
|
|
"aria-describedby",
|
|
[input.getAttribute("aria-describedby"), id].filter(Boolean).join(" "),
|
|
);
|
|
const host = field || input.closest(".unit-input") || input;
|
|
let wrapper = host?.parentElement?.classList.contains("field-help-host")
|
|
? host.parentElement
|
|
: null;
|
|
if (!wrapper && host) {
|
|
wrapper = document.createElement("div");
|
|
wrapper.className = "field-help-host";
|
|
host.replaceWith(wrapper);
|
|
wrapper.append(host);
|
|
}
|
|
// Labels cannot contain another interactive control, so the help button is a sibling.
|
|
wrapper?.append(button, help);
|
|
}
|
|
}
|
|
|
|
// 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();
|
|
wireFieldHelp(document.getElementById("blend-cards"));
|
|
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();
|
|
wireFieldHelp(document.getElementById("actuator-timeline"));
|
|
}
|
|
|
|
// ---- 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 = value ?? "";
|
|
}
|
|
}
|
|
updateBlendVisibility(state.plan.fields["2.1"]);
|
|
document.getElementById("header-coffee-name").textContent =
|
|
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);
|
|
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("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");
|
|
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 =
|
|
state.plan.fields["0.1"] || "";
|
|
document.getElementById("header-coffee-name").textContent =
|
|
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 v = Number.parseFloat(input.value);
|
|
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,
|
|
);
|
|
paintCurveInto(
|
|
document.getElementById("plan-curve-live"),
|
|
document.getElementById("ref-curve-live"),
|
|
planPoints,
|
|
ref,
|
|
);
|
|
}
|
|
|
|
export function recompute() {
|
|
const ledger = renderLedger();
|
|
renderCurve(ledger);
|
|
renderBandMarkers();
|
|
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();
|
|
function syncPlan(snapshot = structuredClone(state.plan)) {
|
|
if (!navigator.onLine || !csrfToken()) {
|
|
setSaveStatus("local");
|
|
return Promise.resolve();
|
|
}
|
|
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;
|
|
if (storageKey)
|
|
localStorage.setItem(
|
|
storageKey,
|
|
JSON.stringify({ plan: snapshot, remotePlanId }),
|
|
);
|
|
history.replaceState(
|
|
null,
|
|
"",
|
|
`/app?plan=${encodeURIComponent(remotePlanId)}`,
|
|
);
|
|
setSaveStatus("saved");
|
|
await loadPlans();
|
|
} catch {
|
|
setSaveStatus("failed");
|
|
}
|
|
});
|
|
return syncQueue;
|
|
}
|
|
async function flushCurrentPlan() {
|
|
clearTimeout(autosave._t);
|
|
const snapshot = structuredClone(state.plan);
|
|
try {
|
|
if (storageKey)
|
|
localStorage.setItem(
|
|
storageKey,
|
|
JSON.stringify({ plan: snapshot, remotePlanId }),
|
|
);
|
|
} catch {
|
|
setSaveStatus("failed");
|
|
return false;
|
|
}
|
|
await syncPlan(snapshot);
|
|
return true;
|
|
}
|
|
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);
|
|
// A shared browser must never retain a previous account's local-only draft.
|
|
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"
|
|
)
|
|
localStorage.removeItem(key);
|
|
}
|
|
const raw = localStorage.getItem(storageKey);
|
|
if (!raw) return null;
|
|
const draft = JSON.parse(raw);
|
|
if (draft?.plan) {
|
|
remotePlanId = draft.remotePlanId || 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`);
|
|
} catch {
|
|
/* unavailable storage */
|
|
}
|
|
storageKey = null;
|
|
remotePlanId = 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();
|
|
}
|
|
|
|
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, el.value);
|
|
if (el.name === "1.1") cultivarAutofill(el.value);
|
|
if (el.name === "2.1") updateBlendVisibility(el.value);
|
|
if (el.name.startsWith("blendComponents.")) updateBlendTotal();
|
|
recompute();
|
|
});
|
|
}
|
|
|
|
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 [
|
|
["btn-toggle-prefill", "panel-prefill"],
|
|
["btn-toggle-alog", "panel-alog"],
|
|
["btn-plans", "panel-plans"],
|
|
["btn-settings", "panel-settings"],
|
|
])
|
|
document
|
|
.getElementById(buttonId)
|
|
.addEventListener("click", (event) =>
|
|
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 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");
|
|
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 = plan.plan?.fields?.["0.1"] || "Untitled plan";
|
|
const updated = document.createElement("span");
|
|
updated.textContent = `Updated ${new Date(plan.updated_at).toLocaleDateString()}`;
|
|
button.append(title, updated);
|
|
button.addEventListener("click", () => selectPlan(plan));
|
|
item.append(button);
|
|
return item;
|
|
}),
|
|
);
|
|
}
|
|
async function selectPlan(plan) {
|
|
if (!(await flushCurrentPlan())) return;
|
|
remotePlanId = plan.id;
|
|
state.plan = { ...blankPlan(), ...plan.plan };
|
|
history.replaceState(
|
|
null,
|
|
"",
|
|
`/app?plan=${encodeURIComponent(remotePlanId)}`,
|
|
);
|
|
renderBlend();
|
|
renderActuators();
|
|
renderFormFromPlan();
|
|
recompute();
|
|
document.querySelector("[data-close-drawer]")?.click();
|
|
}
|
|
async function newPlan() {
|
|
if (!confirm("Start a new plan? Your current plan is already saved locally."))
|
|
return;
|
|
if (!(await flushCurrentPlan())) return;
|
|
remotePlanId = null;
|
|
state.plan = blankPlan();
|
|
history.replaceState(null, "", "/app");
|
|
renderBlend();
|
|
renderActuators();
|
|
renderFormFromPlan();
|
|
recompute();
|
|
}
|
|
|
|
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) {
|
|
window.addEventListener("load", () => {
|
|
navigator.serviceWorker
|
|
.register("/sw.js")
|
|
.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", () =>
|
|
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() {
|
|
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;
|
|
document.getElementById("account-summary").textContent =
|
|
user.email.split("@")[0];
|
|
if (user.role === "admin")
|
|
document.getElementById("menu-admin").classList.remove("hidden");
|
|
const localDraft = loadFromStorage(user.id);
|
|
state.plan = localDraft ?? blankPlan();
|
|
await loadPlans();
|
|
const requestedId = new URLSearchParams(location.search).get("plan");
|
|
const selected = plans.find((plan) => plan.id === requestedId);
|
|
if (selected) {
|
|
state.plan = selected.plan;
|
|
remotePlanId = selected.id;
|
|
} else if (!localDraft && !requestedId && plans[0]) {
|
|
state.plan = plans[0].plan;
|
|
remotePlanId = plans[0].id;
|
|
}
|
|
} 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();
|
|
wireFieldHelp();
|
|
renderBandRanges();
|
|
renderFormFromPlan();
|
|
wireForm();
|
|
wireDrawers();
|
|
wireToolbar();
|
|
wirePwa();
|
|
wireSectionNav();
|
|
initPrefillPanel({
|
|
state,
|
|
renderBlendRows: renderBlend,
|
|
renderActuatorRows: renderActuators,
|
|
renderFormFromPlan,
|
|
recompute,
|
|
});
|
|
initAlogPanel({ state, recompute });
|
|
initPrint({ beforePrint: renderFormFromPlan });
|
|
recompute();
|
|
}
|
|
|
|
init();
|