956a7455eb
VYNDR 2.0 conversion, Phase G (the systems that make the design alive). All 5 wired. Frontend-only; zero backend changes. - lib/parlayMath.js: correlation model (0.62/0.34/0.06/0) + parlayGrade penalty + grade->odds + combined odds (frontend; backend parlayService unchanged). - lib/oddsFormat.js: fmtOdds across american/decimal/fractional/implied with the totals-pass-through rule (safer than the prototype's parseAm, which would mis-convert 228.5) + region presets. - lib/prefs.js: applyPrefs sets <html data-*> (the S33 a11y CSS layer) + load/save. - lib/liveTick.js: single tick engine (SSR/test-safe, no auto-start, fresh state). - lib/checkout.js: checkoutUrl(plan). - LiveLayer (useLive/LiveNumber/HeartbeatBar) under the Nav ticker; GlobalHosts in layout applies prefs + registers __prefs/__goPaywall/__checkout + hosts the Preferences and Paywall modals. Nav read-meter is now a paywall trigger. Gotchas: useEffect can't return a Set.delete unsub directly (boolean != cleanup); header grew to 124px so layout paddingTop + Slate sticky-top updated to match. 18 new tests. Backend 1872 -> 1890, 146 suites, zero regressions. Web build clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
99 lines
3.1 KiB
JavaScript
99 lines
3.1 KiB
JavaScript
/* ============================================================
|
|
VYNDR 2.0 — odds formatting + region presets (§9).
|
|
Plain CommonJS so components import it (allowJs) AND Jest tests it.
|
|
|
|
CRITICAL (the #1 i18n footgun): fmtOdds only converts MONEYLINES —
|
|
bare signed-integer strings (+150 / -110) or integer numbers (the
|
|
grade→odds map). Totals/lines/spreads like 228.5, 229, -7.5 PASS
|
|
THROUGH UNCHANGED. Apply to moneylines/book odds/parlay legs; NEVER
|
|
to O/U totals or stat lines.
|
|
|
|
This deliberately DEVIATES from the prototype's parseAm, whose regex
|
|
accepted decimals (`228.5` would have been mis-converted to a fake
|
|
implied % on O/U cells). The prompt's spec is authoritative here.
|
|
============================================================ */
|
|
|
|
const REGIONS = {
|
|
US: { label: 'United States', odds: 'american', currency: 'USD' },
|
|
UK: { label: 'United Kingdom', odds: 'fractional', currency: 'GBP' },
|
|
EU: { label: 'Europe', odds: 'decimal', currency: 'EUR' },
|
|
BR: { label: 'Brazil', odds: 'decimal', currency: 'BRL' },
|
|
AU: { label: 'Australia', odds: 'decimal', currency: 'AUD' },
|
|
};
|
|
|
|
const CURRENCIES = { USD: '$', GBP: '£', EUR: '€', BRL: 'R$', AUD: 'A$' };
|
|
|
|
const ODDS_FORMATS = ['american', 'decimal', 'fractional', 'implied'];
|
|
|
|
function gcd(a, b) {
|
|
a = Math.abs(a);
|
|
b = Math.abs(b);
|
|
while (b) {
|
|
[a, b] = [b, a % b];
|
|
}
|
|
return a || 1;
|
|
}
|
|
|
|
/**
|
|
* Return the American-odds integer for a moneyline input, or null if the
|
|
* value is NOT a moneyline (and must pass through unchanged).
|
|
* - number → only an integer counts (the grade→odds map); 228.5 → null
|
|
* - string → only an explicitly SIGNED integer (+150 / -110); "228.5",
|
|
* "229", "-7.5" → null
|
|
*/
|
|
function parseMoneyline(value) {
|
|
if (typeof value === 'number') return Number.isInteger(value) ? value : null;
|
|
if (typeof value !== 'string') return null;
|
|
return /^[+-]\d+$/.test(value.trim()) ? parseInt(value, 10) : null;
|
|
}
|
|
|
|
function americanToDecimal(am) {
|
|
return am > 0 ? am / 100 + 1 : 100 / -am + 1;
|
|
}
|
|
|
|
/**
|
|
* Format an odds value into the requested style. Non-moneyline inputs are
|
|
* returned verbatim (the pass-through guarantee).
|
|
*/
|
|
function fmtOdds(value, format = 'american') {
|
|
const am = parseMoneyline(value);
|
|
if (am === null) return value;
|
|
if (format === 'american') return (am > 0 ? '+' : '') + Math.round(am);
|
|
const dec = americanToDecimal(am);
|
|
if (format === 'decimal') return dec.toFixed(2);
|
|
if (format === 'implied') return Math.round((1 / dec) * 100) + '%';
|
|
// fractional
|
|
let n;
|
|
let d;
|
|
if (am > 0) {
|
|
n = Math.round(am);
|
|
d = 100;
|
|
} else {
|
|
n = 100;
|
|
d = Math.round(-am);
|
|
}
|
|
const g = gcd(n, d);
|
|
return `${n / g}/${d / g}`;
|
|
}
|
|
|
|
/** Region → { odds, currency, currencySymbol } preset (§9). */
|
|
function regionPreset(region) {
|
|
const r = REGIONS[region] || REGIONS.US;
|
|
return { odds: r.odds, currency: r.currency, currencySymbol: CURRENCIES[r.currency] || '$' };
|
|
}
|
|
|
|
function currencySymbol(code) {
|
|
return CURRENCIES[code] || '$';
|
|
}
|
|
|
|
module.exports = {
|
|
REGIONS,
|
|
CURRENCIES,
|
|
ODDS_FORMATS,
|
|
parseMoneyline,
|
|
americanToDecimal,
|
|
fmtOdds,
|
|
regionPreset,
|
|
currencySymbol,
|
|
};
|