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:
@@ -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\}/);
|
||||
});
|
||||
});
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
Reference in New Issue
Block a user