Elevate planner UX and offline resilience
This commit is contained in:
+222
-93
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user