Rebuild printable roast plans and remove machine-based adjustments
Test and deploy / test-and-deploy (push) Successful in 2m28s

This commit is contained in:
snowspeeder committed 2026-09-05 14:56:11 -04:00
1 parent e9f2393c96
commit 824a964f9d
30 files changed
+1721 -908

No files matched your search

+15 -81
View File
@@ -11,9 +11,10 @@ import {
findProcess,
findRoastLevel,
} from "/shared/reference-data.js?v=__ASSET_VERSION__";
import { buildPlanCurve, pointsToPathD, tToX, tempToY } from "/shared/curve.js?v=__ASSET_VERSION__";
import { buildPlanCurve, pointsToPathD, tToX, tempToY, curveWarnings } from "/shared/curve.js?v=__ASSET_VERSION__";
import { initPrefillPanel } from "./prefill-ui.js?v=__ASSET_VERSION__";
import { initAlogPanel } from "./alog-ui.js?v=__ASSET_VERSION__";
import { renderReportCurve } from "/shared/report.js?v=__ASSET_VERSION__";
import { initPrint } from "./print.js?v=__ASSET_VERSION__";
import { initLotPicker } from "./lot-picker.js?v=__ASSET_VERSION__";
import { api, protectedFetch, csrfToken } from "./api.js?v=__ASSET_VERSION__";
@@ -37,73 +38,10 @@ 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;
// Learned roaster behavior from uploaded .alogs (server-aggregated) — used as the plan
// curve's fallback milestone temps so the suggested curve matches this user's machine.
let roasterProfile = null;
async function loadRoasterProfile() {
try {
const response = await fetch("/api/roaster-profile");
if (response.ok) roasterProfile = (await response.json()).profile;
// A manual pace tweak on the default machine (Machines page) beats the plan-log
// learned pace in the ledger.
if (Number.isFinite(roasterProfile?.paceFactorOverride))
machineProfile = {
...(machineProfile ?? {}),
pace: {
value: roasterProfile.paceFactorOverride,
source: "override",
roasterName: roasterProfile.roaster?.name ?? null,
},
};
} catch {
/* offline — curve falls back to reference machine temps */
}
}
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) */
}
}
const machineProfile = null;
function renderMachineProfileNote() {
const el = document.getElementById("machine-profile-note");
if (!el) return;
const pace = machineProfile?.pace;
if (pace?.source === "override") {
el.textContent = `Pace ×${pace.value.toFixed(2)} — your manual tweak${pace.roasterName ? ` for ${pace.roasterName}` : ""} (Machines page).`;
return;
}
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}.`;
document.getElementById("machine-profile-note").textContent = "Plans use your inputs. Roaster selection and roast history do not change timing or temperature suggestions.";
}
// Temperature fields are always stored in state.plan as canonical °C — this is purely a
@@ -111,7 +49,7 @@ function renderMachineProfileNote() {
// the actuator schedule respectively; every other numeric field (%, g, days) is untouched.
const TEMP_UNIT_KEY = "roastPlannerTempUnit.v1";
const TEMP_FIELD_RE = /\.(tempC|actualBt|expectedBt)$/;
let tempUnit = localStorage.getItem(TEMP_UNIT_KEY) === "F" ? "F" : "C";
let tempUnit = localStorage.getItem(TEMP_UNIT_KEY) === "C" ? "C" : "F";
const cToF = (c) => (c * 9) / 5 + 32;
const fToC = (f) => ((f - 32) * 5) / 9;
function isTempField(name) {
@@ -357,6 +295,7 @@ function renderLedger() {
fmtOut("check-dtr", pct(ledger.checks.dtr.pct));
fmtOut("check-ceiling", d(ledger.checks.ceiling.valueS));
ledger.warnings.push(...curveWarnings(buildPlanCurve(state.plan, ledger)));
const warnings = document.getElementById("ledger-warnings");
warnings.replaceChildren(
...ledger.warnings.map((warning) => {
@@ -495,7 +434,7 @@ function paintCurveInto(planGroup, refGroup, planPoints, ref) {
}
function renderCurve(ledger) {
const planPoints = buildPlanCurve(state.plan, ledger, roasterProfile);
const planPoints = buildPlanCurve(state.plan, ledger);
const ref = state.plan.reference;
paintCurveInto(
@@ -504,12 +443,10 @@ function renderCurve(ledger) {
planPoints,
ref,
);
paintCurveInto(
document.getElementById("plan-curve-live"),
document.getElementById("ref-curve-live"),
planPoints,
ref,
);
const live = document.getElementById("curve-svg-live");
live.setAttribute("viewBox", "0 0 900 440");
live.innerHTML = renderReportCurve(planPoints, ledger, ref).replace(/^<svg[^>]*>/, "").replace(/<\/svg>$/, "");
}
// The blank shape has some non-empty defaults of its own (e.g. planActual.charge.actualTime
@@ -750,7 +687,7 @@ function updateTempUnitUI() {
const conv = (c) => Math.round(tempUnit === "F" ? cToF(c) : c);
for (const el of document.querySelectorAll("[data-tempc]"))
el.textContent = String(conv(Number(el.dataset.tempc)));
for (const el of document.querySelectorAll("[data-band-lo]")) {
for (const el of document.querySelectorAll("td[data-band-lo], text[data-band-lo]")) {
const lo = conv(Number(el.dataset.bandLo));
const hi = conv(Number(el.dataset.bandHi));
el.textContent = `${el.dataset.bandPrefix ?? ""}${lo}–${hi} ${label}${el.dataset.bandSuffix ?? ""}`;
@@ -1177,14 +1114,12 @@ async function init() {
applyAvatar(document.getElementById("nav-user-avatar"), user);
if (user.role === "admin")
document.getElementById("nav-admin")?.classList.remove("hidden");
document.getElementById("alog-library-refresh").hidden = user.role !== "admin";
const localDraft = loadFromStorage(user.id);
state.plan = localDraft ?? blankPlan();
const draftRemoteId = remotePlanId;
const draftSyncedAtAtLoad = draftSyncedAt;
// Roaster profile loads after the machine profile on purpose: its pace override (if
// any) must be applied on top of the freshly loaded machine profile, not raced with it.
await Promise.all([loadPlans(), loadMachineProfile(user.id)]);
await loadRoasterProfile();
await loadPlans();
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
@@ -1213,7 +1148,6 @@ async function init() {
const offlineUserId = localStorage.getItem(`${STORAGE_PREFIX}:last-user`);
if (offlineUserId && csrfToken()) {
state.plan = loadFromStorage(offlineUserId) ?? blankPlan();
await loadMachineProfile(offlineUserId);
}
} catch {
/* no local draft is available */
@@ -1251,7 +1185,7 @@ async function init() {
getRemotePlanId: () => remotePlanId,
flushCurrentPlan,
});
initPrint({ beforePrint: renderFormFromPlan });
initPrint({ getPlan: () => state.plan, getName: () => currentPlanName });
lotPicker = initLotPicker({
state,
recompute,