Add per-user learned machine profile; fix methodology honesty issues
Test and deploy / test-and-deploy (push) Successful in 1m26s
Test and deploy / test-and-deploy (push) Successful in 1m26s
Replaces the flat additive batch-size correction with a per-user multiplicative pace factor learned from each account's own logged roasts (shared/learn.js, GET /api/machine-profile), plus a lot-scoped "last refine" auto-suggestion. Also fixes the reference data being framed as "your own roasts" on a now-multi-user product, and reclassifies the drying/Maillard sanity checks as informational since they're algebraically derived from the DTR check rather than independent (yellow = 0.56 x first crack, not entered separately). Bug fixes found by an adversarial Opus review of the first pass: - Unicode minus sign (U+2212) broke duration parsing against the app's own generated refine-suggestion text - Printed sanity-checks table still showed a bare pass/fail glyph for the now-informational drying/Maillard rows - Printed time-ledger box didn't show the pace multiplication step, so it stopped reconciling by hand once pace != 1 - Field 6.4 (manual batch correction) was double-counted: excluded from the learned-pace fit but added back after the multiplication - Learned pace had no outlier rejection or hard clamp - computeLedger had no test coverage - Batch-size help copy overstated what the pace factor models (it's a single blanket ratio, not conditioned on batch weight) A follow-up Opus pass also caught the per-user profile cache surviving logout/account-switch in a shared browser; fixed by sweeping it alongside the existing plan-draft cleanup. Co-Authored-By: Claude Sonnet 5 <[email protected]>
This commit is contained in:
+68
-2
@@ -7,15 +7,35 @@ 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 }) {
|
||||
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;
|
||||
@@ -53,6 +73,7 @@ export function initLotPicker({ state, recompute, flushCurrentPlan, getRemotePla
|
||||
}
|
||||
renderOptions();
|
||||
updateConsumeRow();
|
||||
loadLastRefine(state.plan.inventory.lotId);
|
||||
}
|
||||
|
||||
function updateConsumeRow() {
|
||||
@@ -83,6 +104,46 @@ export function initLotPicker({ state, recompute, flushCurrentPlan, getRemotePla
|
||||
}
|
||||
}
|
||||
|
||||
// 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;
|
||||
@@ -102,6 +163,7 @@ export function initLotPicker({ state, recompute, flushCurrentPlan, getRemotePla
|
||||
"Optional — link a lot and the Roast Log can draw the green weight down when you charge.";
|
||||
}
|
||||
updateConsumeRow();
|
||||
loadLastRefine(lotId);
|
||||
recompute();
|
||||
});
|
||||
|
||||
@@ -147,5 +209,9 @@ export function initLotPicker({ state, recompute, flushCurrentPlan, getRemotePla
|
||||
});
|
||||
|
||||
loadLots();
|
||||
return { updateConsumeRow, renderOptions };
|
||||
return {
|
||||
updateConsumeRow,
|
||||
renderOptions,
|
||||
refreshRefineSuggestion: () => loadLastRefine(state.plan.inventory.lotId),
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user