7 changed files with 2005 additions and 502 deletions
+2
View File
@@ -2,4 +2,6 @@
Run `npm test` before every commit and whenever changing authentication, API authorization, service-worker behavior, or persistence. Add a regression test under `test/` for every new route and for every security/ownership bug. Tests use an in-memory PostgreSQL-compatible database; production migration is run automatically at startup against `DATABASE_URL`. Run `npm test` before every commit and whenever changing authentication, API authorization, service-worker behavior, or persistence. Add a regression test under `test/` for every new route and for every security/ownership bug. Tests use an in-memory PostgreSQL-compatible database; production migration is run automatically at startup against `DATABASE_URL`.
Every change pushed to the remote must be deployed to the Unraid Docker service immediately afterward. Verify the deployed container is healthy and the production endpoint responds before reporting completion.
For local development, copy `.env.example`, start `docker compose up --build`, then visit `http://localhost:8090`. Production must set `APP_ORIGIN=https://roast.srmr.xyz`, `COOKIE_SECURE=true`, a strong `POSTGRES_PASSWORD`, and a random `BOOTSTRAP_SETUP_TOKEN`. Bootstrap `[email protected]` exactly once at `POST /api/auth/bootstrap`, then remove `BOOTSTRAP_SETUP_TOKEN` from deployment configuration. Never commit credentials or a bootstrap password/token. For local development, copy `.env.example`, start `docker compose up --build`, then visit `http://localhost:8090`. Production must set `APP_ORIGIN=https://roast.srmr.xyz`, `COOKIE_SECURE=true`, a strong `POSTGRES_PASSWORD`, and a random `BOOTSTRAP_SETUP_TOKEN`. Bootstrap `[email protected]` exactly once at `POST /api/auth/bootstrap`, then remove `BOOTSTRAP_SETUP_TOKEN` from deployment configuration. Never commit credentials or a bootstrap password/token.
+1421 -303
View File
File diff suppressed because it is too large Load Diff
+122 -17
View File
@@ -23,17 +23,15 @@
</div> </div>
</div> </div>
<nav class="section-nav" id="section-nav" aria-label="Jump to section"> <nav class="section-nav" id="section-nav" aria-label="Plan sections">
<a href="#sec-coffee">Coffee</a> <a href="#sec-coffee">Plan</a>
<a href="#sec-blend">Blend</a>
<a href="#sec-process">Roast target</a>
<a href="#sec-bean">Bean condition</a> <a href="#sec-bean">Bean condition</a>
<a href="#sec-machine">Machine plan</a> <a href="#sec-machine">Roast</a>
<a href="#sec-roastlog">Roast log</a> <a href="#sec-after">Learn</a>
<a href="#sec-after">After the roast</a>
</nav> </nav>
<div class="header-actions"> <div class="header-actions">
<button id="btn-plans" type="button" class="ghost-btn">Plans</button>
<button <button
id="btn-toggle-fids" id="btn-toggle-fids"
type="button" type="button"
@@ -53,10 +51,15 @@
<button id="btn-load" type="button" class="ghost-btn">Load</button> <button id="btn-load" type="button" class="ghost-btn">Load</button>
<input id="file-load" type="file" accept="application/json" /> <input id="file-load" type="file" accept="application/json" />
<button id="btn-save" type="button" class="ghost-btn">Download</button> <button id="btn-save" type="button" class="ghost-btn">Download</button>
<button id="btn-admin" type="button" class="ghost-btn hidden"> <details class="account-menu">
Admin <summary class="ghost-btn" id="account-summary">Account</summary>
</button> <div class="account-menu-popover">
<button id="btn-logout" type="button" class="ghost-btn">Log out</button> <p class="account-email" id="account-email"></p>
<button id="btn-settings" type="button">Settings &amp; sync</button>
<a id="menu-admin" class="hidden" href="/admin">Admin</a>
<button id="btn-logout" type="button">Log out</button>
</div>
</details>
<button id="btn-print" type="button" class="primary-btn">Print</button> <button id="btn-print" type="button" class="primary-btn">Print</button>
<span class="autosave-chip" id="autosave-status" <span class="autosave-chip" id="autosave-status"
><span class="dot"></span ><span class="dot"></span
@@ -73,9 +76,17 @@
></div> ></div>
<div class="drawer-overlay hidden" id="drawer-overlay"></div> <div class="drawer-overlay hidden" id="drawer-overlay"></div>
<aside class="drawer hidden" id="panel-prefill" aria-hidden="true"> <aside
class="drawer hidden"
id="panel-prefill"
aria-hidden="true"
role="dialog"
aria-modal="true"
aria-labelledby="prefill-title"
tabindex="-1"
>
<div class="drawer-head"> <div class="drawer-head">
<h3>Prefill from a bean product URL</h3> <h3 id="prefill-title">Prefill from a bean product URL</h3>
<button <button
class="icon-btn" class="icon-btn"
type="button" type="button"
@@ -109,9 +120,17 @@
</div> </div>
</aside> </aside>
<aside class="drawer hidden" id="panel-alog" aria-hidden="true"> <aside
class="drawer hidden"
id="panel-alog"
aria-hidden="true"
role="dialog"
aria-modal="true"
aria-labelledby="alog-title"
tabindex="-1"
>
<div class="drawer-head"> <div class="drawer-head">
<h3>Attach an Artisan .alog reference curve</h3> <h3 id="alog-title">Attach an Artisan .alog reference curve</h3>
<button <button
class="icon-btn" class="icon-btn"
type="button" type="button"
@@ -133,6 +152,78 @@
</div> </div>
</aside> </aside>
<aside
class="drawer hidden"
id="panel-plans"
aria-hidden="true"
role="dialog"
aria-modal="true"
aria-labelledby="plans-title"
tabindex="-1"
>
<div class="drawer-head">
<h3 id="plans-title">Your plans</h3>
<button
class="icon-btn"
type="button"
data-close-drawer
aria-label="Close"
>
</button>
</div>
<div class="drawer-body">
<button id="btn-new-plan" type="button" class="primary-btn">
New plan
</button>
<p class="drawer-result">
Choose a plan to continue. Every edit is stored on this device first
and then synced when online.
</p>
<ul class="plan-list" id="plan-list" aria-live="polite"></ul>
</div>
</aside>
<aside
class="drawer hidden"
id="panel-settings"
aria-hidden="true"
role="dialog"
aria-modal="true"
aria-labelledby="settings-title"
tabindex="-1"
>
<div class="drawer-head">
<h3 id="settings-title">Settings &amp; sync</h3>
<button
class="icon-btn"
type="button"
data-close-drawer
aria-label="Close"
>
</button>
</div>
<div class="drawer-body">
<p class="drawer-result">
<strong id="settings-email"></strong> is the active account. Plans are
saved locally first, then synced to your account whenever you are
online.
</p>
<p class="drawer-result">
Install Roast Planner from your browser menu for a focused workspace.
Updates wait for your confirmation so an in-progress plan is never
discarded.
</p>
<button id="btn-install" type="button" class="primary-btn hidden">
Install Roast Planner
</button>
<button id="btn-refresh" type="button" class="ghost-btn hidden">
Update ready — refresh safely
</button>
</div>
</aside>
<form id="plan-form"> <form id="plan-form">
<div class="workspace-grid"> <div class="workspace-grid">
<div class="form-col"> <div class="form-col">
@@ -404,6 +495,13 @@
<section class="panel-card" id="sec-bean"> <section class="panel-card" id="sec-bean">
<div class="panel-head"><h2>Bean Condition</h2></div> <div class="panel-head"><h2>Bean Condition</h2></div>
<div class="unknown-condition" role="note">
<strong>Don't have lab measurements?</strong> Moisture and density
are optional. Leave them blank and record only evidence you have
(supplier COA, lot notes, or your next roast). They never change
the ledger automatically. Use Net correction only when you can
explain the observation behind it.
</div>
<div class="panel-body"> <div class="panel-body">
<div class="field-grid"> <div class="field-grid">
<label class="field" data-fid="5.1" <label class="field" data-fid="5.1"
@@ -773,8 +871,8 @@
</section> </section>
</div> </div>
<aside class="dock"> <aside class="dock" aria-label="Live plan feedback">
<div class="dock-card ledger-card"> <div class="dock-card ledger-card" id="plan-now">
<div class="dock-card-head"><h2>Time Ledger</h2></div> <div class="dock-card-head"><h2>Time Ledger</h2></div>
<div class="ledger-group"> <div class="ledger-group">
@@ -840,6 +938,13 @@
</div> </div>
</div> </div>
<div
class="ledger-warnings hidden"
id="ledger-warnings"
role="status"
aria-live="polite"
></div>
<div class="dock-card checks-card"> <div class="dock-card checks-card">
<div class="dock-card-head"><h2>Sanity Checks</h2></div> <div class="dock-card-head"><h2>Sanity Checks</h2></div>
<div class="checks-grid"> <div class="checks-grid">
+361 -95
View File
@@ -15,6 +15,9 @@ const FIELD_ID_SET = new Set(FIELD_IDS);
const STORAGE_PREFIX = "roastPlannerPlan.v2"; const STORAGE_PREFIX = "roastPlannerPlan.v2";
let storageKey = null; let storageKey = null;
let remotePlanId = null; let remotePlanId = null;
let plans = [];
let lastDrawerOpener = null;
let deferredInstallPrompt = null;
const csrfToken = () => const csrfToken = () =>
document.cookie document.cookie
.split("; ") .split("; ")
@@ -31,6 +34,93 @@ export const state = { plan: blankPlan() };
const form = document.getElementById("plan-form"); 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 — // 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. // 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. // Every sync pass therefore has to reach both containers explicitly.
@@ -145,6 +235,7 @@ function updateBlendVisibility(mode) {
function renderBlend() { function renderBlend() {
renderBlendPrintRows(); renderBlendPrintRows();
renderBlendCards(); renderBlendCards();
wireFieldHelp(document.getElementById("blend-cards"));
updateBlendTotal(); updateBlendTotal();
} }
@@ -202,6 +293,7 @@ function renderActuatorTimeline() {
function renderActuators() { function renderActuators() {
renderActuatorPrintRows(); renderActuatorPrintRows();
renderActuatorTimeline(); renderActuatorTimeline();
wireFieldHelp(document.getElementById("actuator-timeline"));
} }
// ---- populate every control in the form from state.plan // ---- populate every control in the form from state.plan
@@ -251,6 +343,16 @@ function renderLedger() {
fmtOut("check-dtr", pct(ledger.checks.dtr.pct)); fmtOut("check-dtr", pct(ledger.checks.dtr.pct));
fmtOut("check-ceiling", d(ledger.checks.ceiling.valueS)); 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 [key, check] of Object.entries(ledger.checks)) {
for (const cell of document.querySelectorAll(`[data-pass="${key}"]`)) { for (const cell of document.querySelectorAll(`[data-pass="${key}"]`)) {
cell.classList.remove("pass", "fail", "unknown"); cell.classList.remove("pass", "fail", "unknown");
@@ -398,72 +500,103 @@ export function recompute() {
autosave(); autosave();
} }
let autosaveAgeTimer = null; function setSaveStatus(status) {
function autosave() {
const chip = document.getElementById("autosave-status"); const chip = document.getElementById("autosave-status");
const text = chip.querySelector(".autosave-text"); const text = chip.querySelector(".autosave-text");
chip.classList.remove("saved"); chip.classList.remove("saving", "saved", "failed");
chip.classList.add("saving"); chip.classList.add(status);
text.textContent = "Saving…"; 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 {
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); clearTimeout(autosave._t);
autosave._t = setTimeout(() => { const snapshot = structuredClone(state.plan);
try { try {
if (storageKey) if (storageKey)
localStorage.setItem(storageKey, JSON.stringify(state.plan)); localStorage.setItem(
// Local storage is namespaced per authenticated account; the account copy is authoritative when online. storageKey,
if (navigator.onLine && csrfToken()) { JSON.stringify({ plan: snapshot, remotePlanId }),
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 { } catch {
chip.classList.remove("saving", "saved"); setSaveStatus("failed");
text.textContent = "Save unavailable"; return false;
} }
await syncPlan(snapshot);
return true;
}
function autosave() {
setSaveStatus("saving");
clearTimeout(autosave._t);
autosave._t = setTimeout(() => {
flushCurrentPlan();
}, 400); }, 400);
} }
function loadFromStorage(userId) { function loadFromStorage(userId) {
try { try {
storageKey = `${STORAGE_PREFIX}:${userId}`; storageKey = `${STORAGE_PREFIX}:${userId}`;
localStorage.setItem(`${STORAGE_PREFIX}:last-user`, userId);
// A shared browser must never retain a previous account's local-only draft. // A shared browser must never retain a previous account's local-only draft.
for (let i = localStorage.length - 1; i >= 0; i--) { for (let i = localStorage.length - 1; i >= 0; i--) {
const key = localStorage.key(i); const key = localStorage.key(i);
if ( if (
(key?.startsWith(`${STORAGE_PREFIX}:`) && key !== storageKey) || (key?.startsWith(`${STORAGE_PREFIX}:`) &&
key !== storageKey &&
key !== `${STORAGE_PREFIX}:last-user`) ||
key === "roastPlannerPlan.v1" key === "roastPlannerPlan.v1"
) )
localStorage.removeItem(key); localStorage.removeItem(key);
} }
const raw = localStorage.getItem(storageKey); 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 { } catch {
return null; return null;
} }
@@ -471,6 +604,11 @@ function loadFromStorage(userId) {
function clearDraft() { function clearDraft() {
if (storageKey) localStorage.removeItem(storageKey); if (storageKey) localStorage.removeItem(storageKey);
try {
localStorage.removeItem(`${STORAGE_PREFIX}:last-user`);
} catch {
/* unavailable storage */
}
storageKey = null; storageKey = null;
remotePlanId = null; remotePlanId = null;
} }
@@ -511,35 +649,118 @@ function wireForm() {
function wireDrawers() { function wireDrawers() {
const overlay = document.getElementById("drawer-overlay"); const overlay = document.getElementById("drawer-overlay");
const prefill = document.getElementById("panel-prefill"); const drawers = [...document.querySelectorAll(".drawer")];
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");
}
function closeAll() { function closeAll() {
for (const p of [prefill, alog]) { 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.classList.add("hidden");
p.setAttribute("aria-hidden", "true"); p.setAttribute("aria-hidden", "true");
} }
overlay.classList.add("hidden"); 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);
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 };
} }
document async function loadPlans() {
.getElementById("btn-toggle-prefill") if (!navigator.onLine) return;
.addEventListener("click", () => { const response = await fetch("/api/plans");
prefill.classList.contains("hidden") ? open(prefill) : closeAll(); if (!response.ok) return;
}); plans = (await response.json()).plans || [];
document.getElementById("btn-toggle-alog").addEventListener("click", () => { const list = document.getElementById("plan-list");
alog.classList.contains("hidden") ? open(alog) : closeAll(); list.replaceChildren(
}); ...plans.map((plan) => {
overlay.addEventListener("click", closeAll); const item = document.createElement("li");
for (const btn of document.querySelectorAll("[data-close-drawer]")) { const button = document.createElement("button");
btn.addEventListener("click", closeAll); 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() { function wireToolbar() {
@@ -578,6 +799,7 @@ function wireToolbar() {
} }
}); });
document.getElementById("btn-new-plan").addEventListener("click", newPlan);
document.getElementById("btn-add-blend").addEventListener("click", () => { document.getElementById("btn-add-blend").addEventListener("click", () => {
state.plan.blendComponents.push({ state.plan.blendComponents.push({
cultivar: "", cultivar: "",
@@ -595,7 +817,7 @@ function wireToolbar() {
await protectedFetch("/api/auth/logout", { method: "POST" }); await protectedFetch("/api/auth/logout", { method: "POST" });
} finally { } finally {
clearDraft(); clearDraft();
location.assign("/"); location.replace("/");
} }
}); });
document.getElementById("btn-add-actuator").addEventListener("click", () => { document.getElementById("btn-add-actuator").addEventListener("click", () => {
@@ -622,17 +844,53 @@ function wirePwa() {
: ""; : "";
}; };
window.addEventListener("online", renderConnection); window.addEventListener("online", () => {
renderConnection();
syncPlan();
});
window.addEventListener("offline", renderConnection); 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(); renderConnection();
if ("serviceWorker" in navigator) { if ("serviceWorker" in navigator) {
window.addEventListener("load", () => { window.addEventListener("load", () => {
navigator.serviceWorker.register("/sw.js").catch((error) => { navigator.serviceWorker
console.warn("Service worker registration failed:", error); .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() { function wireSectionNav() {
@@ -647,8 +905,11 @@ function wireSectionNav() {
for (const entry of entries) { for (const entry of entries) {
if (!entry.isIntersecting) continue; if (!entry.isIntersecting) continue;
const id = `#${entry.target.id}`; const id = `#${entry.target.id}`;
for (const a of links) for (const a of links) {
a.classList.toggle("active", a.getAttribute("href") === id); const active = a.getAttribute("href") === id;
a.classList.toggle("active", active);
a.toggleAttribute("aria-current", active);
}
} }
}, },
{ rootMargin: "-20% 0px -70% 0px" }, { rootMargin: "-20% 0px -70% 0px" },
@@ -664,22 +925,39 @@ async function init() {
return; return;
} }
const { user } = await meResponse.json(); const { user } = await meResponse.json();
state.plan = loadFromStorage(user.id) ?? blankPlan(); document.getElementById("account-email").textContent = user.email;
const response = await fetch("/api/plans"); document.getElementById("settings-email").textContent = user.email;
if (response.ok) { document.getElementById("account-summary").textContent =
const body = await response.json(); user.email.split("@")[0];
const latest = body.plans?.[0]; if (user.role === "admin")
if (latest) { document.getElementById("menu-admin").classList.remove("hidden");
state.plan = latest.plan; const localDraft = loadFromStorage(user.id);
remotePlanId = latest.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 { } 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(); renderBlend();
renderActuators(); renderActuators();
wireCultivarDatalist(); wireCultivarDatalist();
wireFieldHelp();
renderBandRanges(); renderBandRanges();
renderFormFromPlan(); renderFormFromPlan();
wireForm(); wireForm();
@@ -687,18 +965,6 @@ async function init() {
wireToolbar(); wireToolbar();
wirePwa(); wirePwa();
wireSectionNav(); wireSectionNav();
fetch("/api/auth/me")
.then((r) => (r.ok ? r.json() : null))
.then((body) => {
if (body?.user?.role === "admin") {
const button = document.getElementById("btn-admin");
button.classList.remove("hidden");
button.onclick = () => {
location.href = "/admin";
};
}
})
.catch(() => {});
initPrefillPanel({ initPrefillPanel({
state, state,
renderBlendRows: renderBlend, renderBlendRows: renderBlend,
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "Roast Planner", "name": "Roast Planner",
"short_name": "Roast Planner", "short_name": "Roast Planner",
"description": "Plan, record, and compare coffee roasts.", "description": "Plan, record, and compare coffee roasts.",
"start_url": "/", "start_url": "/app",
"scope": "/", "scope": "/",
"display": "standalone", "display": "standalone",
"background_color": "#FBF8F4", "background_color": "#FBF8F4",
+18 -9
View File
@@ -1,7 +1,8 @@
const CACHE_NAME = "roast-planner-static-v2"; const CACHE_NAME = "roast-planner-static-v4";
const APP_SHELL = [ const APP_SHELL = [
"/", "/",
"/landing.html", "/landing.html",
"/app",
"/app.css", "/app.css",
"/worksheet.css", "/worksheet.css",
"/manifest.webmanifest", "/manifest.webmanifest",
@@ -23,7 +24,6 @@ self.addEventListener("install", (event) => {
event.waitUntil( event.waitUntil(
caches.open(CACHE_NAME).then((cache) => cache.addAll(APP_SHELL)), caches.open(CACHE_NAME).then((cache) => cache.addAll(APP_SHELL)),
); );
self.skipWaiting();
}); });
self.addEventListener("activate", (event) => { self.addEventListener("activate", (event) => {
@@ -57,16 +57,23 @@ self.addEventListener("fetch", (event) => {
return; return;
if (request.mode === "navigate") { if (request.mode === "navigate") {
// Never serve authenticated application HTML from cache after logout. // /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( event.respondWith(
fetch(request).catch(() => caches.match("/landing.html")), fetch(request).catch(() =>
url.pathname === "/app"
? caches.match("/app")
: caches.match("/landing.html"),
),
); );
return; return;
} }
// Prefer fresh JavaScript and styles so installed clients receive UI/security updates
// immediately; use the cache only when offline.
event.respondWith( event.respondWith(
caches.match(request).then((cached) => { fetch(request)
const update = fetch(request)
.then((response) => { .then((response) => {
if (response.ok) if (response.ok)
caches caches
@@ -74,8 +81,10 @@ self.addEventListener("fetch", (event) => {
.then((cache) => cache.put(request, response.clone())); .then((cache) => cache.put(request, response.clone()));
return response; return response;
}) })
.catch(() => cached); .catch(() => caches.match(request)),
return cached ?? update;
}),
); );
}); });
self.addEventListener("message", (event) => {
if (event.data === "SKIP_WAITING") self.skipWaiting();
});
+5 -2
View File
@@ -76,10 +76,13 @@ test("strict CSP/static modules, no-store data, auth lifecycle, and ownership sh
const mainScript = await anonymous.get("/js/main.js"); const mainScript = await anonymous.get("/js/main.js");
assert.match(mainScript.text, /roastPlannerPlan\.v2/); assert.match(mainScript.text, /roastPlannerPlan\.v2/);
assert.match(mainScript.text, /localStorage\.removeItem\(key\)/); assert.match(mainScript.text, /localStorage\.removeItem\(key\)/);
const serviceWorker = (await anonymous.get("/sw.js")).text;
assert.match(serviceWorker, /data-free authenticated shell/);
assert.match( assert.match(
(await anonymous.get("/sw.js")).text, serviceWorker,
/Never serve authenticated/, /url\.pathname === "\/app"[\s\S]*caches\.match\("\/app"\)/,
); );
assert.match(serviceWorker, /logout clears that namespace/);
const one = await signup(first, "[email protected]"); const one = await signup(first, "[email protected]");
const two = await signup(second, "[email protected]"); const two = await signup(second, "[email protected]");