Fix planner review findings
This commit is contained in:
+228
-81
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user