Session 42: Player Intelligence System — archetypes, stat strips, player profile, enhanced cards (2011 tests)
Built from the Claude Design "VYNDR Player Intelligence" bundle (10 sections). - Archetypes: src/services/archetypeService.js — 41 archetypes (15 NBA / 5 WNBA-unique / 15 MLB / 6 soccer), classify -> primary+secondary+blend. Frontend visual map web/src/lib/archetypes.js (colors verified == backend). ArchetypeBadge (full/ghost/tint + glyphs) + ArchetypeBlend (DNA bar). - StatStrip (compact/expanded): player name once, horizontal mono stats, inline GradeBadge props, onPlayerClick -> profile. - Stats API: extended src/routes/stats.js with /player/:name, /leaders, /game/:id (rate-limited). Aggregation in playerIntelService.js (sanitizes name param; grades cache; graceful on cold cache). Next proxies added. - Player Profile /player/[name]: all 9 design sections, graceful empty states. - Enhanced GameCard (MLB pitchers + player-grouped StatStrips) + GradeResultCard (archetype strip + stat context + VYNDR intelligence, optional/self-hiding via gradeAdapter.buildIntelFields). Player-name links wired everywhere. - Settings page replaces the S41 redirect (account/subscription/notifications/ display/responsible-play/danger-zone with DELETE-gated delete). LINKS to the real /settings/security MFA page — does not replace it. + BookChip. - Bonus: Stats Explorer /explore (real /api/stats/leaders leaderboard); added Explore + Settings to Nav MORE. Deferred (need data pipelines, Session 43): Team Hub, Offseason Intel, Slate redesign, Stats Explorer sub-panels. Backend 1940 -> 2011 tests (+71), 157 suites. Web build clean (exit 0). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,139 @@
|
||||
/* ============================================================
|
||||
VYNDR 2.0 — archetype visual map (Session 42, §Player Intelligence).
|
||||
Ported verbatim from the design's ArchetypeBadge.dc.html MAP +
|
||||
ArchetypeBlend.dc.html. Plain CommonJS so .tsx imports it AND the
|
||||
Jest suite can require it directly (same pattern as vyndrTokens.js).
|
||||
Colors/glyphs MUST match src/services/archetypeService.js ARCHETYPES.
|
||||
============================================================ */
|
||||
|
||||
/* Glyph SVG inner-markup keyed by name (drawn in a 0 0 16 16 viewBox). */
|
||||
const GLYPHS = {
|
||||
triangle: '<path d="M8 2 L14 14 L2 14 Z" fill="currentColor"/>',
|
||||
node: '<circle cx="8" cy="8" r="2.3" fill="currentColor"/><path d="M8 5.7V1.5M5.4 9.3 2.4 13M10.6 9.3 13.6 13" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/>',
|
||||
shield: '<path d="M8 1.6 L13.4 4 V8.2 C13.4 11.2 8 14.4 8 14.4 C8 14.4 2.6 11.2 2.6 8.2 V4 Z" fill="currentColor"/>',
|
||||
shieldCheck: '<path d="M8 1.6 L13.4 4 V8.2 C13.4 11.2 8 14.4 8 14.4 C8 14.4 2.6 11.2 2.6 8.2 V4 Z" stroke="currentColor" stroke-width="1.5" fill="none"/><path d="M5.6 8 L7.4 9.9 L10.4 6.2" stroke="currentColor" stroke-width="1.5" fill="none" stroke-linecap="round" stroke-linejoin="round"/>',
|
||||
target: '<circle cx="8" cy="8" r="6" stroke="currentColor" stroke-width="1.5" fill="none"/><circle cx="8" cy="8" r="2.2" fill="currentColor"/>',
|
||||
bolt: '<path d="M9 1.6 L4 9 H7.6 L7 14.4 L12 6.6 H8.4 Z" fill="currentColor"/>',
|
||||
twin: '<circle cx="6" cy="8" r="3.6" stroke="currentColor" stroke-width="1.6" fill="none"/><circle cx="10" cy="8" r="3.6" stroke="currentColor" stroke-width="1.6" fill="none"/>',
|
||||
chain: '<rect x="2.4" y="5.6" width="7" height="4.8" rx="2.4" stroke="currentColor" stroke-width="1.6" fill="none"/><rect x="6.6" y="5.6" width="7" height="4.8" rx="2.4" stroke="currentColor" stroke-width="1.6" fill="none"/>',
|
||||
batball: '<path d="M3 13.2 L11 5.2" stroke="currentColor" stroke-width="2.4" stroke-linecap="round"/><circle cx="12.4" cy="3.6" r="1.9" fill="currentColor"/>',
|
||||
crosshair: '<circle cx="8" cy="8" r="5.6" stroke="currentColor" stroke-width="1.5" fill="none"/><path d="M8 1.4V4.4M8 11.6V14.6M1.4 8H4.4M11.6 8H14.6" stroke="currentColor" stroke-width="1.5"/><circle cx="8" cy="8" r="1.5" fill="currentColor"/>',
|
||||
diamond: '<path d="M8 1.6 L14.4 8 L8 14.4 L1.6 8 Z" stroke="currentColor" stroke-width="1.6" fill="none"/><path d="M8 5.4 L10.6 8 L8 10.6 L5.4 8 Z" fill="currentColor"/>',
|
||||
diamondLine: '<path d="M8 1.6 L14.4 8 L8 14.4 L1.6 8 Z" stroke="currentColor" stroke-width="1.7" fill="none"/>',
|
||||
star: '<path d="M8 1.4 L9.7 6 L14.6 6.3 L10.7 9.3 L12.1 14 L8 11.2 L3.9 14 L5.3 9.3 L1.4 6.3 L6.3 6 Z" fill="currentColor"/>',
|
||||
clock: '<circle cx="8" cy="9.2" r="5" stroke="currentColor" stroke-width="1.6" fill="none"/><path d="M8 9.2 V5.6 M6.2 2.2 H9.8" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/>',
|
||||
chevrons: '<path d="M2.6 4 L6.6 8 L2.6 12 M7.8 4 L11.8 8 L7.8 12" stroke="currentColor" stroke-width="1.9" fill="none" stroke-linecap="round" stroke-linejoin="round"/>',
|
||||
slash: '<path d="M3 13 L13 3 M13 3 H8.6 M13 3 V7.4" stroke="currentColor" stroke-width="1.9" fill="none" stroke-linecap="round" stroke-linejoin="round"/>',
|
||||
arc: '<path d="M2 11.5 Q8 1.5 14 11.5" stroke="currentColor" stroke-width="1.7" fill="none" stroke-linecap="round"/><circle cx="13.4" cy="11.2" r="1.6" fill="currentColor"/>',
|
||||
plus: '<path d="M8 2.4 V13.6 M2.4 8 H13.6" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/><circle cx="8" cy="8" r="1.9" fill="currentColor"/>',
|
||||
lock: '<rect x="3.6" y="7" width="8.8" height="6.6" rx="1.4" fill="currentColor"/><path d="M5.4 7 V5 a2.6 2.6 0 0 1 5.2 0 V7" stroke="currentColor" stroke-width="1.5" fill="none"/>',
|
||||
swap: '<path d="M3 5.6 H11.4 M9.4 3.6 L11.6 5.6 L9.4 7.6 M13 10.4 H4.6 M6.6 8.4 L4.4 10.4 L6.6 12.4" stroke="currentColor" stroke-width="1.5" fill="none" stroke-linecap="round" stroke-linejoin="round"/>',
|
||||
half: '<circle cx="8" cy="8" r="6" stroke="currentColor" stroke-width="1.6" fill="none"/><path d="M8 2.2 a5.8 5.8 0 0 1 0 11.6 Z" fill="currentColor"/>',
|
||||
uparrow: '<path d="M8 13.4 V3.6 M4.4 7.2 L8 3.6 L11.6 7.2" stroke="currentColor" stroke-width="1.8" fill="none" stroke-linecap="round" stroke-linejoin="round"/>',
|
||||
postup: '<rect x="6.4" y="2.6" width="3.2" height="11" rx="1.6" fill="currentColor"/><circle cx="12" cy="5.2" r="1.7" fill="currentColor"/>',
|
||||
};
|
||||
|
||||
/* name → { color, glyph, desc } — the design's MAP. */
|
||||
const ARCHETYPE_MAP = {
|
||||
// NBA
|
||||
'VOLUME SCORER': { c: '#FF6B4A', d: 'High usage, shot-dependent scorer', g: 'triangle' },
|
||||
'FLOOR GENERAL': { c: '#4A9EFF', d: 'Assist-heavy playmaker', g: 'node' },
|
||||
'TWO-WAY ANCHOR': { c: '#A78BFA', d: 'Defense, rebounds, blocks', g: 'shield' },
|
||||
'STRETCH BIG': { c: '#2DD4BF', d: 'Floor-spacing shooting big', g: 'target' },
|
||||
'USAGE SPONGE': { c: '#FFB347', d: 'Usage spikes when stars sit', g: 'uparrow' },
|
||||
'COMBO GUARD': { c: '#00D4A0', d: 'Scoring + playmaking hybrid', g: 'twin' },
|
||||
'ROLE GLUE': { c: '#9499A8', d: 'Low-usage specialist', g: 'chain' },
|
||||
'TRANSITION ENGINE': { c: '#22D3EE', d: 'Pace-pushing fast-break threat', g: 'chevrons' },
|
||||
'POST SCORER': { c: '#FF5C5C', d: 'Back-to-basket interior scorer', g: 'postup' },
|
||||
'DEFENSIVE SPECIALIST': { c: '#6366F1', d: 'Perimeter stopper, low usage', g: 'shieldCheck' },
|
||||
'POINT FORWARD': { c: '#38BDF8', d: 'Oversized primary creator', g: 'half' },
|
||||
SLASHER: { c: '#FB923C', d: 'Rim-attacking, foul-drawing driver', g: 'slash' },
|
||||
'RIM RUNNER': { c: '#F472B6', d: 'Lob and putback finisher', g: 'arc' },
|
||||
'3-AND-D': { c: '#818CF8', d: 'Catch-and-shoot plus defense', g: 'crosshair' },
|
||||
'SIXTH MAN': { c: '#FACC15', d: 'Bench scoring spark', g: 'bolt' },
|
||||
// WNBA-unique
|
||||
'POST FACILITATOR': { c: '#C084FC', d: 'Playmaking hub from the post', g: 'node' },
|
||||
'TWO-WAY WING': { c: '#A78BFA', d: 'Two-way perimeter wing', g: 'shieldCheck' },
|
||||
'STRETCH FORWARD': { c: '#2DD4BF', d: 'Floor-spacing forward', g: 'target' },
|
||||
'SLASHING GUARD': { c: '#FB923C', d: 'Downhill driving guard', g: 'slash' },
|
||||
'INTERIOR ANCHOR': { c: '#6366F1', d: 'Paint defender and rebounder', g: 'shield' },
|
||||
// MLB
|
||||
'POWER PULL': { c: '#FF5C5C', d: 'HR-dependent, high strikeout power', g: 'batball' },
|
||||
CONTACT: { c: '#3DDC84', d: 'High average, low strikeout', g: 'crosshair' },
|
||||
'RUN PRODUCER': { c: '#4A9EFF', d: 'RBI-dependent, lineup context', g: 'diamond' },
|
||||
ACE: { c: '#A78BFA', d: 'High K/9, low WHIP, deep games', g: 'star' },
|
||||
'BULLPEN ARM': { c: '#FFB347', d: 'Short outings, high leverage', g: 'bolt' },
|
||||
'SPEED THREAT': { c: '#2DD4BF', d: 'Stolen bases, speed score', g: 'chevrons' },
|
||||
'TWO-WAY PLAYER': { c: '#F472B6', d: 'Bats and pitches at elite level', g: 'half' },
|
||||
'UTILITY PLAYER': { c: '#22D3EE', d: 'Multi-position lineup flex', g: 'plus' },
|
||||
'INNINGS EATER': { c: '#818CF8', d: 'Durable, deep-start workhorse', g: 'clock' },
|
||||
'POWER SLUGGER': { c: '#FF6B4A', d: 'All-fields power producer', g: 'triangle' },
|
||||
'TABLE SETTER': { c: '#38BDF8', d: 'On-base leadoff catalyst', g: 'diamondLine' },
|
||||
'GAP HITTER': { c: '#34D399', d: 'Doubles and extra-base gaps', g: 'uparrow' },
|
||||
CLOSER: { c: '#FB7185', d: 'Ninth-inning save specialist', g: 'lock' },
|
||||
SWINGMAN: { c: '#FBBF24', d: 'Spot starter and long relief', g: 'swap' },
|
||||
'DEFENSIVE WIZARD': { c: '#6366F1', d: 'Glove-first defensive value', g: 'shieldCheck' },
|
||||
// Soccer
|
||||
POACHER: { c: '#FF5C5C', d: 'Penalty-box finisher', g: 'crosshair' },
|
||||
CREATOR: { c: '#4A9EFF', d: 'Chance-creating playmaker', g: 'node' },
|
||||
'TARGET MAN': { c: '#FF6B4A', d: 'Hold-up aerial striker', g: 'triangle' },
|
||||
'BOX-TO-BOX': { c: '#00D4A0', d: 'All-action central midfielder', g: 'chevrons' },
|
||||
'WING WIZARD': { c: '#2DD4BF', d: 'Dribbling wide threat', g: 'slash' },
|
||||
'SWEEPER KEEPER': { c: '#A78BFA', d: 'Distributing goalkeeper', g: 'shield' },
|
||||
};
|
||||
|
||||
const FALLBACK = { c: '#9499A8', d: '', g: '' };
|
||||
|
||||
function archetypeInfo(name) {
|
||||
const key = (name == null ? '' : String(name)).toUpperCase();
|
||||
return ARCHETYPE_MAP[key] || FALLBACK;
|
||||
}
|
||||
|
||||
function archetypeColor(name) {
|
||||
return archetypeInfo(name).c;
|
||||
}
|
||||
|
||||
function glyphSvg(glyphKey) {
|
||||
const inner = GLYPHS[glyphKey] || '';
|
||||
return `<svg viewBox="0 0 16 16" width="100%" height="100%" style="display:block">${inner}</svg>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the full style bundle for a badge — mirrors the design's renderVals.
|
||||
* variant: 'full' (solid) | 'ghost' (outline) | 'tint' (default).
|
||||
* size: 'sm' | 'md'.
|
||||
*/
|
||||
function badgeStyle(name, variant = 'tint', size = 'sm') {
|
||||
const info = archetypeInfo(name);
|
||||
const sm = size === 'sm';
|
||||
let textColor, bg, borderColor, glyphColor, textShadow = 'none';
|
||||
if (variant === 'full' || variant === 'solid') {
|
||||
textColor = '#FFFFFF'; bg = info.c; borderColor = info.c; glyphColor = '#FFFFFF';
|
||||
textShadow = '0 1px 2px rgba(0,0,0,0.38)';
|
||||
} else if (variant === 'ghost' || variant === 'outline') {
|
||||
textColor = info.c; bg = 'transparent'; borderColor = info.c + 'CC'; glyphColor = info.c;
|
||||
} else {
|
||||
textColor = info.c; bg = info.c + '1F'; borderColor = info.c + '52'; glyphColor = info.c;
|
||||
}
|
||||
return {
|
||||
name: (name == null ? '' : String(name)).toUpperCase(),
|
||||
desc: info.d,
|
||||
glyph: info.g,
|
||||
color: info.c,
|
||||
textColor, bg, borderColor, glyphColor, textShadow,
|
||||
fontSize: sm ? '9.5px' : '12px',
|
||||
padding: sm ? '3px 7px 3px 6px' : '5px 11px 5px 9px',
|
||||
radius: sm ? '4px' : '6px',
|
||||
glyphSize: sm ? '11px' : '14px',
|
||||
descSize: sm ? '11px' : '13px',
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
GLYPHS,
|
||||
ARCHETYPE_MAP,
|
||||
archetypeInfo,
|
||||
archetypeColor,
|
||||
glyphSvg,
|
||||
badgeStyle,
|
||||
};
|
||||
@@ -0,0 +1,29 @@
|
||||
/* Sportsbook brand map (Session 42) — ported from the design's BookChip.dc.html
|
||||
BOOKS table. CommonJS so it's testable + importable from the .tsx chip. */
|
||||
|
||||
const BOOKS = {
|
||||
DK: { name: 'DraftKings', mono: 'DK', bg: '#0E1E12', fg: '#53D337', bd: '#53D33755' },
|
||||
DRAFTKINGS: { name: 'DraftKings', mono: 'DK', bg: '#0E1E12', fg: '#53D337', bd: '#53D33755' },
|
||||
FD: { name: 'FanDuel', mono: 'FD', bg: '#0A1830', fg: '#1493FF', bd: '#1493FF55' },
|
||||
FANDUEL: { name: 'FanDuel', mono: 'FD', bg: '#0A1830', fg: '#1493FF', bd: '#1493FF55' },
|
||||
MGM: { name: 'BetMGM', mono: 'MGM', bg: '#1A1405', fg: '#C8A24B', bd: '#C8A24B55' },
|
||||
BETMGM: { name: 'BetMGM', mono: 'MGM', bg: '#1A1405', fg: '#C8A24B', bd: '#C8A24B55' },
|
||||
CZR: { name: 'Caesars', mono: 'CZR', bg: '#0C1A14', fg: '#1A7F5A', bd: '#1A7F5A66' },
|
||||
CAESARS: { name: 'Caesars', mono: 'CZR', bg: '#0C1A14', fg: '#1A7F5A', bd: '#1A7F5A66' },
|
||||
ESPN: { name: 'ESPN BET', mono: 'EB', bg: '#2A0A0A', fg: '#FF4A4A', bd: '#FF4A4A55' },
|
||||
'ESPN BET': { name: 'ESPN BET', mono: 'EB', bg: '#2A0A0A', fg: '#FF4A4A', bd: '#FF4A4A55' },
|
||||
BR: { name: 'BetRivers', mono: 'BR', bg: '#1A0E22', fg: '#B07CFF', bd: '#B07CFF55' },
|
||||
PB: { name: 'PrizePicks', mono: 'PP', bg: '#0A1A1A', fg: '#19E0C8', bd: '#19E0C855' },
|
||||
PRIZEPICKS: { name: 'PrizePicks', mono: 'PP', bg: '#0A1A1A', fg: '#19E0C8', bd: '#19E0C855' },
|
||||
FAN: { name: 'Fanatics', mono: 'FAN', bg: '#1A0A0A', fg: '#E84855', bd: '#E8485555' },
|
||||
B365: { name: 'bet365', mono: '365', bg: '#0A1A12', fg: '#2E8B57', bd: '#2E8B5766' },
|
||||
HR: { name: 'Hard Rock', mono: 'HR', bg: '#1A1206', fg: '#D4A24B', bd: '#D4A24B55' },
|
||||
UD: { name: 'Underdog', mono: 'UD', bg: '#15101F', fg: '#A07CFF', bd: '#A07CFF55' },
|
||||
};
|
||||
|
||||
function bookInfo(book) {
|
||||
const key = String(book == null ? '' : book).toUpperCase();
|
||||
return BOOKS[key] || { name: key, mono: key.slice(0, 3) || '?', bg: '#14141E', fg: '#B8BCC8', bd: '#23232F' };
|
||||
}
|
||||
|
||||
module.exports = { BOOKS, bookInfo };
|
||||
@@ -86,7 +86,44 @@ function mapScanToGradeResult(input = {}) {
|
||||
altLadder: includeAlt && Array.isArray(input.alt_lines)
|
||||
? input.alt_lines.map((a) => ({ line: a.line, grade: a.grade }))
|
||||
: [],
|
||||
// Session 42 — Player Intelligence additions. All OPTIONAL + self-hiding:
|
||||
// they only render once the engine supplies them (Session 43 data pipeline).
|
||||
...buildIntelFields(input),
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { mapScanToGradeResult, statLabel, computeEdge, isPhosphorConfirmed, toSignals };
|
||||
/**
|
||||
* Build the optional archetype / stat-context / vyndr-intelligence fields for
|
||||
* the grade card from whatever the engine provided. Returns {} when nothing is
|
||||
* present so the card sections stay hidden (no empty boxes).
|
||||
*/
|
||||
function buildIntelFields(input) {
|
||||
const out = {};
|
||||
if (Array.isArray(input.archetype_blend) && input.archetype_blend.length) {
|
||||
out.archetypeBlend = input.archetype_blend;
|
||||
} else if (input.archetype) {
|
||||
out.archetypeBlend = [{ archetype: String(input.archetype), weight: 1 }];
|
||||
}
|
||||
if (input.prop_dna && (input.prop_dna.reliable || input.prop_dna.volatile)) {
|
||||
out.propDNA = {
|
||||
reliable: input.prop_dna.reliable || [],
|
||||
volatile: input.prop_dna.volatile || [],
|
||||
};
|
||||
}
|
||||
const sc = {};
|
||||
if (input.season_avg != null) sc.season = String(input.season_avg);
|
||||
if (input.last10_avg != null) sc.last10 = String(input.last10_avg);
|
||||
if (input.vs_opp_avg != null) sc.vsOpp = String(input.vs_opp_avg);
|
||||
if (Object.keys(sc).length) out.statContext = sc;
|
||||
|
||||
const vi = {};
|
||||
if (input.form != null) vi.form = input.form;
|
||||
if (input.usage != null) vi.usage = String(input.usage);
|
||||
if (input.matchup_grade != null) vi.matchup = String(input.matchup_grade);
|
||||
if (input.rest != null) vi.rest = String(input.rest);
|
||||
if (Object.keys(vi).length) out.vyndrIntel = vi;
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
module.exports = { mapScanToGradeResult, statLabel, computeEdge, isPhosphorConfirmed, toSignals, buildIntelFields };
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
/* Canonical player-profile link builder (Session 42). Used everywhere a
|
||||
player name should be tappable. CommonJS so it's testable + importable. */
|
||||
|
||||
function playerHref(name, sport) {
|
||||
const n = encodeURIComponent(String(name == null ? '' : name).trim());
|
||||
const sp = String(sport || 'nba').toLowerCase();
|
||||
return `/player/${n}?sport=${encodeURIComponent(sp)}`;
|
||||
}
|
||||
|
||||
module.exports = { playerHref };
|
||||
@@ -0,0 +1,39 @@
|
||||
/* Pure display transforms for the player profile page (Session 42).
|
||||
CommonJS so it's unit-testable + importable from the .tsx page. */
|
||||
|
||||
/** Two-letter mono initials for the hero avatar. */
|
||||
function initials(name) {
|
||||
const parts = String(name || '').trim().split(/\s+/).filter(Boolean);
|
||||
if (parts.length === 0) return '??';
|
||||
if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase();
|
||||
return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
|
||||
}
|
||||
|
||||
const SPORT_LABELS = { nba: 'NBA', mlb: 'MLB', wnba: 'WNBA', soccer: 'SOC' };
|
||||
function sportLabel(sport) {
|
||||
return SPORT_LABELS[String(sport || '').toLowerCase()] || String(sport || '').toUpperCase();
|
||||
}
|
||||
|
||||
/** Prop DNA → flat list of { prop, state, color } for the grid (reliable first). */
|
||||
function dnaRows(propDNA) {
|
||||
const dna = propDNA || { reliable: [], volatile: [] };
|
||||
const pretty = (s) => String(s).replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
|
||||
return [
|
||||
...(dna.reliable || []).map((p) => ({ prop: pretty(p), state: 'RELIABLE', color: '#00D4A0' })),
|
||||
...(dna.volatile || []).map((p) => ({ prop: pretty(p), state: 'VOLATILE', color: '#FFB347' })),
|
||||
];
|
||||
}
|
||||
|
||||
/** Short human readout under the DNA bar, e.g. "Primary lane: Power Pull". */
|
||||
function blendReadout(archetype) {
|
||||
if (!archetype || !archetype.primary) return '';
|
||||
const prim = archetype.primary.name;
|
||||
const sec = archetype.secondary ? `, supported by ${title(archetype.secondary.name)}` : '';
|
||||
return `Primary lane: ${title(prim)}${sec}.`;
|
||||
}
|
||||
|
||||
function title(s) {
|
||||
return String(s || '').toLowerCase().replace(/\b\w/g, (c) => c.toUpperCase());
|
||||
}
|
||||
|
||||
module.exports = { initials, sportLabel, dnaRows, blendReadout, title };
|
||||
Reference in New Issue
Block a user