Test and deploy / test-and-deploy (push) Successful in 1m26s
Replaces the flat additive batch-size correction with a per-user multiplicative pace factor learned from each account's own logged roasts (shared/learn.js, GET /api/machine-profile), plus a lot-scoped "last refine" auto-suggestion. Also fixes the reference data being framed as "your own roasts" on a now-multi-user product, and reclassifies the drying/Maillard sanity checks as informational since they're algebraically derived from the DTR check rather than independent (yellow = 0.56 x first crack, not entered separately). Bug fixes found by an adversarial Opus review of the first pass: - Unicode minus sign (U+2212) broke duration parsing against the app's own generated refine-suggestion text - Printed sanity-checks table still showed a bare pass/fail glyph for the now-informational drying/Maillard rows - Printed time-ledger box didn't show the pace multiplication step, so it stopped reconciling by hand once pace != 1 - Field 6.4 (manual batch correction) was double-counted: excluded from the learned-pace fit but added back after the multiplication - Learned pace had no outlier rejection or hard clamp - computeLedger had no test coverage - Batch-size help copy overstated what the pace factor models (it's a single blanket ratio, not conditioned on batch weight) A follow-up Opus pass also caught the per-user profile cache surviving logout/account-switch in a shared browser; fixed by sweeping it alongside the existing plan-draft cleanup. Co-Authored-By: Claude Sonnet 5 <[email protected]>
55 lines
2.4 KiB
JavaScript
55 lines
2.4 KiB
JavaScript
// Browser-safe. No node:* imports, no DOM. Imported by both server and browser.
|
||
|
||
const DASHES = /[‒–—−-]/; // -, –, —, minus sign
|
||
// Matches only the non-ASCII members of DASHES — used to fold a leading minus/en/em dash down to
|
||
// an ASCII hyphen before sign detection, so a string like "−0:30" (U+2212, the character every
|
||
// signed number in this app's own reference data — SYMPTOM_FIXES, cultivar profiles — is written
|
||
// with) parses instead of silently returning null.
|
||
const NON_ASCII_DASHES = /[‒–—−]/g;
|
||
|
||
/** "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().replace(NON_ASCII_DASHES, "-");
|
||
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}`;
|
||
}
|