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(`
`),
);
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(`
`),
);
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(`