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:
@@ -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:30–9: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}`;
|
||||
}
|
||||
Reference in New Issue
Block a user