Add per-user learned machine profile; fix methodology honesty issues
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:
2026-07-31 12:40:03 -04:00
co-authored by Claude Sonnet 5
parent 0a0356b86e
commit 2237c199c1
14 changed files with 999 additions and 83 deletions
+68 -2
View File
@@ -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),
};
}