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);
|
||||
// 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);
|
||||
|
||||
@@ -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 },
|
||||
};
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
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) {
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user