Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f3d026a109 | ||
|
|
7416898bec | ||
|
|
057d68156b | ||
|
|
99d7e8f121 | ||
|
|
8f82000e31 | ||
|
|
089ee6916f |
@@ -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`.
|
||||
|
||||
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.
|
||||
|
||||
+1421
-303
File diff suppressed because it is too large
Load Diff
+122
-17
@@ -23,17 +23,15 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav class="section-nav" id="section-nav" aria-label="Jump to section">
|
||||
<a href="#sec-coffee">Coffee</a>
|
||||
<a href="#sec-blend">Blend</a>
|
||||
<a href="#sec-process">Roast target</a>
|
||||
<nav class="section-nav" id="section-nav" aria-label="Plan sections">
|
||||
<a href="#sec-coffee">Plan</a>
|
||||
<a href="#sec-bean">Bean condition</a>
|
||||
<a href="#sec-machine">Machine plan</a>
|
||||
<a href="#sec-roastlog">Roast log</a>
|
||||
<a href="#sec-after">After the roast</a>
|
||||
<a href="#sec-machine">Roast</a>
|
||||
<a href="#sec-after">Learn</a>
|
||||
</nav>
|
||||
|
||||
<div class="header-actions">
|
||||
<button id="btn-plans" type="button" class="ghost-btn">Plans</button>
|
||||
<button
|
||||
id="btn-toggle-fids"
|
||||
type="button"
|
||||
@@ -53,10 +51,15 @@
|
||||
<button id="btn-load" type="button" class="ghost-btn">Load</button>
|
||||
<input id="file-load" type="file" accept="application/json" />
|
||||
<button id="btn-save" type="button" class="ghost-btn">Download</button>
|
||||
<button id="btn-admin" type="button" class="ghost-btn hidden">
|
||||
Admin
|
||||
</button>
|
||||
<button id="btn-logout" type="button" class="ghost-btn">Log out</button>
|
||||
<details class="account-menu">
|
||||
<summary class="ghost-btn" id="account-summary">Account</summary>
|
||||
<div class="account-menu-popover">
|
||||
<p class="account-email" id="account-email"></p>
|
||||
<button id="btn-settings" type="button">Settings & 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>
|
||||
<span class="autosave-chip" id="autosave-status"
|
||||
><span class="dot"></span
|
||||
@@ -73,9 +76,17 @@
|
||||
></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">
|
||||
<h3>Prefill from a bean product URL</h3>
|
||||
<h3 id="prefill-title">Prefill from a bean product URL</h3>
|
||||
<button
|
||||
class="icon-btn"
|
||||
type="button"
|
||||
@@ -109,9 +120,17 @@
|
||||
</div>
|
||||
</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">
|
||||
<h3>Attach an Artisan .alog reference curve</h3>
|
||||
<h3 id="alog-title">Attach an Artisan .alog reference curve</h3>
|
||||
<button
|
||||
class="icon-btn"
|
||||
type="button"
|
||||
@@ -133,6 +152,78 @@
|
||||
</div>
|
||||
</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 & 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">
|
||||
<div class="workspace-grid">
|
||||
<div class="form-col">
|
||||
@@ -404,6 +495,13 @@
|
||||
|
||||
<section class="panel-card" id="sec-bean">
|
||||
<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="field-grid">
|
||||
<label class="field" data-fid="5.1"
|
||||
@@ -773,8 +871,8 @@
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<aside class="dock">
|
||||
<div class="dock-card ledger-card">
|
||||
<aside class="dock" aria-label="Live plan feedback">
|
||||
<div class="dock-card ledger-card" id="plan-now">
|
||||
<div class="dock-card-head"><h2>Time Ledger</h2></div>
|
||||
|
||||
<div class="ledger-group">
|
||||
@@ -840,6 +938,13 @@
|
||||
</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-head"><h2>Sanity Checks</h2></div>
|
||||
<div class="checks-grid">
|
||||
|
||||
+361
-95
@@ -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,93 @@ 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 +235,7 @@ function updateBlendVisibility(mode) {
|
||||
function renderBlend() {
|
||||
renderBlendPrintRows();
|
||||
renderBlendCards();
|
||||
wireFieldHelp(document.getElementById("blend-cards"));
|
||||
updateBlendTotal();
|
||||
}
|
||||
|
||||
@@ -202,6 +293,7 @@ function renderActuatorTimeline() {
|
||||
function renderActuators() {
|
||||
renderActuatorPrintRows();
|
||||
renderActuatorTimeline();
|
||||
wireFieldHelp(document.getElementById("actuator-timeline"));
|
||||
}
|
||||
|
||||
// ---- 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-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 +500,103 @@ 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…";
|
||||
|
||||
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 {
|
||||
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);
|
||||
autosave._t = setTimeout(() => {
|
||||
const snapshot = structuredClone(state.plan);
|
||||
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);
|
||||
localStorage.setItem(
|
||||
storageKey,
|
||||
JSON.stringify({ plan: snapshot, remotePlanId }),
|
||||
);
|
||||
} catch {
|
||||
chip.classList.remove("saving", "saved");
|
||||
text.textContent = "Save unavailable";
|
||||
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 +604,11 @@ 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 +649,118 @@ 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]) {
|
||||
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");
|
||||
}
|
||||
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
|
||||
.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();
|
||||
});
|
||||
overlay.addEventListener("click", closeAll);
|
||||
for (const btn of document.querySelectorAll("[data-close-drawer]")) {
|
||||
btn.addEventListener("click", 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 +799,7 @@ function wireToolbar() {
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById("btn-new-plan").addEventListener("click", newPlan);
|
||||
document.getElementById("btn-add-blend").addEventListener("click", () => {
|
||||
state.plan.blendComponents.push({
|
||||
cultivar: "",
|
||||
@@ -595,7 +817,7 @@ function wireToolbar() {
|
||||
await protectedFetch("/api/auth/logout", { method: "POST" });
|
||||
} finally {
|
||||
clearDraft();
|
||||
location.assign("/");
|
||||
location.replace("/");
|
||||
}
|
||||
});
|
||||
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("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 +905,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,22 +925,39 @@ async function init() {
|
||||
return;
|
||||
}
|
||||
const { user } = await meResponse.json();
|
||||
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();
|
||||
@@ -687,18 +965,6 @@ async function init() {
|
||||
wireToolbar();
|
||||
wirePwa();
|
||||
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({
|
||||
state,
|
||||
renderBlendRows: renderBlend,
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "Roast Planner",
|
||||
"short_name": "Roast Planner",
|
||||
"description": "Plan, record, and compare coffee roasts.",
|
||||
"start_url": "/",
|
||||
"start_url": "/app",
|
||||
"scope": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#FBF8F4",
|
||||
|
||||
+18
-9
@@ -1,7 +1,8 @@
|
||||
const CACHE_NAME = "roast-planner-static-v2";
|
||||
const CACHE_NAME = "roast-planner-static-v4";
|
||||
const APP_SHELL = [
|
||||
"/",
|
||||
"/landing.html",
|
||||
"/app",
|
||||
"/app.css",
|
||||
"/worksheet.css",
|
||||
"/manifest.webmanifest",
|
||||
@@ -23,7 +24,6 @@ self.addEventListener("install", (event) => {
|
||||
event.waitUntil(
|
||||
caches.open(CACHE_NAME).then((cache) => cache.addAll(APP_SHELL)),
|
||||
);
|
||||
self.skipWaiting();
|
||||
});
|
||||
|
||||
self.addEventListener("activate", (event) => {
|
||||
@@ -57,16 +57,23 @@ self.addEventListener("fetch", (event) => {
|
||||
return;
|
||||
|
||||
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(
|
||||
fetch(request).catch(() => caches.match("/landing.html")),
|
||||
fetch(request).catch(() =>
|
||||
url.pathname === "/app"
|
||||
? caches.match("/app")
|
||||
: caches.match("/landing.html"),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Prefer fresh JavaScript and styles so installed clients receive UI/security updates
|
||||
// immediately; use the cache only when offline.
|
||||
event.respondWith(
|
||||
caches.match(request).then((cached) => {
|
||||
const update = fetch(request)
|
||||
fetch(request)
|
||||
.then((response) => {
|
||||
if (response.ok)
|
||||
caches
|
||||
@@ -74,8 +81,10 @@ self.addEventListener("fetch", (event) => {
|
||||
.then((cache) => cache.put(request, response.clone()));
|
||||
return response;
|
||||
})
|
||||
.catch(() => cached);
|
||||
return cached ?? update;
|
||||
}),
|
||||
.catch(() => caches.match(request)),
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener("message", (event) => {
|
||||
if (event.data === "SKIP_WAITING") self.skipWaiting();
|
||||
});
|
||||
|
||||
+5
-2
@@ -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");
|
||||
assert.match(mainScript.text, /roastPlannerPlan\.v2/);
|
||||
assert.match(mainScript.text, /localStorage\.removeItem\(key\)/);
|
||||
const serviceWorker = (await anonymous.get("/sw.js")).text;
|
||||
assert.match(serviceWorker, /data-free authenticated shell/);
|
||||
assert.match(
|
||||
(await anonymous.get("/sw.js")).text,
|
||||
/Never serve authenticated/,
|
||||
serviceWorker,
|
||||
/url\.pathname === "\/app"[\s\S]*caches\.match\("\/app"\)/,
|
||||
);
|
||||
assert.match(serviceWorker, /logout clears that namespace/);
|
||||
|
||||
const one = await signup(first, "[email protected]");
|
||||
const two = await signup(second, "[email protected]");
|
||||
|
||||
Reference in New Issue
Block a user