Files
roast_command_center/shared/time.js
snowspeederandClaude Sonnet 5 fedaf29847 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]>
2026-07-29 15:41:35 -04:00

50 lines
2.0 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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}`;
}