Sessions 5-7a: 955 tests, deployment ready
This commit is contained in:
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* Referee impact signal.
|
||||
*
|
||||
* Game-day ref assignments live in `game_ref_assignments` (migration 017).
|
||||
* Per-referee tendencies live in `ref_profiles`, populated by the
|
||||
* Sports-Reference scraper (scripts/scrape-sports-reference.js).
|
||||
*
|
||||
* Crew impact is computed by averaging the three refs' profiles. If any
|
||||
* profile is missing we still return a partial impact (averaging only the
|
||||
* available refs) — the feature cache decides whether to surface the
|
||||
* feature or omit it based on coverage.
|
||||
*/
|
||||
|
||||
const { getSupabaseServiceClient } = require('../../utils/supabase');
|
||||
|
||||
const LOOPBACK_IPS = new Set(['127.0.0.1', '::1', '::ffff:127.0.0.1']);
|
||||
|
||||
async function getRefAssignment(gameId) {
|
||||
const supabase = getSupabaseServiceClient();
|
||||
const { data, error } = await supabase
|
||||
.from('game_ref_assignments')
|
||||
.select('ref1_name, ref2_name, ref3_name, ref_crew_avg_fouls, ref_crew_pace_impact')
|
||||
.eq('game_id', gameId)
|
||||
.maybeSingle();
|
||||
if (error) {
|
||||
console.warn('[refSignals] assignment lookup failed:', error.message);
|
||||
return null;
|
||||
}
|
||||
return data || null;
|
||||
}
|
||||
|
||||
async function getRefProfiles(refNames) {
|
||||
const named = refNames.filter(Boolean);
|
||||
if (named.length === 0) return [];
|
||||
const supabase = getSupabaseServiceClient();
|
||||
const { data, error } = await supabase
|
||||
.from('ref_profiles')
|
||||
.select('ref_name, avg_fouls_per_game, avg_free_throws_per_game, pace_impact, home_whistle_bias')
|
||||
.in('ref_name', named);
|
||||
if (error) {
|
||||
console.warn('[refSignals] profile lookup failed:', error.message);
|
||||
return [];
|
||||
}
|
||||
return data || [];
|
||||
}
|
||||
|
||||
function average(values) {
|
||||
const clean = values.filter((v) => Number.isFinite(v));
|
||||
if (clean.length === 0) return null;
|
||||
return clean.reduce((a, b) => a + b, 0) / clean.length;
|
||||
}
|
||||
|
||||
async function getRefImpact(gameId) {
|
||||
const assignment = await getRefAssignment(gameId);
|
||||
if (!assignment) return null;
|
||||
const crew = [assignment.ref1_name, assignment.ref2_name, assignment.ref3_name].filter(Boolean);
|
||||
if (crew.length === 0) return null;
|
||||
|
||||
// If precomputed crew values exist on the assignment row (scraper wrote
|
||||
// them), prefer those — they were derived from the same profiles but
|
||||
// baked at assignment time. Note: Number(null) === 0 is finite, so guard
|
||||
// explicitly against null/undefined before going through Number().
|
||||
const hasFouls = assignment.ref_crew_avg_fouls != null && Number.isFinite(Number(assignment.ref_crew_avg_fouls));
|
||||
const hasPace = assignment.ref_crew_pace_impact != null && Number.isFinite(Number(assignment.ref_crew_pace_impact));
|
||||
if (hasFouls || hasPace) {
|
||||
return {
|
||||
crew,
|
||||
avg_fouls: assignment.ref_crew_avg_fouls,
|
||||
pace_impact: assignment.ref_crew_pace_impact,
|
||||
foul_adjustment: assignment.ref_crew_avg_fouls,
|
||||
home_bias: null,
|
||||
profilesUsed: crew.length,
|
||||
};
|
||||
}
|
||||
|
||||
const profiles = await getRefProfiles(crew);
|
||||
return {
|
||||
crew,
|
||||
avg_fouls: average(profiles.map((p) => p.avg_fouls_per_game)),
|
||||
pace_impact: average(profiles.map((p) => p.pace_impact)),
|
||||
foul_adjustment: average(profiles.map((p) => p.avg_free_throws_per_game)),
|
||||
home_bias: average(profiles.map((p) => p.home_whistle_bias)),
|
||||
profilesUsed: profiles.length,
|
||||
};
|
||||
}
|
||||
|
||||
// Manual entry endpoint helper — the route module (not built here) calls
|
||||
// this when ops POSTs an assignment.
|
||||
async function setRefAssignment(gameId, sport, gameDate, refs) {
|
||||
const supabase = getSupabaseServiceClient();
|
||||
// Pull profiles synchronously to precompute crew impact at insert time so
|
||||
// downstream reads don't need a join.
|
||||
const profiles = await getRefProfiles(refs);
|
||||
const avgFouls = average(profiles.map((p) => p.avg_fouls_per_game));
|
||||
const paceImpact = average(profiles.map((p) => p.pace_impact));
|
||||
const { error } = await supabase
|
||||
.from('game_ref_assignments')
|
||||
.upsert({
|
||||
game_id: gameId,
|
||||
sport,
|
||||
game_date: gameDate,
|
||||
ref1_name: refs[0] || null,
|
||||
ref2_name: refs[1] || null,
|
||||
ref3_name: refs[2] || null,
|
||||
ref_crew_avg_fouls: avgFouls,
|
||||
ref_crew_pace_impact: paceImpact,
|
||||
}, { onConflict: 'game_id' });
|
||||
if (error) {
|
||||
console.warn('[refSignals] assignment upsert failed:', error.message);
|
||||
return { ok: false, error: error.message };
|
||||
}
|
||||
return { ok: true, avg_fouls: avgFouls, pace_impact: paceImpact };
|
||||
}
|
||||
|
||||
module.exports = { getRefImpact, getRefAssignment, getRefProfiles, setRefAssignment, LOOPBACK_IPS };
|
||||
Reference in New Issue
Block a user