Session 44: Make it visible — VYNDR archetype names, grade intel, schedule fix, landing page (2061 tests)

Frontend + wiring only. Wires existing backend into the pages users see.

- VYNDR Original archetype rename (41) across archetypeService.js + lib/
  archetypes.js + ArchetypeBadge, each keeping legacyName (resolves stale data).
  Judge -> BOMBER. Old POWER PULL slot -> WHIFF strikeout-artist pitcher.
- BACKEND_HANDOFF.md: canonical frontend<->backend data contract.
- Grade card intel: scan/page.tsx now forwards the engine's intel fields
  (season_avg/form/usage/matchup_grade/archetype/...) into mapScanToGradeResult
  -> STAT CONTEXT + VYNDR INTELLIGENCE sections populate. The chain already
  preserved them (tierGating + /api/scan spread); the page was dropping them.
- Schedule freshness: slateAdapter.isRelevantGame drops completed games >24h
  old; Slate.filteredGames applies it. (TTL already 60s.)
- Landing: Features.tsx rewritten to user-facing copy (no Point-biserial/Zone
  14/ABS/Phi-coefficient).
- Depth chart Next proxies added (/api/stats/lineup|depth|cascade) - were 404.
- GameCard swap DEFERRED (Kev): legacy on-demand card stays as a bridge until
  the snapshot pipeline populates the grades cache; vyndr/GameCard swaps in then.

Backend 2045 -> 2061 tests (+16), 167 suites. Web build clean (exit 0).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Kev
2026-06-18 20:15:38 -04:00
parent 80683e71b4
commit 7969a4971a
26 changed files with 766 additions and 302 deletions
+5 -5
View File
@@ -10,21 +10,21 @@ const svc = require('../../src/services/archetypeService');
describe('archetypes lib — badge styling', () => {
it('full variant fills with the archetype color + white text', () => {
const s = arch.badgeStyle('POWER PULL', 'full', 'md');
expect(s.bg).toBe('#FF5C5C');
const s = arch.badgeStyle('BOMBER', 'full', 'md');
expect(s.bg).toBe('#FF6B4A');
expect(s.textColor).toBe('#FFFFFF');
expect(s.borderColor).toBe('#FF5C5C');
expect(s.borderColor).toBe('#FF6B4A');
});
it('ghost variant is transparent with a colored border', () => {
const s = arch.badgeStyle('FLOOR GENERAL', 'ghost', 'sm');
const s = arch.badgeStyle('CONDUCTOR', 'ghost', 'sm');
expect(s.bg).toBe('transparent');
expect(s.textColor).toBe('#4A9EFF');
expect(s.borderColor).toBe('#4A9EFFCC');
});
it('tint variant (default) uses a low-alpha tinted background', () => {
const s = arch.badgeStyle('ACE');
const s = arch.badgeStyle('ALPHA');
expect(s.bg).toBe('#A78BFA1F');
expect(s.borderColor).toBe('#A78BFA52');
});
+28 -14
View File
@@ -10,7 +10,7 @@ describe('archetypeService — registry', () => {
expect(bySport).toEqual({ nba: 15, wnba: 5, mlb: 15, soccer: 6 });
});
it('every archetype has name/tag/color/glyph/description/propDNA/education', () => {
it('every archetype has name/tag/color/glyph/description/propDNA/education/legacyName', () => {
for (const [name, a] of Object.entries(svc.ARCHETYPES)) {
expect(typeof a.tag).toBe('string');
expect(a.color).toMatch(/^#[0-9A-Fa-f]{6}$/);
@@ -21,9 +21,20 @@ describe('archetypeService — registry', () => {
expect(Array.isArray(a.propDNA.volatile)).toBe(true);
expect(a.education.length).toBeGreaterThan(20);
expect(name).toBe(name.toUpperCase());
expect(typeof a.legacyName).toBe('string'); // VYNDR Originals keep the old label
}
});
it('uses VYNDR Original names, not the old descriptive labels', () => {
const keys = Object.keys(svc.ARCHETYPES);
expect(keys).toContain('TORCH');
expect(keys).toContain('BOMBER');
expect(keys).toContain('ALPHA');
expect(keys).not.toContain('VOLUME SCORER');
expect(keys).not.toContain('POWER SLUGGER');
expect(keys).not.toContain('ACE');
});
it('colors are unique WITHIN each sport (reused across sports by design)', () => {
const bySport = {};
for (const a of Object.values(svc.ARCHETYPES)) {
@@ -35,9 +46,12 @@ describe('archetypeService — registry', () => {
}
});
it('getArchetype is case-insensitive and returns null for unknown', () => {
expect(svc.getArchetype('ace').name).toBe('ACE');
expect(svc.getArchetype('Two-Way Anchor').name).toBe('TWO-WAY ANCHOR');
it('getArchetype is case-insensitive, resolves legacy names, returns null for unknown', () => {
expect(svc.getArchetype('alpha').name).toBe('ALPHA');
expect(svc.getArchetype('Fortress').name).toBe('FORTRESS');
// legacy names still resolve to their VYNDR Original
expect(svc.getArchetype('ACE').name).toBe('ALPHA');
expect(svc.getArchetype('POWER SLUGGER').name).toBe('BOMBER');
expect(svc.getArchetype('not a real one')).toBeNull();
});
});
@@ -45,7 +59,7 @@ describe('archetypeService — registry', () => {
describe('archetypeService — NBA classification', () => {
it('classifies a volume scorer', () => {
const r = svc.classifyNBA({ ppg: 30, rpg: 5, apg: 4, usg: 33, threes: 2.5, pos: 'G' });
expect(r.primary.name).toBe('VOLUME SCORER');
expect(r.primary.name).toBe('TORCH');
expect(r.sport).toBe('nba');
});
@@ -58,7 +72,7 @@ describe('archetypeService — NBA classification', () => {
it('classifies a two-way anchor (Wembanyama-type)', () => {
const r = svc.classifyNBA({ ppg: 24, rpg: 11, apg: 3, bpg: 3.2, usg: 31, threes: 1.5, pos: 'C' });
expect(['TWO-WAY ANCHOR', 'POST SCORER', 'STRETCH BIG']).toContain(r.primary.name);
expect(['FORTRESS', 'PAINT BOSS', 'ARTILLERY']).toContain(r.primary.name);
});
it('produces a normalized blend that sums to ~1', () => {
@@ -73,32 +87,32 @@ describe('archetypeService — MLB classification', () => {
it('differentiates an Ace from an Innings Eater', () => {
const ace = svc.classifyMLB({ role: 'SP', era: 2.9, k9: 11.5, whip: 0.98, ip_per_start: 6.2 });
const eater = svc.classifyMLB({ role: 'SP', era: 4.1, k9: 7.0, whip: 1.3, ip_per_start: 6.5 });
expect(ace.primary.name).toBe('ACE');
expect(eater.primary.name).toBe('INNINGS EATER');
expect(ace.primary.name).toBe('ALPHA');
expect(eater.primary.name).toBe('WORKHORSE');
});
it('classifies a closer', () => {
const r = svc.classifyMLB({ role: 'CL', era: 2.2, k9: 12, saves: 24, ip_per_start: 1 });
expect(r.primary.name).toBe('CLOSER');
expect(r.primary.name).toBe('HAMMER');
});
it('classifies a contact hitter and a power-pull hitter differently', () => {
it('classifies a contact hitter (BRUSH) and a power hitter (BOMBER) differently', () => {
const contact = svc.classifyMLB({ avg: 0.315, hr: 8, rbi: 40, k_rate: 12, ops: 0.82 });
const power = svc.classifyMLB({ avg: 0.235, hr: 34, rbi: 88, k_rate: 30, ops: 0.86 });
expect(contact.primary.name).toBe('CONTACT');
expect(power.primary.name).toBe('POWER PULL');
expect(contact.primary.name).toBe('BRUSH');
expect(power.primary.name).toBe('BOMBER');
});
});
describe('archetypeService — WNBA classification', () => {
it('classifies a dominant post / interior anchor (Wilson-type)', () => {
const r = svc.classifyWNBA({ ppg: 27, rpg: 12, apg: 2, bpg: 2.3, usg: 31, pos: 'F' });
expect(['INTERIOR ANCHOR', 'VOLUME SCORER', 'STRETCH FORWARD']).toContain(r.primary.name);
expect(['ANCHOR', 'TORCH', 'RANGE']).toContain(r.primary.name);
});
it('classifies a floor general', () => {
const r = svc.classifyWNBA({ ppg: 16, rpg: 4, apg: 7, usg: 24, pos: 'G' });
expect(r.primary.name).toBe('FLOOR GENERAL');
expect(r.primary.name).toBe('CONDUCTOR');
});
});
+3 -3
View File
@@ -53,9 +53,9 @@ describe('getCascadeProjection', () => {
it('distributes usage to teammates, weighting usage sponges heaviest', async () => {
const r = await svc.getCascadeProjection('nba', 'Keldon Murray', 'SA', {
teammates: [
{ player: 'Wembanyama', archetype: 'VOLUME SCORER' },
{ player: 'Bench Spark', archetype: 'USAGE SPONGE' },
{ player: 'Glue Guy', archetype: 'ROLE GLUE' },
{ player: 'Wembanyama', archetype: 'TORCH' },
{ player: 'Bench Spark', archetype: 'SURGE' },
{ player: 'Glue Guy', archetype: 'CONNECTOR' },
],
});
expect(r).toHaveLength(3);
+19
View File
@@ -0,0 +1,19 @@
// Session 44 — Phase 5: depth chart Next proxies exist (were 404ing).
const fs = require('fs');
const path = require('path');
const API = path.join(__dirname, '..', '..', 'web', 'src', 'app', 'api', 'stats');
describe('Depth chart Next proxy routes', () => {
it.each([
['lineup/[team]/route.ts', '/api/stats/lineup/'],
['depth/[team]/route.ts', '/api/stats/depth/'],
['cascade/[player]/route.ts', '/api/stats/cascade/'],
])('%s exists and forwards to the Express backend', (rel, fwd) => {
const p = path.join(API, rel);
expect(fs.existsSync(p)).toBe(true);
const src = fs.readFileSync(p, 'utf8');
expect(src).toContain('BACKEND_URL');
expect(src).toContain(fwd);
});
});
+19
View File
@@ -0,0 +1,19 @@
// Session 44 — Phase 4: landing feature cards use user-facing language.
const fs = require('fs');
const path = require('path');
const src = fs.readFileSync(path.join(__dirname, '..', '..', 'web', 'src', 'components', 'Features.tsx'), 'utf8');
describe('Landing feature cards', () => {
it('contains NO engineer-speak', () => {
for (const jargon of ['Point-biserial', 'Zone 14', 'ABS intelligence', 'Auto-calibrating', 'framing loss', 'redistribution-aware', 'Phi-coefficient', 'blind-spot detection']) {
expect(src).not.toContain(jargon);
}
});
it('uses the rewritten user-facing titles', () => {
expect(src).toContain('Player DNA archetypes');
expect(src).toContain('Self-improving model');
expect(src).toContain('Lineup intel before tip-off');
expect(src).toContain('Deep pitcher-batter matchups');
});
});
+3 -3
View File
@@ -16,14 +16,14 @@ describe('gradeAdapter — Player Intelligence fields', () => {
});
it('lifts a single archetype name into a one-segment blend', () => {
const f = buildIntelFields({ archetype: 'VOLUME SCORER' });
expect(f.archetypeBlend).toEqual([{ archetype: 'VOLUME SCORER', weight: 1 }]);
const f = buildIntelFields({ archetype: 'TORCH' });
expect(f.archetypeBlend).toEqual([{ archetype: 'TORCH', weight: 1 }]);
});
it('passes through a full blend + propDNA + statContext + vyndrIntel', () => {
const r = mapScanToGradeResult({
player: 'Wemby', stat: 'points', line: 26.5, grade: 'A',
archetype_blend: [{ archetype: 'TWO-WAY ANCHOR', weight: 0.6 }, { archetype: 'STRETCH BIG', weight: 0.4 }],
archetype_blend: [{ archetype: 'FORTRESS', weight: 0.6 }, { archetype: 'ARTILLERY', weight: 0.4 }],
prop_dna: { reliable: ['points'], volatile: ['rebounds'] },
season_avg: 26.9, last10_avg: 28.4, vs_opp_avg: 30.1,
form: 92, usage: '31.2%', matchup_grade: 'A', rest: '+2.4%',
+3 -3
View File
@@ -64,8 +64,8 @@ describe('getPlayerIntel with real stats (Session 43)', () => {
expect(r.team).toBe('New York Yankees');
expect(r.season.length).toBeGreaterThan(0);
// 34 HR + high K-rate → POWER PULL, not the empty-stats fallback.
expect(['POWER PULL', 'POWER SLUGGER', 'RUN PRODUCER']).toContain(r.archetype.primary.name);
expect(r.archetype.primary.name).not.toBe('UTILITY PLAYER');
expect(['BOMBER', 'BOMBER', 'DRIVER']).toContain(r.archetype.primary.name);
expect(r.archetype.primary.name).not.toBe('FLEX');
});
it('classifies an ace pitcher from real stats', async () => {
@@ -73,7 +73,7 @@ describe('getPlayerIntel with real stats (Session 43)', () => {
cacheGet: async () => null,
resolveStats: (name, sport) => svc.resolvePlayerStats(name, sport, { mlbAdapter: aceAdapter }),
});
expect(r.archetype.primary.name).toBe('ACE');
expect(r.archetype.primary.name).toBe('ALPHA');
expect(r.found).toBe(true);
});
+3 -3
View File
@@ -23,9 +23,9 @@ describe('playerProfileAdapter', () => {
expect(rows[1]).toMatchObject({ prop: 'Hits', state: 'VOLATILE', color: '#FFB347' });
});
it('writes a blend readout naming primary + secondary', () => {
const txt = adapter.blendReadout({ primary: { name: 'POWER PULL' }, secondary: { name: 'RUN PRODUCER' } });
expect(txt).toContain('Power Pull');
expect(txt).toContain('Run Producer');
const txt = adapter.blendReadout({ primary: { name: 'BOMBER' }, secondary: { name: 'DRIVER' } });
expect(txt).toContain('Bomber');
expect(txt).toContain('Driver');
});
});
+43
View File
@@ -0,0 +1,43 @@
// Session 44 — Phase 2: the grade card's intel sections actually populate.
// The full chain: engine (Object.assign intel) → analyze route (tierGating
// spread) → /api/scan proxy (spread) → scan page → gradeAdapter → card.
// The broken link was the scan page dropping the fields; this locks it.
const fs = require('fs');
const path = require('path');
const ROOT = path.join(__dirname, '..', '..');
const read = (rel) => fs.readFileSync(path.join(ROOT, rel), 'utf8');
const { applyTierGating } = require('../../src/utils/tierGating');
describe('tierGating preserves engine intel fields', () => {
it('free tier keeps season_avg/form/etc. (spreads ...result)', () => {
const gated = applyTierGating({ grade: 'A', season_avg: 26.9, form: 92, matchup_grade: 'A', usage: '31%' }, 'free');
expect(gated.season_avg).toBe(26.9);
expect(gated.form).toBe(92);
expect(gated.matchup_grade).toBe('A');
});
it('paid tier passes through unchanged', () => {
const out = applyTierGating({ grade: 'A', season_avg: 26.9, form: 92 }, 'desk');
expect(out.season_avg).toBe(26.9);
});
});
describe('scan page forwards intel fields to the grade card', () => {
const src = read('web/src/app/scan/page.tsx');
it('passes the engine intel fields into mapScanToGradeResult', () => {
for (const f of ['season_avg: result.season_avg', 'last10_avg: result.last10_avg', 'form: result.form', 'usage: result.usage', 'matchup_grade: result.matchup_grade', 'rest: result.rest', 'archetype: result.archetype', 'archetype_blend: result.archetype_blend', 'prop_dna: result.prop_dna']) {
expect(src).toContain(f);
}
});
it('ScanResponse type declares the intel fields', () => {
expect(src).toContain('season_avg?: number');
expect(src).toContain('matchup_grade?: string');
});
});
describe('/api/scan proxy preserves engine fields', () => {
it('spreads ...data (does not whitelist)', () => {
const proxy = read('web/src/app/api/scan/route.ts');
expect(proxy).toContain('...data');
});
});
+2 -2
View File
@@ -22,9 +22,9 @@ describe('groupPropsByPlayer', () => {
it('attaches an archetype when a lookup is provided', () => {
const out = adapter.groupPropsByPlayer(
[{ player: 'Riley', stat: 'Hits', line: 1.5, side: 'Over', grade: 'A' }],
() => ({ primary: 'POWER PULL' }),
() => ({ primary: 'BOMBER' }),
);
expect(out[0].archetype).toEqual({ primary: 'POWER PULL' });
expect(out[0].archetype).toEqual({ primary: 'BOMBER' });
});
it('returns [] for empty / non-array input', () => {
+34
View File
@@ -0,0 +1,34 @@
// Session 44 — Phase 3: stale-game filtering.
const fs = require('fs');
const path = require('path');
const adapter = require('../../web/src/lib/slateAdapter');
const NOW = new Date('2026-06-18T20:00:00Z').getTime();
const hoursAgo = (h) => new Date(NOW - h * 3_600_000).toISOString();
describe('isRelevantGame', () => {
it('keeps upcoming and live games regardless of date', () => {
expect(adapter.isRelevantGame({ status: 'pre', gameTime: hoursAgo(-2) }, NOW)).toBe(true);
expect(adapter.isRelevantGame({ status: 'in', gameTime: hoursAgo(1) }, NOW)).toBe(true);
});
it('drops a completed game older than 24h', () => {
expect(adapter.isRelevantGame({ status: 'post', gameTime: hoursAgo(120) }, NOW)).toBe(false); // 5 days
expect(adapter.isRelevantGame({ state: 'final', date: hoursAgo(30) }, NOW)).toBe(false);
});
it('keeps a recently completed game (<24h)', () => {
expect(adapter.isRelevantGame({ status: 'post', gameTime: hoursAgo(3) }, NOW)).toBe(true);
});
it('keeps games with an unparseable/missing date (degrade open)', () => {
expect(adapter.isRelevantGame({ status: 'post' }, NOW)).toBe(true);
expect(adapter.isRelevantGame({ status: 'post', gameTime: 'nonsense' }, NOW)).toBe(true);
});
});
describe('Slate applies the freshness filter', () => {
it('filters games through isRelevantGame', () => {
const src = fs.readFileSync(path.join(__dirname, '..', '..', 'web', 'src', 'components', 'Slate.tsx'), 'utf8');
expect(src).toContain('isRelevantGame');
expect(src).toContain('games.filter((g) => isRelevantGame(g))');
});
});