Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f3d026a109 | ||
|
|
7416898bec | ||
|
|
057d68156b | ||
|
|
99d7e8f121 | ||
|
|
8f82000e31 | ||
|
|
089ee6916f |
@@ -2,4 +2,6 @@
|
|||||||
|
|
||||||
Run `npm test` before every commit and whenever changing authentication, API authorization, service-worker behavior, or persistence. Add a regression test under `test/` for every new route and for every security/ownership bug. Tests use an in-memory PostgreSQL-compatible database; production migration is run automatically at startup against `DATABASE_URL`.
|
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.
|
||||||
|
|||||||
+1486
-368
File diff suppressed because it is too large
Load Diff
+122
-17
@@ -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"
|
||||||
@@ -53,10 +51,15 @@
|
|||||||
<button id="btn-load" type="button" class="ghost-btn">Load</button>
|
<button id="btn-load" type="button" class="ghost-btn">Load</button>
|
||||||
<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>
|
||||||
<button id="btn-admin" type="button" class="ghost-btn hidden">
|
<details class="account-menu">
|
||||||
Admin
|
<summary class="ghost-btn" id="account-summary">Account</summary>
|
||||||
</button>
|
<div class="account-menu-popover">
|
||||||
<button id="btn-logout" type="button" class="ghost-btn">Log out</button>
|
<p class="account-email" id="account-email"></p>
|
||||||
|
<button id="btn-settings" type="button">Settings & sync</button>
|
||||||
|
<a id="menu-admin" class="hidden" href="/admin">Admin</a>
|
||||||
|
<button id="btn-logout" type="button">Log out</button>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
<button id="btn-print" type="button" class="primary-btn">Print</button>
|
<button id="btn-print" type="button" class="primary-btn">Print</button>
|
||||||
<span class="autosave-chip" id="autosave-status"
|
<span class="autosave-chip" id="autosave-status"
|
||||||
><span class="dot"></span
|
><span class="dot"></span
|
||||||
@@ -73,9 +76,17 @@
|
|||||||
></div>
|
></div>
|
||||||
<div class="drawer-overlay hidden" id="drawer-overlay"></div>
|
<div class="drawer-overlay hidden" id="drawer-overlay"></div>
|
||||||
|
|
||||||
<aside class="drawer hidden" id="panel-prefill" aria-hidden="true">
|
<aside
|
||||||
|
class="drawer hidden"
|
||||||
|
id="panel-prefill"
|
||||||
|
aria-hidden="true"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="prefill-title"
|
||||||
|
tabindex="-1"
|
||||||
|
>
|
||||||
<div class="drawer-head">
|
<div class="drawer-head">
|
||||||
<h3>Prefill from a bean product URL</h3>
|
<h3 id="prefill-title">Prefill from a bean product URL</h3>
|
||||||
<button
|
<button
|
||||||
class="icon-btn"
|
class="icon-btn"
|
||||||
type="button"
|
type="button"
|
||||||
@@ -109,9 +120,17 @@
|
|||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<aside class="drawer hidden" id="panel-alog" aria-hidden="true">
|
<aside
|
||||||
|
class="drawer hidden"
|
||||||
|
id="panel-alog"
|
||||||
|
aria-hidden="true"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="alog-title"
|
||||||
|
tabindex="-1"
|
||||||
|
>
|
||||||
<div class="drawer-head">
|
<div class="drawer-head">
|
||||||
<h3>Attach an Artisan .alog reference curve</h3>
|
<h3 id="alog-title">Attach an Artisan .alog reference curve</h3>
|
||||||
<button
|
<button
|
||||||
class="icon-btn"
|
class="icon-btn"
|
||||||
type="button"
|
type="button"
|
||||||
@@ -133,6 +152,78 @@
|
|||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
|
<aside
|
||||||
|
class="drawer hidden"
|
||||||
|
id="panel-plans"
|
||||||
|
aria-hidden="true"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="plans-title"
|
||||||
|
tabindex="-1"
|
||||||
|
>
|
||||||
|
<div class="drawer-head">
|
||||||
|
<h3 id="plans-title">Your plans</h3>
|
||||||
|
<button
|
||||||
|
class="icon-btn"
|
||||||
|
type="button"
|
||||||
|
data-close-drawer
|
||||||
|
aria-label="Close"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="drawer-body">
|
||||||
|
<button id="btn-new-plan" type="button" class="primary-btn">
|
||||||
|
New plan
|
||||||
|
</button>
|
||||||
|
<p class="drawer-result">
|
||||||
|
Choose a plan to continue. Every edit is stored on this device first
|
||||||
|
and then synced when online.
|
||||||
|
</p>
|
||||||
|
<ul class="plan-list" id="plan-list" aria-live="polite"></ul>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<aside
|
||||||
|
class="drawer hidden"
|
||||||
|
id="panel-settings"
|
||||||
|
aria-hidden="true"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="settings-title"
|
||||||
|
tabindex="-1"
|
||||||
|
>
|
||||||
|
<div class="drawer-head">
|
||||||
|
<h3 id="settings-title">Settings & 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">
|
||||||
@@ -404,6 +495,13 @@
|
|||||||
|
|
||||||
<section class="panel-card" id="sec-bean">
|
<section class="panel-card" id="sec-bean">
|
||||||
<div class="panel-head"><h2>Bean Condition</h2></div>
|
<div class="panel-head"><h2>Bean Condition</h2></div>
|
||||||
|
<div class="unknown-condition" role="note">
|
||||||
|
<strong>Don't have lab measurements?</strong> Moisture and density
|
||||||
|
are optional. Leave them blank and record only evidence you have
|
||||||
|
(supplier COA, lot notes, or your next roast). They never change
|
||||||
|
the ledger automatically. Use Net correction only when you can
|
||||||
|
explain the observation behind it.
|
||||||
|
</div>
|
||||||
<div class="panel-body">
|
<div class="panel-body">
|
||||||
<div class="field-grid">
|
<div class="field-grid">
|
||||||
<label class="field" data-fid="5.1"
|
<label class="field" data-fid="5.1"
|
||||||
@@ -773,8 +871,8 @@
|
|||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<aside class="dock">
|
<aside class="dock" aria-label="Live plan feedback">
|
||||||
<div class="dock-card ledger-card">
|
<div class="dock-card ledger-card" id="plan-now">
|
||||||
<div class="dock-card-head"><h2>Time Ledger</h2></div>
|
<div class="dock-card-head"><h2>Time Ledger</h2></div>
|
||||||
|
|
||||||
<div class="ledger-group">
|
<div class="ledger-group">
|
||||||
@@ -840,6 +938,13 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="ledger-warnings hidden"
|
||||||
|
id="ledger-warnings"
|
||||||
|
role="status"
|
||||||
|
aria-live="polite"
|
||||||
|
></div>
|
||||||
|
|
||||||
<div class="dock-card checks-card">
|
<div class="dock-card checks-card">
|
||||||
<div class="dock-card-head"><h2>Sanity Checks</h2></div>
|
<div class="dock-card-head"><h2>Sanity Checks</h2></div>
|
||||||
<div class="checks-grid">
|
<div class="checks-grid">
|
||||||
|
|||||||
+359
-93
@@ -15,6 +15,9 @@ const FIELD_ID_SET = new Set(FIELD_IDS);
|
|||||||
const STORAGE_PREFIX = "roastPlannerPlan.v2";
|
const STORAGE_PREFIX = "roastPlannerPlan.v2";
|
||||||
let storageKey = null;
|
let storageKey = null;
|
||||||
let remotePlanId = null;
|
let remotePlanId = null;
|
||||||
|
let plans = [];
|
||||||
|
let lastDrawerOpener = null;
|
||||||
|
let deferredInstallPrompt = null;
|
||||||
const csrfToken = () =>
|
const csrfToken = () =>
|
||||||
document.cookie
|
document.cookie
|
||||||
.split("; ")
|
.split("; ")
|
||||||
@@ -31,6 +34,93 @@ export const state = { plan: blankPlan() };
|
|||||||
|
|
||||||
const form = document.getElementById("plan-form");
|
const form = document.getElementById("plan-form");
|
||||||
|
|
||||||
|
const FIELD_HELP = {
|
||||||
|
5.1: "Optional moisture percentage. Find it on a supplier certificate of analysis (COA) or measure it with a calibrated meter. Leave it blank when unknown: it does not automatically change your roast timing.",
|
||||||
|
5.2: "Optional green-bean density in g/L. Get it from a supplier COA or measure a known volume. Leave it blank when unknown: it does not automatically change your roast timing.",
|
||||||
|
5.6: "A documented timing adjustment in m:ss, normally no more than ±0:15. Use only after an observation or comparison roast; moisture and density never create this value automatically.",
|
||||||
|
1.4: "Your first-crack anchor in m:ss. It is the starting point for the Time Ledger; use a cultivar reference or a previous comparable roast.",
|
||||||
|
4.3: "Development base in m:ss. Together with the processing and cultivar modifiers it determines development and drop time.",
|
||||||
|
};
|
||||||
|
function helpText(input) {
|
||||||
|
if (FIELD_HELP[input.name]) return FIELD_HELP[input.name];
|
||||||
|
const unit = input
|
||||||
|
.closest(".unit-input")
|
||||||
|
?.querySelector(".unit")?.textContent;
|
||||||
|
const label =
|
||||||
|
input
|
||||||
|
.closest(".field")
|
||||||
|
?.querySelector(".field-label")
|
||||||
|
?.textContent?.trim() ||
|
||||||
|
input.getAttribute("aria-label") ||
|
||||||
|
input.placeholder ||
|
||||||
|
"This value";
|
||||||
|
return `${label} records your plan or roast observation${unit ? ` in ${unit}` : ""}. Use the format shown; leave it blank when you do not know it, then refine it from a supplier record or your next roast.`;
|
||||||
|
}
|
||||||
|
function wireFieldHelp(root = document) {
|
||||||
|
for (const input of root.querySelectorAll(
|
||||||
|
"input[name], select[name], textarea[name], #prefill-url",
|
||||||
|
)) {
|
||||||
|
if (!input.closest("#plan-form") && input.id !== "prefill-url") continue;
|
||||||
|
const field = input.closest(".field");
|
||||||
|
if (
|
||||||
|
input.dataset.helpWired ||
|
||||||
|
(input.type === "radio" && field?.dataset.radioHelp === input.name)
|
||||||
|
)
|
||||||
|
continue;
|
||||||
|
input.dataset.helpWired = "true";
|
||||||
|
if (input.type === "radio" && field) field.dataset.radioHelp = input.name;
|
||||||
|
const id = `field-help-${Math.random().toString(36).slice(2)}`;
|
||||||
|
const help = document.createElement("span");
|
||||||
|
help.id = id;
|
||||||
|
help.className = "field-help-text";
|
||||||
|
help.hidden = true;
|
||||||
|
help.textContent = helpText(input);
|
||||||
|
const button = document.createElement("button");
|
||||||
|
button.type = "button";
|
||||||
|
button.className = "field-help";
|
||||||
|
button.setAttribute("aria-expanded", "false");
|
||||||
|
button.setAttribute("aria-controls", id);
|
||||||
|
button.setAttribute("aria-label", `Learn about ${input.name}`);
|
||||||
|
button.textContent = "?";
|
||||||
|
button.addEventListener("click", () => {
|
||||||
|
const open = help.hidden;
|
||||||
|
help.hidden = !open;
|
||||||
|
button.setAttribute("aria-expanded", String(open));
|
||||||
|
});
|
||||||
|
const milestone = input
|
||||||
|
.closest(".milestone-row")
|
||||||
|
?.querySelector(".milestone-label")
|
||||||
|
?.textContent?.trim();
|
||||||
|
const fieldLabel = field
|
||||||
|
?.querySelector(".field-label")
|
||||||
|
?.textContent?.trim();
|
||||||
|
input.setAttribute(
|
||||||
|
"aria-label",
|
||||||
|
input.getAttribute("aria-label") ||
|
||||||
|
milestone ||
|
||||||
|
fieldLabel ||
|
||||||
|
input.placeholder ||
|
||||||
|
"Plan value",
|
||||||
|
);
|
||||||
|
input.setAttribute(
|
||||||
|
"aria-describedby",
|
||||||
|
[input.getAttribute("aria-describedby"), id].filter(Boolean).join(" "),
|
||||||
|
);
|
||||||
|
const host = field || input.closest(".unit-input") || input;
|
||||||
|
let wrapper = host?.parentElement?.classList.contains("field-help-host")
|
||||||
|
? host.parentElement
|
||||||
|
: null;
|
||||||
|
if (!wrapper && host) {
|
||||||
|
wrapper = document.createElement("div");
|
||||||
|
wrapper.className = "field-help-host";
|
||||||
|
host.replaceWith(wrapper);
|
||||||
|
wrapper.append(host);
|
||||||
|
}
|
||||||
|
// Labels cannot contain another interactive control, so the help button is a sibling.
|
||||||
|
wrapper?.append(button, help);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// The print worksheet (#print-sheet) sits outside #plan-form on purpose — see index.html —
|
// The print worksheet (#print-sheet) sits outside #plan-form on purpose — see index.html —
|
||||||
// so its radios don't fight the screen form's identically-named radios for exclusivity.
|
// so its radios don't fight the screen form's identically-named radios for exclusivity.
|
||||||
// Every sync pass therefore has to reach both containers explicitly.
|
// Every sync pass therefore has to reach both containers explicitly.
|
||||||
@@ -145,6 +235,7 @@ function updateBlendVisibility(mode) {
|
|||||||
function renderBlend() {
|
function renderBlend() {
|
||||||
renderBlendPrintRows();
|
renderBlendPrintRows();
|
||||||
renderBlendCards();
|
renderBlendCards();
|
||||||
|
wireFieldHelp(document.getElementById("blend-cards"));
|
||||||
updateBlendTotal();
|
updateBlendTotal();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -202,6 +293,7 @@ function renderActuatorTimeline() {
|
|||||||
function renderActuators() {
|
function renderActuators() {
|
||||||
renderActuatorPrintRows();
|
renderActuatorPrintRows();
|
||||||
renderActuatorTimeline();
|
renderActuatorTimeline();
|
||||||
|
wireFieldHelp(document.getElementById("actuator-timeline"));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- populate every control in the form from state.plan
|
// ---- populate every control in the form from state.plan
|
||||||
@@ -251,6 +343,16 @@ function renderLedger() {
|
|||||||
fmtOut("check-dtr", pct(ledger.checks.dtr.pct));
|
fmtOut("check-dtr", pct(ledger.checks.dtr.pct));
|
||||||
fmtOut("check-ceiling", d(ledger.checks.ceiling.valueS));
|
fmtOut("check-ceiling", d(ledger.checks.ceiling.valueS));
|
||||||
|
|
||||||
|
const warnings = document.getElementById("ledger-warnings");
|
||||||
|
warnings.replaceChildren(
|
||||||
|
...ledger.warnings.map((warning) => {
|
||||||
|
const item = document.createElement("p");
|
||||||
|
item.textContent = warning;
|
||||||
|
return item;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
warnings.classList.toggle("hidden", ledger.warnings.length === 0);
|
||||||
|
|
||||||
for (const [key, check] of Object.entries(ledger.checks)) {
|
for (const [key, check] of Object.entries(ledger.checks)) {
|
||||||
for (const cell of document.querySelectorAll(`[data-pass="${key}"]`)) {
|
for (const cell of document.querySelectorAll(`[data-pass="${key}"]`)) {
|
||||||
cell.classList.remove("pass", "fail", "unknown");
|
cell.classList.remove("pass", "fail", "unknown");
|
||||||
@@ -398,72 +500,103 @@ export function recompute() {
|
|||||||
autosave();
|
autosave();
|
||||||
}
|
}
|
||||||
|
|
||||||
let autosaveAgeTimer = null;
|
function setSaveStatus(status) {
|
||||||
function autosave() {
|
|
||||||
const chip = document.getElementById("autosave-status");
|
const chip = document.getElementById("autosave-status");
|
||||||
const text = chip.querySelector(".autosave-text");
|
const text = chip.querySelector(".autosave-text");
|
||||||
chip.classList.remove("saved");
|
chip.classList.remove("saving", "saved", "failed");
|
||||||
chip.classList.add("saving");
|
chip.classList.add(status);
|
||||||
text.textContent = "Saving…";
|
text.textContent =
|
||||||
|
{
|
||||||
|
saving: "Saving locally…",
|
||||||
|
saved: "Synced",
|
||||||
|
failed: "Saved locally — sync failed",
|
||||||
|
local: "Saved locally — waiting to sync",
|
||||||
|
}[status] || "Not saved yet";
|
||||||
|
}
|
||||||
|
let syncQueue = Promise.resolve();
|
||||||
|
function syncPlan(snapshot = structuredClone(state.plan)) {
|
||||||
|
if (!navigator.onLine || !csrfToken()) {
|
||||||
|
setSaveStatus("local");
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
syncQueue = syncQueue.then(async () => {
|
||||||
|
try {
|
||||||
|
const response = await protectedFetch(
|
||||||
|
remotePlanId ? `/api/plans/${remotePlanId}` : "/api/plans",
|
||||||
|
{
|
||||||
|
method: remotePlanId ? "PUT" : "POST",
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify({ plan: snapshot }),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (!response.ok) throw new Error("sync_failed");
|
||||||
|
const body = await response.json();
|
||||||
|
remotePlanId = body.plan.id;
|
||||||
|
if (storageKey)
|
||||||
|
localStorage.setItem(
|
||||||
|
storageKey,
|
||||||
|
JSON.stringify({ plan: snapshot, remotePlanId }),
|
||||||
|
);
|
||||||
|
history.replaceState(
|
||||||
|
null,
|
||||||
|
"",
|
||||||
|
`/app?plan=${encodeURIComponent(remotePlanId)}`,
|
||||||
|
);
|
||||||
|
setSaveStatus("saved");
|
||||||
|
await loadPlans();
|
||||||
|
} catch {
|
||||||
|
setSaveStatus("failed");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return syncQueue;
|
||||||
|
}
|
||||||
|
async function flushCurrentPlan() {
|
||||||
clearTimeout(autosave._t);
|
clearTimeout(autosave._t);
|
||||||
autosave._t = setTimeout(() => {
|
const snapshot = structuredClone(state.plan);
|
||||||
try {
|
try {
|
||||||
if (storageKey)
|
if (storageKey)
|
||||||
localStorage.setItem(storageKey, JSON.stringify(state.plan));
|
localStorage.setItem(
|
||||||
// Local storage is namespaced per authenticated account; the account copy is authoritative when online.
|
storageKey,
|
||||||
if (navigator.onLine && csrfToken()) {
|
JSON.stringify({ plan: snapshot, remotePlanId }),
|
||||||
const method = remotePlanId ? "PUT" : "POST";
|
);
|
||||||
const url = remotePlanId ? `/api/plans/${remotePlanId}` : "/api/plans";
|
|
||||||
protectedFetch(url, {
|
|
||||||
method,
|
|
||||||
headers: { "content-type": "application/json" },
|
|
||||||
body: JSON.stringify({ plan: state.plan }),
|
|
||||||
})
|
|
||||||
.then((r) => (r.ok ? r.json() : null))
|
|
||||||
.then((body) => {
|
|
||||||
if (body?.plan?.id) remotePlanId = body.plan.id;
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
/* local copy remains available */
|
|
||||||
});
|
|
||||||
}
|
|
||||||
const savedAt = Date.now();
|
|
||||||
chip.classList.remove("saving");
|
|
||||||
chip.classList.add("saved");
|
|
||||||
const tick = () => {
|
|
||||||
const secs = Math.round((Date.now() - savedAt) / 1000);
|
|
||||||
text.textContent =
|
|
||||||
secs < 8
|
|
||||||
? "Saved just now"
|
|
||||||
: secs < 60
|
|
||||||
? `Saved ${secs}s ago`
|
|
||||||
: `Saved ${Math.round(secs / 60)}m ago`;
|
|
||||||
};
|
|
||||||
tick();
|
|
||||||
clearInterval(autosaveAgeTimer);
|
|
||||||
autosaveAgeTimer = setInterval(tick, 5000);
|
|
||||||
} catch {
|
} catch {
|
||||||
chip.classList.remove("saving", "saved");
|
setSaveStatus("failed");
|
||||||
text.textContent = "Save unavailable";
|
return false;
|
||||||
}
|
}
|
||||||
|
await syncPlan(snapshot);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
function autosave() {
|
||||||
|
setSaveStatus("saving");
|
||||||
|
clearTimeout(autosave._t);
|
||||||
|
autosave._t = setTimeout(() => {
|
||||||
|
flushCurrentPlan();
|
||||||
}, 400);
|
}, 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
function loadFromStorage(userId) {
|
function loadFromStorage(userId) {
|
||||||
try {
|
try {
|
||||||
storageKey = `${STORAGE_PREFIX}:${userId}`;
|
storageKey = `${STORAGE_PREFIX}:${userId}`;
|
||||||
|
localStorage.setItem(`${STORAGE_PREFIX}:last-user`, userId);
|
||||||
// A shared browser must never retain a previous account's local-only draft.
|
// A shared browser must never retain a previous account's local-only draft.
|
||||||
for (let i = localStorage.length - 1; i >= 0; i--) {
|
for (let i = localStorage.length - 1; i >= 0; i--) {
|
||||||
const key = localStorage.key(i);
|
const key = localStorage.key(i);
|
||||||
if (
|
if (
|
||||||
(key?.startsWith(`${STORAGE_PREFIX}:`) && key !== storageKey) ||
|
(key?.startsWith(`${STORAGE_PREFIX}:`) &&
|
||||||
|
key !== storageKey &&
|
||||||
|
key !== `${STORAGE_PREFIX}:last-user`) ||
|
||||||
key === "roastPlannerPlan.v1"
|
key === "roastPlannerPlan.v1"
|
||||||
)
|
)
|
||||||
localStorage.removeItem(key);
|
localStorage.removeItem(key);
|
||||||
}
|
}
|
||||||
const raw = localStorage.getItem(storageKey);
|
const raw = localStorage.getItem(storageKey);
|
||||||
return raw ? JSON.parse(raw) : null;
|
if (!raw) return null;
|
||||||
|
const draft = JSON.parse(raw);
|
||||||
|
if (draft?.plan) {
|
||||||
|
remotePlanId = draft.remotePlanId || null;
|
||||||
|
return draft.plan;
|
||||||
|
}
|
||||||
|
return draft; // legacy v2 draft: retain it once, then upgrade on next save
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -471,6 +604,11 @@ function loadFromStorage(userId) {
|
|||||||
|
|
||||||
function clearDraft() {
|
function clearDraft() {
|
||||||
if (storageKey) localStorage.removeItem(storageKey);
|
if (storageKey) localStorage.removeItem(storageKey);
|
||||||
|
try {
|
||||||
|
localStorage.removeItem(`${STORAGE_PREFIX}:last-user`);
|
||||||
|
} catch {
|
||||||
|
/* unavailable storage */
|
||||||
|
}
|
||||||
storageKey = null;
|
storageKey = null;
|
||||||
remotePlanId = null;
|
remotePlanId = null;
|
||||||
}
|
}
|
||||||
@@ -511,35 +649,118 @@ function wireForm() {
|
|||||||
|
|
||||||
function wireDrawers() {
|
function wireDrawers() {
|
||||||
const overlay = document.getElementById("drawer-overlay");
|
const overlay = document.getElementById("drawer-overlay");
|
||||||
const prefill = document.getElementById("panel-prefill");
|
const drawers = [...document.querySelectorAll(".drawer")];
|
||||||
const alog = document.getElementById("panel-alog");
|
|
||||||
|
|
||||||
function open(panel) {
|
|
||||||
for (const p of [prefill, alog]) p.classList.add("hidden");
|
|
||||||
panel.classList.remove("hidden");
|
|
||||||
panel.setAttribute("aria-hidden", "false");
|
|
||||||
overlay.classList.remove("hidden");
|
|
||||||
}
|
|
||||||
function closeAll() {
|
function closeAll() {
|
||||||
for (const p of [prefill, alog]) {
|
for (const panel of drawers) {
|
||||||
|
panel.classList.add("hidden");
|
||||||
|
panel.setAttribute("aria-hidden", "true");
|
||||||
|
}
|
||||||
|
overlay.classList.add("hidden");
|
||||||
|
lastDrawerOpener?.focus();
|
||||||
|
}
|
||||||
|
function open(panel, opener) {
|
||||||
|
lastDrawerOpener = opener;
|
||||||
|
for (const p of drawers) {
|
||||||
p.classList.add("hidden");
|
p.classList.add("hidden");
|
||||||
p.setAttribute("aria-hidden", "true");
|
p.setAttribute("aria-hidden", "true");
|
||||||
}
|
}
|
||||||
overlay.classList.add("hidden");
|
panel.classList.remove("hidden");
|
||||||
|
panel.setAttribute("aria-hidden", "false");
|
||||||
|
overlay.classList.remove("hidden");
|
||||||
|
panel.querySelector("button, input, [href]")?.focus();
|
||||||
}
|
}
|
||||||
|
for (const [buttonId, panelId] of [
|
||||||
|
["btn-toggle-prefill", "panel-prefill"],
|
||||||
|
["btn-toggle-alog", "panel-alog"],
|
||||||
|
["btn-plans", "panel-plans"],
|
||||||
|
["btn-settings", "panel-settings"],
|
||||||
|
])
|
||||||
document
|
document
|
||||||
.getElementById("btn-toggle-prefill")
|
.getElementById(buttonId)
|
||||||
.addEventListener("click", () => {
|
.addEventListener("click", (event) =>
|
||||||
prefill.classList.contains("hidden") ? open(prefill) : closeAll();
|
open(document.getElementById(panelId), event.currentTarget),
|
||||||
});
|
);
|
||||||
document.getElementById("btn-toggle-alog").addEventListener("click", () => {
|
|
||||||
alog.classList.contains("hidden") ? open(alog) : closeAll();
|
|
||||||
});
|
|
||||||
overlay.addEventListener("click", closeAll);
|
overlay.addEventListener("click", closeAll);
|
||||||
for (const btn of document.querySelectorAll("[data-close-drawer]")) {
|
for (const btn of document.querySelectorAll("[data-close-drawer]"))
|
||||||
btn.addEventListener("click", closeAll);
|
btn.addEventListener("click", closeAll);
|
||||||
|
document.addEventListener("keydown", (event) => {
|
||||||
|
const activeDrawer = drawers.find(
|
||||||
|
(drawer) => !drawer.classList.contains("hidden"),
|
||||||
|
);
|
||||||
|
if (!activeDrawer) return;
|
||||||
|
if (event.key === "Escape") {
|
||||||
|
closeAll();
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
if (event.key !== "Tab") return;
|
||||||
|
const focusable = [
|
||||||
|
...activeDrawer.querySelectorAll(
|
||||||
|
"button:not([disabled]), input:not([disabled]), [href]",
|
||||||
|
),
|
||||||
|
];
|
||||||
|
const first = focusable[0],
|
||||||
|
last = focusable.at(-1);
|
||||||
|
if (event.shiftKey && document.activeElement === first) {
|
||||||
|
event.preventDefault();
|
||||||
|
last?.focus();
|
||||||
|
} else if (!event.shiftKey && document.activeElement === last) {
|
||||||
|
event.preventDefault();
|
||||||
|
first?.focus();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return { open, closeAll };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadPlans() {
|
||||||
|
if (!navigator.onLine) return;
|
||||||
|
const response = await fetch("/api/plans");
|
||||||
|
if (!response.ok) return;
|
||||||
|
plans = (await response.json()).plans || [];
|
||||||
|
const list = document.getElementById("plan-list");
|
||||||
|
list.replaceChildren(
|
||||||
|
...plans.map((plan) => {
|
||||||
|
const item = document.createElement("li");
|
||||||
|
const button = document.createElement("button");
|
||||||
|
button.type = "button";
|
||||||
|
button.className = "plan-list-item";
|
||||||
|
button.classList.toggle("active", plan.id === remotePlanId);
|
||||||
|
const title = document.createElement("strong");
|
||||||
|
title.textContent = plan.plan?.fields?.["0.1"] || "Untitled plan";
|
||||||
|
const updated = document.createElement("span");
|
||||||
|
updated.textContent = `Updated ${new Date(plan.updated_at).toLocaleDateString()}`;
|
||||||
|
button.append(title, updated);
|
||||||
|
button.addEventListener("click", () => selectPlan(plan));
|
||||||
|
item.append(button);
|
||||||
|
return item;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
async function selectPlan(plan) {
|
||||||
|
if (!(await flushCurrentPlan())) return;
|
||||||
|
remotePlanId = plan.id;
|
||||||
|
state.plan = { ...blankPlan(), ...plan.plan };
|
||||||
|
history.replaceState(
|
||||||
|
null,
|
||||||
|
"",
|
||||||
|
`/app?plan=${encodeURIComponent(remotePlanId)}`,
|
||||||
|
);
|
||||||
|
renderBlend();
|
||||||
|
renderActuators();
|
||||||
|
renderFormFromPlan();
|
||||||
|
recompute();
|
||||||
|
document.querySelector("[data-close-drawer]")?.click();
|
||||||
|
}
|
||||||
|
async function newPlan() {
|
||||||
|
if (!confirm("Start a new plan? Your current plan is already saved locally."))
|
||||||
|
return;
|
||||||
|
if (!(await flushCurrentPlan())) return;
|
||||||
|
remotePlanId = null;
|
||||||
|
state.plan = blankPlan();
|
||||||
|
history.replaceState(null, "", "/app");
|
||||||
|
renderBlend();
|
||||||
|
renderActuators();
|
||||||
|
renderFormFromPlan();
|
||||||
|
recompute();
|
||||||
}
|
}
|
||||||
|
|
||||||
function wireToolbar() {
|
function wireToolbar() {
|
||||||
@@ -578,6 +799,7 @@ function wireToolbar() {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
document.getElementById("btn-new-plan").addEventListener("click", newPlan);
|
||||||
document.getElementById("btn-add-blend").addEventListener("click", () => {
|
document.getElementById("btn-add-blend").addEventListener("click", () => {
|
||||||
state.plan.blendComponents.push({
|
state.plan.blendComponents.push({
|
||||||
cultivar: "",
|
cultivar: "",
|
||||||
@@ -595,7 +817,7 @@ function wireToolbar() {
|
|||||||
await protectedFetch("/api/auth/logout", { method: "POST" });
|
await protectedFetch("/api/auth/logout", { method: "POST" });
|
||||||
} finally {
|
} finally {
|
||||||
clearDraft();
|
clearDraft();
|
||||||
location.assign("/");
|
location.replace("/");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
document.getElementById("btn-add-actuator").addEventListener("click", () => {
|
document.getElementById("btn-add-actuator").addEventListener("click", () => {
|
||||||
@@ -622,17 +844,53 @@ function wirePwa() {
|
|||||||
: "";
|
: "";
|
||||||
};
|
};
|
||||||
|
|
||||||
window.addEventListener("online", renderConnection);
|
window.addEventListener("online", () => {
|
||||||
|
renderConnection();
|
||||||
|
syncPlan();
|
||||||
|
});
|
||||||
window.addEventListener("offline", renderConnection);
|
window.addEventListener("offline", renderConnection);
|
||||||
|
window.addEventListener("beforeinstallprompt", (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
deferredInstallPrompt = event;
|
||||||
|
document.getElementById("btn-install").classList.remove("hidden");
|
||||||
|
});
|
||||||
|
document.getElementById("btn-install").addEventListener("click", async () => {
|
||||||
|
if (!deferredInstallPrompt) return;
|
||||||
|
deferredInstallPrompt.prompt();
|
||||||
|
await deferredInstallPrompt.userChoice;
|
||||||
|
deferredInstallPrompt = null;
|
||||||
|
document.getElementById("btn-install").classList.add("hidden");
|
||||||
|
});
|
||||||
renderConnection();
|
renderConnection();
|
||||||
|
|
||||||
if ("serviceWorker" in navigator) {
|
if ("serviceWorker" in navigator) {
|
||||||
window.addEventListener("load", () => {
|
window.addEventListener("load", () => {
|
||||||
navigator.serviceWorker.register("/sw.js").catch((error) => {
|
navigator.serviceWorker
|
||||||
console.warn("Service worker registration failed:", error);
|
.register("/sw.js")
|
||||||
});
|
.then((registration) => {
|
||||||
|
const showUpdate = () =>
|
||||||
|
document.getElementById("btn-refresh").classList.remove("hidden");
|
||||||
|
if (registration.waiting) showUpdate();
|
||||||
|
registration.addEventListener("updatefound", () =>
|
||||||
|
registration.installing?.addEventListener("statechange", () => {
|
||||||
|
if (registration.waiting && navigator.serviceWorker.controller)
|
||||||
|
showUpdate();
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.catch((error) =>
|
||||||
|
console.warn("Service worker registration failed:", error),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
navigator.serviceWorker.addEventListener("controllerchange", () =>
|
||||||
|
location.reload(),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
document.getElementById("btn-refresh").addEventListener("click", async () => {
|
||||||
|
if (!(await flushCurrentPlan())) return;
|
||||||
|
const registration = await navigator.serviceWorker.getRegistration();
|
||||||
|
registration?.waiting?.postMessage("SKIP_WAITING");
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function wireSectionNav() {
|
function wireSectionNav() {
|
||||||
@@ -647,8 +905,11 @@ function wireSectionNav() {
|
|||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
if (!entry.isIntersecting) continue;
|
if (!entry.isIntersecting) continue;
|
||||||
const id = `#${entry.target.id}`;
|
const id = `#${entry.target.id}`;
|
||||||
for (const a of links)
|
for (const a of links) {
|
||||||
a.classList.toggle("active", a.getAttribute("href") === id);
|
const active = a.getAttribute("href") === id;
|
||||||
|
a.classList.toggle("active", active);
|
||||||
|
a.toggleAttribute("aria-current", active);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{ rootMargin: "-20% 0px -70% 0px" },
|
{ rootMargin: "-20% 0px -70% 0px" },
|
||||||
@@ -664,22 +925,39 @@ async function init() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const { user } = await meResponse.json();
|
const { user } = await meResponse.json();
|
||||||
state.plan = loadFromStorage(user.id) ?? blankPlan();
|
document.getElementById("account-email").textContent = user.email;
|
||||||
const response = await fetch("/api/plans");
|
document.getElementById("settings-email").textContent = user.email;
|
||||||
if (response.ok) {
|
document.getElementById("account-summary").textContent =
|
||||||
const body = await response.json();
|
user.email.split("@")[0];
|
||||||
const latest = body.plans?.[0];
|
if (user.role === "admin")
|
||||||
if (latest) {
|
document.getElementById("menu-admin").classList.remove("hidden");
|
||||||
state.plan = latest.plan;
|
const localDraft = loadFromStorage(user.id);
|
||||||
remotePlanId = latest.id;
|
state.plan = localDraft ?? blankPlan();
|
||||||
}
|
await loadPlans();
|
||||||
|
const requestedId = new URLSearchParams(location.search).get("plan");
|
||||||
|
const selected = plans.find((plan) => plan.id === requestedId);
|
||||||
|
if (selected) {
|
||||||
|
state.plan = selected.plan;
|
||||||
|
remotePlanId = selected.id;
|
||||||
|
} else if (!localDraft && !requestedId && plans[0]) {
|
||||||
|
state.plan = plans[0].plan;
|
||||||
|
remotePlanId = plans[0].id;
|
||||||
}
|
}
|
||||||
} catch {
|
} 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();
|
||||||
@@ -687,18 +965,6 @@ async function init() {
|
|||||||
wireToolbar();
|
wireToolbar();
|
||||||
wirePwa();
|
wirePwa();
|
||||||
wireSectionNav();
|
wireSectionNav();
|
||||||
fetch("/api/auth/me")
|
|
||||||
.then((r) => (r.ok ? r.json() : null))
|
|
||||||
.then((body) => {
|
|
||||||
if (body?.user?.role === "admin") {
|
|
||||||
const button = document.getElementById("btn-admin");
|
|
||||||
button.classList.remove("hidden");
|
|
||||||
button.onclick = () => {
|
|
||||||
location.href = "/admin";
|
|
||||||
};
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch(() => {});
|
|
||||||
initPrefillPanel({
|
initPrefillPanel({
|
||||||
state,
|
state,
|
||||||
renderBlendRows: renderBlend,
|
renderBlendRows: renderBlend,
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
+18
-9
@@ -1,7 +1,8 @@
|
|||||||
const CACHE_NAME = "roast-planner-static-v2";
|
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,16 +57,23 @@ self.addEventListener("fetch", (event) => {
|
|||||||
return;
|
return;
|
||||||
|
|
||||||
if (request.mode === "navigate") {
|
if (request.mode === "navigate") {
|
||||||
// Never serve authenticated application HTML from cache after logout.
|
// /app is a data-free authenticated shell: draft data lives only in account-scoped
|
||||||
|
// local storage and logout clears that namespace. This makes offline relaunch useful
|
||||||
|
// without ever caching account data or API responses.
|
||||||
event.respondWith(
|
event.respondWith(
|
||||||
fetch(request).catch(() => caches.match("/landing.html")),
|
fetch(request).catch(() =>
|
||||||
|
url.pathname === "/app"
|
||||||
|
? caches.match("/app")
|
||||||
|
: caches.match("/landing.html"),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Prefer fresh JavaScript and styles so installed clients receive UI/security updates
|
||||||
|
// immediately; use the cache only when offline.
|
||||||
event.respondWith(
|
event.respondWith(
|
||||||
caches.match(request).then((cached) => {
|
fetch(request)
|
||||||
const update = fetch(request)
|
|
||||||
.then((response) => {
|
.then((response) => {
|
||||||
if (response.ok)
|
if (response.ok)
|
||||||
caches
|
caches
|
||||||
@@ -74,8 +81,10 @@ self.addEventListener("fetch", (event) => {
|
|||||||
.then((cache) => cache.put(request, response.clone()));
|
.then((cache) => cache.put(request, response.clone()));
|
||||||
return response;
|
return response;
|
||||||
})
|
})
|
||||||
.catch(() => cached);
|
.catch(() => caches.match(request)),
|
||||||
return cached ?? update;
|
|
||||||
}),
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
self.addEventListener("message", (event) => {
|
||||||
|
if (event.data === "SKIP_WAITING") self.skipWaiting();
|
||||||
|
});
|
||||||
|
|||||||
+5
-2
@@ -76,10 +76,13 @@ test("strict CSP/static modules, no-store data, auth lifecycle, and ownership sh
|
|||||||
const mainScript = await anonymous.get("/js/main.js");
|
const mainScript = await anonymous.get("/js/main.js");
|
||||||
assert.match(mainScript.text, /roastPlannerPlan\.v2/);
|
assert.match(mainScript.text, /roastPlannerPlan\.v2/);
|
||||||
assert.match(mainScript.text, /localStorage\.removeItem\(key\)/);
|
assert.match(mainScript.text, /localStorage\.removeItem\(key\)/);
|
||||||
|
const serviceWorker = (await anonymous.get("/sw.js")).text;
|
||||||
|
assert.match(serviceWorker, /data-free authenticated shell/);
|
||||||
assert.match(
|
assert.match(
|
||||||
(await anonymous.get("/sw.js")).text,
|
serviceWorker,
|
||||||
/Never serve authenticated/,
|
/url\.pathname === "\/app"[\s\S]*caches\.match\("\/app"\)/,
|
||||||
);
|
);
|
||||||
|
assert.match(serviceWorker, /logout clears that namespace/);
|
||||||
|
|
||||||
const one = await signup(first, "[email protected]");
|
const one = await signup(first, "[email protected]");
|
||||||
const two = await signup(second, "[email protected]");
|
const two = await signup(second, "[email protected]");
|
||||||
|
|||||||
Reference in New Issue
Block a user