Initial roast planner webapp: fillable worksheet, live ledger, curve, URL prefill, .alog reference curve

- shared/ ports the worksheet's ledger math, time parsing, and reference tables
  (cultivars/processes/roast-levels/machine bands) as browser-safe ESM, imported
  by both the server and the browser so the arithmetic can't drift between them.
- server/prefill.js runs a zero-tool Pi Coding Agent SDK turn to extract page facts
  from a bean product URL, then derives worksheet field IDs deterministically from
  reference-data.js — the model never invents a first-crack anchor or a modifier.
- server/alog.js ports the Python alog_parser.py's format handling, including the
  tokenizer-based Python-dict-literal-to-JSON conversion the real files need.
- public/ is the worksheet reproduced as a live HTML form: worksheet.css is a
  verbatim copy of the paper worksheet's print CSS, print.js bakes values into
  the print layout so the same DOM renders both on screen and on paper.
- Ledger math verified against both of the paper worksheet's worked examples;
  alog parser verified against all 14 real logs in ref/roasts/.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
This commit is contained in:
2026-07-29 15:41:35 -04:00
co-authored by Claude Sonnet 5
commit fedaf29847
22 changed files with 5000 additions and 0 deletions
+89
View File
@@ -0,0 +1,89 @@
// Wires the "Prefill from URL" panel. Applies the returned field patch only into empty
// fields by default (checkbox to overwrite), tracks a snapshot for undo, and never touches
// the form on any error.
export function initPrefillPanel({ state, renderFormFromPlan, recompute }) {
const urlInput = document.getElementById("prefill-url");
const overwriteBox = document.getElementById("prefill-overwrite");
const goBtn = document.getElementById("prefill-go");
const undoBtn = document.getElementById("prefill-undo");
const resultEl = document.getElementById("prefill-result");
let snapshot = null;
goBtn.addEventListener("click", async () => {
const url = urlInput.value.trim();
if (!url) return;
goBtn.disabled = true;
resultEl.innerHTML = `<p>Fetching…</p>`;
try {
const res = await fetch("/api/prefill", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ url }),
});
const body = await res.json();
if (!body.ok) {
resultEl.innerHTML = `<p style="color:#a8371a">${escapeHtml(body.error ?? body.code)}</p>`;
return;
}
snapshot = JSON.parse(JSON.stringify(state.plan));
const overwrite = overwriteBox.checked;
let applied = 0;
for (const [id, value] of Object.entries(body.fields ?? {})) {
const current = state.plan.fields[id] ?? "";
if (!overwrite && current !== "") continue;
state.plan.fields[id] = value;
applied++;
}
renderFormFromPlan();
markPrefilled(Object.keys(body.fields ?? {}), body.provenance ?? {});
recompute();
undoBtn.disabled = false;
const warnings = (body.warnings ?? []).map((w) => `<li>${escapeHtml(w)}</li>`).join("");
resultEl.innerHTML = `
<p>Applied ${applied} field${applied === 1 ? "" : "s"} from <a href="${escapeHtml(body.source.finalUrl)}" target="_blank" rel="noopener">${escapeHtml(body.source.finalUrl)}</a>.</p>
${warnings ? `<ul class="warnings">${warnings}</ul>` : ""}
`;
} catch (err) {
resultEl.innerHTML = `<p style="color:#a8371a">${escapeHtml(err.message)}</p>`;
} finally {
goBtn.disabled = false;
}
});
undoBtn.addEventListener("click", () => {
if (!snapshot) return;
state.plan = snapshot;
snapshot = null;
undoBtn.disabled = true;
renderFormFromPlan();
clearPrefilledMarks();
recompute();
resultEl.innerHTML = "<p>Prefill undone.</p>";
});
function markPrefilled(ids, provenance) {
for (const id of ids) {
const el = document.querySelector(`[name="${CSS.escape(id)}"]`);
if (!el) continue;
el.classList.add("prefilled");
const prov = provenance[id];
if (prov) el.title = `from: ${prov}`;
}
}
function clearPrefilledMarks() {
for (const el of document.querySelectorAll(".prefilled")) {
el.classList.remove("prefilled");
el.removeAttribute("title");
}
}
}
function escapeHtml(s) {
return String(s ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]));
}