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:
+74
-10
@@ -32,6 +32,48 @@ let deferredInstallPrompt = null;
|
||||
let lotPicker = null;
|
||||
const BAND_DOMAIN = [60, 220]; // shared °C domain for every band-track in the Machine Plan section
|
||||
|
||||
// Learned per-user machine profile (pace factor + temperature bands) from shared/learn.js — see
|
||||
// its header comment. Cached to localStorage per user so it's available offline, same pattern as
|
||||
// the plan draft cache below.
|
||||
const MACHINE_PROFILE_KEY_PREFIX = "roastPlannerMachineProfile.v1";
|
||||
let machineProfile = null;
|
||||
|
||||
async function loadMachineProfile(userId) {
|
||||
const cacheKey = `${MACHINE_PROFILE_KEY_PREFIX}:${userId}`;
|
||||
try {
|
||||
const response = await fetch("/api/machine-profile");
|
||||
if (response.ok) {
|
||||
machineProfile = (await response.json()).profile;
|
||||
localStorage.setItem(cacheKey, JSON.stringify(machineProfile));
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
/* offline — fall through to the cached profile, if any */
|
||||
}
|
||||
try {
|
||||
const cached = localStorage.getItem(cacheKey);
|
||||
if (cached) machineProfile = JSON.parse(cached);
|
||||
} catch {
|
||||
/* no cached profile available; ledger falls back to pace 1.0 (reference) */
|
||||
}
|
||||
}
|
||||
|
||||
function renderMachineProfileNote() {
|
||||
const el = document.getElementById("machine-profile-note");
|
||||
if (!el) return;
|
||||
const pace = machineProfile?.pace;
|
||||
if (!pace || pace.source !== "learned") {
|
||||
el.textContent =
|
||||
"Using reference timing (one operator's Hottop KN-8828B-2K+) — recalibrates to your own machine as you log roasts with an actual first-crack time.";
|
||||
return;
|
||||
}
|
||||
const paceText =
|
||||
Math.abs(pace.value - 1) < 0.01
|
||||
? "running close to reference pace"
|
||||
: `running ${pace.value > 1 ? `${Math.round((pace.value - 1) * 100)}% slower` : `${Math.round((1 - pace.value) * 100)}% faster`} than reference`;
|
||||
el.textContent = `Learned from ${pace.n} of your own roasts — ${paceText}.`;
|
||||
}
|
||||
|
||||
// Temperature fields are always stored in state.plan as canonical °C — this is purely a
|
||||
// display-layer preference. tempC/actualBt/expectedBt cover Machine Plan, Roast Log, and
|
||||
// the actuator schedule respectively; every other numeric field (%, g, days) is untouched.
|
||||
@@ -259,13 +301,15 @@ function fmtOut(id, text) {
|
||||
}
|
||||
|
||||
function renderLedger() {
|
||||
const ledger = computeLedger(state.plan);
|
||||
const ledger = computeLedger(state.plan, machineProfile);
|
||||
const d = (s) => (s === null || s === undefined ? "—" : formatDuration(s));
|
||||
const ds = (s) => (s === null || s === undefined ? "—" : formatSigned(s));
|
||||
|
||||
fmtOut("l1", d(ledger.lines.l1));
|
||||
fmtOut("l2", ds(ledger.lines.l2));
|
||||
fmtOut("l3", ds(ledger.lines.l3));
|
||||
fmtOut("subtotalA", d(ledger.subtotalA));
|
||||
fmtOut("pace-factor", `×${ledger.pace.toFixed(2)}`);
|
||||
fmtOut("A", d(ledger.A));
|
||||
fmtOut("yellow", d(ledger.yellow));
|
||||
fmtOut("maillard", d(ledger.maillard));
|
||||
@@ -293,10 +337,14 @@ function renderLedger() {
|
||||
|
||||
for (const [key, check] of Object.entries(ledger.checks)) {
|
||||
for (const cell of document.querySelectorAll(`[data-pass="${key}"]`)) {
|
||||
cell.classList.remove("pass", "fail", "unknown");
|
||||
cell.classList.add(
|
||||
check.pass === null ? "unknown" : check.pass ? "pass" : "fail",
|
||||
);
|
||||
cell.classList.remove("pass", "fail", "unknown", "informational");
|
||||
// Drying/Maillard are mathematically derived from DTR (yellow = 0.56 × A), so they can
|
||||
// never fail independently of it — shown as plain figures, not a pass/fail verdict.
|
||||
if (check.informational) cell.classList.add("informational");
|
||||
else
|
||||
cell.classList.add(
|
||||
check.pass === null ? "unknown" : check.pass ? "pass" : "fail",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -556,14 +604,16 @@ function loadFromStorage(userId) {
|
||||
try {
|
||||
storageKey = `${STORAGE_PREFIX}:${userId}`;
|
||||
localStorage.setItem(`${STORAGE_PREFIX}:last-user`, userId);
|
||||
// A shared browser must never retain a previous account's local-only draft.
|
||||
const machineProfileKey = `${MACHINE_PROFILE_KEY_PREFIX}:${userId}`;
|
||||
// A shared browser must never retain a previous account's local-only draft or cache.
|
||||
for (let i = localStorage.length - 1; i >= 0; i--) {
|
||||
const key = localStorage.key(i);
|
||||
if (
|
||||
(key?.startsWith(`${STORAGE_PREFIX}:`) &&
|
||||
key !== storageKey &&
|
||||
key !== `${STORAGE_PREFIX}:last-user`) ||
|
||||
key === "roastPlannerPlan.v1"
|
||||
key === "roastPlannerPlan.v1" ||
|
||||
(key?.startsWith(`${MACHINE_PROFILE_KEY_PREFIX}:`) && key !== machineProfileKey)
|
||||
)
|
||||
localStorage.removeItem(key);
|
||||
}
|
||||
@@ -585,6 +635,10 @@ function clearDraft() {
|
||||
if (storageKey) localStorage.removeItem(storageKey);
|
||||
try {
|
||||
localStorage.removeItem(`${STORAGE_PREFIX}:last-user`);
|
||||
for (let i = localStorage.length - 1; i >= 0; i--) {
|
||||
const key = localStorage.key(i);
|
||||
if (key?.startsWith(`${MACHINE_PROFILE_KEY_PREFIX}:`)) localStorage.removeItem(key);
|
||||
}
|
||||
} catch {
|
||||
/* unavailable storage */
|
||||
}
|
||||
@@ -803,6 +857,7 @@ async function selectPlan(plan) {
|
||||
// plan's inventory.lotId so switching to a plan referencing a different (or no) lot doesn't
|
||||
// leave the dropdown showing the previous plan's selection or a blank state.
|
||||
lotPicker?.renderOptions();
|
||||
lotPicker?.refreshRefineSuggestion();
|
||||
document.querySelector("[data-close-drawer]")?.click();
|
||||
}
|
||||
async function newPlan() {
|
||||
@@ -818,6 +873,7 @@ async function newPlan() {
|
||||
renderFormFromPlan();
|
||||
recompute();
|
||||
lotPicker?.renderOptions();
|
||||
lotPicker?.refreshRefineSuggestion();
|
||||
}
|
||||
|
||||
function wireToolbar() {
|
||||
@@ -997,7 +1053,7 @@ async function init() {
|
||||
state.plan = localDraft ?? blankPlan();
|
||||
const draftRemoteId = remotePlanId;
|
||||
const draftSyncedAtAtLoad = draftSyncedAt;
|
||||
await loadPlans();
|
||||
await Promise.all([loadPlans(), loadMachineProfile(user.id)]);
|
||||
const requestedId = new URLSearchParams(location.search).get("plan");
|
||||
const selected = plans.find((plan) => plan.id === requestedId);
|
||||
// Prefer the local draft only when it targets this exact plan AND was last confirmed
|
||||
@@ -1024,8 +1080,10 @@ async function init() {
|
||||
// last account only until logout, allowing that account's local draft to reopen offline.
|
||||
try {
|
||||
const offlineUserId = localStorage.getItem(`${STORAGE_PREFIX}:last-user`);
|
||||
if (offlineUserId && csrfToken())
|
||||
if (offlineUserId && csrfToken()) {
|
||||
state.plan = loadFromStorage(offlineUserId) ?? blankPlan();
|
||||
await loadMachineProfile(offlineUserId);
|
||||
}
|
||||
} catch {
|
||||
/* no local draft is available */
|
||||
}
|
||||
@@ -1034,7 +1092,11 @@ async function init() {
|
||||
renderActuators();
|
||||
wireCultivarDatalist();
|
||||
wireWhyPanels();
|
||||
initFieldHelp({ getPlan: () => state.plan, getTempUnit: () => tempUnit });
|
||||
initFieldHelp({
|
||||
getPlan: () => state.plan,
|
||||
getTempUnit: () => tempUnit,
|
||||
getMachineProfile: () => machineProfile,
|
||||
});
|
||||
renderBandRanges();
|
||||
renderFormFromPlan();
|
||||
wireForm();
|
||||
@@ -1059,8 +1121,10 @@ async function init() {
|
||||
recompute,
|
||||
flushCurrentPlan,
|
||||
getRemotePlanId: () => remotePlanId,
|
||||
renderFormFromPlan,
|
||||
});
|
||||
wireCuppingLink();
|
||||
renderMachineProfileNote();
|
||||
recompute();
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user