/* ============================================================ VYNDR — LIVE GRADE-SHIFT timeline helper (Wave 4B). Pure CommonJS so the client component imports it (allowJs) AND the Jest suite requires it directly. Builds a grade/line-movement timeline from ALREADY-EMITTED data (intradayRefreshService line history {t,line} + revised_from_grade). It NEVER fabricates: absent/short history => { show:false }. COLOR LAW (mirrors ROW-GRAMMAR + StatStrip.LineSparkline): line movement is green = net move TOWARD the graded side, amber = AGAINST, dim = flat. Never red — nothing here is settled. For an OVER the "toward" sign is the raw line delta; for an UNDER it is inverted (a line dropping steams the under). ============================================================ */ const TOWARD_COLOR = 'var(--g-a)'; // green const AGAINST_COLOR = 'var(--amber)'; // amber const FLAT_COLOR = 'var(--text-1)'; // dim — never red const MIN_POINTS = 3; // self-hide below this (same floor as LineSparkline) function isUnder(side) { return String(side || 'O').toUpperCase().startsWith('U'); } /** Classify a signed line delta relative to the graded side. */ function classifyMove(delta, side) { const d = typeof delta === 'number' && Number.isFinite(delta) ? delta : 0; const toward = isUnder(side) ? -d : d; if (toward > 0) return { dir: 'toward', color: TOWARD_COLOR }; if (toward < 0) return { dir: 'against', color: AGAINST_COLOR }; return { dir: 'flat', color: FLAT_COLOR }; } /** Keep only real {t, line} points (strict number guard — Number(null)===0). */ function cleanHistory(history) { if (!Array.isArray(history)) return []; return history .filter((pt) => pt && typeof pt.line === 'number' && Number.isFinite(pt.line)) .map((pt) => ({ t: pt.t != null ? String(pt.t) : '', line: pt.line })); } /** * Build the grade-shift timeline. * prop: { history:[{t,line}], side, grade, revisedFrom|revised_from_grade, gradedLine } * -> { show, points:[{t,line,delta,dir,color}], net:{delta,dir,color}, * revision:{from,to}|null, firstLine, lastLine } */ function buildGradeTimeline(prop = {}) { const history = cleanHistory(prop.history); const show = history.length >= MIN_POINTS; const side = prop.side; const revisedFrom = prop.revisedFrom || prop.revised_from_grade || null; const grade = prop.grade || null; const points = history.map((pt, i) => { if (i === 0) return { t: pt.t, line: pt.line, delta: 0, dir: 'flat', color: FLAT_COLOR }; const delta = Math.round((pt.line - history[i - 1].line) * 100) / 100; const cls = classifyMove(delta, side); return { t: pt.t, line: pt.line, delta, dir: cls.dir, color: cls.color }; }); let net = { delta: 0, dir: 'flat', color: FLAT_COLOR }; if (history.length >= 2) { const raw = Math.round((history[history.length - 1].line - history[0].line) * 100) / 100; const cls = classifyMove(raw, side); net = { delta: raw, dir: cls.dir, color: cls.color }; } // A revision is real only when a prior grade was preserved AND it differs. const revision = revisedFrom && grade && String(revisedFrom) !== String(grade) ? { from: String(revisedFrom), to: String(grade) } : null; return { show, points, net, revision, firstLine: history.length ? history[0].line : null, lastLine: history.length ? history[history.length - 1].line : null, }; } module.exports = { buildGradeTimeline, classifyMove, cleanHistory, isUnder, TOWARD_COLOR, AGAINST_COLOR, FLAT_COLOR, MIN_POINTS, };