Fix planner review findings

This commit is contained in:
2026-07-30 08:11:08 -04:00
parent 7416898bec
commit f3d026a109
6 changed files with 392 additions and 114 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`.
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.
+56 -14
View File
@@ -45,7 +45,9 @@ html {
background: var(--surface);
scroll-padding-top: calc(var(--header-h) + 18px);
}
.panel-card { scroll-margin-top: calc(var(--header-h) + 18px); }
.panel-card {
scroll-margin-top: calc(var(--header-h) + 18px);
}
body {
margin: 0;
background: var(--surface);
@@ -271,7 +273,9 @@ label.filebtn input[type="file"] {
background: var(--ink-3);
transition: background 0.2s;
}
.autosave-chip.failed .dot { background: var(--fail); }
.autosave-chip.failed .dot {
background: var(--fail);
}
.autosave-chip.saving .dot {
background: var(--warn);
animation: pulse 1s ease-in-out infinite;
@@ -544,7 +548,9 @@ background: var(--warn);
flex-direction: column;
min-width: 0;
}
.field-help-host > .field { width: 100%; }
.field-help-host > .field {
width: 100%;
}
.field-help {
position: absolute;
top: -2px;
@@ -559,7 +565,10 @@ background: var(--warn);
cursor: pointer;
z-index: 1;
}
.field-help:focus-visible { outline: 2px solid var(--ember); outline-offset: 2px; }
.field-help:focus-visible {
outline: 2px solid var(--ember);
outline-offset: 2px;
}
.field-help-text {
margin-top: 2px;
padding: 8px 10px;
@@ -1238,15 +1247,41 @@ body.show-fids .field[data-fid]::after {
color: #705318;
font-size: 12px;
}
.ledger-warnings p { margin: 0; }
.plan-list { margin: 0; padding: 0; list-style: none; display: grid; gap: 8px; }
.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;
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);
}
.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 ───────────────────────────────────────────── */
.connection-status {
position: sticky;
@@ -1348,9 +1383,16 @@ body.show-fids .field[data-fid]::after {
.panel-body {
padding: 14px;
}
.unknown-condition { margin: 0 14px 4px; }
.field-help { width: 22px; height: 22px; }
.field-help-text { font-size: 12px; }
.unknown-condition {
margin: 0 14px 4px;
}
.field-help {
width: 22px;
height: 22px;
}
.field-help-text {
font-size: 12px;
}
.field-grid {
grid-template-columns: 1fr;
gap: 12px;
+94 -14
View File
@@ -76,7 +76,15 @@
></div>
<div class="drawer-overlay hidden" id="drawer-overlay"></div>
<aside class="drawer hidden" id="panel-prefill" aria-hidden="true" role="dialog" aria-modal="true" aria-labelledby="prefill-title" tabindex="-1">
<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 id="prefill-title">Prefill from a bean product URL</h3>
<button
@@ -112,7 +120,15 @@
</div>
</aside>
<aside class="drawer hidden" id="panel-alog" aria-hidden="true" role="dialog" aria-modal="true" aria-labelledby="alog-title" tabindex="-1">
<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 id="alog-title">Attach an Artisan .alog reference curve</h3>
<button
@@ -136,22 +152,75 @@
</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>
<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>
<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>
<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>
<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>
@@ -426,7 +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="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"
@@ -863,7 +938,12 @@
</div>
</div>
<div class="ledger-warnings hidden" id="ledger-warnings" role="status" aria-live="polite"></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>
+211 -64
View File
@@ -35,23 +35,38 @@ export const state = { plan: blankPlan() };
const form = document.getElementById("plan-form");
const FIELD_HELP = {
"5.1": "Optional moisture percentage. Find it on a supplier certificate of analysis (COA) or measure it with a calibrated meter. Leave it blank when unknown: it does not automatically change your roast timing.",
"5.2": "Optional green-bean density in g/L. Get it from a supplier COA or measure a known volume. Leave it blank when unknown: it does not automatically change your roast timing.",
"5.6": "A documented timing adjustment in m:ss, normally no more than ±0:15. Use only after an observation or comparison roast; moisture and density never create this value automatically.",
"1.4": "Your first-crack anchor in m:ss. It is the starting point for the Time Ledger; use a cultivar reference or a previous comparable roast.",
"4.3": "Development base in m:ss. Together with the processing and cultivar modifiers it determines development and drop time.",
5.1: "Optional moisture percentage. Find it on a supplier certificate of analysis (COA) or measure it with a calibrated meter. Leave it blank when unknown: it does not automatically change your roast timing.",
5.2: "Optional green-bean density in g/L. Get it from a supplier COA or measure a known volume. Leave it blank when unknown: it does not automatically change your roast timing.",
5.6: "A documented timing adjustment in m:ss, normally no more than ±0:15. Use only after an observation or comparison roast; moisture and density never create this value automatically.",
1.4: "Your first-crack anchor in m:ss. It is the starting point for the Time Ledger; use a cultivar reference or a previous comparable roast.",
4.3: "Development base in m:ss. Together with the processing and cultivar modifiers it determines development and drop time.",
};
function helpText(input) {
if (FIELD_HELP[input.name]) return FIELD_HELP[input.name];
const unit = input.closest(".unit-input")?.querySelector(".unit")?.textContent;
const label = input.closest(".field")?.querySelector(".field-label")?.textContent?.trim() || input.getAttribute("aria-label") || input.placeholder || "This value";
const unit = input
.closest(".unit-input")
?.querySelector(".unit")?.textContent;
const label =
input
.closest(".field")
?.querySelector(".field-label")
?.textContent?.trim() ||
input.getAttribute("aria-label") ||
input.placeholder ||
"This value";
return `${label} records your plan or roast observation${unit ? ` in ${unit}` : ""}. Use the format shown; leave it blank when you do not know it, then refine it from a supplier record or your next roast.`;
}
function wireFieldHelp(root = document) {
for (const input of root.querySelectorAll("input[name], select[name], textarea[name], #prefill-url")) {
for (const input of root.querySelectorAll(
"input[name], select[name], textarea[name], #prefill-url",
)) {
if (!input.closest("#plan-form") && input.id !== "prefill-url") continue;
const field = input.closest(".field");
if (input.dataset.helpWired || (input.type === "radio" && field?.dataset.radioHelp === input.name)) continue;
if (
input.dataset.helpWired ||
(input.type === "radio" && field?.dataset.radioHelp === input.name)
)
continue;
input.dataset.helpWired = "true";
if (input.type === "radio" && field) field.dataset.radioHelp = input.name;
const id = `field-help-${Math.random().toString(36).slice(2)}`;
@@ -72,12 +87,29 @@ function wireFieldHelp(root = document) {
help.hidden = !open;
button.setAttribute("aria-expanded", String(open));
});
const milestone = input.closest(".milestone-row")?.querySelector(".milestone-label")?.textContent?.trim();
const fieldLabel = field?.querySelector(".field-label")?.textContent?.trim();
input.setAttribute("aria-label", input.getAttribute("aria-label") || milestone || fieldLabel || input.placeholder || "Plan value");
input.setAttribute("aria-describedby", [input.getAttribute("aria-describedby"), id].filter(Boolean).join(" "));
const milestone = input
.closest(".milestone-row")
?.querySelector(".milestone-label")
?.textContent?.trim();
const fieldLabel = field
?.querySelector(".field-label")
?.textContent?.trim();
input.setAttribute(
"aria-label",
input.getAttribute("aria-label") ||
milestone ||
fieldLabel ||
input.placeholder ||
"Plan value",
);
input.setAttribute(
"aria-describedby",
[input.getAttribute("aria-describedby"), id].filter(Boolean).join(" "),
);
const host = field || input.closest(".unit-input") || input;
let wrapper = host?.parentElement?.classList.contains("field-help-host") ? host.parentElement : null;
let wrapper = host?.parentElement?.classList.contains("field-help-host")
? host.parentElement
: null;
if (!wrapper && host) {
wrapper = document.createElement("div");
wrapper.className = "field-help-host";
@@ -312,11 +344,13 @@ function renderLedger() {
fmtOut("check-ceiling", d(ledger.checks.ceiling.valueS));
const warnings = document.getElementById("ledger-warnings");
warnings.replaceChildren(...ledger.warnings.map((warning) => {
warnings.replaceChildren(
...ledger.warnings.map((warning) => {
const item = document.createElement("p");
item.textContent = warning;
return item;
}));
}),
);
warnings.classList.toggle("hidden", ledger.warnings.length === 0);
for (const [key, check] of Object.entries(ledger.checks)) {
@@ -471,39 +505,73 @@ function setSaveStatus(status) {
const text = chip.querySelector(".autosave-text");
chip.classList.remove("saving", "saved", "failed");
chip.classList.add(status);
text.textContent = ({ saving: "Saving locally…", saved: "Synced", failed: "Saved locally — sync failed", local: "Saved locally — waiting to sync" })[status] || "Not saved yet";
text.textContent =
{
saving: "Saving locally…",
saved: "Synced",
failed: "Saved locally — sync failed",
local: "Saved locally — waiting to sync",
}[status] || "Not saved yet";
}
let syncQueue = Promise.resolve();
function syncPlan(snapshot = structuredClone(state.plan)) {
if (!navigator.onLine || !csrfToken()) { setSaveStatus("local"); return Promise.resolve(); }
if (!navigator.onLine || !csrfToken()) {
setSaveStatus("local");
return Promise.resolve();
}
syncQueue = syncQueue.then(async () => {
try {
const response = await protectedFetch(remotePlanId ? `/api/plans/${remotePlanId}` : "/api/plans", {
method: remotePlanId ? "PUT" : "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ plan: snapshot }),
});
const response = await protectedFetch(
remotePlanId ? `/api/plans/${remotePlanId}` : "/api/plans",
{
method: remotePlanId ? "PUT" : "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ plan: snapshot }),
},
);
if (!response.ok) throw new Error("sync_failed");
const body = await response.json();
remotePlanId = body.plan.id;
if (storageKey) localStorage.setItem(storageKey, JSON.stringify({ plan: snapshot, remotePlanId }));
history.replaceState(null, "", `/app?plan=${encodeURIComponent(remotePlanId)}`);
if (storageKey)
localStorage.setItem(
storageKey,
JSON.stringify({ plan: snapshot, remotePlanId }),
);
history.replaceState(
null,
"",
`/app?plan=${encodeURIComponent(remotePlanId)}`,
);
setSaveStatus("saved");
await loadPlans();
} catch { setSaveStatus("failed"); }
} catch {
setSaveStatus("failed");
}
});
return syncQueue;
}
async function flushCurrentPlan() {
clearTimeout(autosave._t);
const snapshot = structuredClone(state.plan);
try { if (storageKey) localStorage.setItem(storageKey, JSON.stringify({ plan: snapshot, remotePlanId })); }
catch { setSaveStatus("failed"); return false; }
try {
if (storageKey)
localStorage.setItem(
storageKey,
JSON.stringify({ plan: snapshot, remotePlanId }),
);
} catch {
setSaveStatus("failed");
return false;
}
await syncPlan(snapshot);
return true;
}
function autosave() {
setSaveStatus("saving");
clearTimeout(autosave._t);
autosave._t = setTimeout(() => { flushCurrentPlan(); }, 400);
autosave._t = setTimeout(() => {
flushCurrentPlan();
}, 400);
}
function loadFromStorage(userId) {
@@ -514,7 +582,9 @@ function loadFromStorage(userId) {
for (let i = localStorage.length - 1; i >= 0; i--) {
const key = localStorage.key(i);
if (
(key?.startsWith(`${STORAGE_PREFIX}:`) && key !== storageKey && key !== `${STORAGE_PREFIX}:last-user`) ||
(key?.startsWith(`${STORAGE_PREFIX}:`) &&
key !== storageKey &&
key !== `${STORAGE_PREFIX}:last-user`) ||
key === "roastPlannerPlan.v1"
)
localStorage.removeItem(key);
@@ -534,7 +604,11 @@ function loadFromStorage(userId) {
function clearDraft() {
if (storageKey) localStorage.removeItem(storageKey);
try { localStorage.removeItem(`${STORAGE_PREFIX}:last-user`); } catch { /* unavailable storage */ }
try {
localStorage.removeItem(`${STORAGE_PREFIX}:last-user`);
} catch {
/* unavailable storage */
}
storageKey = null;
remotePlanId = null;
}
@@ -577,29 +651,62 @@ function wireDrawers() {
const overlay = document.getElementById("drawer-overlay");
const drawers = [...document.querySelectorAll(".drawer")];
function closeAll() {
for (const panel of drawers) { panel.classList.add("hidden"); panel.setAttribute("aria-hidden", "true"); }
for (const panel of drawers) {
panel.classList.add("hidden");
panel.setAttribute("aria-hidden", "true");
}
overlay.classList.add("hidden");
lastDrawerOpener?.focus();
}
function open(panel, opener) {
lastDrawerOpener = opener;
for (const p of drawers) { p.classList.add("hidden"); p.setAttribute("aria-hidden", "true"); }
panel.classList.remove("hidden"); panel.setAttribute("aria-hidden", "false"); overlay.classList.remove("hidden");
for (const p of drawers) {
p.classList.add("hidden");
p.setAttribute("aria-hidden", "true");
}
panel.classList.remove("hidden");
panel.setAttribute("aria-hidden", "false");
overlay.classList.remove("hidden");
panel.querySelector("button, input, [href]")?.focus();
}
for (const [buttonId, panelId] of [["btn-toggle-prefill", "panel-prefill"], ["btn-toggle-alog", "panel-alog"], ["btn-plans", "panel-plans"], ["btn-settings", "panel-settings"]])
document.getElementById(buttonId).addEventListener("click", (event) => open(document.getElementById(panelId), event.currentTarget));
for (const [buttonId, panelId] of [
["btn-toggle-prefill", "panel-prefill"],
["btn-toggle-alog", "panel-alog"],
["btn-plans", "panel-plans"],
["btn-settings", "panel-settings"],
])
document
.getElementById(buttonId)
.addEventListener("click", (event) =>
open(document.getElementById(panelId), event.currentTarget),
);
overlay.addEventListener("click", closeAll);
for (const btn of document.querySelectorAll("[data-close-drawer]")) btn.addEventListener("click", closeAll);
for (const btn of document.querySelectorAll("[data-close-drawer]"))
btn.addEventListener("click", closeAll);
document.addEventListener("keydown", (event) => {
const activeDrawer = drawers.find((drawer) => !drawer.classList.contains("hidden"));
const activeDrawer = drawers.find(
(drawer) => !drawer.classList.contains("hidden"),
);
if (!activeDrawer) return;
if (event.key === "Escape") { closeAll(); return; }
if (event.key === "Escape") {
closeAll();
return;
}
if (event.key !== "Tab") return;
const focusable = [...activeDrawer.querySelectorAll("button:not([disabled]), input:not([disabled]), [href]")];
const first = focusable[0], last = focusable.at(-1);
if (event.shiftKey && document.activeElement === first) { event.preventDefault(); last?.focus(); }
else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first?.focus(); }
const focusable = [
...activeDrawer.querySelectorAll(
"button:not([disabled]), input:not([disabled]), [href]",
),
];
const first = focusable[0],
last = focusable.at(-1);
if (event.shiftKey && document.activeElement === first) {
event.preventDefault();
last?.focus();
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault();
first?.focus();
}
});
return { open, closeAll };
}
@@ -610,7 +717,8 @@ async function loadPlans() {
if (!response.ok) return;
plans = (await response.json()).plans || [];
const list = document.getElementById("plan-list");
list.replaceChildren(...plans.map((plan) => {
list.replaceChildren(
...plans.map((plan) => {
const item = document.createElement("li");
const button = document.createElement("button");
button.type = "button";
@@ -624,23 +732,35 @@ async function loadPlans() {
button.addEventListener("click", () => selectPlan(plan));
item.append(button);
return item;
}));
}),
);
}
async function selectPlan(plan) {
if (!(await flushCurrentPlan())) return;
remotePlanId = plan.id;
state.plan = { ...blankPlan(), ...plan.plan };
history.replaceState(null, "", `/app?plan=${encodeURIComponent(remotePlanId)}`);
renderBlend(); renderActuators(); renderFormFromPlan(); recompute();
history.replaceState(
null,
"",
`/app?plan=${encodeURIComponent(remotePlanId)}`,
);
renderBlend();
renderActuators();
renderFormFromPlan();
recompute();
document.querySelector("[data-close-drawer]")?.click();
}
async function newPlan() {
if (!confirm("Start a new plan? Your current plan is already saved locally.")) return;
if (!confirm("Start a new plan? Your current plan is already saved locally."))
return;
if (!(await flushCurrentPlan())) return;
remotePlanId = null;
state.plan = blankPlan();
history.replaceState(null, "", "/app");
renderBlend(); renderActuators(); renderFormFromPlan(); recompute();
renderBlend();
renderActuators();
renderFormFromPlan();
recompute();
}
function wireToolbar() {
@@ -724,30 +844,47 @@ function wirePwa() {
: "";
};
window.addEventListener("online", () => { renderConnection(); syncPlan(); });
window.addEventListener("online", () => {
renderConnection();
syncPlan();
});
window.addEventListener("offline", renderConnection);
window.addEventListener("beforeinstallprompt", (event) => {
event.preventDefault(); deferredInstallPrompt = event;
event.preventDefault();
deferredInstallPrompt = event;
document.getElementById("btn-install").classList.remove("hidden");
});
document.getElementById("btn-install").addEventListener("click", async () => {
if (!deferredInstallPrompt) return;
deferredInstallPrompt.prompt(); await deferredInstallPrompt.userChoice;
deferredInstallPrompt = null; document.getElementById("btn-install").classList.add("hidden");
deferredInstallPrompt.prompt();
await deferredInstallPrompt.userChoice;
deferredInstallPrompt = null;
document.getElementById("btn-install").classList.add("hidden");
});
renderConnection();
if ("serviceWorker" in navigator) {
window.addEventListener("load", () => {
navigator.serviceWorker.register("/sw.js").then((registration) => {
const showUpdate = () => document.getElementById("btn-refresh").classList.remove("hidden");
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));
registration.addEventListener("updatefound", () =>
registration.installing?.addEventListener("statechange", () => {
if (registration.waiting && navigator.serviceWorker.controller)
showUpdate();
}),
);
})
.catch((error) =>
console.warn("Service worker registration failed:", error),
);
});
navigator.serviceWorker.addEventListener("controllerchange", () => location.reload());
navigator.serviceWorker.addEventListener("controllerchange", () =>
location.reload(),
);
}
document.getElementById("btn-refresh").addEventListener("click", async () => {
if (!(await flushCurrentPlan())) return;
@@ -790,22 +927,32 @@ async function init() {
const { user } = await meResponse.json();
document.getElementById("account-email").textContent = user.email;
document.getElementById("settings-email").textContent = user.email;
document.getElementById("account-summary").textContent = user.email.split("@")[0];
if (user.role === "admin") document.getElementById("menu-admin").classList.remove("hidden");
document.getElementById("account-summary").textContent =
user.email.split("@")[0];
if (user.role === "admin")
document.getElementById("menu-admin").classList.remove("hidden");
const localDraft = loadFromStorage(user.id);
state.plan = localDraft ?? blankPlan();
await loadPlans();
const requestedId = new URLSearchParams(location.search).get("plan");
const selected = plans.find((plan) => plan.id === requestedId);
if (selected) { state.plan = selected.plan; remotePlanId = selected.id; }
else if (!localDraft && !requestedId && plans[0]) { state.plan = plans[0].plan; remotePlanId = plans[0].id; }
if (selected) {
state.plan = selected.plan;
remotePlanId = selected.id;
} else if (!localDraft && !requestedId && plans[0]) {
state.plan = plans[0].plan;
remotePlanId = plans[0].id;
}
} catch {
// The cached app shell contains no user data. A successful online sign-in records the
// last account only until logout, allowing that account's local draft to reopen offline.
try {
const offlineUserId = localStorage.getItem(`${STORAGE_PREFIX}:last-user`);
if (offlineUserId && csrfToken()) state.plan = loadFromStorage(offlineUserId) ?? blankPlan();
} catch { /* no local draft is available */ }
if (offlineUserId && csrfToken())
state.plan = loadFromStorage(offlineUserId) ?? blankPlan();
} catch {
/* no local draft is available */
}
}
renderBlend();
renderActuators();
+7 -3
View File
@@ -60,9 +60,13 @@ self.addEventListener("fetch", (event) => {
// /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(() =>
url.pathname === "/app" ? caches.match("/app") : caches.match("/landing.html"),
));
event.respondWith(
fetch(request).catch(() =>
url.pathname === "/app"
? caches.match("/app")
: caches.match("/landing.html"),
),
);
return;
}
+4 -1
View File
@@ -78,7 +78,10 @@ test("strict CSP/static modules, no-store data, auth lifecycle, and ownership sh
assert.match(mainScript.text, /localStorage\.removeItem\(key\)/);
const serviceWorker = (await anonymous.get("/sw.js")).text;
assert.match(serviceWorker, /data-free authenticated shell/);
assert.match(serviceWorker, /url\.pathname === "\/app" \? caches\.match\("\/app"\)/);
assert.match(
serviceWorker,
/url\.pathname === "\/app"[\s\S]*caches\.match\("\/app"\)/,
);
assert.match(serviceWorker, /logout clears that namespace/);
const one = await signup(first, "[email protected]");