Session F (night2): Phase 5 — records + dossier
5.1 Archetype defined on-page: one line from the archetype library under
ARCHETYPE DNA (BOMBER — elite raw power…); expander keeps the long form.
5.2 VYNDR-on-team live: getModelAggregate team scope (migration-020 column)
+ /api/ledger/model?team= + ModelRecord mounted on the Team Hub header.
5.3 WNBA/NBA profile parity: minutes-based usage (+MIN season cell) when
the feed carries minutes — absent beats invented.
5.4 Settings read meter: real rolling-24h usage from the SAME store the
limiter enforces (GET /api/user/scan-meter + proxy). Metered tiers see
'X of N reads today' + bar; unlimited tiers see nothing. Corrects the
stale '5 scans / month' copy.
5.5 Per-tier calibration on the MODEL tab: A+/A/B/C chips with hit% at
n>=20 PER TIER, 'building (n/20)' below — the separation between tiers
is the proof the grades mean something.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -130,6 +130,8 @@ app.use('/api/stats', statsRoutes);
|
|||||||
app.use('/api/props', propsRoutes);
|
app.use('/api/props', propsRoutes);
|
||||||
// Session 60 (night2/E) — the scan search box's canonical player resolver.
|
// Session 60 (night2/E) — the scan search box's canonical player resolver.
|
||||||
app.use('/api/players', require('./routes/players'));
|
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/waitlist', waitlistRoutes);
|
||||||
app.use('/api/pipeline', pipelineRoutes);
|
app.use('/api/pipeline', pipelineRoutes);
|
||||||
app.use('/api/share-card', shareCardRoutes);
|
app.use('/api/share-card', shareCardRoutes);
|
||||||
|
|||||||
@@ -93,7 +93,23 @@ function resetForTests() {
|
|||||||
hits.clear();
|
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 = {
|
module.exports = {
|
||||||
scanLimit,
|
scanLimit,
|
||||||
|
scanUsage,
|
||||||
__internals: { hits, clientKey, resetForTests, WINDOW_MS, MAX_TRACKED },
|
__internals: { hits, clientKey, resetForTests, WINDOW_MS, MAX_TRACKED },
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -84,6 +84,8 @@ router.get('/model', async (req, res) => {
|
|||||||
? String(req.query.sport).toLowerCase() : undefined,
|
? String(req.query.sport).toLowerCase() : undefined,
|
||||||
// VYNDR-on-player (work-order 1.4/5.2): per-player public record.
|
// 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,
|
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 = [];
|
let entries = [];
|
||||||
if (sb) {
|
if (sb) {
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -375,13 +375,14 @@ async function getModelAggregate(opts = {}) {
|
|||||||
const since = new Date(nowMs - AGG_WINDOW_DAYS * 24 * 3600 * 1000).toISOString().slice(0, 10);
|
const since = new Date(nowMs - AGG_WINDOW_DAYS * 24 * 3600 * 1000).toISOString().slice(0, 10);
|
||||||
|
|
||||||
let settledQ = sb.from('ledger_entries')
|
let settledQ = sb.from('ledger_entries')
|
||||||
.select('outcome, clv_result, player_key')
|
.select('outcome, clv_result, player_key, grade')
|
||||||
.is('user_id', null)
|
.is('user_id', null)
|
||||||
.not('outcome', 'is', null)
|
.not('outcome', 'is', null)
|
||||||
.gte('game_date', since)
|
.gte('game_date', since)
|
||||||
.limit(AGG_FETCH_LIMIT);
|
.limit(AGG_FETCH_LIMIT);
|
||||||
if (opts.sport) settledQ = settledQ.eq('sport', String(opts.sport).toLowerCase());
|
if (opts.sport) settledQ = settledQ.eq('sport', String(opts.sport).toLowerCase());
|
||||||
if (opts.playerKey) settledQ = settledQ.eq('player_key', opts.playerKey);
|
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;
|
const { data: settledRows, error } = await settledQ;
|
||||||
if (error) return { ...empty, error: error.message };
|
if (error) return { ...empty, error: error.message };
|
||||||
|
|
||||||
@@ -391,9 +392,19 @@ async function getModelAggregate(opts = {}) {
|
|||||||
.is('outcome', null);
|
.is('outcome', null);
|
||||||
if (opts.sport) pendingQ = pendingQ.eq('sport', String(opts.sport).toLowerCase());
|
if (opts.sport) pendingQ = pendingQ.eq('sport', String(opts.sport).toLowerCase());
|
||||||
if (opts.playerKey) pendingQ = pendingQ.eq('player_key', opts.playerKey);
|
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 { count: pending } = await pendingQ;
|
||||||
|
|
||||||
const agg = { ...empty, pending: pending || 0 };
|
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 || []) {
|
for (const r of settledRows || []) {
|
||||||
agg.settled += 1;
|
agg.settled += 1;
|
||||||
if (r.outcome === 'hit') agg.hits += 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 if (r.clv_result === 'faded') agg.clv_faded += 1;
|
||||||
else agg.clv_flat += 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;
|
const decided = agg.hits + agg.misses;
|
||||||
// n<20 → null: never render a percentage on a small sample.
|
// n<20 → null: never render a percentage on a small sample.
|
||||||
if (agg.settled >= MIN_AGG_SAMPLE && decided > 0) {
|
if (agg.settled >= MIN_AGG_SAMPLE && decided > 0) {
|
||||||
|
|||||||
@@ -156,7 +156,13 @@ async function resolvePlayerStats(name, sport, opts = {}) {
|
|||||||
{ k: 'PPG', v: String(ci.ppg ?? '—') }, { k: 'RPG', v: String(ci.rpg ?? '—') },
|
{ k: 'PPG', v: String(ci.ppg ?? '—') }, { k: 'RPG', v: String(ci.rpg ?? '—') },
|
||||||
{ k: 'APG', v: String(ci.apg ?? '—') }, { k: 'BLK', v: String(ci.bpg ?? '—') },
|
{ 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 };
|
return { found: false };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -233,3 +233,22 @@ describe('getModelAggregate — never a % under min sample', () => {
|
|||||||
expect(agg.beat_close_pct).toBe(65); // 13 beat / 20 with clv
|
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
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -44,6 +44,7 @@ interface LedgerRow {
|
|||||||
revised_from_grade?: string | null;
|
revised_from_grade?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface TierRecord { settled: number; hits: number; misses: number; hit_pct: number | null }
|
||||||
interface ModelAggregate {
|
interface ModelAggregate {
|
||||||
settled: number;
|
settled: number;
|
||||||
hits: number;
|
hits: number;
|
||||||
@@ -55,6 +56,8 @@ interface ModelAggregate {
|
|||||||
beat_close_pct: number | null;
|
beat_close_pct: number | null;
|
||||||
pending: number;
|
pending: number;
|
||||||
min_sample?: number;
|
min_sample?: number;
|
||||||
|
// Session 60 (5.5) — calibration by grade tier (n≥20 rule per tier).
|
||||||
|
by_tier?: Record<string, TierRecord>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const SPORT_COLOR: Record<string, string> = {
|
const SPORT_COLOR: Record<string, string> = {
|
||||||
@@ -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 (
|
||||||
|
<div style={{ display: 'flex', gap: 10, flexWrap: 'wrap', marginTop: 14, paddingTop: 12, borderTop: '1px solid var(--border)' }}>
|
||||||
|
{TIER_ORDER.filter((t) => tiers[t] && tiers[t].settled > 0).map((t) => {
|
||||||
|
const b = tiers[t];
|
||||||
|
const ready = b.hit_pct != null;
|
||||||
|
return (
|
||||||
|
<span key={t} className="mono" style={{ fontSize: 11.5, fontWeight: 700, padding: '4px 10px', borderRadius: 6, border: '1px solid var(--border-hi)', color: ready ? 'var(--text-primary)' : 'var(--text-tertiary)' }}>
|
||||||
|
{t}-TIER · {ready ? `${b.hits}-${b.misses} · ${b.hit_pct}%` : `building (${b.settled}/${minSample})`}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function ModelHeader({ agg, minSample }: { agg: ModelAggregate; minSample: number }) {
|
function ModelHeader({ agg, minSample }: { agg: ModelAggregate; minSample: number }) {
|
||||||
const ready = agg.settled >= minSample && agg.hit_pct != null;
|
const ready = agg.settled >= minSample && agg.hit_pct != null;
|
||||||
return (
|
return (
|
||||||
@@ -200,6 +227,7 @@ function ModelHeader({ agg, minSample }: { agg: ModelAggregate; minSample: numbe
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
<TierCalibration agg={agg} minSample={minSample} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import ArchetypeBadge from '@/components/vyndr/ArchetypeBadge';
|
|||||||
import ArchetypeBlend from '@/components/vyndr/ArchetypeBlend';
|
import ArchetypeBlend from '@/components/vyndr/ArchetypeBlend';
|
||||||
import ModelRecord from '@/components/vyndr/ModelRecord';
|
import ModelRecord from '@/components/vyndr/ModelRecord';
|
||||||
import PlayerStreaks from '@/components/vyndr/PlayerStreaks';
|
import PlayerStreaks from '@/components/vyndr/PlayerStreaks';
|
||||||
|
import { archetypeInfo } from '@/lib/archetypes';
|
||||||
import { initials, sportLabel, dnaRows, blendReadout } from '@/lib/playerProfileAdapter';
|
import { initials, sportLabel, dnaRows, blendReadout } from '@/lib/playerProfileAdapter';
|
||||||
|
|
||||||
interface IntelMetric { label: string; kind: string; value: string; score?: string; color: string }
|
interface IntelMetric { label: string; kind: string; value: string; score?: string; color: string }
|
||||||
@@ -105,6 +106,15 @@ export default function PlayerProfilePage() {
|
|||||||
<div style={{ marginTop: 15 }}>
|
<div style={{ marginTop: 15 }}>
|
||||||
<div className="mono" style={{ fontSize: 10, fontWeight: 700, letterSpacing: '0.1em', color: 'var(--g-a)', marginBottom: 9 }}>ARCHETYPE DNA</div>
|
<div className="mono" style={{ fontSize: 10, fontWeight: 700, letterSpacing: '0.1em', color: 'var(--g-a)', marginBottom: 9 }}>ARCHETYPE DNA</div>
|
||||||
<ArchetypeBlend blend={p.archetype.blend} size="md" showLegend caption={blendReadout(p.archetype)} />
|
<ArchetypeBlend blend={p.archetype.blend} size="md" showLegend caption={blendReadout(p.archetype)} />
|
||||||
|
{/* 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 && (
|
||||||
|
<div className="mono" style={{ marginTop: 8, fontSize: 11.5, color: 'var(--text-1)', lineHeight: 1.5 }}>
|
||||||
|
<span style={{ fontWeight: 800, color: 'var(--text-0)' }}>{String(p.archetype.primary).toUpperCase()}</span>
|
||||||
|
<span style={{ color: 'var(--text-2)' }}> — </span>{archetypeInfo(p.archetype.primary).d}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -68,6 +68,18 @@ export default function SettingsPage() {
|
|||||||
const [deleteText, setDeleteText] = useState('');
|
const [deleteText, setDeleteText] = useState('');
|
||||||
const [deleting, setDeleting] = useState(false);
|
const [deleting, setDeleting] = useState(false);
|
||||||
const [delError, setDelError] = useState('');
|
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).
|
// Session 49 — onboarding preferences (load + edit + save).
|
||||||
const [prefSports, setPrefSports] = useState<string[]>([]);
|
const [prefSports, setPrefSports] = useState<string[]>([]);
|
||||||
@@ -162,10 +174,31 @@ export default function SettingsPage() {
|
|||||||
|
|
||||||
{/* SUBSCRIPTION */}
|
{/* SUBSCRIPTION */}
|
||||||
<Section label="SUBSCRIPTION">
|
<Section label="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 && (
|
||||||
|
<>
|
||||||
|
<Row>
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<div style={{ fontSize: 13, color: 'var(--text-0)' }}>Reads today</div>
|
||||||
|
<div style={{ marginTop: 8, height: 6, background: 'var(--bg-3)', borderRadius: 3, overflow: 'hidden', maxWidth: 260 }}>
|
||||||
|
<div style={{ width: `${Math.min(100, Math.round(((meter.used ?? 0) / meter.limit) * 100))}%`, height: '100%', background: (meter.remaining ?? 1) <= 0 ? 'var(--miss)' : 'var(--g-a)', borderRadius: 3 }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span className="mono" style={{ fontSize: 13, fontWeight: 700, color: (meter.remaining ?? 1) <= 0 ? 'var(--miss)' : 'var(--text-0)' }}>
|
||||||
|
{meter.used ?? 0} of {meter.limit}
|
||||||
|
</span>
|
||||||
|
</Row>
|
||||||
|
<Divider />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
<Row>
|
<Row>
|
||||||
<div>
|
<div>
|
||||||
<div style={{ fontSize: 14, fontWeight: 600, color: '#fff' }}>VYNDR {plan.label === 'FREE' ? 'Free' : plan.label === 'DESK' ? 'Desk' : 'Analyst'}</div>
|
<div style={{ fontSize: 14, fontWeight: 600, color: '#fff' }}>VYNDR {plan.label === 'FREE' ? 'Free' : plan.label === 'DESK' ? 'Desk' : 'Analyst'}</div>
|
||||||
<div className="mono" style={{ fontSize: 12, color: 'var(--text-1)', marginTop: 3 }}>{plan.label === 'FREE' ? '5 scans / month' : 'Active subscription'}</div>
|
<div className="mono" style={{ fontSize: 12, color: 'var(--text-1)', marginTop: 3 }}>
|
||||||
|
{meter && !meter.unlimited && meter.limit != null ? `${meter.limit} reads / day` : plan.label === 'FREE' ? '3 reads / day' : 'Active subscription'}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', gap: 9 }}>
|
<div style={{ display: 'flex', gap: 9 }}>
|
||||||
{plan.label !== 'FREE' && (
|
{plan.label !== 'FREE' && (
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { useRouter } from 'next/navigation';
|
|||||||
import SportBadge from '@/components/vyndr/SportBadge';
|
import SportBadge from '@/components/vyndr/SportBadge';
|
||||||
import GradeBadge from '@/components/vyndr/GradeBadge';
|
import GradeBadge from '@/components/vyndr/GradeBadge';
|
||||||
import ArchetypeBadge from '@/components/vyndr/ArchetypeBadge';
|
import ArchetypeBadge from '@/components/vyndr/ArchetypeBadge';
|
||||||
|
import ModelRecord from '@/components/vyndr/ModelRecord';
|
||||||
import { playerHref } from '@/lib/playerHref';
|
import { playerHref } from '@/lib/playerHref';
|
||||||
import { useParlay, legKey } from '@/contexts/ParlayContext';
|
import { useParlay, legKey } from '@/contexts/ParlayContext';
|
||||||
|
|
||||||
@@ -112,6 +113,12 @@ export default function TeamHub({ abbr, sport }: { abbr: string; sport: string }
|
|||||||
{data.record && <span className="mono" style={{ fontSize: 13, color: 'var(--text-1)' }}>{data.record.wins}-{data.record.losses}</span>}
|
{data.record && <span className="mono" style={{ fontSize: 13, color: 'var(--text-1)' }}>{data.record.wins}-{data.record.losses}</span>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Session 60 (5.2, migration 020) — VYNDR-on-team: the model's settled
|
||||||
|
record on THIS team's players. Deferred-render until rows exist. */}
|
||||||
|
<div style={{ margin: '0 0 16px' }}>
|
||||||
|
<ModelRecord team={data.team.name} sport={data.team.sport} align="left" />
|
||||||
|
</div>
|
||||||
|
|
||||||
{data.note && <p className="mono" style={{ fontSize: 12, color: 'var(--text-2)', marginBottom: 16 }}>{data.note}</p>}
|
{data.note && <p className="mono" style={{ fontSize: 12, color: 'var(--text-2)', marginBottom: 16 }}>{data.note}</p>}
|
||||||
|
|
||||||
{/* Controls */}
|
{/* Controls */}
|
||||||
|
|||||||
@@ -27,10 +27,13 @@ interface Aggregate {
|
|||||||
export default function ModelRecord({
|
export default function ModelRecord({
|
||||||
sport,
|
sport,
|
||||||
player,
|
player,
|
||||||
|
team,
|
||||||
align = 'center',
|
align = 'center',
|
||||||
}: {
|
}: {
|
||||||
sport?: string;
|
sport?: string;
|
||||||
player?: string;
|
player?: string;
|
||||||
|
/** VYNDR-on-team (5.2) — exact team name as the feed writes it. */
|
||||||
|
team?: string;
|
||||||
align?: 'center' | 'left';
|
align?: 'center' | 'left';
|
||||||
}) {
|
}) {
|
||||||
const [agg, setAgg] = useState<Aggregate | null>(null);
|
const [agg, setAgg] = useState<Aggregate | null>(null);
|
||||||
@@ -41,6 +44,7 @@ export default function ModelRecord({
|
|||||||
const params = new URLSearchParams({ limit: '1' });
|
const params = new URLSearchParams({ limit: '1' });
|
||||||
if (sport) params.set('sport', sport.toLowerCase());
|
if (sport) params.set('sport', sport.toLowerCase());
|
||||||
if (player) params.set('player', player);
|
if (player) params.set('player', player);
|
||||||
|
if (team) params.set('team', team);
|
||||||
fetch(`/api/ledger/model?${params}`)
|
fetch(`/api/ledger/model?${params}`)
|
||||||
.then((r) => (r.ok ? r.json() : null))
|
.then((r) => (r.ok ? r.json() : null))
|
||||||
.then((data) => {
|
.then((data) => {
|
||||||
@@ -50,7 +54,7 @@ export default function ModelRecord({
|
|||||||
})
|
})
|
||||||
.catch(() => { /* self-hide */ });
|
.catch(() => { /* self-hide */ });
|
||||||
return () => { active = false; };
|
return () => { active = false; };
|
||||||
}, [sport, player]);
|
}, [sport, player, team]);
|
||||||
|
|
||||||
if (!agg) return null;
|
if (!agg) return null;
|
||||||
const ready = agg.settled >= minSample && agg.hit_pct != null;
|
const ready = agg.settled >= minSample && agg.hit_pct != null;
|
||||||
|
|||||||
Reference in New Issue
Block a user