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
+49
View File
@@ -0,0 +1,49 @@
// Browser-safe. No node:* imports, no DOM. Imported by both server and browser.
const DASHES = /[‒–—−-]/; // -, , —, minus sign
/** "8:45" -> 525, "-0:20" -> -20, "+0:15" -> 15, "0" -> 0, "90" -> 90 (bare seconds). null on junk. */
export function parseDuration(str) {
if (str === null || str === undefined) return null;
const s = String(str).trim();
if (s === "") return null;
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));
return null;
}
/** "7:30-9:00" / "7:309:00" -> midpoint seconds. Single value passes through parseDuration. */
export function parseRangeMidpoint(str) {
if (str === null || str === undefined) return null;
const s = String(str).trim();
if (s === "") return null;
const parts = s.split(DASHES).map((p) => p.trim()).filter(Boolean);
if (parts.length >= 2) {
const a = parseDuration(parts[0]);
const b = parseDuration(parts[parts.length - 1]);
if (a === null || b === null) return null;
return Math.round((a + b) / 2);
}
return parseDuration(s);
}
/** 525 -> "8:45". Negative seconds -> "-0:20". null -> "". */
export function formatDuration(totalSeconds) {
if (totalSeconds === null || totalSeconds === undefined || Number.isNaN(totalSeconds)) return "";
const sign = totalSeconds < 0 ? "-" : "";
const abs = Math.abs(Math.round(totalSeconds));
const m = Math.floor(abs / 60);
const s = abs % 60;
return `${sign}${m}:${String(s).padStart(2, "0")}`;
}
/** Same as formatDuration but always carries an explicit sign, and "0" for zero. */
export function formatSigned(totalSeconds) {
if (totalSeconds === null || totalSeconds === undefined || Number.isNaN(totalSeconds)) return "";
if (totalSeconds === 0) return "0";
const out = formatDuration(Math.abs(totalSeconds));
return totalSeconds < 0 ? `-${out}` : `+${out}`;
}