- 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]>
50 lines
2.0 KiB
JavaScript
50 lines
2.0 KiB
JavaScript
// 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}`;
|
||
}
|