Add brewing section, plan chat, roaster learning, API tokens, Swagger docs, and full backup
Test and deploy / test-and-deploy (push) Successful in 1m6s

Brewing:
- roasted_beans + brews tables; /beans (bean management with LLM URL
  prefill) and /brews (silhouette brewer picker across immersion/
  percolation/espresso, recipe fields, auto ratio, 0-10 rating, tasting
  notes); bean remaining weight derived from logged brew doses
- Green inventory lot form also prefills from a product URL

Navigation/UX:
- Side nav is now generated from one definition in nav.js, grouped
  Roasting / Brewing / account, consistent on every page

LLM:
- 'Ask the LLM' chat drawer on the planner (stateless /api/plan-chat)
  grounded in the plan, computed ledger, learned pace, and a new
  roaster-behavior profile aggregated from uploaded .alogs
  (/api/roaster-profile: TP lag, phase RoR, median milestone temps)
- The profile also feeds roast reviews and the planner curve's fallback
  milestone temps

API platform:
- User-generated bearer tokens (rpt_…) with account-page management;
  token requests skip CSRF; hand-authored OpenAPI 3 spec at
  /api/openapi.json rendered by self-hosted Swagger UI at /api-docs
- Full-database backup export/import (admin) + per-user data export

Co-Authored-By: Claude Fable 5 <[email protected]>
This commit is contained in:
Shane Maynard
2026-08-08 22:37:42 -04:00
co-authored by Claude Fable 5
parent 5efaeb63c9
commit 38c7d01e03
33 changed files with 3498 additions and 205 deletions
+62 -30
View File
@@ -15,36 +15,7 @@
<span class="brand-mark" aria-hidden="true"></span>
<span class="brand-name">Roast Planner</span>
</div>
<div class="nav-group">
<a class="nav-item" href="/app"
><span class="nav-icon" aria-hidden="true"></span
><span class="nav-label">Planner</span></a
>
<a class="nav-item" href="#your-plans"
><span class="nav-icon" aria-hidden="true"></span
><span class="nav-label">Plans</span></a
>
<a class="nav-item" href="/roasts"
><span class="nav-icon" aria-hidden="true"></span
><span class="nav-label">Roasts</span></a
>
<a class="nav-item" href="/inventory"
><span class="nav-icon" aria-hidden="true"></span
><span class="nav-label">Inventory</span></a
>
<a class="nav-item" href="/cupping"
><span class="nav-icon" aria-hidden="true"></span
><span class="nav-label">Cupping</span></a
>
<a class="nav-item" href="/account" aria-current="page"
><span class="nav-icon" aria-hidden="true"></span
><span class="nav-label">Account</span></a
>
<a class="nav-item hidden" id="nav-admin" href="/admin"
><span class="nav-icon" aria-hidden="true"></span
><span class="nav-label">Admin</span></a
>
</div>
<div id="nav-links"></div>
<div class="nav-spacer"></div>
<button
class="icon-btn nav-collapse-toggle"
@@ -225,6 +196,67 @@
</div>
</section>
<section class="panel-card" id="api-tokens">
<div class="panel-head">
<h2>API tokens</h2>
<a class="ghost-btn small" href="/api-docs">API docs</a>
</div>
<div class="panel-body">
<p class="muted">
Tokens authenticate API requests with an
<code>Authorization: Bearer rpt_…</code> header — full access
to your account, so treat them like passwords.
</p>
<form
id="token-form"
style="display:flex;gap:8px;flex-wrap:wrap;align-items:center"
>
<input
class="text-input"
name="name"
placeholder="Token name (e.g. shortcuts, cli)"
style="max-width:260px"
/>
<button class="ghost-btn" type="submit" id="token-create">
Generate token
</button>
</form>
<div class="hidden" id="token-reveal-wrap" style="margin-top:10px">
<p class="field-note" style="margin:0 0 4px">
Copy it now — it will never be shown again.
</p>
<div class="token-reveal" id="token-reveal"></div>
</div>
<div class="table-wrap" style="margin-top:12px">
<table class="data-table" id="tokens-table">
<thead>
<tr>
<th>Name</th>
<th>Created</th>
<th>Last used</th>
<th></th>
</tr>
</thead>
<tbody id="tokens-body"></tbody>
</table>
</div>
</div>
</section>
<section class="panel-card" id="my-data">
<div class="panel-head"><h2>Your data</h2></div>
<div class="panel-body">
<p class="muted">
Download everything you've put into Roast Planner — plans,
roasts, inventory, cupping, beans, and brews — as one JSON
file.
</p>
<a class="ghost-btn" href="/api/account/export" download
>Download my data</a
>
</div>
</section>
<section class="panel-card danger-zone" id="danger">
<div class="panel-head">
<h2>Danger zone</h2>
+25 -30
View File
@@ -15,36 +15,7 @@
<span class="brand-mark" aria-hidden="true"></span>
<span class="brand-name">Roast Planner</span>
</div>
<div class="nav-group">
<a class="nav-item" href="/app"
><span class="nav-icon" aria-hidden="true"></span
><span class="nav-label">Planner</span></a
>
<a class="nav-item" href="#plans"
><span class="nav-icon" aria-hidden="true"></span
><span class="nav-label">Plans</span></a
>
<a class="nav-item" href="/roasts"
><span class="nav-icon" aria-hidden="true"></span
><span class="nav-label">Roasts</span></a
>
<a class="nav-item" href="/inventory"
><span class="nav-icon" aria-hidden="true"></span
><span class="nav-label">Inventory</span></a
>
<a class="nav-item" href="/cupping"
><span class="nav-icon" aria-hidden="true"></span
><span class="nav-label">Cupping</span></a
>
<a class="nav-item" href="/account"
><span class="nav-icon" aria-hidden="true"></span
><span class="nav-label">Account</span></a
>
<a class="nav-item" href="/admin" aria-current="page"
><span class="nav-icon" aria-hidden="true"></span
><span class="nav-label">Admin</span></a
>
</div>
<div id="nav-links"></div>
<div class="nav-spacer"></div>
<button
class="icon-btn nav-collapse-toggle"
@@ -149,6 +120,30 @@
</div>
</section>
<section class="panel-card" id="backup-card">
<div class="panel-head"><h2>Backup</h2></div>
<div class="panel-body">
<p class="muted">
Export downloads the <strong>entire database</strong> (all
users and their data) as one JSON file. Import
<strong>replaces everything</strong> with a previously
exported file.
</p>
<div style="display:flex;gap:8px;flex-wrap:wrap;align-items:center">
<a class="ghost-btn" href="/api/admin/backup" download
>Export full backup</a
>
<label class="filebtn ghost-btn"
>Import backup…<input
type="file"
id="backup-import-file"
accept="application/json,.json"
/></label>
</div>
<p class="field-note" id="backup-note"></p>
</div>
</section>
<section class="panel-card" id="users">
<div class="panel-head">
<h2>Users</h2>
+46
View File
@@ -0,0 +1,46 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>API docs — Roast Planner</title>
<link rel="stylesheet" href="/swagger/swagger-ui.css" />
<link rel="icon" href="/icon.svg?v=__ASSET_VERSION__" type="image/svg+xml" />
<style>
body {
margin: 0;
}
.rp-docs-bar {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 16px;
background: #1b1614;
color: #fbf8f4;
font:
14px -apple-system,
BlinkMacSystemFont,
"Segoe UI",
sans-serif;
}
.rp-docs-bar a {
color: #f3b28c;
text-decoration: none;
}
</style>
</head>
<body>
<div class="rp-docs-bar">
<strong>Roast Planner API</strong>
<span
>Authenticate with your session (already active in this browser) or an
API token from your <a href="/account#api-tokens">account page</a
>.</span
>
<a href="/app" style="margin-left:auto">← Back to the app</a>
</div>
<div id="swagger-ui"></div>
<script src="/swagger/swagger-ui-bundle.js"></script>
<script src="/js/swagger-init.js?v=__ASSET_VERSION__"></script>
</body>
</html>
+134
View File
@@ -2866,3 +2866,137 @@ select.f {
padding: 0 0.5mm;
color: #1a1512;
}
/* ── Brewer picker + brew log (brews/beans pages) ─────────────────────── */
.brewer-cat-title {
font-size: 11px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--ink-3);
margin: 14px 0 6px;
}
.brewer-cat-title:first-child {
margin-top: 0;
}
.brewer-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(94px, 1fr));
gap: 8px;
}
.brewer-tile {
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
padding: 10px 6px 8px;
border: 1px solid var(--line);
border-radius: var(--radius-md);
background: var(--surface-raised);
cursor: pointer;
color: var(--ink-2);
font: inherit;
transition: border-color 0.12s, background 0.12s, color 0.12s;
}
.brewer-tile:hover {
border-color: var(--line-strong);
color: var(--ink);
}
.brewer-tile.selected {
border-color: var(--ember);
color: var(--ember-strong);
background: var(--ember-soft);
box-shadow: inset 0 0 0 1px var(--ember);
}
.brewer-tile svg {
width: 42px;
height: 42px;
display: block;
}
.brewer-tile .brewer-name {
font-size: 11.5px;
line-height: 1.2;
text-align: center;
}
.method-chip {
display: inline-flex;
align-items: center;
gap: 6px;
white-space: nowrap;
}
.method-chip svg {
width: 20px;
height: 20px;
flex: none;
color: var(--ink-2);
}
.rating-pill {
display: inline-block;
min-width: 34px;
text-align: center;
padding: 2px 8px;
border-radius: 999px;
background: var(--surface-sunken);
font-variant-numeric: tabular-nums;
font-weight: 600;
}
.rating-pill.good {
background: var(--pass-bg);
color: var(--pass);
}
.rating-pill.poor {
background: var(--fail-bg);
color: var(--fail);
}
.token-reveal {
background: var(--surface-sunken);
border: 1px dashed var(--line-strong);
border-radius: var(--radius-sm);
padding: 10px 12px;
font-family: var(--font-mono, ui-monospace, monospace);
font-size: 12.5px;
word-break: break-all;
user-select: all;
}
/* ── Plan chat drawer ─────────────────────────────────────────────────── */
.chat-thread {
display: flex;
flex-direction: column;
gap: 8px;
max-height: 50vh;
overflow-y: auto;
margin-bottom: 10px;
}
.chat-msg {
padding: 8px 11px;
border-radius: var(--radius-md);
font-size: 13px;
line-height: 1.45;
white-space: pre-wrap;
max-width: 92%;
}
.chat-msg.user {
align-self: flex-end;
background: var(--ember-soft);
color: var(--ember-strong);
}
.chat-msg.assistant {
align-self: flex-start;
background: var(--surface-sunken);
color: var(--ink);
}
.chat-msg.pending {
color: var(--ink-3);
font-style: italic;
}
.chat-input-row {
display: flex;
gap: 8px;
align-items: flex-end;
}
.chat-input-row textarea {
flex: 1;
resize: vertical;
min-height: 40px;
}
+186
View File
@@ -0,0 +1,186 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>Beans — Roast Planner</title>
<link rel="stylesheet" href="/app.css?v=__ASSET_VERSION__" />
<link rel="icon" href="/icon.svg?v=__ASSET_VERSION__" type="image/svg+xml" />
<meta name="theme-color" content="#A8481A" />
</head>
<body>
<div class="app-shell">
<nav class="side-nav" id="side-nav" aria-label="Main navigation">
<div class="side-nav-brand">
<span class="brand-mark" aria-hidden="true"></span>
<span class="brand-name">Roast Planner</span>
</div>
<div id="nav-links"></div>
<div class="nav-spacer"></div>
<button
class="icon-btn nav-collapse-toggle"
type="button"
id="nav-collapse"
aria-label="Collapse navigation"
title="Collapse navigation"
>
«
</button>
<div class="nav-user">
<div class="nav-user-avatar" id="nav-user-avatar" aria-hidden="true"></div>
<div class="nav-user-detail">
<span class="nav-user-email" id="account-email"></span>
<button class="nav-user-logout" type="button" id="btn-logout">
Log out
</button>
</div>
</div>
</nav>
<div class="app-workspace" id="app-workspace">
<header class="app-header">
<div class="header-row-top">
<button
class="icon-btn nav-hamburger"
type="button"
id="nav-hamburger"
aria-label="Open navigation"
aria-expanded="false"
>
</button>
<div class="brand">
<div class="brand-text">
<h1>Beans</h1>
<p class="brand-sub">Roasted or purchased coffee ready to brew</p>
</div>
</div>
</div>
</header>
<div class="drawer-overlay hidden" id="drawer-overlay"></div>
<main class="page-content">
<section class="panel-card" id="beans">
<div class="panel-head">
<h2>Your beans</h2>
<label
style="display:flex;align-items:center;gap:10px;font-size:13px;color:var(--ink-2)"
><span class="switch"
><input type="checkbox" id="show-archived" /><span
class="switch-track"
></span></span
>Show archived</label
>
</div>
<div class="panel-body">
<div class="table-wrap">
<table class="data-table" id="beans-table">
<thead>
<tr>
<th>Bean</th>
<th>Origin / process</th>
<th>Roasted</th>
<th>Remaining</th>
<th class="num">Brews</th>
<th></th>
</tr>
</thead>
<tbody id="beans-body"></tbody>
</table>
</div>
</div>
</section>
<section class="panel-card" id="bean-form-card">
<div class="panel-head"><h2 id="bean-form-title">Add a bean</h2></div>
<div class="panel-body">
<div
style="display:flex;gap:8px;flex-wrap:wrap;align-items:center;margin-bottom:12px"
>
<input
class="text-input"
id="bean-prefill-url"
type="url"
placeholder="Paste the roaster's product URL to prefill…"
style="flex:1;min-width:220px"
/>
<button class="ghost-btn" type="button" id="bean-prefill-btn">
Prefill from URL
</button>
</div>
<p class="field-note hidden" id="bean-prefill-note"></p>
<form id="bean-form">
<div class="field-grid">
<label class="field"
><span class="field-label">Name *</span
><input
class="field-input"
name="name"
required
placeholder="e.g. Kiamabara AB"
/></label>
<label class="field"
><span class="field-label">Roaster</span
><input class="field-input" name="roaster" placeholder="or “home roast”"
/></label>
<label class="field"
><span class="field-label">Origin</span
><input class="field-input" name="origin"
/></label>
<label class="field"
><span class="field-label">Process</span
><input class="field-input" name="process"
/></label>
<label class="field"
><span class="field-label">Variety</span
><input class="field-input" name="variety"
/></label>
<label class="field"
><span class="field-label">Roast level</span
><input class="field-input" name="roastLevel" placeholder="light / medium / dark"
/></label>
<label class="field"
><span class="field-label">Roast date</span
><input class="field-input" type="date" name="roastDate"
/></label>
<label class="field"
><span class="field-label">Bag weight</span>
<div class="unit-input">
<input
class="field-input"
type="number"
name="initialWeightG"
inputmode="decimal"
min="0"
step="any"
/><span class="unit">g</span>
</div></label
>
</div>
<label class="field"
><span class="field-label">Roaster's tasting notes</span
><input class="field-input" name="tastingNotes" placeholder="e.g. blackcurrant, cocoa"
/></label>
<label class="field"
><span class="field-label">Product URL</span
><input class="field-input" name="url" type="url"
/></label>
<label class="field"
><span class="field-label">Notes</span
><input class="field-input" name="notes"
/></label>
<button class="primary-btn" type="submit" id="bean-form-submit">
Add bean
</button>
<button class="ghost-btn hidden" type="button" id="bean-form-cancel">
Cancel edit
</button>
</form>
</div>
</section>
</main>
</div>
</div>
<script type="module" src="/js/beans.js?v=__ASSET_VERSION__"></script>
</body>
</html>
+190
View File
@@ -0,0 +1,190 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>Brews — Roast Planner</title>
<link rel="stylesheet" href="/app.css?v=__ASSET_VERSION__" />
<link rel="icon" href="/icon.svg?v=__ASSET_VERSION__" type="image/svg+xml" />
<meta name="theme-color" content="#A8481A" />
</head>
<body>
<div class="app-shell">
<nav class="side-nav" id="side-nav" aria-label="Main navigation">
<div class="side-nav-brand">
<span class="brand-mark" aria-hidden="true"></span>
<span class="brand-name">Roast Planner</span>
</div>
<div id="nav-links"></div>
<div class="nav-spacer"></div>
<button
class="icon-btn nav-collapse-toggle"
type="button"
id="nav-collapse"
aria-label="Collapse navigation"
title="Collapse navigation"
>
«
</button>
<div class="nav-user">
<div class="nav-user-avatar" id="nav-user-avatar" aria-hidden="true"></div>
<div class="nav-user-detail">
<span class="nav-user-email" id="account-email"></span>
<button class="nav-user-logout" type="button" id="btn-logout">
Log out
</button>
</div>
</div>
</nav>
<div class="app-workspace" id="app-workspace">
<header class="app-header">
<div class="header-row-top">
<button
class="icon-btn nav-hamburger"
type="button"
id="nav-hamburger"
aria-label="Open navigation"
aria-expanded="false"
>
</button>
<div class="brand">
<div class="brand-text">
<h1>Brews</h1>
<p class="brand-sub">Log every cup — method, recipe, and how it tasted</p>
</div>
</div>
</div>
</header>
<div class="drawer-overlay hidden" id="drawer-overlay"></div>
<main class="page-content">
<section class="panel-card" id="brew-form-card">
<div class="panel-head">
<h2 id="brew-form-title">Log a brew</h2>
<button class="ghost-btn small hidden" type="button" id="brew-form-cancel">
Cancel edit
</button>
</div>
<div class="panel-body">
<form id="brew-form">
<label class="field" style="max-width:420px"
><span class="field-label">Bean</span>
<select class="field-input" name="beanId" id="brew-bean-select">
<option value="">— no bean selected —</option>
</select></label
>
<div id="brewer-picker" style="margin:12px 0"></div>
<div class="field-grid">
<label class="field"
><span class="field-label">Dose</span>
<div class="unit-input">
<input class="field-input" type="number" name="doseG" inputmode="decimal" min="0" step="any" /><span class="unit">g</span>
</div></label
>
<label class="field" data-espresso-hide
><span class="field-label">Water</span>
<div class="unit-input">
<input class="field-input" type="number" name="waterG" inputmode="decimal" min="0" step="any" /><span class="unit">g</span>
</div></label
>
<label class="field hidden" data-espresso-show
><span class="field-label">Yield (out)</span>
<div class="unit-input">
<input class="field-input" type="number" name="yieldG" inputmode="decimal" min="0" step="any" /><span class="unit">g</span>
</div></label
>
<label class="field"
><span class="field-label">Ratio</span
><input class="field-input" id="brew-ratio" readonly placeholder="—"
/></label>
<label class="field"
><span class="field-label">Water temp</span>
<div class="unit-input">
<input class="field-input" type="number" name="waterTempC" inputmode="decimal" min="0" max="100" step="any" /><span class="unit">°C</span>
</div></label
>
<label class="field"
><span class="field-label">Grinder</span
><input class="field-input" name="grinder" placeholder="e.g. Comandante C40"
/></label>
<label class="field"
><span class="field-label">Grind setting</span
><input class="field-input" name="grindSetting" placeholder="e.g. 22 clicks"
/></label>
<label class="field"
><span class="field-label">Total time</span
><input class="field-input" name="brewTime" placeholder="m:ss"
/></label>
<label class="field" data-espresso-hide
><span class="field-label">Bloom</span
><input class="field-input" name="bloomTime" placeholder="m:ss"
/></label>
</div>
<label class="field" style="margin-top:6px"
><span class="field-label"
>Rating <span class="rating-pill" id="rating-value"></span></span
>
<input
type="range"
name="rating"
id="brew-rating"
min="0"
max="10"
step="0.5"
value="5"
style="width:min(360px,100%)"
/>
</label>
<label class="field"
><span class="field-label">Tasting notes</span
><textarea
class="field-input"
name="tastingNotes"
rows="2"
placeholder="What you actually tasted — sweetness, acidity, body, finish…"
></textarea></label
>
<label class="field"
><span class="field-label">Notes / next time</span
><input class="field-input" name="notes" placeholder="e.g. grind finer next time"
/></label>
<button class="primary-btn" type="submit" id="brew-form-submit">
Save brew
</button>
</form>
</div>
</section>
<section class="panel-card" id="brew-history">
<div class="panel-head">
<h2>Brew history</h2>
<button class="ghost-btn small hidden" id="clear-bean-filter">
Clear bean filter
</button>
</div>
<div class="panel-body">
<div class="table-wrap">
<table class="data-table" id="brews-table">
<thead>
<tr>
<th>When</th>
<th>Method</th>
<th>Bean</th>
<th>Recipe</th>
<th>Rating</th>
<th></th>
</tr>
</thead>
<tbody id="brews-body"></tbody>
</table>
</div>
</div>
</section>
</main>
</div>
</div>
<script type="module" src="/js/brews.js?v=__ASSET_VERSION__"></script>
</body>
</html>
+1 -30
View File
@@ -15,36 +15,7 @@
<span class="brand-mark" aria-hidden="true"></span>
<span class="brand-name">Roast Planner</span>
</div>
<div class="nav-group">
<a class="nav-item" href="/app"
><span class="nav-icon" aria-hidden="true"></span
><span class="nav-label">Planner</span></a
>
<a class="nav-item" href="/account#your-plans"
><span class="nav-icon" aria-hidden="true"></span
><span class="nav-label">Plans</span></a
>
<a class="nav-item" href="/roasts"
><span class="nav-icon" aria-hidden="true"></span
><span class="nav-label">Roasts</span></a
>
<a class="nav-item" href="/inventory"
><span class="nav-icon" aria-hidden="true"></span
><span class="nav-label">Inventory</span></a
>
<a class="nav-item" href="/cupping" aria-current="page"
><span class="nav-icon" aria-hidden="true"></span
><span class="nav-label">Cupping</span></a
>
<a class="nav-item" href="/account"
><span class="nav-icon" aria-hidden="true"></span
><span class="nav-label">Account</span></a
>
<a class="nav-item hidden" id="nav-admin" href="/admin"
><span class="nav-icon" aria-hidden="true"></span
><span class="nav-label">Admin</span></a
>
</div>
<div id="nav-links"></div>
<div class="nav-spacer"></div>
<button
class="icon-btn nav-collapse-toggle"
+44 -27
View File
@@ -20,38 +20,17 @@
<span class="brand-mark" aria-hidden="true"></span>
<span class="brand-name">Roast Planner</span>
</div>
<div id="nav-links"></div>
<div class="nav-group">
<a class="nav-item" href="/app" aria-current="page"
><span class="nav-icon" aria-hidden="true"></span
><span class="nav-label">Planner</span></a
>
<div class="nav-group-title">Tools</div>
<button class="nav-item" type="button" id="nav-plans">
<span class="nav-icon" aria-hidden="true"></span
><span class="nav-label">Plans</span>
</button>
<a class="nav-item" href="/roasts"
><span class="nav-icon" aria-hidden="true"></span
><span class="nav-label">Roasts</span></a
>
<a class="nav-item" href="/inventory"
><span class="nav-icon" aria-hidden="true"></span
><span class="nav-label">Inventory</span></a
>
<a class="nav-item" href="/cupping"
><span class="nav-icon" aria-hidden="true"></span
><span class="nav-label">Cupping</span></a
>
<a class="nav-item" href="/account"
><span class="nav-icon" aria-hidden="true"></span
><span class="nav-label">Account</span></a
>
<a class="nav-item hidden" id="nav-admin" href="/admin"
><span class="nav-icon" aria-hidden="true"></span
><span class="nav-label">Admin</span></a
>
</div>
<div class="nav-group">
<div class="nav-group-title">Tools</div>
<button class="nav-item" type="button" id="nav-chat">
<span class="nav-icon" aria-hidden="true"></span
><span class="nav-label">Ask the LLM</span>
</button>
<button class="nav-item" type="button" id="nav-prefill">
<span class="nav-icon" aria-hidden="true"></span
><span class="nav-label">Prefill from URL</span>
@@ -243,6 +222,44 @@
</div>
</aside>
<aside
class="drawer hidden"
id="panel-chat"
aria-hidden="true"
role="dialog"
aria-modal="true"
aria-labelledby="chat-title"
tabindex="-1"
>
<div class="drawer-head">
<h3 id="chat-title">Ask the LLM about this plan</h3>
<button
class="icon-btn"
type="button"
data-close-drawer
aria-label="Close"
>
</button>
</div>
<div class="drawer-body">
<div class="chat-thread" id="chat-thread"></div>
<form id="chat-form" class="chat-input-row">
<textarea
class="field-input"
id="chat-input"
rows="2"
placeholder="e.g. Why is first crack at 8:30? What should I change for more sweetness?"
></textarea>
<button class="primary-btn" type="submit" id="chat-send">Send</button>
</form>
<p class="field-note">
Answers are grounded in this plan's numbers, your learned pace, and
the roaster behavior learned from your uploaded roasts.
</p>
</div>
</aside>
<aside
class="drawer hidden"
id="panel-plans"
+16 -30
View File
@@ -15,36 +15,7 @@
<span class="brand-mark" aria-hidden="true"></span>
<span class="brand-name">Roast Planner</span>
</div>
<div class="nav-group">
<a class="nav-item" href="/app"
><span class="nav-icon" aria-hidden="true"></span
><span class="nav-label">Planner</span></a
>
<a class="nav-item" href="/account#your-plans"
><span class="nav-icon" aria-hidden="true"></span
><span class="nav-label">Plans</span></a
>
<a class="nav-item" href="/roasts"
><span class="nav-icon" aria-hidden="true"></span
><span class="nav-label">Roasts</span></a
>
<a class="nav-item" href="/inventory" aria-current="page"
><span class="nav-icon" aria-hidden="true"></span
><span class="nav-label">Inventory</span></a
>
<a class="nav-item" href="/cupping"
><span class="nav-icon" aria-hidden="true"></span
><span class="nav-label">Cupping</span></a
>
<a class="nav-item" href="/account"
><span class="nav-icon" aria-hidden="true"></span
><span class="nav-label">Account</span></a
>
<a class="nav-item hidden" id="nav-admin" href="/admin"
><span class="nav-icon" aria-hidden="true"></span
><span class="nav-label">Admin</span></a
>
</div>
<div id="nav-links"></div>
<div class="nav-spacer"></div>
<button
class="icon-btn nav-collapse-toggle"
@@ -122,6 +93,21 @@
<section class="panel-card" id="lot-form-card">
<div class="panel-head"><h2 id="lot-form-title">Add a lot</h2></div>
<div class="panel-body">
<div
style="display:flex;gap:8px;flex-wrap:wrap;align-items:center;margin-bottom:12px"
>
<input
class="text-input"
id="lot-prefill-url"
type="url"
placeholder="Paste the seller's product URL to prefill…"
style="flex:1;min-width:220px"
/>
<button class="ghost-btn" type="button" id="lot-prefill-btn">
Prefill from URL
</button>
</div>
<p class="field-note hidden" id="lot-prefill-note"></p>
<form id="lot-form">
<input type="hidden" name="id" />
<div class="field-grid">
+69 -1
View File
@@ -331,6 +331,73 @@ function wirePwaButtons() {
});
}
async function loadTokens() {
const body = document.getElementById("tokens-body");
try {
const { tokens } = await api("/api/tokens");
if (!tokens.length) {
body.innerHTML = `<tr><td colspan="4" class="empty-state">No API tokens yet.</td></tr>`;
return;
}
body.replaceChildren(
...tokens.map((token) => {
const tr = document.createElement("tr");
const name = document.createElement("td");
name.textContent = token.name || "(unnamed)";
const created = document.createElement("td");
created.textContent = fmtDate(token.createdAt);
const used = document.createElement("td");
used.textContent = token.lastUsedAt ? fmtDate(token.lastUsedAt) : "Never";
const actions = document.createElement("td");
actions.className = "data-table-actions";
const revoke = document.createElement("button");
revoke.className = "ghost-btn small";
revoke.type = "button";
revoke.textContent = "Revoke";
revoke.addEventListener("click", async () => {
if (!confirm(`Revoke "${token.name || "this token"}"? Anything using it stops working immediately.`))
return;
try {
await api(`/api/tokens/${token.id}`, { method: "DELETE" });
showToast("Token revoked.");
await loadTokens();
} catch (error) {
showToast(error.message, "fail");
}
});
actions.append(revoke);
tr.append(name, created, used, actions);
return tr;
}),
);
} catch {
body.innerHTML = `<tr><td colspan="4" class="empty-state">Could not load tokens.</td></tr>`;
}
}
function wireTokenForm() {
document.getElementById("token-form").addEventListener("submit", async (event) => {
event.preventDefault();
const button = document.getElementById("token-create");
button.disabled = true;
try {
const created = await api("/api/tokens", {
method: "POST",
body: JSON.stringify({ name: event.target.name.value.trim() }),
});
// Shown exactly once — the server only stores a hash.
document.getElementById("token-reveal").textContent = created.token;
document.getElementById("token-reveal-wrap").classList.remove("hidden");
event.target.reset();
await loadTokens();
} catch (error) {
showToast(error.message, "fail");
} finally {
button.disabled = false;
}
});
}
document.getElementById("btn-logout").addEventListener("click", async () => {
await protectedFetch("/api/auth/logout", { method: "POST" });
location.assign("/");
@@ -345,7 +412,8 @@ async function init() {
await loadProfile(user);
wireForms(user);
wirePwaButtons();
await Promise.all([loadSessions(), loadPlans()]);
wireTokenForm();
await Promise.all([loadSessions(), loadPlans(), loadTokens()]);
}
init();
+50
View File
@@ -151,6 +151,55 @@ function wireLlmSave() {
});
}
function wireBackupImport() {
const input = document.getElementById("backup-import-file");
const note = document.getElementById("backup-note");
input.addEventListener("change", async (event) => {
const file = event.target.files?.[0];
event.target.value = "";
if (!file) return;
let backup;
try {
backup = JSON.parse(await file.text());
} catch {
note.textContent = "That file is not valid JSON.";
return;
}
if (backup.format !== "roast-planner-backup") {
note.textContent =
"That file is not a Roast Planner backup export (expected the file downloaded by “Export full backup”).";
return;
}
const userCount = backup.tables?.users?.length ?? 0;
if (
!confirm(
`Replace the ENTIRE database with "${file.name}" (${userCount} user${userCount === 1 ? "" : "s"}, exported ${backup.exportedAt ?? "unknown date"})?\n\nEverything currently stored will be deleted. This cannot be undone.`,
)
)
return;
note.textContent = "Importing…";
try {
const result = await api("/api/admin/backup/import", {
method: "POST",
body: JSON.stringify(backup),
});
note.textContent = `Imported: ${Object.entries(result.counts)
.map(([table, count]) => `${table} ${count}`)
.join(", ")}.`;
showToast("Backup imported.");
if (!result.sessionKept) {
alert("The restored data does not include your current session's account — log in again with the restored credentials.");
location.assign("/login");
return;
}
await Promise.all([loadMetrics(), loadUsers(), loadPlans(), loadAudit()]);
} catch (error) {
note.textContent = `Import failed: ${error.message}. Nothing was changed.`;
showToast(error.message, "fail");
}
});
}
function roleBadge(user) {
return user.role === "admin"
? `<span class="badge badge-admin">Admin</span>`
@@ -376,6 +425,7 @@ async function init() {
currentUserId = user.id;
wireSignupToggle();
wireLlmSave();
wireBackupImport();
await Promise.all([
loadMetrics(),
loadResetLinks(),
+274
View File
@@ -0,0 +1,274 @@
import { api, protectedFetch } from "./api.js?v=__ASSET_VERSION__";
import { initSideNav, loadNavUser } from "./nav.js?v=__ASSET_VERSION__";
import { showToast } from "./toast.js?v=__ASSET_VERSION__";
let beans = [];
let showArchived = false;
let editingId = null;
const fmtDate = (v) => (v ? new Date(v).toLocaleDateString() : "—");
async function guarded(button, run) {
if (!button || button.disabled) return;
button.disabled = true;
try {
await run();
} finally {
button.disabled = false;
}
}
function renderBeans() {
const body = document.getElementById("beans-body");
const visible = showArchived ? beans : beans.filter((b) => !b.archived);
if (!visible.length) {
body.innerHTML = `<tr><td colspan="6" class="empty-state">No beans yet. Add a bag below — paste the roaster's product URL to prefill the details.</td></tr>`;
return;
}
body.replaceChildren(
...visible.map((bean) => {
const tr = document.createElement("tr");
if (bean.archived) tr.className = "archived";
const beanCell = document.createElement("td");
const strong = document.createElement("strong");
strong.textContent = bean.name;
const sub = document.createElement("div");
sub.className = "muted";
sub.style.fontSize = "11.5px";
sub.textContent =
[bean.roaster, bean.roastLevel].filter(Boolean).join(" · ") || "—";
beanCell.append(strong, sub);
const originCell = document.createElement("td");
originCell.textContent =
[bean.origin, bean.process].filter(Boolean).join(" · ") || "—";
const roastedCell = document.createElement("td");
roastedCell.textContent = fmtDate(bean.roastDate);
const remainingCell = document.createElement("td");
if (bean.initialWeightG == null) remainingCell.textContent = "—";
else {
const pct = bean.initialWeightG > 0
? Math.max(0, Math.min(100, (bean.remainingWeightG / bean.initialWeightG) * 100))
: 0;
const wrap = document.createElement("div");
wrap.className = "lot-remaining";
const bar = document.createElement("div");
bar.className = "blend-total-bar";
const fill = document.createElement("div");
fill.className = "blend-total-fill";
if (bean.remainingWeightG < 0) fill.classList.add("over");
fill.style.width = `${bean.remainingWeightG < 0 ? 0 : pct}%`;
bar.append(fill);
const label = document.createElement("span");
label.className = "blend-total-label";
label.textContent = `${Math.round(bean.remainingWeightG)} g of ${Math.round(bean.initialWeightG)} g`;
wrap.append(bar, label);
remainingCell.append(wrap);
}
const brewsCell = document.createElement("td");
brewsCell.className = "num";
const brewsLink = document.createElement("a");
brewsLink.href = `/brews?bean=${encodeURIComponent(bean.id)}`;
brewsLink.textContent = String(bean.brewCount ?? 0);
brewsCell.append(brewsLink);
const actions = document.createElement("td");
actions.className = "data-table-actions";
const brewBtn = document.createElement("a");
brewBtn.className = "ghost-btn small";
brewBtn.href = `/brews?new=1&bean=${encodeURIComponent(bean.id)}`;
brewBtn.textContent = "Brew";
const editBtn = document.createElement("button");
editBtn.className = "ghost-btn small";
editBtn.type = "button";
editBtn.textContent = "Edit";
editBtn.addEventListener("click", () => startEdit(bean));
const archiveBtn = document.createElement("button");
archiveBtn.className = "ghost-btn small";
archiveBtn.type = "button";
archiveBtn.textContent = bean.archived ? "Unarchive" : "Archive";
archiveBtn.addEventListener("click", (event) =>
guarded(event.currentTarget, async () => {
try {
await api(`/api/beans/${bean.id}`, {
method: "PUT",
body: JSON.stringify({ archived: !bean.archived }),
});
showToast(bean.archived ? "Bean unarchived." : "Bean archived.");
await loadBeans();
} catch (error) {
showToast(error.message, "fail");
}
}),
);
const deleteBtn = document.createElement("button");
deleteBtn.className = "ghost-btn small";
deleteBtn.type = "button";
deleteBtn.textContent = "Delete";
deleteBtn.addEventListener("click", (event) =>
guarded(event.currentTarget, async () => {
if (
!confirm(
`Delete "${bean.name}"? Its logged brews are kept but lose the bean link.`,
)
)
return;
try {
await api(`/api/beans/${bean.id}`, { method: "DELETE" });
showToast("Bean deleted.");
if (editingId === bean.id) cancelEdit();
await loadBeans();
} catch (error) {
showToast(error.message, "fail");
}
}),
);
actions.append(brewBtn, editBtn, archiveBtn, deleteBtn);
tr.append(beanCell, originCell, roastedCell, remainingCell, brewsCell, actions);
return tr;
}),
);
}
async function loadBeans() {
try {
beans = (await api("/api/beans")).beans;
renderBeans();
} catch {
document.getElementById("beans-body").innerHTML =
`<tr><td colspan="6" class="empty-state">Could not load beans.</td></tr>`;
}
}
function startEdit(bean) {
editingId = bean.id;
const form = document.getElementById("bean-form");
form.name.value = bean.name;
form.roaster.value = bean.roaster;
form.origin.value = bean.origin;
form.process.value = bean.process;
form.variety.value = bean.variety;
form.roastLevel.value = bean.roastLevel;
form.roastDate.value = bean.roastDate ? bean.roastDate.slice(0, 10) : "";
form.initialWeightG.value = bean.initialWeightG ?? "";
form.tastingNotes.value = bean.tastingNotes;
form.url.value = bean.url;
form.notes.value = bean.notes;
document.getElementById("bean-form-title").textContent = "Edit bean";
document.getElementById("bean-form-submit").textContent = "Save changes";
document.getElementById("bean-form-cancel").classList.remove("hidden");
document.getElementById("bean-form-card").scrollIntoView({ behavior: "smooth" });
}
function cancelEdit() {
editingId = null;
const form = document.getElementById("bean-form");
form.reset();
document.getElementById("bean-form-title").textContent = "Add a bean";
document.getElementById("bean-form-submit").textContent = "Add bean";
document.getElementById("bean-form-cancel").classList.add("hidden");
}
// The LLM prefill endpoint returns raw `extracted` page facts — map them straight into the
// bean form (only overwriting fields the page actually stated).
function wirePrefill() {
const button = document.getElementById("bean-prefill-btn");
const note = document.getElementById("bean-prefill-note");
button.addEventListener("click", () =>
guarded(button, async () => {
const url = document.getElementById("bean-prefill-url").value.trim();
if (!url) return;
note.classList.remove("hidden");
note.textContent = "Reading the page with the LLM…";
try {
const body = await api("/api/prefill", {
method: "POST",
body: JSON.stringify({ url }),
});
const x = body.extracted ?? {};
const form = document.getElementById("bean-form");
const setIf = (field, value) => {
if (value) form[field].value = value;
};
setIf("name", x.coffeeName);
setIf("roaster", x.producer);
setIf("origin", x.origin);
setIf("process", x.process);
setIf("variety", x.cultivar);
setIf("roastLevel", x.roastLevel);
if (Array.isArray(x.tastingNotes) && x.tastingNotes.length)
form.tastingNotes.value = x.tastingNotes.join(", ");
form.url.value = url;
const filled = ["coffeeName", "producer", "origin", "process", "cultivar", "roastLevel"]
.filter((k) => x[k]).length + (x.tastingNotes?.length ? 1 : 0);
note.textContent = filled
? `Prefilled ${filled} field${filled === 1 ? "" : "s"} — check them, then save.`
: "The page didn't state anything usable — fill the form by hand.";
} catch (error) {
note.textContent = `Prefill failed: ${error.message}`;
}
}),
);
}
document.getElementById("bean-form-cancel").addEventListener("click", cancelEdit);
document.getElementById("show-archived").addEventListener("change", (event) => {
showArchived = event.target.checked;
renderBeans();
});
document.getElementById("bean-form").addEventListener("submit", (event) => {
event.preventDefault();
const form = event.target;
const data = Object.fromEntries(new FormData(form));
guarded(document.getElementById("bean-form-submit"), async () => {
try {
const payload = {
name: data.name,
roaster: data.roaster,
origin: data.origin,
process: data.process,
variety: data.variety,
roastLevel: data.roastLevel,
roastDate: data.roastDate || null,
initialWeightG: data.initialWeightG === "" ? null : Number(data.initialWeightG),
tastingNotes: data.tastingNotes,
url: data.url,
notes: data.notes,
};
if (editingId) {
await api(`/api/beans/${editingId}`, {
method: "PUT",
body: JSON.stringify(payload),
});
showToast("Bean updated.");
} else {
await api("/api/beans", { method: "POST", body: JSON.stringify(payload) });
showToast("Bean added.");
}
cancelEdit();
await loadBeans();
} catch (error) {
showToast(error.message, "fail");
}
});
});
document.getElementById("btn-logout").addEventListener("click", async () => {
await protectedFetch("/api/auth/logout", { method: "POST" });
location.assign("/");
});
async function init() {
initSideNav();
const user = await loadNavUser();
if (!user) return;
wirePrefill();
await loadBeans();
}
init();
+376
View File
@@ -0,0 +1,376 @@
import { api, protectedFetch } from "./api.js?v=__ASSET_VERSION__";
import { initSideNav, loadNavUser } from "./nav.js?v=__ASSET_VERSION__";
import { showToast } from "./toast.js?v=__ASSET_VERSION__";
import {
BREW_CATEGORIES,
BREW_METHODS,
BREW_SILHOUETTES,
findBrewMethod,
} from "/shared/brew-data.js?v=__ASSET_VERSION__";
const SVG_NS = "http://www.w3.org/2000/svg";
let beans = [];
let brews = [];
let editingId = null;
let selectedMethod = null;
let beanFilter = null;
const fmtDate = (v) => (v ? new Date(v).toLocaleDateString() : "—");
const fmtTime = (s) =>
s == null
? null
: `${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}`;
function parseTime(str) {
if (!str || !str.trim()) return null;
const m = str.trim().match(/^(\d+):(\d{1,2})$/);
if (m) return Number(m[1]) * 60 + Number(m[2]);
const n = Number(str);
return Number.isInteger(n) && n >= 0 ? n : undefined; // undefined = unparseable
}
function silhouetteSvg(methodKey, size = 42) {
const svg = document.createElementNS(SVG_NS, "svg");
svg.setAttribute("viewBox", "0 0 64 64");
svg.setAttribute("width", size);
svg.setAttribute("height", size);
svg.setAttribute("aria-hidden", "true");
for (const d of BREW_SILHOUETTES[methodKey] ?? []) {
const path = document.createElementNS(SVG_NS, "path");
path.setAttribute("d", d);
path.setAttribute("fill", "currentColor");
svg.append(path);
}
return svg;
}
const isEspresso = () => findBrewMethod(selectedMethod)?.category === "espresso";
function renderBrewerPicker() {
const mount = document.getElementById("brewer-picker");
mount.replaceChildren();
for (const category of BREW_CATEGORIES) {
const title = document.createElement("div");
title.className = "brewer-cat-title";
title.textContent = category.name;
mount.append(title);
const grid = document.createElement("div");
grid.className = "brewer-grid";
for (const method of BREW_METHODS.filter((m) => m.category === category.key)) {
const tile = document.createElement("button");
tile.type = "button";
tile.className = "brewer-tile";
tile.dataset.method = method.key;
tile.classList.toggle("selected", method.key === selectedMethod);
tile.setAttribute("aria-pressed", String(method.key === selectedMethod));
tile.append(silhouetteSvg(method.key));
const name = document.createElement("span");
name.className = "brewer-name";
name.textContent = method.name;
tile.append(name);
tile.addEventListener("click", () => selectMethod(method.key));
grid.append(tile);
}
mount.append(grid);
}
}
function selectMethod(key) {
selectedMethod = key;
for (const tile of document.querySelectorAll(".brewer-tile")) {
const on = tile.dataset.method === key;
tile.classList.toggle("selected", on);
tile.setAttribute("aria-pressed", String(on));
}
// Espresso-style methods log yield-out instead of brew water and have no bloom.
const espresso = isEspresso();
for (const el of document.querySelectorAll("[data-espresso-show]"))
el.classList.toggle("hidden", !espresso);
for (const el of document.querySelectorAll("[data-espresso-hide]"))
el.classList.toggle("hidden", espresso);
updateRatio();
}
function updateRatio() {
const form = document.getElementById("brew-form");
const dose = Number.parseFloat(form.doseG.value);
const out = Number.parseFloat(isEspresso() ? form.yieldG.value : form.waterG.value);
document.getElementById("brew-ratio").value =
Number.isFinite(dose) && dose > 0 && Number.isFinite(out) && out > 0
? `1:${Math.round((out / dose) * 10) / 10}`
: "";
}
function updateRatingPill() {
const value = document.getElementById("brew-rating").value;
const pill = document.getElementById("rating-value");
pill.textContent = value;
pill.classList.toggle("good", Number(value) >= 7.5);
pill.classList.toggle("poor", Number(value) <= 4);
}
function renderBeanSelect() {
const select = document.getElementById("brew-bean-select");
const current = select.value;
select.replaceChildren(
Object.assign(document.createElement("option"), {
value: "",
textContent: "— no bean selected —",
}),
...beans
.filter((b) => !b.archived || b.id === current)
.map((bean) => {
const option = document.createElement("option");
option.value = bean.id;
const remaining =
bean.remainingWeightG == null ? "" : ` (${Math.round(bean.remainingWeightG)} g left)`;
option.textContent = `${bean.name}${bean.roaster ? `${bean.roaster}` : ""}${remaining}`;
return option;
}),
);
select.value = current;
}
function ratingPill(rating) {
const pill = document.createElement("span");
pill.className = "rating-pill";
if (rating == null) pill.textContent = "—";
else {
pill.textContent = String(rating);
if (rating >= 7.5) pill.classList.add("good");
if (rating <= 4) pill.classList.add("poor");
}
return pill;
}
function recipeText(brew) {
const method = findBrewMethod(brew.method);
const parts = [];
const out = method?.category === "espresso" ? brew.yieldG : brew.waterG;
if (brew.doseG != null && out != null)
parts.push(`${brew.doseG} g → ${out} g (1:${Math.round((out / brew.doseG) * 10) / 10})`);
else if (brew.doseG != null) parts.push(`${brew.doseG} g`);
if (brew.waterTempC != null) parts.push(`${brew.waterTempC}°C`);
if (brew.brewTimeS != null) parts.push(fmtTime(brew.brewTimeS));
return parts.join(" · ") || "—";
}
function renderBrews() {
const body = document.getElementById("brews-body");
const visible = beanFilter ? brews.filter((b) => b.beanId === beanFilter) : brews;
document
.getElementById("clear-bean-filter")
.classList.toggle("hidden", !beanFilter);
if (!visible.length) {
body.innerHTML = `<tr><td colspan="6" class="empty-state">No brews logged yet. Pick a brewer above and log your first cup.</td></tr>`;
return;
}
body.replaceChildren(
...visible.map((brew) => {
const tr = document.createElement("tr");
const whenCell = document.createElement("td");
whenCell.textContent = fmtDate(brew.brewedAt);
const methodCell = document.createElement("td");
const chip = document.createElement("span");
chip.className = "method-chip";
chip.append(silhouetteSvg(brew.method, 20));
const methodName = document.createElement("span");
methodName.textContent = findBrewMethod(brew.method)?.name ?? brew.method;
chip.append(methodName);
methodCell.append(chip);
const beanCell = document.createElement("td");
beanCell.textContent = brew.beanName ?? "—";
const recipeCell = document.createElement("td");
recipeCell.textContent = recipeText(brew);
if (brew.tastingNotes) {
const notes = document.createElement("div");
notes.className = "muted";
notes.style.fontSize = "11.5px";
notes.style.maxWidth = "340px";
notes.textContent = brew.tastingNotes;
recipeCell.append(notes);
}
const ratingCell = document.createElement("td");
ratingCell.append(ratingPill(brew.rating));
const actions = document.createElement("td");
actions.className = "data-table-actions";
const editBtn = document.createElement("button");
editBtn.className = "ghost-btn small";
editBtn.type = "button";
editBtn.textContent = "Edit";
editBtn.addEventListener("click", () => startEdit(brew));
const againBtn = document.createElement("button");
againBtn.className = "ghost-btn small";
againBtn.type = "button";
againBtn.textContent = "Brew again";
againBtn.title = "Copy this brew's recipe into the form as a new brew";
againBtn.addEventListener("click", () => {
startEdit(brew);
editingId = null;
document.getElementById("brew-form-title").textContent = "Log a brew";
document.getElementById("brew-form-submit").textContent = "Save brew";
document.getElementById("brew-form-cancel").classList.add("hidden");
});
const deleteBtn = document.createElement("button");
deleteBtn.className = "ghost-btn small";
deleteBtn.type = "button";
deleteBtn.textContent = "Delete";
deleteBtn.addEventListener("click", async () => {
if (!confirm("Delete this brew? This cannot be undone.")) return;
try {
await api(`/api/brews/${brew.id}`, { method: "DELETE" });
showToast("Brew deleted.");
if (editingId === brew.id) cancelEdit();
await Promise.all([loadBrews(), loadBeans()]);
} catch (error) {
showToast(error.message, "fail");
}
});
actions.append(editBtn, againBtn, deleteBtn);
tr.append(whenCell, methodCell, beanCell, recipeCell, ratingCell, actions);
return tr;
}),
);
}
async function loadBeans() {
try {
beans = (await api("/api/beans")).beans;
renderBeanSelect();
} catch {
/* bean select stays empty; brews still work without beans */
}
}
async function loadBrews() {
try {
brews = (await api("/api/brews")).brews;
renderBrews();
} catch {
document.getElementById("brews-body").innerHTML =
`<tr><td colspan="6" class="empty-state">Could not load brews.</td></tr>`;
}
}
function startEdit(brew) {
editingId = brew.id;
const form = document.getElementById("brew-form");
form.beanId.value = brew.beanId ?? "";
selectMethod(brew.method);
form.doseG.value = brew.doseG ?? "";
form.waterG.value = brew.waterG ?? "";
form.yieldG.value = brew.yieldG ?? "";
form.waterTempC.value = brew.waterTempC ?? "";
form.grinder.value = brew.grinder;
form.grindSetting.value = brew.grindSetting;
form.brewTime.value = fmtTime(brew.brewTimeS) ?? "";
form.bloomTime.value = fmtTime(brew.bloomTimeS) ?? "";
form.rating.value = brew.rating ?? 5;
form.tastingNotes.value = brew.tastingNotes;
form.notes.value = brew.notes;
updateRatio();
updateRatingPill();
document.getElementById("brew-form-title").textContent = "Edit brew";
document.getElementById("brew-form-submit").textContent = "Save changes";
document.getElementById("brew-form-cancel").classList.remove("hidden");
document.getElementById("brew-form-card").scrollIntoView({ behavior: "smooth" });
}
function cancelEdit() {
editingId = null;
const form = document.getElementById("brew-form");
form.reset();
updateRatio();
updateRatingPill();
document.getElementById("brew-form-title").textContent = "Log a brew";
document.getElementById("brew-form-submit").textContent = "Save brew";
document.getElementById("brew-form-cancel").classList.add("hidden");
}
document.getElementById("brew-form").addEventListener("submit", async (event) => {
event.preventDefault();
if (!selectedMethod) {
showToast("Pick a brewer first.", "fail");
return;
}
const form = event.target;
const brewTimeS = parseTime(form.brewTime.value);
const bloomTimeS = parseTime(form.bloomTime.value);
if (brewTimeS === undefined || bloomTimeS === undefined) {
showToast("Times must look like m:ss (e.g. 2:45).", "fail");
return;
}
const num = (v) => (v === "" ? null : Number(v));
const payload = {
beanId: form.beanId.value || null,
method: selectedMethod,
doseG: num(form.doseG.value),
waterG: num(form.waterG.value),
yieldG: num(form.yieldG.value),
waterTempC: num(form.waterTempC.value),
grinder: form.grinder.value,
grindSetting: form.grindSetting.value,
brewTimeS,
bloomTimeS,
rating: Number(form.rating.value),
tastingNotes: form.tastingNotes.value,
notes: form.notes.value,
};
const submit = document.getElementById("brew-form-submit");
submit.disabled = true;
try {
if (editingId) {
await api(`/api/brews/${editingId}`, { method: "PUT", body: JSON.stringify(payload) });
showToast("Brew updated.");
} else {
await api("/api/brews", { method: "POST", body: JSON.stringify(payload) });
showToast("Brew logged.");
}
cancelEdit();
await Promise.all([loadBrews(), loadBeans()]);
} catch (error) {
showToast(error.message, "fail");
} finally {
submit.disabled = false;
}
});
document.getElementById("brew-form-cancel").addEventListener("click", cancelEdit);
document.getElementById("brew-form").addEventListener("input", (event) => {
if (["doseG", "waterG", "yieldG"].includes(event.target.name)) updateRatio();
if (event.target.name === "rating") updateRatingPill();
});
document.getElementById("clear-bean-filter").addEventListener("click", () => {
beanFilter = null;
renderBrews();
});
document.getElementById("btn-logout").addEventListener("click", async () => {
await protectedFetch("/api/auth/logout", { method: "POST" });
location.assign("/");
});
async function init() {
initSideNav();
const user = await loadNavUser();
if (!user) return;
renderBrewerPicker();
updateRatingPill();
await Promise.all([loadBeans(), loadBrews()]);
const params = new URLSearchParams(location.search);
const requestedBean = params.get("bean");
if (requestedBean && beans.some((b) => b.id === requestedBean)) {
if (params.get("new")) {
document.getElementById("brew-bean-select").value = requestedBean;
} else {
beanFilter = requestedBean;
renderBrews();
}
}
}
init();
+32
View File
@@ -250,6 +250,38 @@ document.getElementById("lot-form").addEventListener("submit", (event) => {
});
});
// The LLM prefill endpoint returns raw `extracted` page facts — map the green-relevant ones
// into the lot form (only overwriting what the page actually stated).
document.getElementById("lot-prefill-btn").addEventListener("click", (event) =>
guarded(event.currentTarget, async () => {
const url = document.getElementById("lot-prefill-url").value.trim();
const note = document.getElementById("lot-prefill-note");
if (!url) return;
note.classList.remove("hidden");
note.textContent = "Reading the page with the LLM…";
try {
const body = await api("/api/prefill", {
method: "POST",
body: JSON.stringify({ url }),
});
const x = body.extracted ?? {};
const form = document.getElementById("lot-form");
if (x.origin) form.origin.value = x.origin;
if (x.cultivar) form.variety.value = x.cultivar;
if (x.process) form.process.value = x.process;
if (x.producer) form.producer.value = x.producer;
if (x.moisturePct) form.moisturePct.value = x.moisturePct;
const filled = ["origin", "cultivar", "process", "producer", "moisturePct"]
.filter((k) => x[k]).length;
note.textContent = filled
? `Prefilled ${filled} field${filled === 1 ? "" : "s"} — check them, then save.`
: "The page didn't state anything usable — fill the form by hand.";
} catch (error) {
note.textContent = `Prefill failed: ${error.message}`;
}
}),
);
document.getElementById("btn-logout").addEventListener("click", async () => {
await protectedFetch("/api/auth/logout", { method: "POST" });
location.assign("/");
+17 -2
View File
@@ -20,6 +20,7 @@ import { api, protectedFetch, csrfToken } from "./api.js?v=__ASSET_VERSION__";
import { initSideNav } from "./nav.js?v=__ASSET_VERSION__";
import { wireWhyPanels } from "./why-panels.js?v=__ASSET_VERSION__";
import { initFieldHelp } from "./field-help.js?v=__ASSET_VERSION__";
import { initPlanChat } from "./plan-chat-ui.js?v=__ASSET_VERSION__";
import { showToast } from "./toast.js?v=__ASSET_VERSION__";
const FIELD_ID_SET = new Set(FIELD_IDS);
@@ -38,6 +39,18 @@ const BAND_DOMAIN = [60, 220]; // shared °C domain for every band-track in the
// the plan draft cache below.
const MACHINE_PROFILE_KEY_PREFIX = "roastPlannerMachineProfile.v1";
let machineProfile = null;
// Learned roaster behavior from uploaded .alogs (server-aggregated) — used as the plan
// curve's fallback milestone temps so the suggested curve matches this user's machine.
let roasterProfile = null;
async function loadRoasterProfile() {
try {
const response = await fetch("/api/roaster-profile");
if (response.ok) roasterProfile = (await response.json()).profile;
} catch {
/* offline — curve falls back to reference machine temps */
}
}
async function loadMachineProfile(userId) {
const cacheKey = `${MACHINE_PROFILE_KEY_PREFIX}:${userId}`;
@@ -464,7 +477,7 @@ function paintCurveInto(planGroup, refGroup, planPoints, ref) {
}
function renderCurve(ledger) {
const planPoints = buildPlanCurve(state.plan, ledger);
const planPoints = buildPlanCurve(state.plan, ledger, roasterProfile);
const ref = state.plan.reference;
paintCurveInto(
@@ -775,6 +788,7 @@ function wireDrawers() {
["nav-prefill", "panel-prefill"],
["nav-alog", "panel-alog"],
["nav-plans", "panel-plans"],
["nav-chat", "panel-chat"],
["nav-settings", "panel-settings"],
["nav-import-export", "panel-import-export"],
])
@@ -1089,7 +1103,7 @@ async function init() {
state.plan = localDraft ?? blankPlan();
const draftRemoteId = remotePlanId;
const draftSyncedAtAtLoad = draftSyncedAt;
await Promise.all([loadPlans(), loadMachineProfile(user.id)]);
await Promise.all([loadPlans(), loadMachineProfile(user.id), loadRoasterProfile()]);
const requestedId = new URLSearchParams(location.search).get("plan");
const selected = plans.find((plan) => plan.id === requestedId);
// Prefer the local draft only when it targets this exact plan AND was last confirmed
@@ -1165,6 +1179,7 @@ async function init() {
renderFormFromPlan,
});
wireCuppingLink();
initPlanChat({ getPlan: () => state.plan });
renderMachineProfileNote();
recompute();
}
+67
View File
@@ -1,6 +1,73 @@
// Shared side-nav behavior (collapse/expand, mobile drawer) for every authenticated page
// (planner, account, admin) so each page's own script doesn't reimplement it.
// Single source of truth for the page links, grouped by workflow — every page renders this
// into its <div id="nav-links"> instead of hand-maintaining a diverging copy of the nav.
// Page-specific tool buttons (the planner's drawers) stay in that page's own markup below it.
const NAV_GROUPS = [
{
title: "Roasting",
items: [
{ href: "/app", icon: "◐", label: "Planner" },
{ href: "/roasts", icon: "∿", label: "Roasts" },
{ href: "/inventory", icon: "▥", label: "Green lots" },
{ href: "/cupping", icon: "◒", label: "Cupping" },
],
},
{
title: "Brewing",
items: [
{ href: "/brews", icon: "◉", label: "Brews" },
{ href: "/beans", icon: "◍", label: "Beans" },
],
},
{
title: null,
items: [
{ href: "/account", icon: "◔", label: "Account" },
{ href: "/api-docs", icon: "⌗", label: "API docs" },
{ href: "/admin", icon: "⚙", label: "Admin", id: "nav-admin", hidden: true },
],
},
];
export function renderNavLinks() {
const mount = document.getElementById("nav-links");
if (!mount) return;
mount.replaceChildren(
...NAV_GROUPS.map((group) => {
const wrap = document.createElement("div");
wrap.className = "nav-group";
if (group.title) {
const title = document.createElement("div");
title.className = "nav-group-title";
title.textContent = group.title;
wrap.append(title);
}
for (const item of group.items) {
const a = document.createElement("a");
a.className = "nav-item";
if (item.hidden) a.classList.add("hidden");
if (item.id) a.id = item.id;
a.href = item.href;
if (item.href === location.pathname) a.setAttribute("aria-current", "page");
const icon = document.createElement("span");
icon.className = "nav-icon";
icon.setAttribute("aria-hidden", "true");
icon.textContent = item.icon;
const label = document.createElement("span");
label.className = "nav-label";
label.textContent = item.label;
a.append(icon, label);
wrap.append(a);
}
return wrap;
}),
);
}
export function initSideNav() {
renderNavLinks();
const nav = document.getElementById("side-nav");
const hamburger = document.getElementById("nav-hamburger");
const collapseBtn = document.getElementById("nav-collapse");
+55
View File
@@ -0,0 +1,55 @@
// Chat-with-the-LLM drawer on the planner: stateless per page load, grounded server-side in
// the current plan + learned profiles. The whole visible conversation is resent each turn.
import { api } from "./api.js?v=__ASSET_VERSION__";
export function initPlanChat({ getPlan }) {
const thread = document.getElementById("chat-thread");
const form = document.getElementById("chat-form");
const input = document.getElementById("chat-input");
const send = document.getElementById("chat-send");
if (!thread || !form) return;
const messages = [];
function bubble(role, content, pending = false) {
const div = document.createElement("div");
div.className = `chat-msg ${role}${pending ? " pending" : ""}`;
div.textContent = content;
thread.append(div);
thread.scrollTop = thread.scrollHeight;
return div;
}
bubble(
"assistant",
"Ask me anything about this plan — why a number is what it is, what to change for a different cup, or how your machine's history should shape it.",
);
form.addEventListener("submit", async (event) => {
event.preventDefault();
const content = input.value.trim();
if (!content || send.disabled) return;
input.value = "";
messages.push({ role: "user", content });
bubble("user", content);
const pending = bubble("assistant", "Thinking…", true);
send.disabled = true;
try {
const body = await api("/api/plan-chat", {
method: "POST",
body: JSON.stringify({ plan: getPlan(), messages }),
});
messages.push({ role: "assistant", content: body.reply });
pending.classList.remove("pending");
pending.textContent = body.reply;
} catch (error) {
messages.pop(); // keep history consistent with what's on screen
pending.classList.remove("pending");
pending.textContent =
error.code === "no_model"
? "No LLM model is configured on the server — ask an admin to set one on the Admin page."
: `That didn't work: ${error.message}`;
} finally {
send.disabled = false;
thread.scrollTop = thread.scrollHeight;
}
});
}
+23
View File
@@ -0,0 +1,23 @@
// Boots the self-hosted Swagger UI (the app's CSP forbids inline scripts and CDNs).
// "Try it out" calls run same-origin, so the browser session cookie authenticates them;
// write endpoints additionally need the x-csrf-token header, which is injected below.
/* global SwaggerUIBundle */
const csrf = () =>
document.cookie
.split("; ")
.find((v) => v.startsWith("rp_csrf="))
?.split("=")[1] || "";
window.addEventListener("DOMContentLoaded", () => {
SwaggerUIBundle({
url: "/api/openapi.json",
dom_id: "#swagger-ui",
docExpansion: "none",
defaultModelsExpandDepth: -1,
requestInterceptor: (request) => {
if (!/^(GET|HEAD)$/i.test(request.method) && !request.headers.Authorization)
request.headers["x-csrf-token"] = csrf();
return request;
},
});
});
+1 -30
View File
@@ -15,36 +15,7 @@
<span class="brand-mark" aria-hidden="true"></span>
<span class="brand-name">Roast Planner</span>
</div>
<div class="nav-group">
<a class="nav-item" href="/app"
><span class="nav-icon" aria-hidden="true"></span
><span class="nav-label">Planner</span></a
>
<a class="nav-item" href="/account#your-plans"
><span class="nav-icon" aria-hidden="true"></span
><span class="nav-label">Plans</span></a
>
<a class="nav-item" href="/roasts" aria-current="page"
><span class="nav-icon" aria-hidden="true"></span
><span class="nav-label">Roasts</span></a
>
<a class="nav-item" href="/inventory"
><span class="nav-icon" aria-hidden="true"></span
><span class="nav-label">Inventory</span></a
>
<a class="nav-item" href="/cupping"
><span class="nav-icon" aria-hidden="true"></span
><span class="nav-label">Cupping</span></a
>
<a class="nav-item" href="/account"
><span class="nav-icon" aria-hidden="true"></span
><span class="nav-label">Account</span></a
>
<a class="nav-item hidden" id="nav-admin" href="/admin"
><span class="nav-icon" aria-hidden="true"></span
><span class="nav-label">Admin</span></a
>
</div>
<div id="nav-links"></div>
<div class="nav-spacer"></div>
<button
class="icon-btn nav-collapse-toggle"
+1
View File
@@ -19,6 +19,7 @@ const APP_SHELL = [
"/js/field-help.js?v=__ASSET_VERSION__",
"/js/field-help-content.js?v=__ASSET_VERSION__",
"/js/lot-picker.js?v=__ASSET_VERSION__",
"/js/plan-chat-ui.js?v=__ASSET_VERSION__",
"/js/prefill-ui.js?v=__ASSET_VERSION__",
"/js/alog-ui.js?v=__ASSET_VERSION__",
"/js/print.js?v=__ASSET_VERSION__",