From eda3d8e77f289e70d0a11f8e5c90620b3599cb9a Mon Sep 17 00:00:00 2001 From: Shane Maynard Date: Mon, 7 Sep 2026 07:39:33 -0400 Subject: [PATCH] Add explicit per-plan gauge temperature offset --- public/app.css | 4 ++++ public/js/planner-workspace.js | 14 ++++++++++++++ server/plan-chat.js | 8 ++++++-- shared/curve.js | 13 ++++++++----- shared/fields.js | 1 + shared/report.js | 2 ++ test/bean-bro-context.test.js | 22 ++++++++++++++++++++++ test/report-browser.test.js | 33 +++++++++++++++++++++++++++++++++ 8 files changed, 90 insertions(+), 7 deletions(-) diff --git a/public/app.css b/public/app.css index 73e8537..f8028a5 100644 --- a/public/app.css +++ b/public/app.css @@ -4463,3 +4463,7 @@ select.f { .calendar-toolbar button { padding-left:8px; padding-right:8px; } .calendar-toolbar select { grid-column:1 / -1; width:100%; max-width:none; } } + +.plan-temperature-offset { display:grid; grid-template-columns:minmax(180px,240px) 1fr; gap:16px; align-items:center; margin:16px 0; padding:16px; border:1px solid var(--line); border-radius:10px; background:#faf8f5; } +.plan-temperature-offset .field-note { margin:0; line-height:1.5; } +@media(max-width:700px) { .plan-temperature-offset { grid-template-columns:1fr; gap:10px; } } diff --git a/public/js/planner-workspace.js b/public/js/planner-workspace.js index 99e9f1c..6753cbc 100644 --- a/public/js/planner-workspace.js +++ b/public/js/planner-workspace.js @@ -115,6 +115,20 @@ export function initPlannerWorkspace({ "field-note", ), ); + const calibration = n("div", "", "plan-temperature-offset"); + const offsetLabel = n("label", "", "field"); + offsetLabel.append(n("span", "Gauge temperature offset (°F)", "field-label")); + const offsetInput = n("input", "", "field-input"); + offsetInput.name = "temperatureOffsetF"; + offsetInput.type = "number"; + offsetInput.step = "any"; + offsetInput.placeholder = "0"; + offsetInput.setAttribute("aria-describedby", "temperature-offset-help"); + offsetLabel.append(offsetInput); + const offsetHelp = n("p", "Gauge reads 50°F lower? Enter −50. Shifts blank event-temperature references; drop follows the adjusted milestones. Entered temperatures, preheat and recorded roasts stay as entered.", "field-note"); + offsetHelp.id = "temperature-offset-help"; + calibration.append(offsetLabel, offsetHelp); + curve.append(calibration); const overview = n("section", "", "plan-overview"); overview.append(n("span", "YOUR NEXT BATCH", "eyebrow")); const stats = n("div", "", "overview-stats"); diff --git a/server/plan-chat.js b/server/plan-chat.js index 2633bcb..20c9950 100644 --- a/server/plan-chat.js +++ b/server/plan-chat.js @@ -10,7 +10,7 @@ import { } from "@earendil-works/pi-coding-agent"; import { getModelRuntime, noModelError, pickModel } from "./llm.js"; import { computeLedger } from "../shared/ledger.js"; -import { buildPlanCurve, curveWarnings } from "../shared/curve.js"; +import { buildPlanCurve, curveWarnings, planTemperatureOffsetF } from "../shared/curve.js"; import { formatDuration } from "../shared/time.js"; const SYSTEM_PROMPT = `You are an experienced specialty-coffee roasting coach embedded in a roast-planning app, @@ -23,7 +23,10 @@ because the raw field is blank. When asked about an event temperature, state its value and provenance first, then explain limitations. "illustrative" means a planning reference, not a measured or calibrated prediction; "entered" means a user-entered target, not an actual reading. Use recordedMilestones and tastingEvidence separately for actual roast measurements. Prefer the -current computed context over stale claims in earlier conversation. Use the supplied timingBreakdown: +current computed context over stale claims in earlier conversation. temperatureOffsetF is the user's +explicit per-plan gauge offset. displayedMilestones already includes it: never apply it twice. +It adjusts illustrative references, with drop derived from adjusted milestones; entered targets, +preheat setpoint and actual readings are not offset. Explain it when discussing these temperatures. Use the supplied timingBreakdown: process field 3.2 changes DEVELOPMENT duration, not the first-crack anchor. Never infer worksheet field meanings from their numeric IDs. Answer in Fahrenheit and grams. Never adjust plans based on roaster identity, learned machine pace, or historical machine temperature medians. Distinguish preheat setpoint @@ -86,6 +89,7 @@ export function buildPlanChatContext(plan = {}, evidence = null) { blendComponents: plan.blendComponents ?? [], afterRoast: plan.afterRoast ?? {}, }, + temperatureOffsetF: planTemperatureOffsetF(plan), displayedMilestones: points.map((point) => ({ event: point.key, label: point.label, diff --git a/shared/curve.js b/shared/curve.js index dce8fc2..f768999 100644 --- a/shared/curve.js +++ b/shared/curve.js @@ -9,11 +9,14 @@ const numeric = (v) => ? Number(v) : null; -// No roaster selection, learned median, or override changes these illustrative points. +// An explicit per-plan gauge offset is a temperature difference: no +32 conversion. +// Legacy roaster selection, learned medians, and machine overrides remain ignored. +export const planTemperatureOffsetF = (plan) => numeric(plan?.temperatureOffsetF) ?? 0; export function buildPlanCurve(plan, ledger) { const t = plan.temps ?? {}; - const fc = numeric(t.fc?.tempC) ?? 200; - const yellow = numeric(t.yellow?.tempC) ?? 160; + const offsetC = planTemperatureOffsetF(plan) / 1.8; + const fc = numeric(t.fc?.tempC) ?? (200 + offsetC); + const yellow = numeric(t.yellow?.tempC) ?? (160 + offsetC); const maillardRate = ledger.maillard > 0 ? Math.max(0, (fc - yellow) / ledger.maillard) : 0; // Development's mean rise is lower than Maillard's, even for a short development. @@ -23,13 +26,13 @@ export function buildPlanCurve(plan, ledger) { key: "charge", label: "Charge", timeS: 0, - tempC: numeric(t.charge?.tempC) ?? 200, + tempC: numeric(t.charge?.tempC) ?? (200 + offsetC), }, { key: "tp", label: "Turning point", timeS: t.tp?.time ? parseDuration(t.tp.time) : 60, - tempC: numeric(t.tp?.tempC) ?? 95, + tempC: numeric(t.tp?.tempC) ?? (95 + offsetC), }, { key: "yellow", diff --git a/shared/fields.js b/shared/fields.js index 838bbf0..c3afb35 100644 --- a/shared/fields.js +++ b/shared/fields.js @@ -19,6 +19,7 @@ export function blankPlan() { return { schemaVersion: 1, sourceUrl: "", + temperatureOffsetF: "", fields, blendComponents: [ { cultivar: "", group: "", process: "", sharePct: "", fcAnchor: "" }, diff --git a/shared/report.js b/shared/report.js index 8a1a5fc..8d99e5d 100644 --- a/shared/report.js +++ b/shared/report.js @@ -1,6 +1,7 @@ import { computeLedger } from "./ledger.js?v=__ASSET_VERSION__"; import { buildPlanCurve, + planTemperatureOffsetF, pointsToPathD, curveWarnings, } from "./curve.js?v=__ASSET_VERSION__"; @@ -121,6 +122,7 @@ export function renderReportBody(plan, name = "") {

Coffee & roast intent

Origin: ${e(fields["1.3"] || "—")} · Cultivar: ${e(fields["1.1"] || "—")} · Density: ${e(fields["5.2"] || "—")} g/L

Weight-loss target: ${e(fields["4.2"] || "—")}% · Development: ${time(l.C)} · DTR: ${l.checks.dtr.pct?.toFixed(1) ?? "—"}%

Use this as a starting hypothesis. Light, fruity coffee depends on the green coffee, roast progression and cup results. Machine selection never changes this plan. * Blank temperatures use an illustrative reference curve, not measured or calibrated targets.
${warnings.length ? `
${warnings.map((w) => `

${e(w)}

`).join("")}
` : ""} + ${planTemperatureOffsetF(plan) ? `

Gauge temperature offset: ${planTemperatureOffsetF(plan) > 0 ? "+" : ""}${e(planTemperatureOffsetF(plan))}°F. Applied to illustrative temperature references; drop follows adjusted milestones. Entered targets, preheat and actual readings are unchanged.

` : ""}

Milestone plan

${rows}
MilestoneTimeBT (°F)What to look for

Roast curve

${renderReportCurve(p, l)}

The smooth line interpolates milestones; it is not a predicted sensor trace. Aim for controlled progression after recovery. Phase averages cannot rule out a brief crash or flick.

Roast schedule & record

${e(name || fields["0.1"] || "Roast plan")} · Use at the roaster
diff --git a/test/bean-bro-context.test.js b/test/bean-bro-context.test.js index dacad91..5716f06 100644 --- a/test/bean-bro-context.test.js +++ b/test/bean-bro-context.test.js @@ -34,3 +34,25 @@ test("streaming JSON decoder exposes only reply text and handles split escapes", assert.equal(partialReply('{"reply":"392\\u00'),"392"); assert.equal(partialReply('{"reply":"392\\u00b0F"}'),"392°F"); }); +test("a manual gauge offset shifts illustrative events exactly once and reaches Bean Bro",()=>{ + const p=blankPlan();p.fields["1.4"]="8:45";p.fields["4.4"]="1:20"; + const original=buildPlanCurve(p,computeLedger(p)); + p.temperatureOffsetF="-50"; + const shifted=buildPlanCurve(p,computeLedger(p)); + for(let i=0;ip.event==="fc").tempF,342); + p.temps.fc.tempC="190";p.preheat.tempC="175";p.planActual.fc.actualBt="171.111111"; + const entered=buildPlanChatContext(p); + assert.equal(entered.displayedMilestones.find(p=>p.event==="fc").tempF,374); + assert.equal(entered.preheatSetpointF,347); + assert.equal(entered.recordedMilestones.find(p=>p.event==="fc").actualBtF,340); + p.temperatureOffsetF="";delete p.temps.fc.tempC; + assert.deepEqual(buildPlanCurve(p,computeLedger(p)),original); + p.temperatureOffsetF="25"; + assert.equal(buildPlanChatContext(p).displayedMilestones.find(p=>p.event==="fc").tempF,417); +}); diff --git a/test/report-browser.test.js b/test/report-browser.test.js index 99db3cb..d9a97ff 100644 --- a/test/report-browser.test.js +++ b/test/report-browser.test.js @@ -815,3 +815,36 @@ test("browser: an interrupted Bean Bro stream keeps partial text, restores the q assert.equal(await page.locator("#chat-thread .user").count(),1); assert.deepEqual(errors,[]); }); + +test("browser: a per-plan gauge offset updates the graph, persists, clones and prints",browserOptions,async(t)=>{ + const {page,browser,base,create,csrfToken,errors}=await session(t); + const saved=await create(warningPlan()); + await page.goto(base+"/app?plan="+saved.id); + await page.getByRole("button",{name:"Preview report",exact:true}).waitFor(); + const input=page.getByRole("spinbutton",{name:"Gauge temperature offset (°F)"}); + await input.fill("-50"); + await page.waitForFunction(()=>document.querySelector("#curve-svg-live").textContent.includes("342°F")); + await input.scrollIntoViewIfNeeded(); + await page.screenshot({path:"/tmp/gauge-offset-desktop.png",animations:"disabled"}); + await page.getByRole("link",{name:"← Your plans",exact:true}).click(); + const loaded=(await (await page.request.get(base+"/api/plans/"+saved.id)).json()).plan.plan; + assert.equal(loaded.temperatureOffsetF,"-50"); + const cloned=await (await page.request.post(base+"/api/plans/"+saved.id+"/clone",{headers:{"x-csrf-token":csrfToken}})).json(); + assert.equal(cloned.plan.plan.temperatureOffsetF,"-50"); + await page.setViewportSize({width:390,height:844}); + await page.goto(base+"/app?plan="+saved.id); + await input.waitFor(); + assert.equal(await input.inputValue(),"-50"); + await input.scrollIntoViewIfNeeded(); + await page.screenshot({path:"/tmp/gauge-offset-mobile.png",animations:"disabled"}); + assert.equal(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth),true); + const report=await browser.newPage(); + const html=renderReportHtml(loaded); + assert.match(html,/Gauge temperature offset/);assert.match(html,/-50°F/);assert.match(html,/342°F/); + await report.setContent(html); + const {PDFDocument}=await import("pdf-lib"); + const pdf=await PDFDocument.load(await report.pdf({format:"Letter",preferCSSPageSize:true})); + assert.equal(pdf.getPageCount(),2); + await report.close(); + assert.deepEqual(errors,[]); +});