Fix report pagination and rebuild saved plan navigation
Test and deploy / test-and-deploy (push) Successful in 1m43s
Test and deploy / test-and-deploy (push) Successful in 1m43s
This commit is contained in:
1 parent
824a964f9d
commit
5f3a015ea2
20 files changed
+1480
-809
No files matched your search
+7
-7
@@ -75,7 +75,7 @@ export const REPORT_CSS = `
|
||||
.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}}
|
||||
@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:auto;padding-top:18px}.roast-report h2{margin-top:12px}.roast-report th,.roast-report td{padding:5px}.roast-report .chart{max-width:100%;max-height:3.1in}.roast-report .chart svg{height:auto;max-height:2.95in}.roast-report h1,.roast-report h2{break-after:avoid}.roast-report .record-intro{break-inside:avoid}.roast-report .footer{break-before:avoid}.roast-report p{orphans:3;widows:3}.roast-report a{color:inherit;text-decoration:none}.roast-report p{margin:5px 0}.roast-report .notes{min-height:40px}.roast-report .record-notes{display:grid;grid-template-columns:1fr 1fr;gap:18px;break-inside:avoid}.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}.roast-report .subtitle{padding-bottom:8px;margin-bottom:10px}.roast-report .callout{padding:8px 10px;margin:8px 0}}
|
||||
`;
|
||||
export function renderReportBody(plan, name = "") {
|
||||
const l = computeLedger(plan),
|
||||
@@ -122,11 +122,11 @@ export function renderReportBody(plan, name = "") {
|
||||
<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>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</div></section>
|
||||
<section class="sheet"><div class="record-intro"><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>Follow a repeatable preheat routine. Record setpoint separately from the charge BT shown on the curve.</p></div>
|
||||
<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>
|
||||
<div class="callout">Watch RoR, color and aroma. Anticipate the machine’s response and avoid abrupt late cuts. Test one change in the next batch.</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] ?? {};
|
||||
@@ -134,8 +134,8 @@ export function renderReportBody(plan, name = "") {
|
||||
})
|
||||
.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>`;
|
||||
<div class="record-notes"><div><h2>Cup / brew notes</h2><div class="notes">${e([after.cupNotes, after.cupNotesLine2].filter(Boolean).join("\n"))}</div></div><div><h2>One change for the next batch</h2><div class="notes">${e(after.oneChange)}</div></div></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>Cultivar timings, the 1:15 light-development suggestion floor and the illustrated curve are planning heuristics. Your explicit development override takes precedence.</p><div class="footer">Roast record · Grams & Fahrenheit</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>`;
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import { computeLedger } from "./ledger.js?v=__ASSET_VERSION__";
|
||||
import { buildPlanCurve } from "./curve.js?v=__ASSET_VERSION__";
|
||||
import { parseDuration } from "./time.js?v=__ASSET_VERSION__";
|
||||
export const MILESTONES = [
|
||||
["charge", "Charge"],
|
||||
["tp", "Turning point"],
|
||||
["yellow", "Yellow / dry end"],
|
||||
["fc", "First crack"],
|
||||
["drop", "Drop"],
|
||||
];
|
||||
export function sanitizeObservations(raw) {
|
||||
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
|
||||
const out = {};
|
||||
for (const [key] of MILESTONES) {
|
||||
if (raw[key] === undefined) continue;
|
||||
const row = raw[key];
|
||||
if (!row || typeof row !== "object" || Array.isArray(row)) return null;
|
||||
const clean = {};
|
||||
for (const field of ["timeS", "tempC"]) {
|
||||
const v = row[field];
|
||||
if (v === undefined || v === null) {
|
||||
clean[field] = null;
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
typeof v !== "number" ||
|
||||
!Number.isFinite(v) ||
|
||||
v < 0 ||
|
||||
v > (field === "timeS" ? 7200 : 350)
|
||||
)
|
||||
return null;
|
||||
clean[field] = v;
|
||||
}
|
||||
clean.note = String(row.note ?? "").slice(0, 2000);
|
||||
out[key] = clean;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
export function buildComparison(detail) {
|
||||
const points = detail.plan
|
||||
? buildPlanCurve(detail.plan, computeLedger(detail.plan))
|
||||
: [];
|
||||
return MILESTONES.map(([key, label]) => {
|
||||
const target = points.find((p) => p.key === key);
|
||||
const uploaded =
|
||||
key === "tp"
|
||||
? detail.parsed?.turningPoint
|
||||
: detail.parsed?.milestones?.find((p) => p.key === key);
|
||||
const saved = detail.observations?.[key],
|
||||
legacy = detail.plan?.planActual?.[key];
|
||||
const timeS =
|
||||
saved?.timeS ?? uploaded?.timeS ?? parseDuration(legacy?.actualTime);
|
||||
const legacyTemp =
|
||||
legacy?.actualBt !== "" && legacy?.actualBt != null
|
||||
? Number(legacy.actualBt)
|
||||
: null;
|
||||
const tempC =
|
||||
saved?.tempC ??
|
||||
uploaded?.tempC ??
|
||||
(Number.isFinite(legacyTemp) ? legacyTemp : null);
|
||||
return {
|
||||
key,
|
||||
label,
|
||||
target,
|
||||
timeS,
|
||||
tempC,
|
||||
note: saved?.note ?? legacy?.note ?? "",
|
||||
deltaS: target && timeS != null ? timeS - target.timeS : null,
|
||||
};
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user