Files
roast_command_center/public/js/lot-picker.js
T
2026-08-09 14:30:42 -04:00

219 lines
7.7 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { 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),
};
}