Files
Shane MaynardandClaude Fable 5 38c7d01e03
Test and deploy / test-and-deploy (push) Successful in 1m6s
Add brewing section, plan chat, roaster learning, API tokens, Swagger docs, and full backup
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]>
2026-08-08 22:37:42 -04:00

61 lines
2.9 KiB
JavaScript

// Browser-safe. Geometry for the roast-curve SVG grid (viewBox 0 0 714 334), matching
// manual-roast-planner.html's worksheet box 10 grid exactly.
import { MACHINE } from "./reference-data.js?v=__ASSET_VERSION__";
export const PLOT = { x0: 52, x1: 652, y0: 24, y1: 294, tMaxS: 900 };
export function tToX(seconds) {
const clamped = Math.max(0, Math.min(PLOT.tMaxS, seconds));
return PLOT.x0 + (clamped / PLOT.tMaxS) * (PLOT.x1 - PLOT.x0);
}
// 60C -> y294 (bottom axis), 200C label sits at y42 (confirmed by the FC band rect y=65.4,h=19.8 = 176-187C)
export function tempToY(degC) {
return PLOT.y1 - (degC - 60) * 1.8;
}
export function rorToY(rorCPerMin) {
return PLOT.y1 - rorCPerMin * 9;
}
/** 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) ?? 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, 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]);
}
/** Build an SVG <path> "d" string through points, sorted by time, plain polyline (no kink invented). */
export function pointsToPathD(points) {
const sorted = [...points].sort((a, b) => a.timeS - b.timeS);
if (sorted.length === 0) return "";
return sorted
.map((p, i) => `${i === 0 ? "M" : "L"}${tToX(p.timeS).toFixed(1)},${tempToY(p.tempC).toFixed(1)}`)
.join(" ");
}