Rebuild printable roast plans and remove machine-based adjustments
Test and deploy / test-and-deploy (push) Successful in 2m28s
Test and deploy / test-and-deploy (push) Successful in 2m28s
This commit is contained in:
1 parent
e9f2393c96
commit
824a964f9d
30 files changed
+1721
-908
No files matched your search
+107
-52
@@ -1,60 +1,115 @@
|
||||
// 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__";
|
||||
|
||||
// Canonical temperatures remain Celsius internally; reports render Fahrenheit.
|
||||
import { parseDuration } from "./time.js?v=__ASSET_VERSION__";
|
||||
export const PLOT = { x0: 52, x1: 652, y0: 24, y1: 294, tMaxS: 900 };
|
||||
export const tToX = (s) => PLOT.x0 + (s / PLOT.tMaxS) * (PLOT.x1 - PLOT.x0);
|
||||
export const tempToY = (c) => PLOT.y1 - (c - 60) * 1.8;
|
||||
export const rorToY = (r) => PLOT.y1 - r * 9;
|
||||
const numeric = (v) =>
|
||||
v != null && String(v).trim() !== "" && Number.isFinite(Number(v))
|
||||
? Number(v)
|
||||
: null;
|
||||
|
||||
export function tToX(seconds) {
|
||||
const clamped = Math.max(0, Math.min(PLOT.tMaxS, seconds));
|
||||
return PLOT.x0 + (clamped / PLOT.tMaxS) * (PLOT.x1 - PLOT.x0);
|
||||
// No roaster selection, learned median, or override changes these illustrative points.
|
||||
export function buildPlanCurve(plan, ledger) {
|
||||
const t = plan.temps ?? {};
|
||||
const fc = numeric(t.fc?.tempC) ?? 200;
|
||||
const yellow = numeric(t.yellow?.tempC) ?? 160;
|
||||
const maillardRate =
|
||||
ledger.maillard > 0 ? Math.max(0, (fc - yellow) / ledger.maillard) : 0;
|
||||
// Development's mean rise is lower than Maillard's, even for a short development.
|
||||
const drop = fc + maillardRate * (ledger.C ?? 0) * 0.65;
|
||||
return [
|
||||
{
|
||||
key: "charge",
|
||||
label: "Charge",
|
||||
timeS: 0,
|
||||
tempC: numeric(t.charge?.tempC) ?? 200,
|
||||
},
|
||||
{
|
||||
key: "tp",
|
||||
label: "Turning point",
|
||||
timeS: t.tp?.time ? parseDuration(t.tp.time) : 60,
|
||||
tempC: numeric(t.tp?.tempC) ?? 95,
|
||||
},
|
||||
{
|
||||
key: "yellow",
|
||||
label: "Yellow / dry end",
|
||||
timeS: ledger.yellow,
|
||||
tempC: yellow,
|
||||
},
|
||||
{ key: "fc", label: "First crack", timeS: ledger.A, tempC: fc },
|
||||
{
|
||||
key: "drop",
|
||||
label: "Drop",
|
||||
timeS: ledger.D,
|
||||
tempC: numeric(t.drop?.tempC) ?? drop,
|
||||
},
|
||||
]
|
||||
.filter((p) => Number.isFinite(p.timeS) && p.timeS >= 0)
|
||||
.map((p) => ({ ...p, illustrative: numeric(t[p.key]?.tempC) === null }));
|
||||
}
|
||||
|
||||
// 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;
|
||||
// Shape-preserving cubic Hermite interpolation. At TP the slope is zero; on the
|
||||
// rising side harmonic slopes avoid overshoot and keep phase transitions smooth.
|
||||
export function curveSegments(points) {
|
||||
const p = [...points]
|
||||
.filter((p) => Number.isFinite(p.timeS) && Number.isFinite(p.tempC))
|
||||
.sort((a, b) => a.timeS - b.timeS)
|
||||
.filter((p, i, a) => !i || p.timeS > a[i - 1].timeS);
|
||||
if (p.length < 2) return { points: p, segments: [] };
|
||||
const d = p
|
||||
.slice(1)
|
||||
.map((b, i) => (b.tempC - p[i].tempC) / (b.timeS - p[i].timeS));
|
||||
const m = p.map((_, i) => {
|
||||
if (!i) return d[0];
|
||||
if (i === p.length - 1) return d.at(-1) * 0.75;
|
||||
if (d[i - 1] * d[i] <= 0) return 0;
|
||||
const h0 = p[i].timeS - p[i - 1].timeS,
|
||||
h1 = p[i + 1].timeS - p[i].timeS;
|
||||
return (3 * (h0 + h1)) / ((2 * h1 + h0) / d[i - 1] + (h1 + 2 * h0) / d[i]);
|
||||
});
|
||||
return {
|
||||
points: p,
|
||||
segments: p.slice(1).map((b, i) => {
|
||||
const a = p[i],
|
||||
h = (b.timeS - a.timeS) / 3;
|
||||
return {
|
||||
a,
|
||||
b,
|
||||
c1: { timeS: a.timeS + h, tempC: a.tempC + m[i] * h },
|
||||
c2: { timeS: b.timeS - h, tempC: b.tempC - m[i + 1] * h },
|
||||
};
|
||||
}),
|
||||
};
|
||||
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]);
|
||||
export function pointsToPathD(points, x = tToX, y = tempToY) {
|
||||
const { points: p, segments } = curveSegments(points);
|
||||
const coord = (p) => `${x(p.timeS).toFixed(2)},${y(p.tempC).toFixed(2)}`;
|
||||
return p.length
|
||||
? `M${coord(p[0])} ` +
|
||||
segments
|
||||
.map((s) => `C${coord(s.c1)} ${coord(s.c2)} ${coord(s.b)}`)
|
||||
.join(" ")
|
||||
: "";
|
||||
}
|
||||
|
||||
/** 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(" ");
|
||||
export function curveWarnings(points) {
|
||||
const byKey = Object.fromEntries(points.map((p) => [p.key, p]));
|
||||
const p = ["tp", "yellow", "fc", "drop"].map((k) => byKey[k]);
|
||||
if (p.some((v) => !v)) return [];
|
||||
if (
|
||||
p.some(
|
||||
(v, i) => i && (v.timeS <= p[i - 1].timeS || v.tempC <= p[i - 1].tempC),
|
||||
)
|
||||
)
|
||||
return [
|
||||
"Milestones must progress in time and temperature after turning point. Review your entries.",
|
||||
];
|
||||
const rates = p
|
||||
.slice(1)
|
||||
.map((b, i) => (b.tempC - p[i].tempC) / (b.timeS - p[i].timeS));
|
||||
return rates.some((r, i) => i && r > rates[i - 1])
|
||||
? [
|
||||
"Entered milestones imply an increasing phase-average rate of rise. Review temperatures and timing; smoothing cannot correct inconsistent targets.",
|
||||
]
|
||||
: [];
|
||||
}
|
||||
+2
-1
@@ -5,7 +5,7 @@ export const FIELD_IDS = [
|
||||
"1.1", "1.2", "1.3", "1.4", "1.5", "1.6", "1.7",
|
||||
"2.1", "2.3", "2.4", "2.5",
|
||||
"3.1", "3.2",
|
||||
"4.1", "4.2", "4.3",
|
||||
"4.1", "4.2", "4.3", "4.4",
|
||||
"5.1", "5.2", "5.3", "5.4", "5.5", "5.6",
|
||||
"6.4",
|
||||
];
|
||||
@@ -23,6 +23,7 @@ export function blankPlan() {
|
||||
{ cultivar: "", group: "", process: "", sharePct: "", fcAnchor: "" },
|
||||
{ cultivar: "", group: "", process: "", sharePct: "", fcAnchor: "" },
|
||||
],
|
||||
preheat: { tempC: "", minutes: "", fanPct: "", drumPct: "" },
|
||||
temps: {
|
||||
charge: { time: "0:00", tempC: "", tempF: "" },
|
||||
tp: { time: "", tempC: "", tempF: "" },
|
||||
|
||||
+61
-33
@@ -1,42 +1,31 @@
|
||||
// Browser-safe. THE ONLY implementation of the worksheet's time-ledger math.
|
||||
// Mirrors manual-roast-planner.html's worksheet box 6 (lines 1-9, totals A/C/D)
|
||||
// and box 7 (the four sanity checks) exactly. Imported by both server and browser.
|
||||
//
|
||||
// Two changes from the original worksheet, made 2026-07-31 after a methodology review (see
|
||||
// shared/learn.js's header): first crack is now scaled by a multiplicative machine-pace factor —
|
||||
// the operator's own 14-roast data showed a ~13% batch-size increase stretching first crack ~40%,
|
||||
// a scaling relationship a flat added-seconds guess can't represent. Pace is applied to the WHOLE
|
||||
// pre-pace sum, INCLUDING line 4 (± batch-size correction), not just lines 1-3: shared/learn.js
|
||||
// fits pace from each historical roast's l1+l2+l3+l4 against what actually happened, so applying
|
||||
// it only to l1+l2+l3 here and adding l4 back afterwards would double-count whatever that
|
||||
// historical l4 was already compensating for. And the drying/Maillard sanity checks are now
|
||||
// marked `informational`: because yellow is DERIVED as YELLOW_RATIO × A rather than entered
|
||||
// independently, drying% and Maillard% are algebraic consequences of the DTR check, not
|
||||
// independent validations — whenever DTR passes, both are mathematically guaranteed to also pass.
|
||||
// Only DTR and the development ceiling test anything a plan could actually fail.
|
||||
// Shared planning math. Machine profiles are accepted for legacy callers but ignored.
|
||||
import {
|
||||
parseDuration,
|
||||
parseRangeMidpoint,
|
||||
} from "./time.js?v=__ASSET_VERSION__";
|
||||
import {
|
||||
YELLOW_RATIO,
|
||||
SANITY_BANDS,
|
||||
} from "./reference-data.js?v=__ASSET_VERSION__";
|
||||
|
||||
import { parseDuration, parseRangeMidpoint } from "./time.js?v=__ASSET_VERSION__";
|
||||
import { YELLOW_RATIO, SANITY_BANDS } from "./reference-data.js?v=__ASSET_VERSION__";
|
||||
|
||||
/**
|
||||
* @param {import("./fields.js").Plan} plan
|
||||
* @param {import("./learn.js").computeMachineProfile extends (...a:any)=>infer R ? R : never} [machineProfile]
|
||||
* optional learned per-user profile from shared/learn.js; omitted or a profile with no learned
|
||||
* data behaves exactly as before (pace 1.0 — the reference/original worksheet behavior).
|
||||
* @returns ledger result — every time value in seconds, null when not computable.
|
||||
*/
|
||||
/** All durations are seconds; null means the plan cannot yet be computed.
|
||||
* The optional legacy profile argument is intentionally ignored. */
|
||||
export function computeLedger(plan, machineProfile = null) {
|
||||
const f = plan.fields ?? {};
|
||||
const isBlend = f["2.1"] === "blend";
|
||||
const warnings = [];
|
||||
const pace = machineProfile?.pace?.value ?? 1;
|
||||
const pace = 1; // Roaster history and overrides never modify a plan.
|
||||
|
||||
const l1 = parseRangeMidpoint(isBlend ? f["2.4"] : f["1.4"]);
|
||||
const l2 = parseDuration(f["1.6"]) ?? 0;
|
||||
const l3 = parseDuration(f["5.6"]) ?? 0;
|
||||
const l4 = parseDuration(f["6.4"]) ?? 0;
|
||||
const baseA = l1 === null ? null : l1 + l2 + l3 + l4;
|
||||
const A = baseA === null ? null : Math.round(baseA * pace);
|
||||
const A = baseA !== null && baseA > 0 ? baseA : null;
|
||||
if (baseA !== null && A === null)
|
||||
warnings.push(
|
||||
"First crack must be after charge; check timing adjustments.",
|
||||
);
|
||||
|
||||
const yellow = A === null ? null : Math.round(A * YELLOW_RATIO);
|
||||
const maillard = A === null ? null : A - yellow;
|
||||
@@ -44,18 +33,56 @@ export function computeLedger(plan, machineProfile = null) {
|
||||
const l7 = parseRangeMidpoint(f["4.3"]);
|
||||
const l8 = parseDuration(isBlend ? f["2.5"] : f["3.2"]) ?? 0;
|
||||
const l9 = parseDuration(f["1.7"]) ?? 0;
|
||||
const C = l7 === null ? null : l7 + l8 + l9; // rows 5 and 6 (yellow/Maillard) are NEVER summed here
|
||||
const rawC = l7 === null ? null : l7 + l8 + l9;
|
||||
const explicitC = parseDuration(f["4.4"]);
|
||||
const suggestedC =
|
||||
rawC !== null && f["4.1"] === "light" ? Math.max(75, rawC) : rawC;
|
||||
const invalidOverride = f["4.4"] && explicitC === null;
|
||||
if (invalidOverride)
|
||||
warnings.push(
|
||||
"Development override is invalid; use m:ss with seconds below 60.",
|
||||
);
|
||||
const candidateC = invalidOverride ? null : (explicitC ?? suggestedC);
|
||||
const C = candidateC !== null && candidateC > 0 ? candidateC : null;
|
||||
if (explicitC === null && rawC !== suggestedC)
|
||||
warnings.push(
|
||||
"Development modifiers reduced the light-roast suggestion below 1:15. Using a 1:15 starting target; enter a development override to change it.",
|
||||
);
|
||||
if (candidateC !== null && C === null)
|
||||
warnings.push("Development must be positive."); // rows 5 and 6 (yellow/Maillard) are NEVER summed here
|
||||
|
||||
const D = A === null || C === null ? null : A + C;
|
||||
|
||||
if (l1 === null) warnings.push("No cultivar/blend anchor (field 1.4 or 2.4) — first crack cannot be computed.");
|
||||
if (l7 === null) warnings.push("No development base (field 4.3) — development cannot be computed.");
|
||||
if (l1 === null)
|
||||
warnings.push(
|
||||
"No cultivar/blend anchor (field 1.4 or 2.4) — first crack cannot be computed.",
|
||||
);
|
||||
if (l7 === null && explicitC === null)
|
||||
warnings.push(
|
||||
"No development base (field 4.3) — development cannot be computed.",
|
||||
);
|
||||
|
||||
const pct = (part) => (D && D > 0 && part !== null ? (part / D) * 100 : null);
|
||||
|
||||
if (pct(C) !== null && (pct(C) < 12 || pct(C) > 20))
|
||||
warnings.push(
|
||||
"Development ratio is outside the 12–20% planning range. Review color, aroma and the cup; DTR alone does not determine roast quality.",
|
||||
);
|
||||
const density = Number(f["5.2"]);
|
||||
if (f["5.2"] && Number.isFinite(density) && (density < 300 || density > 1000))
|
||||
warnings.push(
|
||||
"Check density units: enter g/L, not g/mL. For example, 0.7614 g/mL = 761.4 g/L. No automatic correction was made.",
|
||||
);
|
||||
|
||||
const checks = {
|
||||
drying: { ...passCheck(pct(yellow), SANITY_BANDS.drying), informational: true },
|
||||
maillard: { ...passCheck(pct(maillard), SANITY_BANDS.maillard), informational: true },
|
||||
drying: {
|
||||
...passCheck(pct(yellow), SANITY_BANDS.drying),
|
||||
informational: true,
|
||||
},
|
||||
maillard: {
|
||||
...passCheck(pct(maillard), SANITY_BANDS.maillard),
|
||||
informational: true,
|
||||
},
|
||||
dtr: passCheck(pct(C), SANITY_BANDS.dtr),
|
||||
ceiling: {
|
||||
valueS: C,
|
||||
@@ -74,6 +101,7 @@ export function computeLedger(plan, machineProfile = null) {
|
||||
A,
|
||||
yellow,
|
||||
maillard,
|
||||
rawC,
|
||||
C,
|
||||
D,
|
||||
checks,
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
import { computeLedger } from "./ledger.js?v=__ASSET_VERSION__";
|
||||
import {
|
||||
buildPlanCurve,
|
||||
pointsToPathD,
|
||||
curveWarnings,
|
||||
} from "./curve.js?v=__ASSET_VERSION__";
|
||||
import { formatDuration } from "./time.js?v=__ASSET_VERSION__";
|
||||
export const escapeHtml = (v) =>
|
||||
String(v ?? "").replace(
|
||||
/[&<>"']/g,
|
||||
(c) =>
|
||||
({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[
|
||||
c
|
||||
],
|
||||
);
|
||||
const e = escapeHtml,
|
||||
time = (s) => (s == null ? "—" : formatDuration(s)),
|
||||
f = (c) => (Number.isFinite(c) ? `${Math.round(c * 1.8 + 32)}°F` : "—");
|
||||
const number = (v) =>
|
||||
v != null && String(v).trim() !== "" && Number.isFinite(Number(v))
|
||||
? Number(v)
|
||||
: null;
|
||||
export function renderReportCurve(points, ledger, reference = null) {
|
||||
const ref = (reference?.curve ?? []).filter(
|
||||
(p) => Number.isFinite(p.t) && p.t >= 0 && Number.isFinite(p.bt),
|
||||
);
|
||||
const maxT =
|
||||
Math.max(660, ...points.map((p) => p.timeS), ...ref.map((p) => p.t)) + 60;
|
||||
const minC =
|
||||
Math.floor(
|
||||
(Math.min(80, ...points.map((p) => p.tempC), ...ref.map((p) => p.bt)) -
|
||||
10) /
|
||||
20,
|
||||
) * 20;
|
||||
const maxC =
|
||||
Math.ceil(
|
||||
(Math.max(220, ...points.map((p) => p.tempC), ...ref.map((p) => p.bt)) +
|
||||
15) /
|
||||
20,
|
||||
) * 20;
|
||||
const x = (t) => 70 + (t / maxT) * 740,
|
||||
y = (c) => 365 - ((c - minC) / (maxC - minC)) * 250;
|
||||
let svg =
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 900 440" role="img" aria-label="Roast curve with milestone times, Fahrenheit temperatures and phase durations"><rect width="900" height="440" fill="white"/>';
|
||||
const phases = [
|
||||
["Drying", 0, ledger.yellow, "#f6efe7"],
|
||||
["Maillard", ledger.yellow, ledger.A, "#efe6d8"],
|
||||
["Development", ledger.A, ledger.D, "#e9ddd0"],
|
||||
];
|
||||
phases.forEach(([name, a, b, color], i) => {
|
||||
if (a == null || b == null) return;
|
||||
svg += `<rect x="${x(a)}" y="105" width="${Math.max(0, x(b) - x(a))}" height="260" fill="${color}"/><text x="${80 + i * 280}" y="24" font-size="13" font-weight="700">${name} · ${time(b - a)}</text><text x="${80 + i * 280}" y="44" font-size="12">${time(a)} → ${time(b)}</text>`;
|
||||
});
|
||||
for (let c = minC; c <= maxC; c += 20)
|
||||
svg += `<path d="M70 ${y(c)}H810" stroke="#ddd"/><text x="60" y="${y(c) + 4}" text-anchor="end" font-size="11">${f(c)}</text>`;
|
||||
const step = Math.ceil(maxT / 720) * 60;
|
||||
for (let t = 0; t <= maxT; t += step)
|
||||
svg += `<path d="M${x(t)} 105V365" stroke="#ddd"/><text x="${x(t)}" y="390" text-anchor="middle" font-size="11">${time(t)}</text>`;
|
||||
if (ref.length)
|
||||
svg += `<path d="${ref.map((p, i) => `${i ? "L" : "M"}${x(p.t)},${y(p.bt)}`).join(" ")}" fill="none" stroke="#8b6b4a" stroke-width="2" stroke-dasharray="5 4"/>`;
|
||||
svg += `<path d="${pointsToPathD(points, x, y)}" fill="none" stroke="#25201b" stroke-width="3"/>`;
|
||||
points.forEach((p, i) => {
|
||||
const lx = 80 + i * 164,
|
||||
ly = i % 2 ? 85 : 65;
|
||||
svg += `<path d="M${x(p.timeS)} ${y(p.tempC)}L${lx + 30} ${ly + 17}" stroke="#9d8976" stroke-dasharray="3 3"/><circle cx="${x(p.timeS)}" cy="${y(p.tempC)}" r="4" fill="white" stroke="#25201b" stroke-width="2"/><text x="${lx}" y="${ly}" font-size="11" font-weight="700">${e(p.label)}<tspan x="${lx}" dy="15" font-weight="400">${time(p.timeS)} · ${f(p.tempC)}${p.illustrative ? "*" : ""}</tspan></text>`;
|
||||
});
|
||||
return (
|
||||
svg +
|
||||
'<text x="450" y="425" text-anchor="middle" font-size="12">Time from charge · BT (°F) · * illustrative · dashed = attached reference</text></svg>'
|
||||
);
|
||||
}
|
||||
export const REPORT_CSS = `
|
||||
.roast-report{color:#161616;background:#ececea;font:12px/1.45 system-ui,Arial,sans-serif;padding:24px;box-sizing:border-box}
|
||||
.roast-report *{box-sizing:border-box}.roast-report .sheet{max-width:8.5in;margin:0 auto 24px;background:white;padding:32px 38px;box-shadow:0 6px 28px #0002}
|
||||
.roast-report h1{font-size:25px;line-height:1.12;margin:0 0 8px}.roast-report h2{display:block;border:0;padding:0;font-size:13px;text-transform:uppercase;letter-spacing:.08em;margin:18px 0 8px}.roast-report p{margin:7px 0}.roast-report .subtitle{color:#626262;border-bottom:3px solid #161616;padding-bottom:14px;margin-bottom:16px}
|
||||
.roast-report .cards{display:grid;grid-template-columns:repeat(3,1fr);gap:10px}.roast-report .card{border:1px solid #d7d7d7;border-radius:9px;padding:11px}.roast-report .label{font-size:10px;color:#626262;text-transform:uppercase}.roast-report .value{font-size:20px;font-weight:700}.roast-report .callout{background:#f5f5f2;border-left:4px solid #7a4c2a;padding:10px 12px;margin:12px 0}
|
||||
.roast-report table{width:100%;border-collapse:collapse;font-size:11px}.roast-report th{text-align:left;border-bottom:2px solid #161616;font-size:10px}.roast-report td{border-bottom:1px solid #d7d7d7}.roast-report th,.roast-report td{padding:7px 5px;vertical-align:top}.roast-report svg{display:block;width:100%;height:auto}.roast-report .chart{border:1px solid #d7d7d7;border-radius:10px;padding:6px;break-inside:avoid}.roast-report .notes{white-space:pre-wrap;min-height:45px;border-bottom:1px solid #bbb}.roast-report .footer{margin-top:16px;padding-top:8px;border-top:1px solid #ddd;font-size:10px;color:#626262}.roast-report a{color:#6c482b;overflow-wrap:anywhere}.roast-report .cards,.roast-report tr{break-inside:avoid}
|
||||
@page{size:Letter;margin:.35in}@media print{.roast-report{background:white;padding:0;font-size:10px}.roast-report .sheet{width:auto;max-width:none;margin:0;padding:.16in .2in;box-shadow:none}.roast-report .sheet+.sheet{break-before:page}.roast-report h2{margin-top:12px}.roast-report th,.roast-report td{padding:5px}.roast-report .chart{max-width:100%}.roast-report a{color:inherit;text-decoration:none}.roast-report p{margin:5px 0}.roast-report .notes{min-height:32px}.roast-report .card{padding:8px}.roast-report .value{font-size:18px}.roast-report table{font-size:10px}.roast-report .footer{margin-top:10px}.roast-report h1{font-size:23px}}
|
||||
`;
|
||||
export function renderReportBody(plan, name = "") {
|
||||
const l = computeLedger(plan),
|
||||
p = buildPlanCurve(plan, l),
|
||||
fields = plan.fields ?? {},
|
||||
after = plan.afterRoast ?? {};
|
||||
const green = number(fields["0.4"]),
|
||||
loss = number(fields["4.2"]);
|
||||
const out =
|
||||
green !== null && green > 0 && loss !== null && loss >= 0 && loss < 100
|
||||
? Math.round(green * (1 - loss / 100))
|
||||
: null;
|
||||
const card = (label, value) =>
|
||||
`<div class="card"><div class="label">${e(label)}</div><div class="value">${e(value)}</div></div>`;
|
||||
const cues = {
|
||||
charge:
|
||||
"Stable preheat; record charge BT separately from the preheat setpoint.",
|
||||
tp: "Probe recovery, not a direct measure of energy absorbed by the beans.",
|
||||
yellow: "Beans yellow; grassy aroma fades.",
|
||||
fc: "Mark sustained audible first crack; do not chase the illustrated temperature.",
|
||||
drop: "Judge color, aroma and progression; cool promptly. DTR alone is not a drop signal.",
|
||||
};
|
||||
const warnings = [...l.warnings, ...curveWarnings(p)];
|
||||
const rows = p
|
||||
.map(
|
||||
(v) =>
|
||||
`<tr><td><b>${e(v.label)}</b></td><td>${time(v.timeS)}</td><td>${f(v.tempC)}${v.illustrative ? "*" : ""}</td><td>${cues[v.key]}</td></tr>`,
|
||||
)
|
||||
.join("");
|
||||
const schedule = (plan.actuators ?? []).filter(
|
||||
(v) => v.heatPct || v.fanPct || v.why || v.expectedBt,
|
||||
);
|
||||
const sr = schedule.length
|
||||
? schedule
|
||||
.map(
|
||||
(v) =>
|
||||
`<tr><td>${e(v.time)}</td><td>${e(v.heatPct || "—")}%</td><td>${e(v.fanPct || "—")}%</td><td>${f(number(v.expectedBt))}</td><td>${e(v.why)}</td></tr>`,
|
||||
)
|
||||
.join("")
|
||||
: '<tr><td colspan="5">Record your heat and airflow steps in the planner. No universal actuator percentages are prescribed.</td></tr>';
|
||||
return `<div class="roast-report"><section class="sheet"><h1>${e(name || fields["0.1"] || "Roast plan")}</h1><div class="subtitle">${e([fields["1.1"], fields["3.1"], fields["4.1"] ? fields["4.1"] + " roast" : ""].filter(Boolean).join(" · "))} · Planning guide</div>
|
||||
<div class="cards">${card("Green coffee in", green === null ? "—" : green + " g")}${card("Target roasted out", out === null ? "—" : out + " g")}${card("Target total time", time(l.D))}</div>
|
||||
<h2>Coffee & roast intent</h2><p><b>Origin:</b> ${e(fields["1.3"] || "—")} · <b>Cultivar:</b> ${e(fields["1.1"] || "—")} · <b>Density:</b> ${e(fields["5.2"] || "—")} g/L</p><p><b>Weight-loss target:</b> ${e(fields["4.2"] || "—")}% · <b>Development:</b> ${time(l.C)} · <b>DTR:</b> ${l.checks.dtr.pct?.toFixed(1) ?? "—"}%</p>
|
||||
<div class="callout">Use this as a starting hypothesis. Light, fruity coffee depends on the green coffee, roast progression and cup results. Machine selection never changes this plan. * Blank temperatures use an illustrative reference curve, not measured or calibrated targets.</div>
|
||||
${warnings.length ? `<div class="callout">${warnings.map((w) => `<p>${e(w)}</p>`).join("")}</div>` : ""}
|
||||
<h2>Milestone plan</h2><table><thead><tr><th>Milestone</th><th>Time</th><th>BT (°F)</th><th>What to look for</th></tr></thead><tbody>${rows}</tbody></table>
|
||||
<h2>Roast curve</h2><div class="chart">${renderReportCurve(p, l)}</div><p>The smooth line interpolates milestones; it is not a predicted sensor trace. Aim for controlled progression after recovery. Phase averages cannot rule out a brief crash or flick.</p><div class="footer">Roast plan · Grams & Fahrenheit · Page 1</div></section>
|
||||
<section class="sheet"><h1>Roast schedule & record</h1><div class="subtitle">${e(name || fields["0.1"] || "Roast plan")} · Use at the roaster</div>
|
||||
<h2>Preheat</h2><div class="cards">${card("Setpoint (°F)", number(plan.preheat?.tempC) === null ? "________" : f(number(plan.preheat.tempC)))}${card("Preheat time (min)", plan.preheat?.minutes || "________")}${card("Drum / fan (%)", (plan.preheat?.drumPct || "____") + " / " + (plan.preheat?.fanPct || "____"))}</div><p>Use your manufacturer’s guidance and a repeatable preheat routine. The charge BT plotted on page 1 is not a preheat setpoint. Record preheat separately.</p>
|
||||
<h2>Actuator schedule</h2><table><thead><tr><th>When</th><th>Heat</th><th>Fan</th><th>BT (°F)</th><th>Purpose</th></tr></thead><tbody>${sr}</tbody></table>
|
||||
<div class="callout">Watch actual RoR, color and aroma. Plan heat changes ahead of the machine’s response; avoid abrupt late cuts. Record one change to evaluate in the next cup.</div>
|
||||
<h2>At-the-roaster record</h2><table><thead><tr><th>Milestone</th><th>Plan time / BT</th><th>Actual time</th><th>Actual BT (°F)</th><th>Notes</th></tr></thead><tbody>${p
|
||||
.map((v) => {
|
||||
const a = plan.planActual?.[v.key] ?? {};
|
||||
return `<tr><td>${e(v.label)}</td><td>${time(v.timeS)} / ${f(v.tempC)}${v.illustrative ? "*" : ""}</td><td>${e(a.actualTime || "________")}</td><td>${number(a.actualBt) === null ? "________" : f(number(a.actualBt))}</td><td>${e(a.note || "________________")}</td></tr>`;
|
||||
})
|
||||
.join("")}</tbody></table>
|
||||
<h2>After the roast</h2><div class="cards">${card("Roasted out", after.out ? after.out + " g" : "________ g")}${card("Weight loss", after.weightLossPct ? after.weightLossPct + "%" : "________ %")}${card("Rest before tasting", after.restedDays ? after.restedDays + " days" : "________ days")}</div>
|
||||
<h2>Cup / brew notes</h2><div class="notes">${e([after.cupNotes, after.cupNotesLine2].filter(Boolean).join("\n"))}</div><h2>One change for the next batch</h2><div class="notes">${e(after.oneChange)}</div>
|
||||
<h2>Sources & interpretation</h2><p><a href="https://library.sweetmarias.com/first-crack-faq-what-is-first-crack-what-is-second-crack/">Sweet Maria’s: First Crack FAQ</a> — audible event and temperature context.</p><p><a href="https://www.scottrao.com/blog/2020/3/29/coffee-roasting-fundamentals">Scott Rao: Coffee Roasting Fundamentals</a> — probe readings do not transfer directly; time and DTR alone do not establish quality.</p><p>Timing begins with the app’s cultivar worksheet. The 1:15 light-development suggestion floor and illustrated curve are app planning heuristics, not universal roasting rules. Your explicit development override takes precedence.</p><div class="footer">Roast record · Grams & Fahrenheit · Page 2</div></section></div>`;
|
||||
}
|
||||
export function renderReportHtml(plan, name = "") {
|
||||
return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${e(name || plan.fields?.["0.1"] || "Roast plan")}</title><style>body{margin:0}${REPORT_CSS}</style></head><body>${renderReportBody(plan, name)}</body></html>`;
|
||||
}
|
||||
+8
-2
@@ -15,8 +15,14 @@ export function parseDuration(str) {
|
||||
const sign = s.startsWith("-") ? -1 : 1;
|
||||
const body = s.replace(/^[+-]/, "");
|
||||
const m = body.match(/^(\d+):(\d{1,2})$/);
|
||||
if (m) return sign * (Number(m[1]) * 60 + Number(m[2]));
|
||||
if (/^\d+(\.\d+)?$/.test(body)) return sign * Math.round(Number(body));
|
||||
if (m) {
|
||||
const seconds = Number(m[1]) * 60 + Number(m[2]);
|
||||
return Number(m[2]) < 60 && Number.isSafeInteger(seconds) ? sign * seconds : null;
|
||||
}
|
||||
if (/^\d+(\.\d+)?$/.test(body)) {
|
||||
const seconds = Math.round(Number(body));
|
||||
return Number.isSafeInteger(seconds) ? sign * seconds : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user