Add brewing section, plan chat, roaster learning, API tokens, Swagger docs, and full backup
Test and deploy / test-and-deploy (push) Successful in 1m6s
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:
co-authored by
Claude Fable 5
parent
5efaeb63c9
commit
38c7d01e03
+62
-30
@@ -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
@@ -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>
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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();
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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__",
|
||||
|
||||
Reference in New Issue
Block a user