Sanity Checks
diff --git a/public/js/main.js b/public/js/main.js
index b423515..08f9aeb 100644
--- a/public/js/main.js
+++ b/public/js/main.js
@@ -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();
diff --git a/public/sw.js b/public/sw.js
index e0f9179..aaadcc8 100644
--- a/public/sw.js
+++ b/public/sw.js
@@ -60,9 +60,13 @@ self.addEventListener("fetch", (event) => {
// /app is a data-free authenticated shell: draft data lives only in account-scoped
// local storage and logout clears that namespace. This makes offline relaunch useful
// without ever caching account data or API responses.
- event.respondWith(fetch(request).catch(() =>
- url.pathname === "/app" ? caches.match("/app") : caches.match("/landing.html"),
- ));
+ event.respondWith(
+ fetch(request).catch(() =>
+ url.pathname === "/app"
+ ? caches.match("/app")
+ : caches.match("/landing.html"),
+ ),
+ );
return;
}
diff --git a/test/auth.test.js b/test/auth.test.js
index 4dbdc75..472eb64 100644
--- a/test/auth.test.js
+++ b/test/auth.test.js
@@ -78,7 +78,10 @@ test("strict CSP/static modules, no-store data, auth lifecycle, and ownership sh
assert.match(mainScript.text, /localStorage\.removeItem\(key\)/);
const serviceWorker = (await anonymous.get("/sw.js")).text;
assert.match(serviceWorker, /data-free authenticated shell/);
- assert.match(serviceWorker, /url\.pathname === "\/app" \? caches\.match\("\/app"\)/);
+ assert.match(
+ serviceWorker,
+ /url\.pathname === "\/app"[\s\S]*caches\.match\("\/app"\)/,
+ );
assert.match(serviceWorker, /logout clears that namespace/);
const one = await signup(first, "one@example.com");