Fix planner review findings
This commit is contained in:
@@ -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.
|
||||||
|
|||||||
+57
-15
@@ -45,7 +45,9 @@ html {
|
|||||||
background: var(--surface);
|
background: var(--surface);
|
||||||
scroll-padding-top: calc(var(--header-h) + 18px);
|
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 {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
background: var(--surface);
|
background: var(--surface);
|
||||||
@@ -271,9 +273,11 @@ 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.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 {
|
||||||
@@ -544,7 +548,9 @@ background: var(--warn);
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
.field-help-host > .field { width: 100%; }
|
.field-help-host > .field {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
.field-help {
|
.field-help {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: -2px;
|
top: -2px;
|
||||||
@@ -559,7 +565,10 @@ background: var(--warn);
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
z-index: 1;
|
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 {
|
.field-help-text {
|
||||||
margin-top: 2px;
|
margin-top: 2px;
|
||||||
padding: 8px 10px;
|
padding: 8px 10px;
|
||||||
@@ -1238,15 +1247,41 @@ body.show-fids .field[data-fid]::after {
|
|||||||
color: #705318;
|
color: #705318;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
.ledger-warnings p { margin: 0; }
|
.ledger-warnings p {
|
||||||
.plan-list { margin: 0; padding: 0; list-style: none; display: grid; gap: 8px; }
|
margin: 0;
|
||||||
|
}
|
||||||
|
.plan-list {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
.plan-list-item {
|
.plan-list-item {
|
||||||
width: 100%; display: grid; gap: 3px; padding: 12px; text-align: left; border: 1px solid var(--line);
|
width: 100%;
|
||||||
border-radius: var(--radius-sm); background: var(--surface-raised); color: var(--ink); font: inherit; cursor: pointer;
|
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 ───────────────────────────────────────────── */
|
/* ─── PWA and mobile layout ───────────────────────────────────────────── */
|
||||||
.connection-status {
|
.connection-status {
|
||||||
position: sticky;
|
position: sticky;
|
||||||
@@ -1348,9 +1383,16 @@ body.show-fids .field[data-fid]::after {
|
|||||||
.panel-body {
|
.panel-body {
|
||||||
padding: 14px;
|
padding: 14px;
|
||||||
}
|
}
|
||||||
.unknown-condition { margin: 0 14px 4px; }
|
.unknown-condition {
|
||||||
.field-help { width: 22px; height: 22px; }
|
margin: 0 14px 4px;
|
||||||
.field-help-text { font-size: 12px; }
|
}
|
||||||
|
.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;
|
||||||
|
|||||||
+94
-14
@@ -76,7 +76,15 @@
|
|||||||
></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" 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">
|
<div class="drawer-head">
|
||||||
<h3 id="prefill-title">Prefill from a bean product URL</h3>
|
<h3 id="prefill-title">Prefill from a bean product URL</h3>
|
||||||
<button
|
<button
|
||||||
@@ -112,7 +120,15 @@
|
|||||||
</div>
|
</div>
|
||||||
</aside>
|
</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">
|
<div class="drawer-head">
|
||||||
<h3 id="alog-title">Attach an Artisan .alog reference curve</h3>
|
<h3 id="alog-title">Attach an Artisan .alog reference curve</h3>
|
||||||
<button
|
<button
|
||||||
@@ -136,22 +152,75 @@
|
|||||||
</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">
|
<aside
|
||||||
<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>
|
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">
|
<div class="drawer-body">
|
||||||
<button id="btn-new-plan" type="button" class="primary-btn">New plan</button>
|
<button id="btn-new-plan" type="button" class="primary-btn">
|
||||||
<p class="drawer-result">Choose a plan to continue. Every edit is stored on this device first and then synced when online.</p>
|
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>
|
<ul class="plan-list" id="plan-list" aria-live="polite"></ul>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<aside class="drawer hidden" id="panel-settings" aria-hidden="true" role="dialog" aria-modal="true" aria-labelledby="settings-title" tabindex="-1">
|
<aside
|
||||||
<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>
|
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">
|
<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">
|
||||||
<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>
|
<strong id="settings-email"></strong> is the active account. Plans are
|
||||||
<button id="btn-install" type="button" class="primary-btn hidden">Install Roast Planner</button>
|
saved locally first, then synced to your account whenever you are
|
||||||
<button id="btn-refresh" type="button" class="ghost-btn hidden">Update ready — refresh safely</button>
|
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>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
@@ -426,7 +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="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"
|
||||||
@@ -863,7 +938,12 @@
|
|||||||
</div>
|
</div>
|
||||||
</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 checks-card">
|
||||||
<div class="dock-card-head"><h2>Sanity Checks</h2></div>
|
<div class="dock-card-head"><h2>Sanity Checks</h2></div>
|
||||||
|
|||||||
+228
-81
@@ -35,23 +35,38 @@ export const state = { plan: blankPlan() };
|
|||||||
const form = document.getElementById("plan-form");
|
const form = document.getElementById("plan-form");
|
||||||
|
|
||||||
const FIELD_HELP = {
|
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.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.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.",
|
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.",
|
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.",
|
4.3: "Development base in m:ss. Together with the processing and cultivar modifiers it determines development and drop time.",
|
||||||
};
|
};
|
||||||
function helpText(input) {
|
function helpText(input) {
|
||||||
if (FIELD_HELP[input.name]) return FIELD_HELP[input.name];
|
if (FIELD_HELP[input.name]) return FIELD_HELP[input.name];
|
||||||
const unit = input.closest(".unit-input")?.querySelector(".unit")?.textContent;
|
const unit = input
|
||||||
const label = input.closest(".field")?.querySelector(".field-label")?.textContent?.trim() || input.getAttribute("aria-label") || input.placeholder || "This value";
|
.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.`;
|
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) {
|
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;
|
if (!input.closest("#plan-form") && input.id !== "prefill-url") continue;
|
||||||
const field = input.closest(".field");
|
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";
|
input.dataset.helpWired = "true";
|
||||||
if (input.type === "radio" && field) field.dataset.radioHelp = input.name;
|
if (input.type === "radio" && field) field.dataset.radioHelp = input.name;
|
||||||
const id = `field-help-${Math.random().toString(36).slice(2)}`;
|
const id = `field-help-${Math.random().toString(36).slice(2)}`;
|
||||||
@@ -72,12 +87,29 @@ function wireFieldHelp(root = document) {
|
|||||||
help.hidden = !open;
|
help.hidden = !open;
|
||||||
button.setAttribute("aria-expanded", String(open));
|
button.setAttribute("aria-expanded", String(open));
|
||||||
});
|
});
|
||||||
const milestone = input.closest(".milestone-row")?.querySelector(".milestone-label")?.textContent?.trim();
|
const milestone = input
|
||||||
const fieldLabel = field?.querySelector(".field-label")?.textContent?.trim();
|
.closest(".milestone-row")
|
||||||
input.setAttribute("aria-label", input.getAttribute("aria-label") || milestone || fieldLabel || input.placeholder || "Plan value");
|
?.querySelector(".milestone-label")
|
||||||
input.setAttribute("aria-describedby", [input.getAttribute("aria-describedby"), id].filter(Boolean).join(" "));
|
?.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;
|
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) {
|
if (!wrapper && host) {
|
||||||
wrapper = document.createElement("div");
|
wrapper = document.createElement("div");
|
||||||
wrapper.className = "field-help-host";
|
wrapper.className = "field-help-host";
|
||||||
@@ -312,11 +344,13 @@ function renderLedger() {
|
|||||||
fmtOut("check-ceiling", d(ledger.checks.ceiling.valueS));
|
fmtOut("check-ceiling", d(ledger.checks.ceiling.valueS));
|
||||||
|
|
||||||
const warnings = document.getElementById("ledger-warnings");
|
const warnings = document.getElementById("ledger-warnings");
|
||||||
warnings.replaceChildren(...ledger.warnings.map((warning) => {
|
warnings.replaceChildren(
|
||||||
const item = document.createElement("p");
|
...ledger.warnings.map((warning) => {
|
||||||
item.textContent = warning;
|
const item = document.createElement("p");
|
||||||
return item;
|
item.textContent = warning;
|
||||||
}));
|
return item;
|
||||||
|
}),
|
||||||
|
);
|
||||||
warnings.classList.toggle("hidden", ledger.warnings.length === 0);
|
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)) {
|
||||||
@@ -471,39 +505,73 @@ function setSaveStatus(status) {
|
|||||||
const text = chip.querySelector(".autosave-text");
|
const text = chip.querySelector(".autosave-text");
|
||||||
chip.classList.remove("saving", "saved", "failed");
|
chip.classList.remove("saving", "saved", "failed");
|
||||||
chip.classList.add(status);
|
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();
|
let syncQueue = Promise.resolve();
|
||||||
function syncPlan(snapshot = structuredClone(state.plan)) {
|
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 () => {
|
syncQueue = syncQueue.then(async () => {
|
||||||
try {
|
try {
|
||||||
const response = await protectedFetch(remotePlanId ? `/api/plans/${remotePlanId}` : "/api/plans", {
|
const response = await protectedFetch(
|
||||||
method: remotePlanId ? "PUT" : "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ plan: snapshot }),
|
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");
|
if (!response.ok) throw new Error("sync_failed");
|
||||||
const body = await response.json();
|
const body = await response.json();
|
||||||
remotePlanId = body.plan.id;
|
remotePlanId = body.plan.id;
|
||||||
if (storageKey) localStorage.setItem(storageKey, JSON.stringify({ plan: snapshot, remotePlanId }));
|
if (storageKey)
|
||||||
history.replaceState(null, "", `/app?plan=${encodeURIComponent(remotePlanId)}`);
|
localStorage.setItem(
|
||||||
|
storageKey,
|
||||||
|
JSON.stringify({ plan: snapshot, remotePlanId }),
|
||||||
|
);
|
||||||
|
history.replaceState(
|
||||||
|
null,
|
||||||
|
"",
|
||||||
|
`/app?plan=${encodeURIComponent(remotePlanId)}`,
|
||||||
|
);
|
||||||
setSaveStatus("saved");
|
setSaveStatus("saved");
|
||||||
await loadPlans();
|
await loadPlans();
|
||||||
} catch { setSaveStatus("failed"); }
|
} catch {
|
||||||
|
setSaveStatus("failed");
|
||||||
|
}
|
||||||
});
|
});
|
||||||
return syncQueue;
|
return syncQueue;
|
||||||
}
|
}
|
||||||
async function flushCurrentPlan() {
|
async function flushCurrentPlan() {
|
||||||
clearTimeout(autosave._t);
|
clearTimeout(autosave._t);
|
||||||
const snapshot = structuredClone(state.plan);
|
const snapshot = structuredClone(state.plan);
|
||||||
try { if (storageKey) localStorage.setItem(storageKey, JSON.stringify({ plan: snapshot, remotePlanId })); }
|
try {
|
||||||
catch { setSaveStatus("failed"); return false; }
|
if (storageKey)
|
||||||
|
localStorage.setItem(
|
||||||
|
storageKey,
|
||||||
|
JSON.stringify({ plan: snapshot, remotePlanId }),
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
setSaveStatus("failed");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
await syncPlan(snapshot);
|
await syncPlan(snapshot);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
function autosave() {
|
function autosave() {
|
||||||
setSaveStatus("saving");
|
setSaveStatus("saving");
|
||||||
clearTimeout(autosave._t);
|
clearTimeout(autosave._t);
|
||||||
autosave._t = setTimeout(() => { flushCurrentPlan(); }, 400);
|
autosave._t = setTimeout(() => {
|
||||||
|
flushCurrentPlan();
|
||||||
|
}, 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
function loadFromStorage(userId) {
|
function loadFromStorage(userId) {
|
||||||
@@ -514,7 +582,9 @@ function loadFromStorage(userId) {
|
|||||||
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 !== `${STORAGE_PREFIX}:last-user`) ||
|
(key?.startsWith(`${STORAGE_PREFIX}:`) &&
|
||||||
|
key !== storageKey &&
|
||||||
|
key !== `${STORAGE_PREFIX}:last-user`) ||
|
||||||
key === "roastPlannerPlan.v1"
|
key === "roastPlannerPlan.v1"
|
||||||
)
|
)
|
||||||
localStorage.removeItem(key);
|
localStorage.removeItem(key);
|
||||||
@@ -534,7 +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 */ }
|
try {
|
||||||
|
localStorage.removeItem(`${STORAGE_PREFIX}:last-user`);
|
||||||
|
} catch {
|
||||||
|
/* unavailable storage */
|
||||||
|
}
|
||||||
storageKey = null;
|
storageKey = null;
|
||||||
remotePlanId = null;
|
remotePlanId = null;
|
||||||
}
|
}
|
||||||
@@ -577,29 +651,62 @@ function wireDrawers() {
|
|||||||
const overlay = document.getElementById("drawer-overlay");
|
const overlay = document.getElementById("drawer-overlay");
|
||||||
const drawers = [...document.querySelectorAll(".drawer")];
|
const drawers = [...document.querySelectorAll(".drawer")];
|
||||||
function closeAll() {
|
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");
|
overlay.classList.add("hidden");
|
||||||
lastDrawerOpener?.focus();
|
lastDrawerOpener?.focus();
|
||||||
}
|
}
|
||||||
function open(panel, opener) {
|
function open(panel, opener) {
|
||||||
lastDrawerOpener = opener;
|
lastDrawerOpener = opener;
|
||||||
for (const p of drawers) { p.classList.add("hidden"); p.setAttribute("aria-hidden", "true"); }
|
for (const p of drawers) {
|
||||||
panel.classList.remove("hidden"); panel.setAttribute("aria-hidden", "false"); overlay.classList.remove("hidden");
|
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();
|
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"]])
|
for (const [buttonId, panelId] of [
|
||||||
document.getElementById(buttonId).addEventListener("click", (event) => open(document.getElementById(panelId), event.currentTarget));
|
["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);
|
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) => {
|
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 (!activeDrawer) return;
|
||||||
if (event.key === "Escape") { closeAll(); return; }
|
if (event.key === "Escape") {
|
||||||
|
closeAll();
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (event.key !== "Tab") return;
|
if (event.key !== "Tab") return;
|
||||||
const focusable = [...activeDrawer.querySelectorAll("button:not([disabled]), input:not([disabled]), [href]")];
|
const focusable = [
|
||||||
const first = focusable[0], last = focusable.at(-1);
|
...activeDrawer.querySelectorAll(
|
||||||
if (event.shiftKey && document.activeElement === first) { event.preventDefault(); last?.focus(); }
|
"button:not([disabled]), input:not([disabled]), [href]",
|
||||||
else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first?.focus(); }
|
),
|
||||||
|
];
|
||||||
|
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 };
|
return { open, closeAll };
|
||||||
}
|
}
|
||||||
@@ -610,37 +717,50 @@ async function loadPlans() {
|
|||||||
if (!response.ok) return;
|
if (!response.ok) return;
|
||||||
plans = (await response.json()).plans || [];
|
plans = (await response.json()).plans || [];
|
||||||
const list = document.getElementById("plan-list");
|
const list = document.getElementById("plan-list");
|
||||||
list.replaceChildren(...plans.map((plan) => {
|
list.replaceChildren(
|
||||||
const item = document.createElement("li");
|
...plans.map((plan) => {
|
||||||
const button = document.createElement("button");
|
const item = document.createElement("li");
|
||||||
button.type = "button";
|
const button = document.createElement("button");
|
||||||
button.className = "plan-list-item";
|
button.type = "button";
|
||||||
button.classList.toggle("active", plan.id === remotePlanId);
|
button.className = "plan-list-item";
|
||||||
const title = document.createElement("strong");
|
button.classList.toggle("active", plan.id === remotePlanId);
|
||||||
title.textContent = plan.plan?.fields?.["0.1"] || "Untitled plan";
|
const title = document.createElement("strong");
|
||||||
const updated = document.createElement("span");
|
title.textContent = plan.plan?.fields?.["0.1"] || "Untitled plan";
|
||||||
updated.textContent = `Updated ${new Date(plan.updated_at).toLocaleDateString()}`;
|
const updated = document.createElement("span");
|
||||||
button.append(title, updated);
|
updated.textContent = `Updated ${new Date(plan.updated_at).toLocaleDateString()}`;
|
||||||
button.addEventListener("click", () => selectPlan(plan));
|
button.append(title, updated);
|
||||||
item.append(button);
|
button.addEventListener("click", () => selectPlan(plan));
|
||||||
return item;
|
item.append(button);
|
||||||
}));
|
return item;
|
||||||
|
}),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
async function selectPlan(plan) {
|
async function selectPlan(plan) {
|
||||||
if (!(await flushCurrentPlan())) return;
|
if (!(await flushCurrentPlan())) return;
|
||||||
remotePlanId = plan.id;
|
remotePlanId = plan.id;
|
||||||
state.plan = { ...blankPlan(), ...plan.plan };
|
state.plan = { ...blankPlan(), ...plan.plan };
|
||||||
history.replaceState(null, "", `/app?plan=${encodeURIComponent(remotePlanId)}`);
|
history.replaceState(
|
||||||
renderBlend(); renderActuators(); renderFormFromPlan(); recompute();
|
null,
|
||||||
|
"",
|
||||||
|
`/app?plan=${encodeURIComponent(remotePlanId)}`,
|
||||||
|
);
|
||||||
|
renderBlend();
|
||||||
|
renderActuators();
|
||||||
|
renderFormFromPlan();
|
||||||
|
recompute();
|
||||||
document.querySelector("[data-close-drawer]")?.click();
|
document.querySelector("[data-close-drawer]")?.click();
|
||||||
}
|
}
|
||||||
async function newPlan() {
|
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;
|
if (!(await flushCurrentPlan())) return;
|
||||||
remotePlanId = null;
|
remotePlanId = null;
|
||||||
state.plan = blankPlan();
|
state.plan = blankPlan();
|
||||||
history.replaceState(null, "", "/app");
|
history.replaceState(null, "", "/app");
|
||||||
renderBlend(); renderActuators(); renderFormFromPlan(); recompute();
|
renderBlend();
|
||||||
|
renderActuators();
|
||||||
|
renderFormFromPlan();
|
||||||
|
recompute();
|
||||||
}
|
}
|
||||||
|
|
||||||
function wireToolbar() {
|
function wireToolbar() {
|
||||||
@@ -724,30 +844,47 @@ function wirePwa() {
|
|||||||
: "";
|
: "";
|
||||||
};
|
};
|
||||||
|
|
||||||
window.addEventListener("online", () => { renderConnection(); syncPlan(); });
|
window.addEventListener("online", () => {
|
||||||
|
renderConnection();
|
||||||
|
syncPlan();
|
||||||
|
});
|
||||||
window.addEventListener("offline", renderConnection);
|
window.addEventListener("offline", renderConnection);
|
||||||
window.addEventListener("beforeinstallprompt", (event) => {
|
window.addEventListener("beforeinstallprompt", (event) => {
|
||||||
event.preventDefault(); deferredInstallPrompt = event;
|
event.preventDefault();
|
||||||
|
deferredInstallPrompt = event;
|
||||||
document.getElementById("btn-install").classList.remove("hidden");
|
document.getElementById("btn-install").classList.remove("hidden");
|
||||||
});
|
});
|
||||||
document.getElementById("btn-install").addEventListener("click", async () => {
|
document.getElementById("btn-install").addEventListener("click", async () => {
|
||||||
if (!deferredInstallPrompt) return;
|
if (!deferredInstallPrompt) return;
|
||||||
deferredInstallPrompt.prompt(); await deferredInstallPrompt.userChoice;
|
deferredInstallPrompt.prompt();
|
||||||
deferredInstallPrompt = null; document.getElementById("btn-install").classList.add("hidden");
|
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").then((registration) => {
|
navigator.serviceWorker
|
||||||
const showUpdate = () => document.getElementById("btn-refresh").classList.remove("hidden");
|
.register("/sw.js")
|
||||||
if (registration.waiting) showUpdate();
|
.then((registration) => {
|
||||||
registration.addEventListener("updatefound", () => registration.installing?.addEventListener("statechange", () => {
|
const showUpdate = () =>
|
||||||
if (registration.waiting && navigator.serviceWorker.controller) showUpdate();
|
document.getElementById("btn-refresh").classList.remove("hidden");
|
||||||
}));
|
if (registration.waiting) 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 () => {
|
document.getElementById("btn-refresh").addEventListener("click", async () => {
|
||||||
if (!(await flushCurrentPlan())) return;
|
if (!(await flushCurrentPlan())) return;
|
||||||
@@ -790,22 +927,32 @@ async function init() {
|
|||||||
const { user } = await meResponse.json();
|
const { user } = await meResponse.json();
|
||||||
document.getElementById("account-email").textContent = user.email;
|
document.getElementById("account-email").textContent = user.email;
|
||||||
document.getElementById("settings-email").textContent = user.email;
|
document.getElementById("settings-email").textContent = user.email;
|
||||||
document.getElementById("account-summary").textContent = user.email.split("@")[0];
|
document.getElementById("account-summary").textContent =
|
||||||
if (user.role === "admin") document.getElementById("menu-admin").classList.remove("hidden");
|
user.email.split("@")[0];
|
||||||
|
if (user.role === "admin")
|
||||||
|
document.getElementById("menu-admin").classList.remove("hidden");
|
||||||
const localDraft = loadFromStorage(user.id);
|
const localDraft = loadFromStorage(user.id);
|
||||||
state.plan = localDraft ?? blankPlan();
|
state.plan = localDraft ?? blankPlan();
|
||||||
await loadPlans();
|
await loadPlans();
|
||||||
const requestedId = new URLSearchParams(location.search).get("plan");
|
const requestedId = new URLSearchParams(location.search).get("plan");
|
||||||
const selected = plans.find((plan) => plan.id === requestedId);
|
const selected = plans.find((plan) => plan.id === requestedId);
|
||||||
if (selected) { state.plan = selected.plan; remotePlanId = selected.id; }
|
if (selected) {
|
||||||
else if (!localDraft && !requestedId && plans[0]) { state.plan = plans[0].plan; remotePlanId = plans[0].id; }
|
state.plan = selected.plan;
|
||||||
|
remotePlanId = selected.id;
|
||||||
|
} else if (!localDraft && !requestedId && plans[0]) {
|
||||||
|
state.plan = plans[0].plan;
|
||||||
|
remotePlanId = plans[0].id;
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// The cached app shell contains no user data. A successful online sign-in records the
|
// 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.
|
// last account only until logout, allowing that account's local draft to reopen offline.
|
||||||
try {
|
try {
|
||||||
const offlineUserId = localStorage.getItem(`${STORAGE_PREFIX}:last-user`);
|
const offlineUserId = localStorage.getItem(`${STORAGE_PREFIX}:last-user`);
|
||||||
if (offlineUserId && csrfToken()) state.plan = loadFromStorage(offlineUserId) ?? blankPlan();
|
if (offlineUserId && csrfToken())
|
||||||
} catch { /* no local draft is available */ }
|
state.plan = loadFromStorage(offlineUserId) ?? blankPlan();
|
||||||
|
} catch {
|
||||||
|
/* no local draft is available */
|
||||||
|
}
|
||||||
}
|
}
|
||||||
renderBlend();
|
renderBlend();
|
||||||
renderActuators();
|
renderActuators();
|
||||||
|
|||||||
+7
-3
@@ -60,9 +60,13 @@ self.addEventListener("fetch", (event) => {
|
|||||||
// /app is a data-free authenticated shell: draft data lives only in account-scoped
|
// /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
|
// local storage and logout clears that namespace. This makes offline relaunch useful
|
||||||
// without ever caching account data or API responses.
|
// without ever caching account data or API responses.
|
||||||
event.respondWith(fetch(request).catch(() =>
|
event.respondWith(
|
||||||
url.pathname === "/app" ? caches.match("/app") : caches.match("/landing.html"),
|
fetch(request).catch(() =>
|
||||||
));
|
url.pathname === "/app"
|
||||||
|
? caches.match("/app")
|
||||||
|
: caches.match("/landing.html"),
|
||||||
|
),
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+4
-1
@@ -78,7 +78,10 @@ test("strict CSP/static modules, no-store data, auth lifecycle, and ownership sh
|
|||||||
assert.match(mainScript.text, /localStorage\.removeItem\(key\)/);
|
assert.match(mainScript.text, /localStorage\.removeItem\(key\)/);
|
||||||
const serviceWorker = (await anonymous.get("/sw.js")).text;
|
const serviceWorker = (await anonymous.get("/sw.js")).text;
|
||||||
assert.match(serviceWorker, /data-free authenticated shell/);
|
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/);
|
assert.match(serviceWorker, /logout clears that namespace/);
|
||||||
|
|
||||||
const one = await signup(first, "[email protected]");
|
const one = await signup(first, "[email protected]");
|
||||||
|
|||||||
Reference in New Issue
Block a user