3 Commits
Author SHA1 Message Date
snowspeeder f3d026a109 Fix planner review findings 2026-07-30 08:11:08 -04:00
snowspeeder 7416898bec Elevate planner UX and offline resilience 2026-07-30 07:03:35 -04:00
snowspeeder 057d68156b Polish planner styling 2026-07-30 06:37:41 -04:00
7 changed files with 650 additions and 118 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.
+151 -12
View File
@@ -43,6 +43,10 @@
} }
html { html {
background: var(--surface); background: var(--surface);
scroll-padding-top: calc(var(--header-h) + 18px);
}
.panel-card {
scroll-margin-top: calc(var(--header-h) + 18px);
} }
body { body {
margin: 0; margin: 0;
@@ -144,20 +148,53 @@ output {
background: var(--line); background: var(--line);
margin: 0 2px; margin: 0 2px;
} }
.account-menu { position: relative; } .account-menu {
.account-menu summary { list-style: none; } position: relative;
.account-menu summary::-webkit-details-marker { display: none; } }
.account-menu summary {
list-style: none;
}
.account-menu summary::-webkit-details-marker {
display: none;
}
.account-menu-popover { .account-menu-popover {
position: absolute; right: 0; top: calc(100% + 6px); z-index: 35; min-width: 132px; position: absolute;
padding: 6px; border: 1px solid var(--line); border-radius: var(--radius-sm); right: 0;
background: var(--surface-raised); box-shadow: var(--shadow-pop); top: calc(100% + 6px);
z-index: 35;
min-width: 132px;
padding: 6px;
border: 1px solid var(--line);
border-radius: var(--radius-sm);
background: var(--surface-raised);
box-shadow: var(--shadow-pop);
} }
.account-menu-popover a, .account-menu-popover button { .account-email {
display: block; width: 100%; padding: 9px 10px; border: 0; border-radius: var(--radius-sm); margin: 4px 10px 7px;
background: transparent; color: var(--ink-2); font: inherit; font-size: 13px; text-align: left; font-size: 11px;
text-decoration: none; cursor: pointer; color: var(--ink-3);
overflow-wrap: anywhere;
}
.account-menu-popover a,
.account-menu-popover button {
display: block;
width: 100%;
padding: 9px 10px;
border: 0;
border-radius: var(--radius-sm);
background: transparent;
color: var(--ink-2);
font: inherit;
font-size: 13px;
text-align: left;
text-decoration: none;
cursor: pointer;
}
.account-menu-popover a:hover,
.account-menu-popover button:hover {
background: var(--surface-sunken);
color: var(--ink);
} }
.account-menu-popover a:hover, .account-menu-popover button:hover { background: var(--surface-sunken); color: var(--ink); }
.ghost-btn, .ghost-btn,
.primary-btn, .primary-btn,
@@ -236,6 +273,9 @@ label.filebtn input[type="file"] {
background: var(--ink-3); background: var(--ink-3);
transition: background 0.2s; transition: background 0.2s;
} }
.autosave-chip.failed .dot {
background: var(--fail);
}
.autosave-chip.saving .dot { .autosave-chip.saving .dot {
background: var(--warn); background: var(--warn);
animation: pulse 1s ease-in-out infinite; animation: pulse 1s ease-in-out infinite;
@@ -502,6 +542,43 @@ label.filebtn input[type="file"] {
align-items: center; align-items: center;
gap: 4px; gap: 4px;
} }
.field-help-host {
position: relative;
display: flex;
flex-direction: column;
min-width: 0;
}
.field-help-host > .field {
width: 100%;
}
.field-help {
position: absolute;
top: -2px;
right: 0;
width: 18px;
height: 18px;
border: 1px solid var(--line-strong);
border-radius: 50%;
background: var(--surface-raised);
color: var(--ember-strong);
font-weight: 800;
cursor: pointer;
z-index: 1;
}
.field-help:focus-visible {
outline: 2px solid var(--ember);
outline-offset: 2px;
}
.field-help-text {
margin-top: 2px;
padding: 8px 10px;
border-left: 2px solid var(--ember);
border-radius: 0 var(--radius-sm) var(--radius-sm) 0;
background: var(--ember-soft);
font-size: 11.5px;
line-height: 1.45;
color: var(--ink-2);
}
.hint { .hint {
font-size: 11px; font-size: 11px;
color: var(--ink-3); color: var(--ink-3);
@@ -1154,6 +1231,57 @@ body.show-fids .field[data-fid]::after {
border: 1px solid var(--line); border: 1px solid var(--line);
} }
.unknown-condition {
margin: 0 22px 4px;
padding: 10px 12px;
border-left: 3px solid var(--ember);
background: var(--ember-soft);
font-size: 12.5px;
color: var(--ink-2);
}
.ledger-warnings {
padding: 10px 12px;
border: 1px solid #e8d49e;
border-radius: var(--radius-md);
background: var(--warn-bg);
color: #705318;
font-size: 12px;
}
.ledger-warnings p {
margin: 0;
}
.plan-list {
margin: 0;
padding: 0;
list-style: none;
display: grid;
gap: 8px;
}
.plan-list-item {
width: 100%;
display: grid;
gap: 3px;
padding: 12px;
text-align: left;
border: 1px solid var(--line);
border-radius: var(--radius-sm);
background: var(--surface-raised);
color: var(--ink);
font: inherit;
cursor: pointer;
}
.plan-list-item:hover,
.plan-list-item.active {
border-color: var(--ember);
background: var(--ember-soft);
}
.plan-list-item strong {
font-size: 13px;
}
.plan-list-item span {
font-size: 11px;
color: var(--ink-3);
}
/* ─── PWA and mobile layout ───────────────────────────────────────────── */ /* ─── PWA and mobile layout ───────────────────────────────────────────── */
.connection-status { .connection-status {
position: sticky; position: sticky;
@@ -1173,7 +1301,8 @@ body.show-fids .field[data-fid]::after {
@media (max-width: 720px) { @media (max-width: 720px) {
:root { :root {
--header-h: auto; /* The mobile header is multi-row; keep anchor offsets valid rather than using auto in calc(). */
--header-h: 220px;
} }
body { body {
font-size: 16px; font-size: 16px;
@@ -1254,6 +1383,16 @@ body.show-fids .field[data-fid]::after {
.panel-body { .panel-body {
padding: 14px; padding: 14px;
} }
.unknown-condition {
margin: 0 14px 4px;
}
.field-help {
width: 22px;
height: 22px;
}
.field-help-text {
font-size: 12px;
}
.field-grid { .field-grid {
grid-template-columns: 1fr; grid-template-columns: 1fr;
gap: 12px; gap: 12px;
+116 -14
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"
@@ -54,8 +52,10 @@
<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>
<details class="account-menu"> <details class="account-menu">
<summary class="ghost-btn">Account</summary> <summary class="ghost-btn" id="account-summary">Account</summary>
<div class="account-menu-popover"> <div class="account-menu-popover">
<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> <a id="menu-admin" class="hidden" href="/admin">Admin</a>
<button id="btn-logout" type="button">Log out</button> <button id="btn-logout" type="button">Log out</button>
</div> </div>
@@ -76,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"
@@ -112,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"
@@ -136,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">
@@ -407,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"
@@ -776,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">
@@ -843,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">
+356 -80
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 document
.getElementById("btn-toggle-prefill") .getElementById(buttonId)
.addEventListener("click", () => { .addEventListener("click", (event) =>
prefill.classList.contains("hidden") ? open(prefill) : closeAll(); open(document.getElementById(panelId), event.currentTarget),
}); );
document.getElementById("btn-toggle-alog").addEventListener("click", () => {
alog.classList.contains("hidden") ? open(alog) : closeAll();
});
overlay.addEventListener("click", closeAll); overlay.addEventListener("click", closeAll);
for (const btn of document.querySelectorAll("[data-close-drawer]")) { for (const btn of document.querySelectorAll("[data-close-drawer]"))
btn.addEventListener("click", closeAll); 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() { 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: "",
@@ -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,24 +925,39 @@ async function init() {
return; return;
} }
const { user } = await meResponse.json(); 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") if (user.role === "admin")
document.getElementById("menu-admin").classList.remove("hidden"); document.getElementById("menu-admin").classList.remove("hidden");
state.plan = loadFromStorage(user.id) ?? blankPlan(); const localDraft = loadFromStorage(user.id);
const response = await fetch("/api/plans"); state.plan = localDraft ?? blankPlan();
if (response.ok) { await loadPlans();
const body = await response.json(); const requestedId = new URLSearchParams(location.search).get("plan");
const latest = body.plans?.[0]; const selected = plans.find((plan) => plan.id === requestedId);
if (latest) { if (selected) {
state.plan = latest.plan; state.plan = selected.plan;
remotePlanId = latest.id; 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();
+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",
+14 -4
View File
@@ -1,7 +1,8 @@
const CACHE_NAME = "roast-planner-static-v3"; 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,9 +57,15 @@ 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;
} }
@@ -78,3 +84,7 @@ self.addEventListener("fetch", (event) => {
.catch(() => caches.match(request)), .catch(() => caches.match(request)),
); );
}); });
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]");