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
@@ -0,0 +1,122 @@
|
||||
// Browser-safe. Brew-method taxonomy + inline SVG silhouettes for the brewer picker.
|
||||
// Categories follow the classic extraction split (immersion / percolation / espresso & pressure);
|
||||
// the concrete method list mirrors what dedicated brew-logging apps (e.g. Beanconqueror) ship as
|
||||
// ready-made preparation methods. Silhouettes are single-path, filled with currentColor, drawn on
|
||||
// a 64×64 viewBox — no external images (the app's CSP allows self/data images only).
|
||||
|
||||
export const BREW_CATEGORIES = [
|
||||
{ key: "immersion", name: "Immersion" },
|
||||
{ key: "percolation", name: "Percolation" },
|
||||
{ key: "espresso", name: "Espresso & pressure" },
|
||||
];
|
||||
|
||||
export const BREW_METHODS = [
|
||||
{ key: "french-press", name: "French press", category: "immersion" },
|
||||
{ key: "aeropress", name: "AeroPress", category: "immersion" },
|
||||
{ key: "clever", name: "Clever dripper", category: "immersion" },
|
||||
{ key: "siphon", name: "Siphon", category: "immersion" },
|
||||
{ key: "cold-brew", name: "Cold brew", category: "immersion" },
|
||||
{ key: "cupping", name: "Cupping bowl", category: "immersion" },
|
||||
{ key: "v60", name: "V60", category: "percolation" },
|
||||
{ key: "chemex", name: "Chemex", category: "percolation" },
|
||||
{ key: "kalita", name: "Kalita Wave", category: "percolation" },
|
||||
{ key: "batch", name: "Auto drip", category: "percolation" },
|
||||
{ key: "percolator", name: "Stovetop percolator", category: "percolation" },
|
||||
{ key: "espresso", name: "Espresso", category: "espresso" },
|
||||
{ key: "moka", name: "Moka pot", category: "espresso" },
|
||||
];
|
||||
|
||||
export const findBrewMethod = (key) => BREW_METHODS.find((m) => m.key === key) ?? null;
|
||||
|
||||
/** Single-color silhouette path data per method (64×64 viewBox, fill currentColor). */
|
||||
export const BREW_SILHOUETTES = {
|
||||
"french-press": [
|
||||
"M30 6h4v6h-4z", // plunger rod
|
||||
"M24 4h16v4H24z", // knob
|
||||
"M16 12h32v4H16z", // lid
|
||||
"M18 16h28l-2 40H20z", // beaker
|
||||
"M46 20h8c2 0 3 2 3 5s-1 5-3 5l-7 1z", // handle
|
||||
"M14 58h36v4H14z", // base
|
||||
],
|
||||
aeropress: [
|
||||
"M22 6h20v6H22z", // plunger cap
|
||||
"M26 12h12v10H26z", // plunger shaft
|
||||
"M20 22h24v26H20z", // chamber
|
||||
"M16 48h32v6H16z", // filter cap flange
|
||||
"M24 54h16v6H24z", // hex cap
|
||||
],
|
||||
clever: [
|
||||
"M14 10h36l-8 30H22z", // cone
|
||||
"M26 40h12v6H26z", // valve stem
|
||||
"M18 46h28v6H18z", // base ring
|
||||
"M50 14h6c2 0 3 2 2 4l-4 10h-6z", // handle
|
||||
"M22 56h20v4H22z",
|
||||
],
|
||||
siphon: [
|
||||
"M26 4h12v4H26z", // lid
|
||||
"M22 8c0 8 4 8 4 12h12c0-4 4-4 4-12z", // upper globe
|
||||
"M30 20h4v10h-4z", // tube
|
||||
"M20 30c-2 10 4 18 12 18s14-8 12-18z", // lower globe
|
||||
"M28 48h8v6h-8z", // stand stem
|
||||
"M20 54h24v4H20z", // stand base
|
||||
],
|
||||
"cold-brew": [
|
||||
"M26 4h12v6H26z", // cap
|
||||
"M24 10h16l4 10v34a4 4 0 0 1-4 4H24a4 4 0 0 1-4-4V20z", // bottle
|
||||
],
|
||||
cupping: [
|
||||
"M12 24h40l-4 22c-1 5-5 8-9 8h-14c-4 0-8-3-9-8z", // bowl
|
||||
"M10 54h44v4H10z",
|
||||
],
|
||||
v60: [
|
||||
"M12 12h40L34 38h-4z", // cone
|
||||
"M52 12h8l-2 6h-8z", // handle tab
|
||||
"M26 38h12v6H26z", // base neck
|
||||
"M18 44h28v6H18z", // base ring
|
||||
"M22 54h20v4H22z",
|
||||
],
|
||||
chemex: [
|
||||
"M18 6h28l-8 20h-12z", // funnel top
|
||||
"M26 26h12l10 22c2 5-2 10-8 10H24c-6 0-10-5-8-10z", // flask
|
||||
"M22 28h20v8H22z", // wood collar
|
||||
],
|
||||
kalita: [
|
||||
"M14 14h36l-6 20H20z", // flat cone
|
||||
"M50 16h8l-2 6h-8z", // handle
|
||||
"M22 34h20v6H22z", // flat base
|
||||
"M18 40h28v6H18z", // base ring
|
||||
"M22 54h20v4H22z",
|
||||
],
|
||||
batch: [
|
||||
"M14 4h36v8H14z", // top
|
||||
"M14 12h10v46H14z", // tower
|
||||
"M24 12h26v10H24z", // head
|
||||
"M28 26h18l-2 18H30z", // carafe under head
|
||||
"M26 48h22v4H26z", // warmer plate
|
||||
"M14 58h36v4H14z", // base
|
||||
],
|
||||
percolator: [
|
||||
"M28 2h8v6h-8z", // percolator knob
|
||||
"M22 8h20l4 44c0 4-4 6-8 6H26c-4 0-8-2-8-6z", // pot body
|
||||
"M44 16h8c3 0 4 3 3 6l-4 14h-6z", // handle
|
||||
"M18 16l-8 4 2 6 8-2z", // spout
|
||||
],
|
||||
espresso: [
|
||||
"M20 8h24v8H20z", // group head
|
||||
"M24 16h16v6H24z", // portafilter body
|
||||
"M16 18h8v4h-8z M40 18h8v4h-8z", // ears
|
||||
"M28 22h8v4h-8z", // spout block
|
||||
"M26 34h12l-2 8h-8z", // cup
|
||||
"M38 36h5c2 0 2 4 0 4h-6z", // cup handle
|
||||
"M20 46h24v4H20z", // tray
|
||||
"M14 54h36v6H14z", // base
|
||||
],
|
||||
moka: [
|
||||
"M24 4h16v6H24z", // lid knob band
|
||||
"M20 10h24l-4 12H24z", // upper chamber (tapered)
|
||||
"M26 22h12v6H26z", // waist band
|
||||
"M20 28l4 26h16l4-26z", // lower chamber (flared octagon)
|
||||
"M44 12h8c3 0 4 3 2 6l-6 10h-6z", // handle
|
||||
"M18 56h28v4H18z",
|
||||
],
|
||||
};
|
||||
+14
-9
@@ -19,27 +19,32 @@ export function rorToY(rorCPerMin) {
|
||||
return PLOT.y1 - rorCPerMin * 9;
|
||||
}
|
||||
|
||||
/** Build the five plan-curve points from the ledger + box-8 temps, falling back to machine medians. */
|
||||
export function buildPlanCurve(plan, ledger) {
|
||||
/** Build the five plan-curve points from the ledger + box-8 temps. Blank temps fall back to
|
||||
* the user's own learned roaster behavior (median milestone temps from their uploaded .alogs,
|
||||
* via /api/roaster-profile) when available, else the reference machine medians. */
|
||||
export function buildPlanCurve(plan, ledger, roasterProfile = null) {
|
||||
const temps = plan.temps ?? {};
|
||||
const num = (v) => {
|
||||
const n = Number.parseFloat(v);
|
||||
return Number.isFinite(n) ? n : null;
|
||||
};
|
||||
const learned = roasterProfile?.n > 0 ? roasterProfile.medians : {};
|
||||
const fallback = (learnedValue, referenceValue) =>
|
||||
Number.isFinite(learnedValue) ? learnedValue : referenceValue;
|
||||
|
||||
const points = [
|
||||
{ key: "charge", label: "Charge", timeS: 0, tempC: num(temps.charge?.tempC) ?? MACHINE.charge.medianC },
|
||||
{ key: "tp", label: "Turning point", timeS: parseTpTime(temps.tp?.time), tempC: num(temps.tp?.tempC) ?? MACHINE.turningPoint.medianC },
|
||||
{ key: "yellow", label: "Yellow", timeS: ledger.yellow, tempC: num(temps.yellow?.tempC) ?? MACHINE.yellow.medianC },
|
||||
{ key: "fc", label: "First crack", timeS: ledger.A, tempC: num(temps.fc?.tempC) ?? MACHINE.firstCrack.medianC },
|
||||
{ key: "drop", label: "Drop", timeS: ledger.D, tempC: num(temps.drop?.tempC) ?? MACHINE.drop.medianC },
|
||||
{ key: "charge", label: "Charge", timeS: 0, tempC: num(temps.charge?.tempC) ?? fallback(learned.chargeTempC, MACHINE.charge.medianC) },
|
||||
{ key: "tp", label: "Turning point", timeS: parseTpTime(temps.tp?.time, learned.turningPointS), tempC: num(temps.tp?.tempC) ?? fallback(learned.turningPointTempC, MACHINE.turningPoint.medianC) },
|
||||
{ key: "yellow", label: "Yellow", timeS: ledger.yellow, tempC: num(temps.yellow?.tempC) ?? fallback(learned.yellowTempC, MACHINE.yellow.medianC) },
|
||||
{ key: "fc", label: "First crack", timeS: ledger.A, tempC: num(temps.fc?.tempC) ?? fallback(learned.firstCrackTempC, MACHINE.firstCrack.medianC) },
|
||||
{ key: "drop", label: "Drop", timeS: ledger.D, tempC: num(temps.drop?.tempC) ?? fallback(learned.dropTempC, MACHINE.drop.medianC) },
|
||||
];
|
||||
|
||||
return points.filter((p) => p.timeS !== null && p.timeS !== undefined);
|
||||
}
|
||||
|
||||
function parseTpTime(str) {
|
||||
if (!str) return 52; // MACHINE.turningPoint.medianTime, "0:52"
|
||||
function parseTpTime(str, learnedS = null) {
|
||||
if (!str) return Number.isFinite(learnedS) ? learnedS : 52; // 52 = MACHINE.turningPoint.medianTime "0:52"
|
||||
const m = String(str).match(/^(\d+):(\d{1,2})$/);
|
||||
if (!m) return null;
|
||||
return Number(m[1]) * 60 + Number(m[2]);
|
||||
|
||||
Reference in New Issue
Block a user