Elevate planner UX and offline resilience
This commit is contained in:
+72
-2
@@ -43,7 +43,9 @@
|
|||||||
}
|
}
|
||||||
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;
|
||||||
background: var(--surface);
|
background: var(--surface);
|
||||||
@@ -165,6 +167,12 @@ output {
|
|||||||
background: var(--surface-raised);
|
background: var(--surface-raised);
|
||||||
box-shadow: var(--shadow-pop);
|
box-shadow: var(--shadow-pop);
|
||||||
}
|
}
|
||||||
|
.account-email {
|
||||||
|
margin: 4px 10px 7px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--ink-3);
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
.account-menu-popover a,
|
.account-menu-popover a,
|
||||||
.account-menu-popover button {
|
.account-menu-popover button {
|
||||||
display: block;
|
display: block;
|
||||||
@@ -263,8 +271,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;
|
||||||
}
|
}
|
||||||
.autosave-chip.saved .dot {
|
.autosave-chip.saved .dot {
|
||||||
@@ -529,6 +538,38 @@ 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);
|
||||||
@@ -1181,6 +1222,31 @@ 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;
|
||||||
@@ -1200,7 +1266,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;
|
||||||
@@ -1281,6 +1348,9 @@ 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;
|
||||||
|
|||||||
+36
-14
@@ -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 & 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,9 @@
|
|||||||
></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 +112,9 @@
|
|||||||
</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 +136,25 @@
|
|||||||
</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 & 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 +426,7 @@
|
|||||||
|
|
||||||
<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 +796,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 +863,8 @@
|
|||||||
</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">
|
||||||
|
|||||||
+221
-92
@@ -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,61 @@ 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 +203,7 @@ function updateBlendVisibility(mode) {
|
|||||||
function renderBlend() {
|
function renderBlend() {
|
||||||
renderBlendPrintRows();
|
renderBlendPrintRows();
|
||||||
renderBlendCards();
|
renderBlendCards();
|
||||||
|
wireFieldHelp(document.getElementById("blend-cards"));
|
||||||
updateBlendTotal();
|
updateBlendTotal();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -202,6 +261,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 +311,14 @@ 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 +466,67 @@ 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";
|
||||||
|
}
|
||||||
clearTimeout(autosave._t);
|
let syncQueue = Promise.resolve();
|
||||||
autosave._t = setTimeout(() => {
|
function syncPlan(snapshot = structuredClone(state.plan)) {
|
||||||
|
if (!navigator.onLine || !csrfToken()) { setSaveStatus("local"); return Promise.resolve(); }
|
||||||
|
syncQueue = syncQueue.then(async () => {
|
||||||
try {
|
try {
|
||||||
if (storageKey)
|
const response = await protectedFetch(remotePlanId ? `/api/plans/${remotePlanId}` : "/api/plans", {
|
||||||
localStorage.setItem(storageKey, JSON.stringify(state.plan));
|
method: remotePlanId ? "PUT" : "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ plan: snapshot }),
|
||||||
// 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 */
|
|
||||||
});
|
});
|
||||||
}
|
if (!response.ok) throw new Error("sync_failed");
|
||||||
const savedAt = Date.now();
|
const body = await response.json();
|
||||||
chip.classList.remove("saving");
|
remotePlanId = body.plan.id;
|
||||||
chip.classList.add("saved");
|
if (storageKey) localStorage.setItem(storageKey, JSON.stringify({ plan: snapshot, remotePlanId }));
|
||||||
const tick = () => {
|
history.replaceState(null, "", `/app?plan=${encodeURIComponent(remotePlanId)}`);
|
||||||
const secs = Math.round((Date.now() - savedAt) / 1000);
|
setSaveStatus("saved");
|
||||||
text.textContent =
|
await loadPlans();
|
||||||
secs < 8
|
} catch { setSaveStatus("failed"); }
|
||||||
? "Saved just now"
|
});
|
||||||
: secs < 60
|
return syncQueue;
|
||||||
? `Saved ${secs}s ago`
|
}
|
||||||
: `Saved ${Math.round(secs / 60)}m ago`;
|
async function flushCurrentPlan() {
|
||||||
};
|
clearTimeout(autosave._t);
|
||||||
tick();
|
const snapshot = structuredClone(state.plan);
|
||||||
clearInterval(autosaveAgeTimer);
|
try { if (storageKey) localStorage.setItem(storageKey, JSON.stringify({ plan: snapshot, remotePlanId })); }
|
||||||
autosaveAgeTimer = setInterval(tick, 5000);
|
catch { setSaveStatus("failed"); return false; }
|
||||||
} catch {
|
await syncPlan(snapshot);
|
||||||
chip.classList.remove("saving", "saved");
|
return true;
|
||||||
text.textContent = "Save unavailable";
|
}
|
||||||
}
|
function autosave() {
|
||||||
}, 400);
|
setSaveStatus("saving");
|
||||||
|
clearTimeout(autosave._t);
|
||||||
|
autosave._t = setTimeout(() => { flushCurrentPlan(); }, 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 +534,7 @@ 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 +575,72 @@ 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"); }
|
||||||
p.classList.add("hidden");
|
|
||||||
p.setAttribute("aria-hidden", "true");
|
|
||||||
}
|
|
||||||
overlay.classList.add("hidden");
|
overlay.classList.add("hidden");
|
||||||
|
lastDrawerOpener?.focus();
|
||||||
}
|
}
|
||||||
|
function open(panel, opener) {
|
||||||
document
|
lastDrawerOpener = opener;
|
||||||
.getElementById("btn-toggle-prefill")
|
for (const p of drawers) { p.classList.add("hidden"); p.setAttribute("aria-hidden", "true"); }
|
||||||
.addEventListener("click", () => {
|
panel.classList.remove("hidden"); panel.setAttribute("aria-hidden", "false"); overlay.classList.remove("hidden");
|
||||||
prefill.classList.contains("hidden") ? open(prefill) : closeAll();
|
panel.querySelector("button, input, [href]")?.focus();
|
||||||
});
|
}
|
||||||
document.getElementById("btn-toggle-alog").addEventListener("click", () => {
|
for (const [buttonId, panelId] of [["btn-toggle-prefill", "panel-prefill"], ["btn-toggle-alog", "panel-alog"], ["btn-plans", "panel-plans"], ["btn-settings", "panel-settings"]])
|
||||||
alog.classList.contains("hidden") ? open(alog) : closeAll();
|
document.getElementById(buttonId).addEventListener("click", (event) => open(document.getElementById(panelId), event.currentTarget));
|
||||||
});
|
|
||||||
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 +679,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 +724,36 @@ 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.register("/sw.js").then((registration) => {
|
||||||
console.warn("Service worker registration failed:", error);
|
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 +768,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 +788,29 @@ async function init() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const { user } = await meResponse.json();
|
const { user } = await meResponse.json();
|
||||||
if (user.role === "admin")
|
document.getElementById("account-email").textContent = user.email;
|
||||||
document.getElementById("menu-admin").classList.remove("hidden");
|
document.getElementById("settings-email").textContent = user.email;
|
||||||
state.plan = loadFromStorage(user.id) ?? blankPlan();
|
document.getElementById("account-summary").textContent = user.email.split("@")[0];
|
||||||
const response = await fetch("/api/plans");
|
if (user.role === "admin") document.getElementById("menu-admin").classList.remove("hidden");
|
||||||
if (response.ok) {
|
const localDraft = loadFromStorage(user.id);
|
||||||
const body = await response.json();
|
state.plan = localDraft ?? blankPlan();
|
||||||
const latest = body.plans?.[0];
|
await loadPlans();
|
||||||
if (latest) {
|
const requestedId = new URLSearchParams(location.search).get("plan");
|
||||||
state.plan = latest.plan;
|
const selected = plans.find((plan) => plan.id === requestedId);
|
||||||
remotePlanId = latest.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 {
|
} 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();
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
+12
-6
@@ -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,10 +57,12 @@ 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
|
||||||
event.respondWith(
|
// local storage and logout clears that namespace. This makes offline relaunch useful
|
||||||
fetch(request).catch(() => caches.match("/landing.html")),
|
// without ever caching account data or API responses.
|
||||||
);
|
event.respondWith(fetch(request).catch(() =>
|
||||||
|
url.pathname === "/app" ? caches.match("/app") : caches.match("/landing.html"),
|
||||||
|
));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -78,3 +80,7 @@ self.addEventListener("fetch", (event) => {
|
|||||||
.catch(() => caches.match(request)),
|
.catch(() => caches.match(request)),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
self.addEventListener("message", (event) => {
|
||||||
|
if (event.data === "SKIP_WAITING") self.skipWaiting();
|
||||||
|
});
|
||||||
|
|||||||
+4
-4
@@ -76,10 +76,10 @@ 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\)/);
|
||||||
assert.match(
|
const serviceWorker = (await anonymous.get("/sw.js")).text;
|
||||||
(await anonymous.get("/sw.js")).text,
|
assert.match(serviceWorker, /data-free authenticated shell/);
|
||||||
/Never serve authenticated/,
|
assert.match(serviceWorker, /url\.pathname === "\/app" \? 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]");
|
||||||
|
|||||||
Reference in New Issue
Block a user