Test and deploy / test-and-deploy (push) Successful in 59s
Co-Authored-By: Claude Fable 5 <[email protected]>
219 lines
7.7 KiB
JavaScript
219 lines
7.7 KiB
JavaScript
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),
|
||
};
|
||
}
|