Files
snowspeederandClaude Sonnet 5 2237c199c1
Test and deploy / test-and-deploy (push) Successful in 1m26s
Add per-user learned machine profile; fix methodology honesty issues
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]>
2026-07-31 12:40:03 -04:00

55 lines
2.4 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
// 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: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}`;
}