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({