Elevate planner UX and offline resilience

This commit is contained in:
2026-07-30 07:03:35 -04:00
parent 057d68156b
commit 7416898bec
6 changed files with 347 additions and 120 deletions
+222 -93
View File
@@ -15,6 +15,9 @@ const FIELD_ID_SET = new Set(FIELD_IDS);
const STORAGE_PREFIX = "roastPlannerPlan.v2";
let storageKey = null;
let remotePlanId = null;
let plans = [];
let lastDrawerOpener = null;
let deferredInstallPrompt = null;
const csrfToken = () =>
document.cookie
.split("; ")
@@ -31,6 +34,61 @@ 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.",
};
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";
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")) {
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;
input.dataset.helpWired = "true";
if (input.type === "radio" && field) field.dataset.radioHelp = input.name;
const id = `field-help-${Math.random().toString(36).slice(2)}`;
const help = document.createElement("span");
help.id = id;
help.className = "field-help-text";
help.hidden = true;
help.textContent = helpText(input);
const button = document.createElement("button");
button.type = "button";
button.className = "field-help";
button.setAttribute("aria-expanded", "false");
button.setAttribute("aria-controls", id);
button.setAttribute("aria-label", `Learn about ${input.name}`);
button.textContent = "?";
button.addEventListener("click", () => {
const open = help.hidden;
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 host = field || input.closest(".unit-input") || input;
let wrapper = host?.parentElement?.classList.contains("field-help-host") ? host.parentElement : null;
if (!wrapper && host) {
wrapper = document.createElement("div");
wrapper.className = "field-help-host";
host.replaceWith(wrapper);
wrapper.append(host);
}
// Labels cannot contain another interactive control, so the help button is a sibling.
wrapper?.append(button, help);
}
}
// The print worksheet (#print-sheet) sits outside #plan-form on purpose — see index.html —
// so its radios don't fight the screen form's identically-named radios for exclusivity.
// Every sync pass therefore has to reach both containers explicitly.
@@ -145,6 +203,7 @@ function updateBlendVisibility(mode) {
function renderBlend() {
renderBlendPrintRows();
renderBlendCards();
wireFieldHelp(document.getElementById("blend-cards"));
updateBlendTotal();
}
@@ -202,6 +261,7 @@ function renderActuatorTimeline() {
function renderActuators() {
renderActuatorPrintRows();
renderActuatorTimeline();
wireFieldHelp(document.getElementById("actuator-timeline"));
}
// ---- populate every control in the form from state.plan
@@ -251,6 +311,14 @@ function renderLedger() {
fmtOut("check-dtr", pct(ledger.checks.dtr.pct));
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.classList.toggle("hidden", ledger.warnings.length === 0);
for (const [key, check] of Object.entries(ledger.checks)) {
for (const cell of document.querySelectorAll(`[data-pass="${key}"]`)) {
cell.classList.remove("pass", "fail", "unknown");
@@ -398,72 +466,67 @@ export function recompute() {
autosave();
}
let autosaveAgeTimer = null;
function autosave() {
function setSaveStatus(status) {
const chip = document.getElementById("autosave-status");
const text = chip.querySelector(".autosave-text");
chip.classList.remove("saved");
chip.classList.add("saving");
text.textContent = "Saving…";
clearTimeout(autosave._t);
autosave._t = setTimeout(() => {
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";
}
let syncQueue = Promise.resolve();
function syncPlan(snapshot = structuredClone(state.plan)) {
if (!navigator.onLine || !csrfToken()) { setSaveStatus("local"); return Promise.resolve(); }
syncQueue = syncQueue.then(async () => {
try {
if (storageKey)
localStorage.setItem(storageKey, JSON.stringify(state.plan));
// Local storage is namespaced per authenticated account; the account copy is authoritative when online.
if (navigator.onLine && csrfToken()) {
const method = remotePlanId ? "PUT" : "POST";
const url = remotePlanId ? `/api/plans/${remotePlanId}` : "/api/plans";
protectedFetch(url, {
method,
headers: { "content-type": "application/json" },
body: JSON.stringify({ plan: state.plan }),
})
.then((r) => (r.ok ? r.json() : null))
.then((body) => {
if (body?.plan?.id) remotePlanId = body.plan.id;
})
.catch(() => {
/* local copy remains available */
});
}
const savedAt = Date.now();
chip.classList.remove("saving");
chip.classList.add("saved");
const tick = () => {
const secs = Math.round((Date.now() - savedAt) / 1000);
text.textContent =
secs < 8
? "Saved just now"
: secs < 60
? `Saved ${secs}s ago`
: `Saved ${Math.round(secs / 60)}m ago`;
};
tick();
clearInterval(autosaveAgeTimer);
autosaveAgeTimer = setInterval(tick, 5000);
} catch {
chip.classList.remove("saving", "saved");
text.textContent = "Save unavailable";
}
}, 400);
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)}`);
setSaveStatus("saved");
await loadPlans();
} 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; }
await syncPlan(snapshot);
return true;
}
function autosave() {
setSaveStatus("saving");
clearTimeout(autosave._t);
autosave._t = setTimeout(() => { flushCurrentPlan(); }, 400);
}
function loadFromStorage(userId) {
try {
storageKey = `${STORAGE_PREFIX}:${userId}`;
localStorage.setItem(`${STORAGE_PREFIX}:last-user`, userId);
// A shared browser must never retain a previous account's local-only draft.
for (let i = localStorage.length - 1; i >= 0; i--) {
const key = localStorage.key(i);
if (
(key?.startsWith(`${STORAGE_PREFIX}:`) && key !== storageKey) ||
(key?.startsWith(`${STORAGE_PREFIX}:`) && key !== storageKey && key !== `${STORAGE_PREFIX}:last-user`) ||
key === "roastPlannerPlan.v1"
)
localStorage.removeItem(key);
}
const raw = localStorage.getItem(storageKey);
return raw ? JSON.parse(raw) : null;
if (!raw) return null;
const draft = JSON.parse(raw);
if (draft?.plan) {
remotePlanId = draft.remotePlanId || null;
return draft.plan;
}
return draft; // legacy v2 draft: retain it once, then upgrade on next save
} catch {
return null;
}
@@ -471,6 +534,7 @@ function loadFromStorage(userId) {
function clearDraft() {
if (storageKey) localStorage.removeItem(storageKey);
try { localStorage.removeItem(`${STORAGE_PREFIX}:last-user`); } catch { /* unavailable storage */ }
storageKey = null;
remotePlanId = null;
}
@@ -511,35 +575,72 @@ function wireForm() {
function wireDrawers() {
const overlay = document.getElementById("drawer-overlay");
const prefill = document.getElementById("panel-prefill");
const alog = document.getElementById("panel-alog");
function open(panel) {
for (const p of [prefill, alog]) p.classList.add("hidden");
panel.classList.remove("hidden");
panel.setAttribute("aria-hidden", "false");
overlay.classList.remove("hidden");
}
const drawers = [...document.querySelectorAll(".drawer")];
function closeAll() {
for (const p of [prefill, alog]) {
p.classList.add("hidden");
p.setAttribute("aria-hidden", "true");
}
for (const panel of drawers) { panel.classList.add("hidden"); panel.setAttribute("aria-hidden", "true"); }
overlay.classList.add("hidden");
lastDrawerOpener?.focus();
}
document
.getElementById("btn-toggle-prefill")
.addEventListener("click", () => {
prefill.classList.contains("hidden") ? open(prefill) : closeAll();
});
document.getElementById("btn-toggle-alog").addEventListener("click", () => {
alog.classList.contains("hidden") ? open(alog) : closeAll();
});
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");
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));
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"));
if (!activeDrawer) 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(); }
});
return { open, closeAll };
}
async function loadPlans() {
if (!navigator.onLine) return;
const response = await fetch("/api/plans");
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;
}));
}
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();
document.querySelector("[data-close-drawer]")?.click();
}
async function newPlan() {
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();
}
function wireToolbar() {
@@ -578,6 +679,7 @@ function wireToolbar() {
}
});
document.getElementById("btn-new-plan").addEventListener("click", newPlan);
document.getElementById("btn-add-blend").addEventListener("click", () => {
state.plan.blendComponents.push({
cultivar: "",
@@ -622,17 +724,36 @@ function wirePwa() {
: "";
};
window.addEventListener("online", renderConnection);
window.addEventListener("online", () => { renderConnection(); syncPlan(); });
window.addEventListener("offline", renderConnection);
window.addEventListener("beforeinstallprompt", (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");
});
renderConnection();
if ("serviceWorker" in navigator) {
window.addEventListener("load", () => {
navigator.serviceWorker.register("/sw.js").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());
}
document.getElementById("btn-refresh").addEventListener("click", async () => {
if (!(await flushCurrentPlan())) return;
const registration = await navigator.serviceWorker.getRegistration();
registration?.waiting?.postMessage("SKIP_WAITING");
});
}
function wireSectionNav() {
@@ -647,8 +768,11 @@ function wireSectionNav() {
for (const entry of entries) {
if (!entry.isIntersecting) continue;
const id = `#${entry.target.id}`;
for (const a of links)
a.classList.toggle("active", a.getAttribute("href") === id);
for (const a of links) {
const active = a.getAttribute("href") === id;
a.classList.toggle("active", active);
a.toggleAttribute("aria-current", active);
}
}
},
{ rootMargin: "-20% 0px -70% 0px" },
@@ -664,24 +788,29 @@ async function init() {
return;
}
const { user } = await meResponse.json();
if (user.role === "admin")
document.getElementById("menu-admin").classList.remove("hidden");
state.plan = loadFromStorage(user.id) ?? blankPlan();
const response = await fetch("/api/plans");
if (response.ok) {
const body = await response.json();
const latest = body.plans?.[0];
if (latest) {
state.plan = latest.plan;
remotePlanId = latest.id;
}
}
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");
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; }
} catch {
// An authenticated user can still use their own namespaced local draft offline.
// 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 */ }
}
renderBlend();
renderActuators();
wireCultivarDatalist();
wireFieldHelp();
renderBandRanges();
renderFormFromPlan();
wireForm();