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:
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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%',
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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))');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user