From 306d256832a48f6d75f567f99cdd687bb5985550 Mon Sep 17 00:00:00 2001
From: Shane Maynard
Date: Thu, 30 Jul 2026 16:51:01 -0400
Subject: [PATCH] =?UTF-8?q?Add=20a=20=C2=B0C/=C2=B0F=20display=20toggle=20?=
=?UTF-8?q?for=20Machine=20Plan=20and=20Roast=20Log=20temps?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Plans are always stored in canonical °C; the unit is a client-side
display preference (localStorage) that converts Machine Plan planned
temps, Roast Log actual temps, and actuator expected BT on the fly,
including band-marker positioning. Toggle lives in Settings & sync.
---
public/index.html | 26 +++++++++++++++++-
public/js/main.js | 69 ++++++++++++++++++++++++++++++++++++++++++++---
2 files changed, 91 insertions(+), 4 deletions(-)
diff --git a/public/index.html b/public/index.html
index 3ab28f3..74d7bff 100644
--- a/public/index.html
+++ b/public/index.html
@@ -310,6 +310,30 @@
saved locally first, then synced to your account whenever you are
online.
+
+ Temperature unit
+
+
+
+
+
+ Changes how Machine Plan and Roast Log temperatures are shown.
+ Plans are always stored in °C, so switching back is lossless.
+
+
Install Roast Planner from your browser menu for a focused workspace.
Updates wait for your confirmation so an in-progress plan is never
@@ -1979,7 +2003,7 @@
Milestone
Time
-
°C
+
°C
Your logged band
diff --git a/public/js/main.js b/public/js/main.js
index 670861c..e5c16d4 100644
--- a/public/js/main.js
+++ b/public/js/main.js
@@ -26,6 +26,30 @@ let deferredInstallPrompt = null;
let lotPicker = null;
const BAND_DOMAIN = [60, 220]; // shared °C domain for every band-track in the Machine Plan section
+// 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.
+const TEMP_UNIT_KEY = "roastPlannerTempUnit.v1";
+const TEMP_FIELD_RE = /\.(tempC|actualBt|expectedBt)$/;
+let tempUnit = localStorage.getItem(TEMP_UNIT_KEY) === "F" ? "F" : "C";
+const cToF = (c) => (c * 9) / 5 + 32;
+const fToC = (f) => ((f - 32) * 5) / 9;
+function isTempField(name) {
+ return TEMP_FIELD_RE.test(name);
+}
+// Canonical °C string -> what the field should display right now.
+function tempToDisplay(canonical) {
+ const n = Number.parseFloat(canonical);
+ if (!Number.isFinite(n)) return canonical ?? "";
+ return String(Math.round(tempUnit === "F" ? cToF(n) : n));
+}
+// What the user typed (in the current display unit) -> canonical °C string.
+function tempToCanonical(display) {
+ const n = Number.parseFloat(display);
+ if (!Number.isFinite(n)) return display;
+ return String(Math.round((tempUnit === "F" ? fToC(n) : n) * 10) / 10);
+}
+
export const state = { plan: blankPlan() };
const form = document.getElementById("plan-form");
@@ -201,6 +225,7 @@ function renderActuatorTimeline() {
function renderActuators() {
renderActuatorPrintRows();
renderActuatorTimeline();
+ updateTempUnitUI(); // new actuator rows need their expectedBt placeholder set too
}
// ---- populate every control in the form from state.plan
@@ -214,7 +239,7 @@ export function renderFormFromPlan() {
} else if (el.type === "checkbox") {
el.checked = Boolean(value);
} else {
- el.value = value ?? "";
+ el.value = isTempField(name) ? tempToDisplay(value) : (value ?? "");
}
}
updateBlendVisibility(state.plan.fields["2.1"]);
@@ -310,7 +335,8 @@ function renderBandMarkers() {
const input = row.querySelector('input[name$=".tempC"]');
const marker = row.querySelector(".band-marker");
if (!input || !marker) continue;
- const v = Number.parseFloat(input.value);
+ const raw = Number.parseFloat(input.value);
+ const v = Number.isFinite(raw) ? (tempUnit === "F" ? fToC(raw) : raw) : NaN;
if (!Number.isFinite(v)) {
marker.style.display = "none";
continue;
@@ -587,7 +613,10 @@ function wireForm() {
const el = e.target;
if (!el.name) return;
if (el.type === "radio" && !el.checked) return;
- setValueForName(el.name, el.value);
+ setValueForName(
+ el.name,
+ isTempField(el.name) ? tempToCanonical(el.value) : el.value,
+ );
if (el.name === "1.1") cultivarAutofill(el.value);
if (el.name === "2.1") updateBlendVisibility(el.value);
if (el.name.startsWith("blendComponents.")) updateBlendTotal();
@@ -595,6 +624,38 @@ function wireForm() {
});
}
+// Refreshes every unit-facing bit of chrome (labels, placeholders, the toggle itself) after
+// tempUnit changes. Field *values* are handled separately by renderFormFromPlan().
+function updateTempUnitUI() {
+ const label = tempUnit === "F" ? "°F" : "°C";
+ for (const input of document.querySelectorAll(
+ 'input[name$=".tempC"], input[name$=".actualBt"], input[name$=".expectedBt"]',
+ ))
+ input.placeholder = label;
+ for (const span of document.querySelectorAll("#sec-machine .unit-input .unit"))
+ span.textContent = label;
+ const printHeader = document.getElementById("print-machine-temp-th");
+ if (printHeader) printHeader.textContent = label;
+ for (const input of document.querySelectorAll('#temp-unit-toggle input[name="tempUnit"]'))
+ input.checked = input.value === tempUnit;
+}
+
+function setTempUnit(unit) {
+ tempUnit = unit === "F" ? "F" : "C";
+ localStorage.setItem(TEMP_UNIT_KEY, tempUnit);
+ updateTempUnitUI();
+ renderFormFromPlan();
+ recompute();
+}
+
+function wireTempUnitToggle() {
+ const toggle = document.getElementById("temp-unit-toggle");
+ if (!toggle) return;
+ toggle.addEventListener("change", (e) => {
+ if (e.target.name === "tempUnit") setTempUnit(e.target.value);
+ });
+}
+
function wireDrawers() {
const overlay = document.getElementById("drawer-overlay");
const drawers = [...document.querySelectorAll(".drawer")];
@@ -941,6 +1002,8 @@ async function init() {
initSideNav();
wireDrawers();
wireToolbar();
+ wireTempUnitToggle();
+ updateTempUnitUI();
wirePwa();
wireSectionNav();
initPrefillPanel({