import { api, protectedFetch } from "./api.js?v=__ASSET_VERSION__"; import { showToast } from "./toast.js?v=__ASSET_VERSION__"; let lots = []; function findLot(id) { return lots.find((l) => l.id === id); } // Matches the phrasing the app's own symptom-fix reference table uses ("First crack +0:30", // "First crack −0:30") — only a note in exactly that shape is safe to offer as a one-click ± Refine // value. Free text like "Re-weight the blend" or "Smaller batch" isn't a first-crack correction at // all, so it's shown for context but never auto-applied. The sign class includes the U+2212 minus // sign the app's own SYMPTOM_FIXES table is written with (see shared/reference-data.js) — an // ASCII-only class would silently refuse to recognize every slow-down correction it suggests. const FC_REFINE_RE = /first crack\s*([+‒–—−-]\d{1,2}:\d{2})/i; // The captured sign may be a non-ASCII dash; normalize to ASCII "-" before it's stored into // field 1.6, matching the canonical form parseDuration/formatDuration use everywhere else. const normalizeSign = (value) => value.replace(/^[+‒–—−]/, (c) => (c === "+" ? "+" : "-")); /** Wires the "From inventory lot" picker (in The Coffee) and the "Draw from lot" action * (in Roast Log — Plan vs. Actual). `getRemotePlanId` is a function since the planner's * remotePlanId is module-local and can change after a sync. */ export function initLotPicker({ state, recompute, flushCurrentPlan, getRemotePlanId, renderFormFromPlan, }) { const select = document.getElementById("lot-select"); const note = document.getElementById("lot-picker-note"); const consumeRow = document.getElementById("inventory-consume"); const consumeLabel = document.getElementById("inventory-consume-label"); const consumeBtn = document.getElementById("btn-consume"); const refineBox = document.getElementById("refine-suggestion"); const refineText = document.getElementById("refine-suggestion-text"); const refineApplyBtn = document.getElementById("btn-apply-refine"); function renderOptions() { const current = state.plan.inventory.lotId; select.replaceChildren( Object.assign(document.createElement("option"), { value: "", textContent: "— none —" }), ...lots .filter((l) => !l.archived) .map((l) => Object.assign(document.createElement("option"), { value: l.id, textContent: `${l.name || l.origin}${l.variety ? ` — ${l.variety}` : ""} (${Math.round(l.remainingWeightG)} g remaining)`, }), ), ); if (current && !findLot(current)) { // The plan references a lot that's now archived/deleted — keep it selectable so the // stored value still displays instead of silently reverting to "— none —". select.append( Object.assign(document.createElement("option"), { value: current, textContent: state.plan.inventory.lotLabel || "Unavailable lot", disabled: true, }), ); } select.value = current || ""; } async function loadLots() { try { const body = await api("/api/inventory"); lots = body.lots; } catch { lots = []; } renderOptions(); updateConsumeRow(); loadLastRefine(state.plan.inventory.lotId); } function updateConsumeRow() { const lotId = state.plan.inventory.lotId; if (!lotId) { consumeRow.classList.add("hidden"); return; } consumeRow.classList.remove("hidden"); const consumed = state.plan.inventory.consumed; if (consumed) { consumeLabel.textContent = `✓ ${Math.round(consumed.weightG)} g drawn from ${consumed.lotLabel} · ${new Date(consumed.atIso).toLocaleDateString()}`; consumeBtn.classList.add("hidden"); return; } consumeBtn.classList.remove("hidden"); const weightG = Number(state.plan.fields["0.4"]); const lot = findLot(lotId); const lotLabel = lot ? (lot.name || lot.origin) : state.plan.inventory.lotLabel || "this lot"; if (Number.isFinite(weightG) && weightG > 0) { consumeLabel.textContent = `Charging will draw ${weightG} g from ${lotLabel}.`; consumeBtn.disabled = false; consumeBtn.removeAttribute("title"); } else { consumeLabel.textContent = `Set "Green in" (field 0.4) to draw weight from ${lotLabel}.`; consumeBtn.disabled = true; consumeBtn.title = "Enter a Green in weight on The Coffee first"; } } // The auto-refine-carry-forward feature: closes the loop between cupping's "One change next // batch" and this plan's ± Refine (1.6) instead of leaving the user to retype their own past // conclusion. Never applied silently — only offered, and only when it can be parsed as an // unambiguous first-crack correction (see FC_REFINE_RE above). async function loadLastRefine(lotId) { if (!lotId) { refineBox.classList.add("hidden"); return; } let refine = null; try { refine = (await api(`/api/inventory/${lotId}/last-refine`)).refine; } catch { refine = null; } if (!refine?.oneChange) { refineBox.classList.add("hidden"); return; } refineBox.classList.remove("hidden"); refineText.textContent = `Last time on this lot: "${refine.oneChange}"`; const match = refine.oneChange.match(FC_REFINE_RE); const value = match ? normalizeSign(match[1]) : null; const currentRefine = (state.plan.fields["1.6"] || "").trim(); const isDefault = currentRefine === "" || currentRefine === "0"; if (value && isDefault) { refineApplyBtn.classList.remove("hidden"); refineApplyBtn.textContent = `Apply ${value} to ± Refine`; refineApplyBtn.onclick = () => { state.plan.fields["1.6"] = value; renderFormFromPlan?.(); recompute(); refineApplyBtn.classList.add("hidden"); showToast(`± Refine set to ${value}.`); }; } else { refineApplyBtn.classList.add("hidden"); } } select.addEventListener("change", () => { const lotId = select.value; state.plan.inventory.lotId = lotId; const lot = findLot(lotId); const displayName = lot ? lot.name || lot.origin : ""; state.plan.inventory.lotLabel = lot ? `${displayName}${lot.variety ? ` — ${lot.variety}` : ""}` : ""; if (lot) { note.textContent = `${Math.round(lot.remainingWeightG)} g remaining in this lot.`; const nameInput = document.querySelector('[name="0.1"]'); if (nameInput && !nameInput.value) { nameInput.value = displayName; state.plan.fields["0.1"] = displayName; } } else { note.textContent = "Optional — link a lot and the Roast Log can draw the green weight down when you charge."; } updateConsumeRow(); loadLastRefine(lotId); recompute(); }); consumeBtn.addEventListener("click", async () => { consumeBtn.disabled = true; try { if (!(await flushCurrentPlan())) { showToast("Could not sync the plan. Try again.", "fail"); return; } const roastPlanId = getRemotePlanId(); if (!roastPlanId) { showToast("Sync the plan first, then draw from the lot.", "fail"); return; } const weightG = Number(state.plan.fields["0.4"]); const response = await protectedFetch( `/api/inventory/${state.plan.inventory.lotId}/consume`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ weightG, roastPlanId }), }, ); const body = await response.json().catch(() => ({})); if (!response.ok && body.code !== "already_consumed") { showToast(body.error || "Could not draw from the lot.", "fail"); return; } state.plan.inventory.consumed = { lotId: state.plan.inventory.lotId, lotLabel: state.plan.inventory.lotLabel, weightG, atIso: new Date().toISOString(), }; updateConsumeRow(); recompute(); showToast(`${weightG} g drawn from lot.`); await loadLots(); } finally { consumeBtn.disabled = false; } }); loadLots(); return { updateConsumeRow, renderOptions, refreshRefineSuggestion: () => loadLastRefine(state.plan.inventory.lotId), }; }