diff --git a/src/app.js b/src/app.js index 0027e39..5d0f905 100644 --- a/src/app.js +++ b/src/app.js @@ -130,6 +130,8 @@ app.use('/api/stats', statsRoutes); app.use('/api/props', propsRoutes); // Session 60 (night2/E) — the scan search box's canonical player resolver. app.use('/api/players', require('./routes/players')); +// Session 60 (night2/F) — per-user utility reads (Settings scan meter). +app.use('/api/user', require('./routes/user')); app.use('/api/waitlist', waitlistRoutes); app.use('/api/pipeline', pipelineRoutes); app.use('/api/share-card', shareCardRoutes); diff --git a/src/middleware/scanLimit.js b/src/middleware/scanLimit.js index d986b43..2f102c4 100644 --- a/src/middleware/scanLimit.js +++ b/src/middleware/scanLimit.js @@ -93,7 +93,23 @@ function resetForTests() { hits.clear(); } +/** + * Session 60 (night2/F, 5.4) — read-only usage for the Settings meter. + * Reports the SAME rolling-24h window the limiter enforces, from the same + * store — the meter can never disagree with the gate. Infinity limit → + * { unlimited: true }. + */ +function scanUsage(req) { + const tier = req.user?.tier || 'free'; + const limit = getScanLimit(tier); + if (limit === Infinity) return { tier, unlimited: true, used: null, limit: null }; + const ts = hits.get(clientKey(req)); + const used = ts ? pruneOlderThan(ts, Date.now() - WINDOW_MS) : 0; + return { tier, unlimited: false, used, limit, remaining: Math.max(0, limit - used) }; +} + module.exports = { scanLimit, + scanUsage, __internals: { hits, clientKey, resetForTests, WINDOW_MS, MAX_TRACKED }, }; diff --git a/src/routes/ledger.js b/src/routes/ledger.js index 9ce7a83..9d42e17 100644 --- a/src/routes/ledger.js +++ b/src/routes/ledger.js @@ -84,6 +84,8 @@ router.get('/model', async (req, res) => { ? String(req.query.sport).toLowerCase() : undefined, // VYNDR-on-player (work-order 1.4/5.2): per-player public record. playerKey: req.query.player ? nameKey(String(req.query.player).slice(0, 60)) : undefined, + // VYNDR-on-team (5.2, migration 020): exact statsapi team name. + team: req.query.team ? String(req.query.team).slice(0, 60) : undefined, }); let entries = []; if (sb) { diff --git a/src/routes/user.js b/src/routes/user.js new file mode 100644 index 0000000..a6286f0 --- /dev/null +++ b/src/routes/user.js @@ -0,0 +1,27 @@ +'use strict'; + +/** + * /api/user — per-user utility reads (Session 60, night2/F). + * + * GET /scan-meter (auth) — the Settings read meter: real usage from the + * SAME rolling-24h store the scan limiter enforces. Metered tiers get + * { used, limit, remaining }; unlimited tiers get { unlimited: true } + * (the UI hides the meter). + */ + +const express = require('express'); +const { requireAuth } = require('../middleware/auth'); +const { scanUsage } = require('../middleware/scanLimit'); + +const router = express.Router(); + +router.get('/scan-meter', requireAuth, (req, res) => { + try { + return res.json(scanUsage(req)); + } catch (err) { + console.error('[user/scan-meter]', err.message); + return res.status(200).json({ tier: req.user?.tier || 'free', unlimited: false, used: null, limit: null }); + } +}); + +module.exports = router; diff --git a/src/services/ledgerService.js b/src/services/ledgerService.js index b507e01..8fc54c0 100644 --- a/src/services/ledgerService.js +++ b/src/services/ledgerService.js @@ -375,13 +375,14 @@ async function getModelAggregate(opts = {}) { const since = new Date(nowMs - AGG_WINDOW_DAYS * 24 * 3600 * 1000).toISOString().slice(0, 10); let settledQ = sb.from('ledger_entries') - .select('outcome, clv_result, player_key') + .select('outcome, clv_result, player_key, grade') .is('user_id', null) .not('outcome', 'is', null) .gte('game_date', since) .limit(AGG_FETCH_LIMIT); if (opts.sport) settledQ = settledQ.eq('sport', String(opts.sport).toLowerCase()); if (opts.playerKey) settledQ = settledQ.eq('player_key', opts.playerKey); + if (opts.team) settledQ = settledQ.eq('team', opts.team); // Session 60 (5.2) — VYNDR-on-team const { data: settledRows, error } = await settledQ; if (error) return { ...empty, error: error.message }; @@ -391,9 +392,19 @@ async function getModelAggregate(opts = {}) { .is('outcome', null); if (opts.sport) pendingQ = pendingQ.eq('sport', String(opts.sport).toLowerCase()); if (opts.playerKey) pendingQ = pendingQ.eq('player_key', opts.playerKey); + if (opts.team) pendingQ = pendingQ.eq('team', opts.team); const { count: pending } = await pendingQ; const agg = { ...empty, pending: pending || 0 }; + // Session 60 (5.5) — calibration by grade tier (A+ alone, then first + // letter). Same n≥20 rule PER TIER: a tier below threshold reports a + // null pct and the UI shows "building", never a small-sample %. + const tierOf = (g) => { + const s = String(g || '').trim().toUpperCase(); + if (!s) return null; + return s === 'A+' ? 'A+' : s[0]; + }; + const byTier = {}; for (const r of settledRows || []) { agg.settled += 1; if (r.outcome === 'hit') agg.hits += 1; @@ -405,7 +416,22 @@ async function getModelAggregate(opts = {}) { else if (r.clv_result === 'faded') agg.clv_faded += 1; else agg.clv_flat += 1; } + const t = tierOf(r.grade); + if (t) { + byTier[t] = byTier[t] || { settled: 0, hits: 0, misses: 0, pushes: 0, hit_pct: null }; + const b = byTier[t]; + b.settled += 1; + if (r.outcome === 'hit') b.hits += 1; + else if (r.outcome === 'miss') b.misses += 1; + else if (r.outcome === 'push') b.pushes += 1; + } } + for (const t of Object.keys(byTier)) { + const b = byTier[t]; + const d = b.hits + b.misses; + if (b.settled >= MIN_AGG_SAMPLE && d > 0) b.hit_pct = Math.round((b.hits / d) * 100); + } + agg.by_tier = byTier; const decided = agg.hits + agg.misses; // n<20 → null: never render a percentage on a small sample. if (agg.settled >= MIN_AGG_SAMPLE && decided > 0) { diff --git a/src/services/playerIntelService.js b/src/services/playerIntelService.js index aef3181..64a9ba8 100644 --- a/src/services/playerIntelService.js +++ b/src/services/playerIntelService.js @@ -156,7 +156,13 @@ async function resolvePlayerStats(name, sport, opts = {}) { { k: 'PPG', v: String(ci.ppg ?? '—') }, { k: 'RPG', v: String(ci.rpg ?? '—') }, { k: 'APG', v: String(ci.apg ?? '—') }, { k: 'BLK', v: String(ci.bpg ?? '—') }, ]; - return { found: true, team: e.team || '', classifierInput: { ...ci, pos: e.position }, season, last10: [], splits: [] }; + // Session 60 (5.3) — WNBA/NBA parity: minutes-based usage on the + // profile (the basketball equivalent of AB/G). Only when the feed + // carries minutes — absent beats invented. + const mpg = Number(ci.mpg ?? ci.min ?? ci.minutes); + const extra = Number.isFinite(mpg) && mpg > 0 ? { usage: `${Math.round(mpg)} min` } : {}; + if (extra.usage) season.push({ k: 'MIN', v: String(Math.round(mpg)) }); + return { found: true, team: e.team || '', classifierInput: { ...ci, pos: e.position, ...extra }, season, last10: [], splits: [] }; } return { found: false }; } diff --git a/tests/unit/ledgerService.test.js b/tests/unit/ledgerService.test.js index 47e7a59..2a90261 100644 --- a/tests/unit/ledgerService.test.js +++ b/tests/unit/ledgerService.test.js @@ -233,3 +233,22 @@ describe('getModelAggregate — never a % under min sample', () => { expect(agg.beat_close_pct).toBe(65); // 13 beat / 20 with clv }); }); + +// Session 60 (night2/F, 5.5) — calibration by grade tier. +describe('getModelAggregate — per-tier calibration (n≥20 per tier)', () => { + test('a tier at 20+ settles gets a pct; a small tier stays building (null)', async () => { + const sb = fakeSb(); + const rows = [ + ...Array.from({ length: 14 }, () => ({ outcome: 'hit', clv_result: null, grade: 'A' })), + ...Array.from({ length: 6 }, () => ({ outcome: 'miss', clv_result: null, grade: 'A-' })), + ...Array.from({ length: 3 }, () => ({ outcome: 'hit', clv_result: null, grade: 'B' })), + ]; + sb._state.selectResults = [rows]; + sb._state.countResult = 0; + const agg = await ledger.getModelAggregate({ sb }); + expect(agg.by_tier.A.settled).toBe(20); // A + A- bucket together + expect(agg.by_tier.A.hit_pct).toBe(70); // 14/(14+6) + expect(agg.by_tier.B.settled).toBe(3); + expect(agg.by_tier.B.hit_pct).toBeNull(); // under 20 → building + }); +}); diff --git a/web/src/app/api/user/scan-meter/route.ts b/web/src/app/api/user/scan-meter/route.ts new file mode 100644 index 0000000..99f53d9 --- /dev/null +++ b/web/src/app/api/user/scan-meter/route.ts @@ -0,0 +1,22 @@ +import { NextRequest, NextResponse } from 'next/server'; + +export const dynamic = 'force-dynamic'; + +const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:3000'; + +/** Scan-meter proxy (Session 60, night2/F) — forwards the caller's auth to + * Express, which reads the live limiter's rolling-24h usage. */ +export async function GET(req: NextRequest) { + const auth = req.headers.get('authorization'); + if (!auth) return NextResponse.json({ unlimited: false, used: null, limit: null }, { status: 401 }); + try { + const upstream = await fetch(`${BACKEND_URL}/api/user/scan-meter`, { + headers: { Accept: 'application/json', Authorization: auth }, + cache: 'no-store', + }); + const data = await upstream.json().catch(() => ({})); + return NextResponse.json(data, { status: upstream.ok ? 200 : upstream.status }); + } catch { + return NextResponse.json({ unlimited: false, used: null, limit: null }, { status: 200 }); + } +} diff --git a/web/src/app/ledger/page.tsx b/web/src/app/ledger/page.tsx index 56312c9..feede8c 100644 --- a/web/src/app/ledger/page.tsx +++ b/web/src/app/ledger/page.tsx @@ -44,6 +44,7 @@ interface LedgerRow { revised_from_grade?: string | null; } +interface TierRecord { settled: number; hits: number; misses: number; hit_pct: number | null } interface ModelAggregate { settled: number; hits: number; @@ -55,6 +56,8 @@ interface ModelAggregate { beat_close_pct: number | null; pending: number; min_sample?: number; + // Session 60 (5.5) — calibration by grade tier (n≥20 rule per tier). + by_tier?: Record; } const SPORT_COLOR: Record = { @@ -160,6 +163,30 @@ export default function LedgerPage() { ); } +const TIER_ORDER = ['A+', 'A', 'B', 'C', 'D', 'F']; + +/** Session 60 (5.5) — per-tier calibration: the separation between tiers is + * the proof the grades mean something. A tier under n≥20 shows "building", + * never a small-sample percentage. Self-hides until ANY tier is ready. */ +function TierCalibration({ agg, minSample }: { agg: ModelAggregate; minSample: number }) { + const tiers = agg.by_tier || {}; + const anyReady = TIER_ORDER.some((t) => tiers[t] && tiers[t].hit_pct != null); + if (!anyReady) return null; + return ( +
+ {TIER_ORDER.filter((t) => tiers[t] && tiers[t].settled > 0).map((t) => { + const b = tiers[t]; + const ready = b.hit_pct != null; + return ( + + {t}-TIER · {ready ? `${b.hits}-${b.misses} · ${b.hit_pct}%` : `building (${b.settled}/${minSample})`} + + ); + })} +
+ ); +} + function ModelHeader({ agg, minSample }: { agg: ModelAggregate; minSample: number }) { const ready = agg.settled >= minSample && agg.hit_pct != null; return ( @@ -200,6 +227,7 @@ function ModelHeader({ agg, minSample }: { agg: ModelAggregate; minSample: numbe

)} + ); } diff --git a/web/src/app/player/[name]/page.tsx b/web/src/app/player/[name]/page.tsx index 0fdb4f1..6db4cb6 100644 --- a/web/src/app/player/[name]/page.tsx +++ b/web/src/app/player/[name]/page.tsx @@ -8,6 +8,7 @@ import ArchetypeBadge from '@/components/vyndr/ArchetypeBadge'; import ArchetypeBlend from '@/components/vyndr/ArchetypeBlend'; import ModelRecord from '@/components/vyndr/ModelRecord'; import PlayerStreaks from '@/components/vyndr/PlayerStreaks'; +import { archetypeInfo } from '@/lib/archetypes'; import { initials, sportLabel, dnaRows, blendReadout } from '@/lib/playerProfileAdapter'; interface IntelMetric { label: string; kind: string; value: string; score?: string; color: string } @@ -105,6 +106,15 @@ export default function PlayerProfilePage() {
ARCHETYPE DNA
+ {/* Session 60 (5.1, spec amendment) — define the term on-page: + one line from the archetype library under the dual bar. + The "What does this mean?" expander keeps the long version. */} + {p.archetype.primary && archetypeInfo(p.archetype.primary)?.d && ( +
+ {String(p.archetype.primary).toUpperCase()} + {archetypeInfo(p.archetype.primary).d} +
+ )}
)} diff --git a/web/src/app/settings/page.tsx b/web/src/app/settings/page.tsx index 470d737..61553ae 100644 --- a/web/src/app/settings/page.tsx +++ b/web/src/app/settings/page.tsx @@ -68,6 +68,18 @@ export default function SettingsPage() { const [deleteText, setDeleteText] = useState(''); const [deleting, setDeleting] = useState(false); const [delError, setDelError] = useState(''); + // Session 60 (5.4) — the daily read meter (metered tiers only). + const [meter, setMeter] = useState<{ unlimited: boolean; used: number | null; limit: number | null; remaining?: number | null } | null>(null); + useEffect(() => { + const token = session?.access_token; + if (!token) return; + let active = true; + fetch('/api/user/scan-meter', { headers: { Authorization: `Bearer ${token}` } }) + .then((r) => (r.ok ? r.json() : null)) + .then((data) => { if (active && data && typeof data.unlimited === 'boolean') setMeter(data); }) + .catch(() => { /* meter hides itself */ }); + return () => { active = false; }; + }, [session]); // Session 49 — onboarding preferences (load + edit + save). const [prefSports, setPrefSports] = useState([]); @@ -162,10 +174,31 @@ export default function SettingsPage() { {/* SUBSCRIPTION */}
+ {/* Session 60 (5.4) — the daily read meter for METERED tiers, from + the live limiter's rolling-24h window (never disagrees with the + gate). Unlimited tiers hide it. */} + {meter && !meter.unlimited && meter.limit != null && ( + <> + +
+
Reads today
+
+
+
+
+ + {meter.used ?? 0} of {meter.limit} + + + + + )}
VYNDR {plan.label === 'FREE' ? 'Free' : plan.label === 'DESK' ? 'Desk' : 'Analyst'}
-
{plan.label === 'FREE' ? '5 scans / month' : 'Active subscription'}
+
+ {meter && !meter.unlimited && meter.limit != null ? `${meter.limit} reads / day` : plan.label === 'FREE' ? '3 reads / day' : 'Active subscription'} +
{plan.label !== 'FREE' && ( diff --git a/web/src/app/team/[abbr]/TeamHub.tsx b/web/src/app/team/[abbr]/TeamHub.tsx index bb1dd09..aad8585 100644 --- a/web/src/app/team/[abbr]/TeamHub.tsx +++ b/web/src/app/team/[abbr]/TeamHub.tsx @@ -5,6 +5,7 @@ import { useRouter } from 'next/navigation'; import SportBadge from '@/components/vyndr/SportBadge'; import GradeBadge from '@/components/vyndr/GradeBadge'; import ArchetypeBadge from '@/components/vyndr/ArchetypeBadge'; +import ModelRecord from '@/components/vyndr/ModelRecord'; import { playerHref } from '@/lib/playerHref'; import { useParlay, legKey } from '@/contexts/ParlayContext'; @@ -112,6 +113,12 @@ export default function TeamHub({ abbr, sport }: { abbr: string; sport: string } {data.record && {data.record.wins}-{data.record.losses}}
+ {/* Session 60 (5.2, migration 020) — VYNDR-on-team: the model's settled + record on THIS team's players. Deferred-render until rows exist. */} +
+ +
+ {data.note &&

{data.note}

} {/* Controls */} diff --git a/web/src/components/vyndr/ModelRecord.tsx b/web/src/components/vyndr/ModelRecord.tsx index b91e5d9..7f9bed1 100644 --- a/web/src/components/vyndr/ModelRecord.tsx +++ b/web/src/components/vyndr/ModelRecord.tsx @@ -27,10 +27,13 @@ interface Aggregate { export default function ModelRecord({ sport, player, + team, align = 'center', }: { sport?: string; player?: string; + /** VYNDR-on-team (5.2) — exact team name as the feed writes it. */ + team?: string; align?: 'center' | 'left'; }) { const [agg, setAgg] = useState(null); @@ -41,6 +44,7 @@ export default function ModelRecord({ const params = new URLSearchParams({ limit: '1' }); if (sport) params.set('sport', sport.toLowerCase()); if (player) params.set('player', player); + if (team) params.set('team', team); fetch(`/api/ledger/model?${params}`) .then((r) => (r.ok ? r.json() : null)) .then((data) => { @@ -50,7 +54,7 @@ export default function ModelRecord({ }) .catch(() => { /* self-hide */ }); return () => { active = false; }; - }, [sport, player]); + }, [sport, player, team]); if (!agg) return null; const ready = agg.settled >= minSample && agg.hit_pct != null;