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
{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