Wave 2B: Offseason never-dark hub — NewsWire + FuturesBoard on /explore

Extend /explore (ExploreHub) into the 365-day never-dark hub. Two new
self-hiding sections feed REAL always-available data into the offseason:

- NewsWire (components/vyndr/NewsWire.tsx): real ESPN headlines from
  /api/news/:sport (newest-first, mono timestamps, type chips, player/team
  links) + real injuries from /api/schedule/:sport/injuries (OUT/GTD chips,
  token colors). Reuses the retired TerminalTemplates INJURY_WIRE layout but
  never routes its sample constants. Self-hides when both feeds are empty.
- FuturesBoard (components/vyndr/FuturesBoard.tsx): real futures from
  /api/futures/:sport — championship/win-total/award markets, mono tabular
  prices + movement colored by the contract (shortening=green / drifting=amber
  / flat=dim, NEVER red; move shown ONLY when the backend supplies one).
  Carries the honest "TRACKED · NOT GRADED" label — no fabricated grades on
  futures. Self-hides when markets:[].
- ExploreHub is offseason-aware (via emptyState OFF_SEASON month check): the
  hub LEADS with futures + wire when the board is dark, COMPLEMENTS the live
  board in-season. Sport selector kept; each section self-hides independently.
- Testable pure helpers: lib/futuresMove.js (move→color, never red) +
  lib/newsFormat.js (timeAgo mono-stamp, ESPN type labels).

Contracts consumed (Wave 2A owns the proxy/service files); code self-hides on
fetch failure if a proxy isn't present yet.

Tests: tests/unit/newsWire.test.js + futuresBoard.test.js (26 new). Full suite
259 suites / 3144 green; web build exit 0. vyndrParityQA stays green
(mono data, no glitch on data surfaces).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Kev
2026-07-13 23:27:19 -04:00
parent a4b6255bed
commit 1664e1b3d5
8 changed files with 558 additions and 0 deletions
+80
View File
@@ -0,0 +1,80 @@
// Wave 2B — OFFSEASON HUB · FuturesBoard. Source + helper asserts. Futures are
// TRACKED, NOT GRADED — the board carries that label, never renders a grade,
// self-hides on an empty feed, and colors movement per the contract
// (shortening→green / drifting→amber / flat→dim) — NEVER red for a drift.
const fs = require('fs');
const path = require('path');
const WEB = path.join(__dirname, '..', '..', 'web', 'src');
const src = fs.readFileSync(path.join(WEB, 'components', 'vyndr', 'FuturesBoard.tsx'), 'utf8');
const { moveColor, moveLabel, MOVE_COLOR } = require('../../web/src/lib/futuresMove');
describe('futuresMove — the color contract (never red)', () => {
it('shortening → green (toward the selection)', () => {
expect(moveColor('shortening')).toBe('var(--g-a)');
});
it('drifting → amber (caution, NOT a miss)', () => {
expect(moveColor('drifting')).toBe('var(--amber)');
});
it('flat / absent / unknown → dim (say less)', () => {
expect(moveColor('flat')).toBe('var(--text-2)');
expect(moveColor(undefined)).toBe('var(--text-2)');
expect(moveColor('sideways')).toBe('var(--text-2)');
});
it('NO move ever renders red (--miss reserved for settled-negative)', () => {
Object.values(MOVE_COLOR).forEach((v) => expect(v).not.toMatch(/miss/));
['shortening', 'drifting', 'flat', undefined, 'x'].forEach((m) =>
expect(moveColor(m)).not.toMatch(/miss/)
);
});
it('moveLabel is empty when there is no move (never fabricated)', () => {
expect(moveLabel(undefined)).toBe('');
expect(moveLabel('shortening')).toMatch(/SHORTENING/);
});
});
describe('FuturesBoard.tsx source', () => {
it('fetches the REAL futures endpoint', () => {
expect(src).toMatch(/\/api\/futures\//);
});
it('carries the honest "TRACKED · NOT GRADED" label', () => {
expect(src).toContain('TRACKED · NOT GRADED');
});
it('NEVER renders a grade on a future (no GradeBadge)', () => {
expect(src).not.toContain('GradeBadge');
expect(src).not.toMatch(/import.*GradeBadge/);
});
it('SELF-HIDES when there are no markets (returns null)', () => {
expect(src).toMatch(/return null/);
expect(src).toMatch(/list\.length === 0/);
});
it('renders real markets + selections with mono tabular prices', () => {
expect(src).toMatch(/list\.map/);
expect(src).toMatch(/selections/);
expect(src).toContain('className="mono"');
expect(src).toContain('tabular-nums');
});
it('shows a move ONLY when the backend supplied one, via the contract color', () => {
expect(src).toContain('moveColor');
expect(src).toContain('moveLabel');
// movement is gated on a truthy label (never fabricated)
expect(src).toMatch(/label &&/);
});
it('data surface carries NO glitch classes', () => {
expect(src).not.toMatch(/wm-tear|glitch-shift|head-tear|glitch-hover/);
});
});
describe('ExploreHub mounts FuturesBoard', () => {
const hub = fs.readFileSync(path.join(WEB, 'components', 'ExploreHub.tsx'), 'utf8');
it('imports + mounts <FuturesBoard sport=', () => {
expect(hub).toMatch(/import FuturesBoard from '@\/components\/vyndr\/FuturesBoard'/);
expect(hub).toMatch(/<FuturesBoard sport=\{sport\}/);
});
});
+85
View File
@@ -0,0 +1,85 @@
// Wave 2B — OFFSEASON HUB · NewsWire. Source + helper asserts. The wire is REAL
// ESPN news + real injuries fed into the retired INJURY_WIRE layout; it self-
// hides when both feeds are empty, timestamps are mono, and no sample data
// from TerminalTemplates is imported.
const fs = require('fs');
const path = require('path');
const WEB = path.join(__dirname, '..', '..', 'web', 'src');
const src = fs.readFileSync(path.join(WEB, 'components', 'vyndr', 'NewsWire.tsx'), 'utf8');
const { timeAgo, typeLabel } = require('../../web/src/lib/newsFormat');
describe('newsFormat.timeAgo — mono-timestamp source', () => {
const now = Date.parse('2026-07-13T12:00:00Z');
it('absent / invalid → empty string (never a fabricated stamp)', () => {
expect(timeAgo(null, now)).toBe('');
expect(timeAgo('', now)).toBe('');
expect(timeAgo('not-a-date', now)).toBe('');
});
it('minutes / hours / days ago', () => {
expect(timeAgo(now - 5 * 60000, now)).toBe('5m ago');
expect(timeAgo(now - 3 * 3600000, now)).toBe('3h ago');
expect(timeAgo(now - 2 * 86400000, now)).toBe('2d ago');
});
it('sub-minute → now', () => {
expect(timeAgo(now - 10000, now)).toBe('now');
});
});
describe('newsFormat.typeLabel', () => {
it('maps known ESPN types', () => {
expect(typeLabel('Recap')).toBe('RECAP');
expect(typeLabel('HeadlineNews')).toBe('NEWS');
});
it('falls back to a title-cased split for unknown types (never dropped)', () => {
expect(typeLabel('Story')).toBe('STORY');
expect(typeLabel('')).toBe('');
});
});
describe('NewsWire.tsx source', () => {
it('fetches the REAL news + injuries endpoints (no sample data)', () => {
expect(src).toMatch(/\/api\/news\//);
expect(src).toMatch(/\/api\/schedule\/.*\/injuries/);
expect(src).not.toMatch(/import[^\n]*TerminalTemplates/); // never routes sample data
expect(src).not.toContain('Jamal Murray'); // no sample constants
});
it('SELF-HIDES when both feeds are empty (returns null)', () => {
expect(src).toMatch(/return null/);
// the guard combines news + injuries emptiness
expect(src).toMatch(/news\.length === 0 && inj\.length === 0/);
});
it('renders real items newest-first and maps them', () => {
expect(src).toMatch(/news\.map/);
expect(src).toMatch(/\.sort\(/);
expect(src).toContain('headline');
});
it('timestamps + statuses render in mono (data-is-mono rule)', () => {
expect(src).toContain('className="mono"');
expect(src).toContain('timeAgo(');
});
it('links player/team via the canonical helpers', () => {
expect(src).toContain('playerHref');
expect(src).toMatch(/\/team\//);
});
it('data surface carries NO glitch classes', () => {
expect(src).not.toMatch(/wm-tear|glitch-shift|head-tear|glitch-hover/);
});
});
describe('ExploreHub mounts NewsWire', () => {
const hub = fs.readFileSync(path.join(WEB, 'components', 'ExploreHub.tsx'), 'utf8');
it('imports + mounts <NewsWire sport=', () => {
expect(hub).toMatch(/import NewsWire from '@\/components\/vyndr\/NewsWire'/);
expect(hub).toMatch(/<NewsWire sport=\{sport\}/);
});
it('is offseason-aware (leads with the hub when the board is dark)', () => {
expect(hub).toContain('OFF_SEASON');
expect(hub).toMatch(/isOffseason/);
});
});
+24
View File
@@ -13,8 +13,11 @@ import { playerHref } from '@/lib/playerHref';
*/
import StreaksPanel from '@/components/StreaksPanel';
import HotListPanel from '@/components/HotListPanel';
import NewsWire from '@/components/vyndr/NewsWire';
import FuturesBoard from '@/components/vyndr/FuturesBoard';
import { useAuth } from '@/contexts/AuthContext';
import { nextRunLabelET } from '@/lib/pipelineSchedule';
import { OFF_SEASON } from '@/lib/emptyState';
interface Leader {
player: string;
@@ -54,6 +57,21 @@ export default function ExploreHub() {
return q ? leaders.filter((l) => (l.player || '').toLowerCase().includes(q)) : leaders;
}, [leaders, query]);
// Wave 2B — never-dark hub. When the selected sport is in its OFF-SEASON, the
// hub LEADS with futures + the wire (real, always-available data) instead of
// a dark leaderboard; in-season those sections COMPLEMENT the live board
// below. Each self-hides independently on an empty feed — no empty boxes.
const off = OFF_SEASON[sport as keyof typeof OFF_SEASON];
const isOffseason = !!(off && off.months.includes(new Date().getMonth()));
// Both sections self-hide (return null) when their feeds are empty.
const hubSections = (
<>
<FuturesBoard sport={sport} />
<NewsWire sport={sport} />
</>
);
return (
<section style={{ maxWidth: 920, margin: '0 auto', padding: '24px 16px 120px' }}>
<div style={{ display: 'flex', alignItems: 'flex-end', justifyContent: 'space-between', gap: 14, marginBottom: 22, flexWrap: 'wrap' }}>
@@ -78,6 +96,9 @@ export default function ExploreHub() {
</div>
</div>
{/* OFF-SEASON LEAD — futures + wire come FIRST when the board is dark. */}
{isOffseason && hubSections}
{/* FILTER BAR */}
<div style={{ display: 'flex', alignItems: 'center', gap: 11, flexWrap: 'wrap', marginBottom: 14, background: 'var(--bg-1)', border: '1px solid var(--border)', borderRadius: 11, padding: '11px 14px' }}>
<span className="mono" style={{ fontSize: 14, color: 'var(--text-2)' }}></span>
@@ -135,6 +156,9 @@ export default function ExploreHub() {
<StreaksPanel sport={sport} tier={tier} stat="all" />
<HotListPanel sport={sport} tier={tier} stat="all" />
</div>
{/* IN-SEASON — futures + wire COMPLEMENT the live board below it. */}
{!isOffseason && hubSections}
</section>
);
}
+107
View File
@@ -0,0 +1,107 @@
'use client';
import { useEffect, useState } from 'react';
import SportBadge from '@/components/vyndr/SportBadge';
import SectionHead from '@/components/vyndr/SectionHead';
import { moveColor, moveLabel } from '@/lib/futuresMove';
/* ============================================================
FuturesBoard (Wave 2B — Offseason Hub). The never-dark FUTURES board: REAL
championship / win-total / award markets from `/api/futures/:sport`.
HONESTY (Wave 3 line): futures are TRACKED, NOT GRADED — season-long
grading is a different model that does not exist yet. So this board SHOWS
prices + movement and carries an explicit "TRACKED · NOT GRADED" label. It
NEVER renders a grade on a future, and a price MOVE is shown ONLY when the
backend supplies one (never fabricated). Movement colors follow the
contract (shortening→green / drifting→amber / flat→dim) and NEVER red.
SELF-HIDES when there are no markets (markets:[]). Prices are mono +
tabular (data-is-mono brand rule).
============================================================ */
interface Selection {
name: string;
price: number | string;
prevPrice?: number | string;
move?: 'shortening' | 'drifting' | 'flat';
}
interface FuturesMarket {
key: string;
title: string;
selections: Selection[];
}
export interface FuturesBoardProps {
sport: string;
}
const card: React.CSSProperties = { background: 'var(--bg-1)', border: '1px solid var(--border)', borderRadius: 10, padding: 16 };
// American-odds display: a signed integer keeps its sign; anything else prints
// as-is (absent beats a fabricated/mis-converted number).
function fmtPrice(price: number | string): string {
if (price == null || price === '') return '—';
if (typeof price === 'number' && Number.isFinite(price)) {
return price > 0 ? `+${price}` : String(price);
}
const s = String(price);
return /^-?\d+$/.test(s) && Number(s) > 0 ? `+${s}` : s;
}
export default function FuturesBoard({ sport }: FuturesBoardProps) {
const [markets, setMarkets] = useState<FuturesMarket[] | null>(null);
useEffect(() => {
let cancelled = false;
setMarkets(null);
fetch(`/api/futures/${encodeURIComponent(sport)}`)
.then((r) => (r.ok ? r.json() : { markets: [] }))
.then((d) => { if (!cancelled) setMarkets(Array.isArray(d?.markets) ? d.markets : []); })
.catch(() => { if (!cancelled) setMarkets([]); });
return () => { cancelled = true; };
}, [sport]);
const list = markets || [];
// SELF-HIDE: no markets (empty feed or still loading).
if (list.length === 0) return null;
return (
<div style={{ marginTop: 28 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 6, flexWrap: 'wrap' }}>
<SectionHead>FUTURES BOARD</SectionHead>
<SportBadge sport={sport} size="sm" />
{/* The honesty line — futures are shown/tracked, not model-graded. */}
<span className="mono" style={{ fontSize: 9, fontWeight: 700, letterSpacing: '0.08em', padding: '2px 7px', borderRadius: 3, color: 'var(--text-2)', border: '1px solid var(--border)' }}>
TRACKED · NOT GRADED
</span>
</div>
<p className="mono" style={{ margin: '0 0 14px', fontSize: 11, color: 'var(--text-2)' }}>
Live book prices, tracked over time. VYNDR does not grade futures season-long grading is a separate model.
</p>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(300px, 1fr))', gap: 12 }}>
{list.map((m) => (
<div key={m.key} style={card}>
<div className="label" style={{ marginBottom: 11 }}>{m.title}</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 9 }}>
{(Array.isArray(m.selections) ? m.selections : []).map((sel, i) => {
const label = moveLabel(sel.move);
return (
<div key={`${sel.name}-${i}`} style={{ display: 'flex', alignItems: 'center', gap: 9 }}>
<span style={{ flex: 1, minWidth: 0, fontSize: 13, fontWeight: 600, color: '#fff', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{sel.name}</span>
{/* Movement — ONLY when the backend supplied a `move`. */}
{label && (
<span className="mono" style={{ fontSize: 9.5, fontWeight: 700, letterSpacing: '0.05em', color: moveColor(sel.move) }}>{label}</span>
)}
<span className="mono" style={{ fontSize: 13, fontWeight: 700, color: 'var(--text-0)', fontVariantNumeric: 'tabular-nums', minWidth: 54, textAlign: 'right' }}>{fmtPrice(sel.price)}</span>
</div>
);
})}
</div>
</div>
))}
</div>
</div>
);
}
+163
View File
@@ -0,0 +1,163 @@
'use client';
import { useEffect, useMemo, useState } from 'react';
import SportBadge from '@/components/vyndr/SportBadge';
import SectionHead from '@/components/vyndr/SectionHead';
import { playerHref } from '@/lib/playerHref';
import { timeAgo, typeLabel } from '@/lib/newsFormat';
/* ============================================================
NewsWire (Wave 2B — Offseason Hub). The never-dark LEAGUE WIRE: REAL ESPN
headlines (`/api/news/:sport`) + REAL injuries (`/api/schedule/:sport/
injuries`), fed into the retired TerminalTemplates INJURY_WIRE layout.
HONESTY: everything real or absent. No sample data ever reaches here. The
whole component SELF-HIDES when both feeds are empty (news items:[] AND no
injuries) — the offseason hub shows what is genuinely available or nothing.
Timestamps + statuses are mono (the data-is-mono brand rule).
============================================================ */
interface Athlete { name: string; key?: string }
interface NewsItem {
id: string | number;
headline: string;
description?: string;
published?: string | number;
type?: string;
athlete?: Athlete | null;
team?: string | null;
href?: string | null;
}
export interface NewsWireProps {
sport: string;
}
// Injury status → color token. OUT is a genuine availability negative (red);
// GTD/questionable is caution (amber); probable/other reads neutral. Tokens
// only — no raw hex (QA parity rule).
function statusColor(s: string): string {
const v = String(s || '').toUpperCase();
if (v === 'OUT') return 'var(--miss)';
if (v === 'GTD' || v === 'QUESTIONABLE' || v === 'DTD') return 'var(--amber)';
return 'var(--text-1)';
}
// nameKey ("aaron judge") → display ("Aaron Judge"). Real data, just cased.
function titleCase(key: string): string {
return String(key || '')
.split(/\s+/)
.filter(Boolean)
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
.join(' ');
}
const card: React.CSSProperties = { background: 'var(--bg-1)', border: '1px solid var(--border)', borderRadius: 10, padding: 16 };
export default function NewsWire({ sport }: NewsWireProps) {
const [items, setItems] = useState<NewsItem[] | null>(null);
const [injuries, setInjuries] = useState<Array<[string, string]> | null>(null);
useEffect(() => {
let cancelled = false;
setItems(null);
setInjuries(null);
// News feed (Wave 2A contract). Self-hide on any failure — never invent.
fetch(`/api/news/${encodeURIComponent(sport)}`)
.then((r) => (r.ok ? r.json() : { items: [] }))
.then((d) => { if (!cancelled) setItems(Array.isArray(d?.items) ? d.items : []); })
.catch(() => { if (!cancelled) setItems([]); });
// Injury wire (already-live contract: { byPlayer: { nameKey -> STATUS } }).
fetch(`/api/schedule/${encodeURIComponent(sport)}/injuries`)
.then((r) => (r.ok ? r.json() : { byPlayer: {} }))
.then((d) => {
if (cancelled) return;
const bp = d && typeof d.byPlayer === 'object' && d.byPlayer ? d.byPlayer : {};
setInjuries(Object.entries(bp).map(([k, v]) => [k, String(v)] as [string, string]));
})
.catch(() => { if (!cancelled) setInjuries([]); });
return () => { cancelled = true; };
}, [sport]);
// Newest-first (defensive — the backend already sorts, but never trust order).
const news = useMemo(() => {
const list = Array.isArray(items) ? [...items] : [];
return list.sort((a, b) => {
const ta = a.published ? Date.parse(String(a.published)) : 0;
const tb = b.published ? Date.parse(String(b.published)) : 0;
return (Number.isFinite(tb) ? tb : 0) - (Number.isFinite(ta) ? ta : 0);
});
}, [items]);
const inj = injuries || [];
// SELF-HIDE: both feeds empty (still loading = null on both → also hidden).
if (news.length === 0 && inj.length === 0) return null;
return (
<div style={{ marginTop: 28 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 12 }}>
<SectionHead>LEAGUE WIRE · NEWS + INJURIES</SectionHead>
<SportBadge sport={sport} size="sm" />
</div>
{/* INJURY WIRE — real chips (self-hides its own block when empty). */}
{inj.length > 0 && (
<div style={{ ...card, marginBottom: 12 }}>
<div className="label" style={{ marginBottom: 10 }}>INJURY REPORT · {inj.length} FLAGGED</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
{inj.map(([key, status]) => (
<a
key={key}
href={playerHref(titleCase(key), sport)}
className="mono"
style={{ display: 'inline-flex', alignItems: 'center', gap: 7, textDecoration: 'none', fontSize: 11.5, padding: '4px 9px', borderRadius: 5, border: '1px solid var(--border)', background: 'var(--bg-2, #0d0d14)', color: 'var(--text-1)' }}
>
<span style={{ fontWeight: 600, color: '#fff', fontFamily: 'var(--sans)' }}>{titleCase(key)}</span>
<span style={{ fontWeight: 700, letterSpacing: '0.06em', color: statusColor(status) }}>{String(status).toUpperCase()}</span>
</a>
))}
</div>
</div>
)}
{/* NEWS HEADLINES — real ESPN feed, newest first (self-hides when empty). */}
{news.length > 0 && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
{news.map((n) => {
const stamp = timeAgo(n.published);
const chip = typeLabel(n.type);
const HeadlineTag = n.href ? 'a' : 'div';
return (
<div key={n.id} style={card}>
<div style={{ display: 'flex', alignItems: 'center', gap: 9, marginBottom: 7, flexWrap: 'wrap' }}>
{chip && <span className="mono" style={{ fontSize: 9, fontWeight: 700, letterSpacing: '0.08em', padding: '2px 6px', borderRadius: 3, color: 'var(--g-a)', border: '1px solid rgba(0,212,160,.3)' }}>{chip}</span>}
{stamp && <span className="mono" style={{ fontSize: 10.5, color: 'var(--text-2)' }}>{stamp}</span>}
</div>
<HeadlineTag
{...(n.href ? { href: n.href, target: '_blank', rel: 'noopener noreferrer' } : {})}
style={{ display: 'block', fontSize: 14, fontWeight: 700, lineHeight: 1.4, color: '#fff', textDecoration: 'none' }}
>
{n.headline}
</HeadlineTag>
{n.description && (
<p style={{ margin: '6px 0 0', fontSize: 12.5, lineHeight: 1.55, color: 'var(--text-1)' }}>{n.description}</p>
)}
{(n.athlete?.name || n.team) && (
<div className="mono" style={{ marginTop: 9, display: 'flex', alignItems: 'center', gap: 10, fontSize: 11 }}>
{n.athlete?.name && (
<a href={playerHref(n.athlete.name, sport)} style={{ color: 'var(--g-a)', textDecoration: 'none', fontWeight: 700 }}> {n.athlete.name}</a>
)}
{n.team && (
<a href={`/team/${encodeURIComponent(n.team)}?sport=${encodeURIComponent(sport)}`} style={{ color: 'var(--text-1)', textDecoration: 'none' }}>{n.team}</a>
)}
</div>
)}
</div>
);
})}
</div>
)}
</div>
);
}
+6
View File
@@ -10,6 +10,12 @@ export { default as Sparkline } from './Sparkline';
export { default as Ticker } from './Ticker';
export { default as EmptyState } from './EmptyState';
export { default as MarketBreadth } from './MarketBreadth';
/* Wave 2B — Offseason / never-dark hub */
export { default as NewsWire } from './NewsWire';
export type { NewsWireProps } from './NewsWire';
export { default as FuturesBoard } from './FuturesBoard';
export type { FuturesBoardProps } from './FuturesBoard';
export type { EmptyStateProps, EmptyStateAction } from './EmptyState';
export { default as GradeResultCard } from './GradeResultCard';
export type { GradeResultData } from './GradeResultCard';
+40
View File
@@ -0,0 +1,40 @@
/* ============================================================
VYNDR — FUTURES MOVEMENT color/label (Wave 2B, Offseason Hub).
The color contract for a futures selection's price movement. HONESTY RULE:
futures are TRACKED, not model-graded — a movement is only ever shown when
the backend actually supplies a `move` (never fabricated). The color space
is deliberately narrow and NEVER red:
• shortening → the market moved TOWARD the selection → green (value/steam)
• drifting → the market moved AWAY → amber (caution)
• flat / absent → no movement to report → dim (say less)
Red is reserved system-wide for settled-negative outcomes; a futures drift
is NOT a miss, so it must never render red.
CommonJS so the .tsx surface imports it (allowJs) AND Jest exercises it.
============================================================ */
// Canonical move → CSS custom-property token. No branch returns the red
// (--miss) token — a normal drift is caution (amber), never a miss.
const MOVE_COLOR = {
shortening: 'var(--g-a)', // green — toward the selection (value)
drifting: 'var(--amber)', // amber — away from the selection (caution)
flat: 'var(--text-2)', // dim — no movement
};
/** moveColor(move) — the token for a move, defaulting to dim for flat/absent. */
function moveColor(move) {
const m = String(move || '').toLowerCase();
return MOVE_COLOR[m] || MOVE_COLOR.flat;
}
/** moveLabel(move) — a short mono glyph+word, or '' when there is no move. */
function moveLabel(move) {
const m = String(move || '').toLowerCase();
if (m === 'shortening') return '▼ SHORTENING';
if (m === 'drifting') return '▲ DRIFTING';
if (m === 'flat') return '— FLAT';
return '';
}
module.exports = { MOVE_COLOR, moveColor, moveLabel };
+53
View File
@@ -0,0 +1,53 @@
/* ============================================================
VYNDR — NEWS WIRE formatting (Wave 2B, Offseason Hub).
Small pure helpers for the real-ESPN news wire. Timestamps are rendered in
mono (the brand rule: all data is mono); an unparseable/absent published
time yields '' (absent beats a fabricated "just now"). Type chips map the
raw ESPN feed type to a short human label without inventing categories.
CommonJS so the .tsx surface imports it (allowJs) AND Jest exercises it.
============================================================ */
/**
* timeAgo(published, now) — compact relative time for a mono timestamp.
* Invalid / missing input → '' (never a fabricated stamp). `published` is an
* ISO string or epoch ms; `now` defaults to Date.now().
*/
function timeAgo(published, now = Date.now()) {
if (published == null || published === '') return '';
const t = typeof published === 'number' ? published : Date.parse(String(published));
if (!Number.isFinite(t)) return '';
const diff = Number(now) - t;
if (!Number.isFinite(diff)) return '';
if (diff < 0) return 'now';
const min = Math.floor(diff / 60000);
if (min < 1) return 'now';
if (min < 60) return `${min}m ago`;
const hr = Math.floor(min / 60);
if (hr < 24) return `${hr}h ago`;
const day = Math.floor(hr / 24);
return `${day}d ago`;
}
// Raw ESPN feed types → short display labels. Unknown types fall back to a
// title-cased version of the raw string (never dropped, never invented).
const TYPE_LABEL = {
Recap: 'RECAP',
HeadlineNews: 'NEWS',
Story: 'STORY',
Preview: 'PREVIEW',
Notebook: 'NOTEBOOK',
Media: 'MEDIA',
};
/** typeLabel(type) — short uppercase chip label, or '' when absent. */
function typeLabel(type) {
if (!type) return '';
const raw = String(type);
if (TYPE_LABEL[raw]) return TYPE_LABEL[raw];
// split camelCase / snake and uppercase
return raw.replace(/([a-z])([A-Z])/g, '$1 $2').replace(/[_-]+/g, ' ').trim().toUpperCase();
}
module.exports = { timeAgo, typeLabel, TYPE_LABEL };