Fix planner review findings

This commit is contained in:
2026-07-30 08:11:08 -04:00
parent 7416898bec
commit f3d026a109
6 changed files with 392 additions and 114 deletions
+228 -81
View File
@@ -35,23 +35,38 @@ export const state = { plan: blankPlan() };
const form = document.getElementById("plan-form");
const FIELD_HELP = {
"5.1": "Optional moisture percentage. Find it on a supplier certificate of analysis (COA) or measure it with a calibrated meter. Leave it blank when unknown: it does not automatically change your roast timing.",
"5.2": "Optional green-bean density in g/L. Get it from a supplier COA or measure a known volume. Leave it blank when unknown: it does not automatically change your roast timing.",
"5.6": "A documented timing adjustment in m:ss, normally no more than ±0:15. Use only after an observation or comparison roast; moisture and density never create this value automatically.",
"1.4": "Your first-crack anchor in m:ss. It is the starting point for the Time Ledger; use a cultivar reference or a previous comparable roast.",
"4.3": "Development base in m:ss. Together with the processing and cultivar modifiers it determines development and drop time.",
5.1: "Optional moisture percentage. Find it on a supplier certificate of analysis (COA) or measure it with a calibrated meter. Leave it blank when unknown: it does not automatically change your roast timing.",
5.2: "Optional green-bean density in g/L. Get it from a supplier COA or measure a known volume. Leave it blank when unknown: it does not automatically change your roast timing.",
5.6: "A documented timing adjustment in m:ss, normally no more than ±0:15. Use only after an observation or comparison roast; moisture and density never create this value automatically.",
1.4: "Your first-crack anchor in m:ss. It is the starting point for the Time Ledger; use a cultivar reference or a previous comparable roast.",
4.3: "Development base in m:ss. Together with the processing and cultivar modifiers it determines development and drop time.",
};
function helpText(input) {
if (FIELD_HELP[input.name]) return FIELD_HELP[input.name];
const unit = input.closest(".unit-input")?.querySelector(".unit")?.textContent;
const label = input.closest(".field")?.querySelector(".field-label")?.textContent?.trim() || input.getAttribute("aria-label") || input.placeholder || "This value";
const unit = input
.closest(".unit-input")
?.querySelector(".unit")?.textContent;
const label =
input
.closest(".field")
?.querySelector(".field-label")
?.textContent?.trim() ||
input.getAttribute("aria-label") ||
input.placeholder ||
"This value";
return `${label} records your plan or roast observation${unit ? ` in ${unit}` : ""}. Use the format shown; leave it blank when you do not know it, then refine it from a supplier record or your next roast.`;
}
function wireFieldHelp(root = document) {
for (const input of root.querySelectorAll("input[name], select[name], textarea[name], #prefill-url")) {
for (const input of root.querySelectorAll(
"input[name], select[name], textarea[name], #prefill-url",
)) {
if (!input.closest("#plan-form") && input.id !== "prefill-url") continue;
const field = input.closest(".field");
if (input.dataset.helpWired || (input.type === "radio" && field?.dataset.radioHelp === input.name)) continue;
if (
input.dataset.helpWired ||
(input.type === "radio" && field?.dataset.radioHelp === input.name)
)
continue;
input.dataset.helpWired = "true";
if (input.type === "radio" && field) field.dataset.radioHelp = input.name;
const id = `field-help-${Math.random().toString(36).slice(2)}`;
@@ -72,12 +87,29 @@ function wireFieldHelp(root = document) {
help.hidden = !open;
button.setAttribute("aria-expanded", String(open));
});
const milestone = input.closest(".milestone-row")?.querySelector(".milestone-label")?.textContent?.trim();
const fieldLabel = field?.querySelector(".field-label")?.textContent?.trim();
input.setAttribute("aria-label", input.getAttribute("aria-label") || milestone || fieldLabel || input.placeholder || "Plan value");
input.setAttribute("aria-describedby", [input.getAttribute("aria-describedby"), id].filter(Boolean).join(" "));
const milestone = input
.closest(".milestone-row")
?.querySelector(".milestone-label")
?.textContent?.trim();
const fieldLabel = field
?.querySelector(".field-label")
?.textContent?.trim();
input.setAttribute(
"aria-label",
input.getAttribute("aria-label") ||
milestone ||
fieldLabel ||
input.placeholder ||
"Plan value",
);
input.setAttribute(
"aria-describedby",
[input.getAttribute("aria-describedby"), id].filter(Boolean).join(" "),
);
const host = field || input.closest(".unit-input") || input;
let wrapper = host?.parentElement?.classList.contains("field-help-host") ? host.parentElement : null;
let wrapper = host?.parentElement?.classList.contains("field-help-host")
? host.parentElement
: null;
if (!wrapper && host) {
wrapper = document.createElement("div");
wrapper.className = "field-help-host";
@@ -312,11 +344,13 @@ function renderLedger() {
fmtOut("check-ceiling", d(ledger.checks.ceiling.valueS));
const warnings = document.getElementById("ledger-warnings");
warnings.replaceChildren(...ledger.warnings.map((warning) => {
const item = document.createElement("p");
item.textContent = warning;
return item;
}));
warnings.replaceChildren(
...ledger.warnings.map((warning) => {
const item = document.createElement("p");
item.textContent = warning;
return item;
}),
);
warnings.classList.toggle("hidden", ledger.warnings.length === 0);
for (const [key, check] of Object.entries(ledger.checks)) {
@@ -471,39 +505,73 @@ function setSaveStatus(status) {
const text = chip.querySelector(".autosave-text");
chip.classList.remove("saving", "saved", "failed");
chip.classList.add(status);
text.textContent = ({ saving: "Saving locally…", saved: "Synced", failed: "Saved locally — sync failed", local: "Saved locally — waiting to sync" })[status] || "Not saved yet";
text.textContent =
{
saving: "Saving locally…",
saved: "Synced",
failed: "Saved locally — sync failed",
local: "Saved locally — waiting to sync",
}[status] || "Not saved yet";
}
let syncQueue = Promise.resolve();
function syncPlan(snapshot = structuredClone(state.plan)) {
if (!navigator.onLine || !csrfToken()) { setSaveStatus("local"); return Promise.resolve(); }
if (!navigator.onLine || !csrfToken()) {
setSaveStatus("local");
return Promise.resolve();
}
syncQueue = syncQueue.then(async () => {
try {
const response = await protectedFetch(remotePlanId ? `/api/plans/${remotePlanId}` : "/api/plans", {
method: remotePlanId ? "PUT" : "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ plan: snapshot }),
});
const response = await protectedFetch(
remotePlanId ? `/api/plans/${remotePlanId}` : "/api/plans",
{
method: remotePlanId ? "PUT" : "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ plan: snapshot }),
},
);
if (!response.ok) throw new Error("sync_failed");
const body = await response.json();
remotePlanId = body.plan.id;
if (storageKey) localStorage.setItem(storageKey, JSON.stringify({ plan: snapshot, remotePlanId }));
history.replaceState(null, "", `/app?plan=${encodeURIComponent(remotePlanId)}`);
if (storageKey)
localStorage.setItem(
storageKey,
JSON.stringify({ plan: snapshot, remotePlanId }),
);
history.replaceState(
null,
"",
`/app?plan=${encodeURIComponent(remotePlanId)}`,
);
setSaveStatus("saved");
await loadPlans();
} catch { setSaveStatus("failed"); }
} catch {
setSaveStatus("failed");
}
});
return syncQueue;
}
async function flushCurrentPlan() {
clearTimeout(autosave._t);
const snapshot = structuredClone(state.plan);
try { if (storageKey) localStorage.setItem(storageKey, JSON.stringify({ plan: snapshot, remotePlanId })); }
catch { setSaveStatus("failed"); return false; }
try {
if (storageKey)
localStorage.setItem(
storageKey,
JSON.stringify({ plan: snapshot, remotePlanId }),
);
} catch {
setSaveStatus("failed");
return false;
}
await syncPlan(snapshot);
return true;
}
function autosave() {
setSaveStatus("saving");
clearTimeout(autosave._t);
autosave._t = setTimeout(() => { flushCurrentPlan(); }, 400);
autosave._t = setTimeout(() => {
flushCurrentPlan();
}, 400);
}
function loadFromStorage(userId) {
@@ -514,7 +582,9 @@ function loadFromStorage(userId) {
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?.startsWith(`${STORAGE_PREFIX}:`) &&
key !== storageKey &&
key !== `${STORAGE_PREFIX}:last-user`) ||
key === "roastPlannerPlan.v1"
)
localStorage.removeItem(key);
@@ -534,7 +604,11 @@ function loadFromStorage(userId) {
function clearDraft() {
if (storageKey) localStorage.removeItem(storageKey);
try { localStorage.removeItem(`${STORAGE_PREFIX}:last-user`); } catch { /* unavailable storage */ }
try {
localStorage.removeItem(`${STORAGE_PREFIX}:last-user`);
} catch {
/* unavailable storage */
}
storageKey = null;
remotePlanId = null;
}
@@ -577,29 +651,62 @@ function wireDrawers() {
const overlay = document.getElementById("drawer-overlay");
const drawers = [...document.querySelectorAll(".drawer")];
function closeAll() {
for (const panel of drawers) { panel.classList.add("hidden"); panel.setAttribute("aria-hidden", "true"); }
for (const panel of drawers) {
panel.classList.add("hidden");
panel.setAttribute("aria-hidden", "true");
}
overlay.classList.add("hidden");
lastDrawerOpener?.focus();
}
function open(panel, opener) {
lastDrawerOpener = opener;
for (const p of drawers) { p.classList.add("hidden"); p.setAttribute("aria-hidden", "true"); }
panel.classList.remove("hidden"); panel.setAttribute("aria-hidden", "false"); overlay.classList.remove("hidden");
for (const p of drawers) {
p.classList.add("hidden");
p.setAttribute("aria-hidden", "true");
}
panel.classList.remove("hidden");
panel.setAttribute("aria-hidden", "false");
overlay.classList.remove("hidden");
panel.querySelector("button, input, [href]")?.focus();
}
for (const [buttonId, panelId] of [["btn-toggle-prefill", "panel-prefill"], ["btn-toggle-alog", "panel-alog"], ["btn-plans", "panel-plans"], ["btn-settings", "panel-settings"]])
document.getElementById(buttonId).addEventListener("click", (event) => open(document.getElementById(panelId), event.currentTarget));
for (const [buttonId, panelId] of [
["btn-toggle-prefill", "panel-prefill"],
["btn-toggle-alog", "panel-alog"],
["btn-plans", "panel-plans"],
["btn-settings", "panel-settings"],
])
document
.getElementById(buttonId)
.addEventListener("click", (event) =>
open(document.getElementById(panelId), event.currentTarget),
);
overlay.addEventListener("click", closeAll);
for (const btn of document.querySelectorAll("[data-close-drawer]")) btn.addEventListener("click", closeAll);
for (const btn of document.querySelectorAll("[data-close-drawer]"))
btn.addEventListener("click", closeAll);
document.addEventListener("keydown", (event) => {
const activeDrawer = drawers.find((drawer) => !drawer.classList.contains("hidden"));
const activeDrawer = drawers.find(
(drawer) => !drawer.classList.contains("hidden"),
);
if (!activeDrawer) return;
if (event.key === "Escape") { closeAll(); return; }
if (event.key === "Escape") {
closeAll();
return;
}
if (event.key !== "Tab") return;
const focusable = [...activeDrawer.querySelectorAll("button:not([disabled]), input:not([disabled]), [href]")];
const first = focusable[0], last = focusable.at(-1);
if (event.shiftKey && document.activeElement === first) { event.preventDefault(); last?.focus(); }
else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first?.focus(); }
const focusable = [
...activeDrawer.querySelectorAll(
"button:not([disabled]), input:not([disabled]), [href]",
),
];
const first = focusable[0],
last = focusable.at(-1);
if (event.shiftKey && document.activeElement === first) {
event.preventDefault();
last?.focus();
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault();
first?.focus();
}
});
return { open, closeAll };
}
@@ -610,37 +717,50 @@ async function loadPlans() {
if (!response.ok) return;
plans = (await response.json()).plans || [];
const list = document.getElementById("plan-list");
list.replaceChildren(...plans.map((plan) => {
const item = document.createElement("li");
const button = document.createElement("button");
button.type = "button";
button.className = "plan-list-item";
button.classList.toggle("active", plan.id === remotePlanId);
const title = document.createElement("strong");
title.textContent = plan.plan?.fields?.["0.1"] || "Untitled plan";
const updated = document.createElement("span");
updated.textContent = `Updated ${new Date(plan.updated_at).toLocaleDateString()}`;
button.append(title, updated);
button.addEventListener("click", () => selectPlan(plan));
item.append(button);
return item;
}));
list.replaceChildren(
...plans.map((plan) => {
const item = document.createElement("li");
const button = document.createElement("button");
button.type = "button";
button.className = "plan-list-item";
button.classList.toggle("active", plan.id === remotePlanId);
const title = document.createElement("strong");
title.textContent = plan.plan?.fields?.["0.1"] || "Untitled plan";
const updated = document.createElement("span");
updated.textContent = `Updated ${new Date(plan.updated_at).toLocaleDateString()}`;
button.append(title, updated);
button.addEventListener("click", () => selectPlan(plan));
item.append(button);
return item;
}),
);
}
async function selectPlan(plan) {
if (!(await flushCurrentPlan())) return;
remotePlanId = plan.id;
state.plan = { ...blankPlan(), ...plan.plan };
history.replaceState(null, "", `/app?plan=${encodeURIComponent(remotePlanId)}`);
renderBlend(); renderActuators(); renderFormFromPlan(); recompute();
history.replaceState(
null,
"",
`/app?plan=${encodeURIComponent(remotePlanId)}`,
);
renderBlend();
renderActuators();
renderFormFromPlan();
recompute();
document.querySelector("[data-close-drawer]")?.click();
}
async function newPlan() {
if (!confirm("Start a new plan? Your current plan is already saved locally.")) return;
if (!confirm("Start a new plan? Your current plan is already saved locally."))
return;
if (!(await flushCurrentPlan())) return;
remotePlanId = null;
state.plan = blankPlan();
history.replaceState(null, "", "/app");
renderBlend(); renderActuators(); renderFormFromPlan(); recompute();
renderBlend();
renderActuators();
renderFormFromPlan();
recompute();
}
function wireToolbar() {
@@ -724,30 +844,47 @@ function wirePwa() {
: "";
};
window.addEventListener("online", () => { renderConnection(); syncPlan(); });
window.addEventListener("online", () => {
renderConnection();
syncPlan();
});
window.addEventListener("offline", renderConnection);
window.addEventListener("beforeinstallprompt", (event) => {
event.preventDefault(); deferredInstallPrompt = event;
event.preventDefault();
deferredInstallPrompt = event;
document.getElementById("btn-install").classList.remove("hidden");
});
document.getElementById("btn-install").addEventListener("click", async () => {
if (!deferredInstallPrompt) return;
deferredInstallPrompt.prompt(); await deferredInstallPrompt.userChoice;
deferredInstallPrompt = null; document.getElementById("btn-install").classList.add("hidden");
deferredInstallPrompt.prompt();
await deferredInstallPrompt.userChoice;
deferredInstallPrompt = null;
document.getElementById("btn-install").classList.add("hidden");
});
renderConnection();
if ("serviceWorker" in navigator) {
window.addEventListener("load", () => {
navigator.serviceWorker.register("/sw.js").then((registration) => {
const showUpdate = () => document.getElementById("btn-refresh").classList.remove("hidden");
if (registration.waiting) showUpdate();
registration.addEventListener("updatefound", () => registration.installing?.addEventListener("statechange", () => {
if (registration.waiting && navigator.serviceWorker.controller) showUpdate();
}));
}).catch((error) => console.warn("Service worker registration failed:", error));
navigator.serviceWorker
.register("/sw.js")
.then((registration) => {
const showUpdate = () =>
document.getElementById("btn-refresh").classList.remove("hidden");
if (registration.waiting) showUpdate();
registration.addEventListener("updatefound", () =>
registration.installing?.addEventListener("statechange", () => {
if (registration.waiting && navigator.serviceWorker.controller)
showUpdate();
}),
);
})
.catch((error) =>
console.warn("Service worker registration failed:", error),
);
});
navigator.serviceWorker.addEventListener("controllerchange", () => location.reload());
navigator.serviceWorker.addEventListener("controllerchange", () =>
location.reload(),
);
}
document.getElementById("btn-refresh").addEventListener("click", async () => {
if (!(await flushCurrentPlan())) return;
@@ -790,22 +927,32 @@ async function init() {
const { user } = await meResponse.json();
document.getElementById("account-email").textContent = user.email;
document.getElementById("settings-email").textContent = user.email;
document.getElementById("account-summary").textContent = user.email.split("@")[0];
if (user.role === "admin") document.getElementById("menu-admin").classList.remove("hidden");
document.getElementById("account-summary").textContent =
user.email.split("@")[0];
if (user.role === "admin")
document.getElementById("menu-admin").classList.remove("hidden");
const localDraft = loadFromStorage(user.id);
state.plan = localDraft ?? blankPlan();
await loadPlans();
const requestedId = new URLSearchParams(location.search).get("plan");
const selected = plans.find((plan) => plan.id === requestedId);
if (selected) { state.plan = selected.plan; remotePlanId = selected.id; }
else if (!localDraft && !requestedId && plans[0]) { state.plan = plans[0].plan; remotePlanId = plans[0].id; }
if (selected) {
state.plan = selected.plan;
remotePlanId = selected.id;
} else if (!localDraft && !requestedId && plans[0]) {
state.plan = plans[0].plan;
remotePlanId = plans[0].id;
}
} catch {
// The cached app shell contains no user data. A successful online sign-in records the
// 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()) state.plan = loadFromStorage(offlineUserId) ?? blankPlan();
} catch { /* no local draft is available */ }
if (offlineUserId && csrfToken())
state.plan = loadFromStorage(offlineUserId) ?? blankPlan();
} catch {
/* no local draft is available */
}
}
renderBlend();
renderActuators();