diff --git a/.gitignore b/.gitignore index 24c34a4..1c0dad9 100644 --- a/.gitignore +++ b/.gitignore @@ -19,3 +19,7 @@ data/*.db-wal .vscode/ node_modules/ + +.claude/ +CLAUDE.md +BLUEPRINT.md diff --git a/config.py b/config.py index 8e064ca..6483236 100644 --- a/config.py +++ b/config.py @@ -1,6 +1,7 @@ """All configurations.""" import os +from datetime import date NFL_BASE_PATH = os.path.dirname(os.path.abspath(__file__)) DATA_FOLDER_PATH = os.path.join(NFL_BASE_PATH, "data") @@ -17,7 +18,45 @@ ) # Default "historical seasons'" thresholds applied on page load. -DEFAULT_THRESHOLDS = { +DEFAULT_THRESHOLDS: dict[str, int] = { "pass_rush": 230, # Min PR Opp for pass rush filter. "pass_block": 300, # Min Non Spike PB Snaps for pass block filter. } + +# Dynamic threshold for the current season in progress. +# Set thresholds BY HAND each week during data ingestion. +# Eyeballed against how far the season has actually progressed. + +# Deliberately NOT derived from a fraction-of-season-elapsed formula. +# Because season pacing is not uniform. But — the final end is quite stable throughout years. +# Hereby, we use static thresholds for already ended seasons. + +# Add/edit current year's entry weekly; once a season ends, leave it in place. +# With a season's end, resolve_default_threshold() falls back to static +# DEFAULT_THRESHOLDS automatically after entering next following calendar year. +# Key, which means current season, must be "renamed" when a new season comes. +DYNAMIC_THRESHOLDS: dict[int, dict[str, int]] = { + 2026: { + "pass_rush": 20, # Min PR Opp for pass rush filter. + "pass_block": 30, # Min Non Spike PB Snaps for pass block filter. + }, +} + + +def resolve_default_threshold( + category: str, season: int, *, today: date | None = None +) -> int: + """Static preset for a finalized season, dynamic preset for the season + currently in progress. Routing is a Calendar Year comparison + (season_year vs. today's year), not schedule-aware. + Regular season is ~90% complete before New Year's Day, so this is an accepted + near-100%-accurate approximation. No need to be perfectly precise. + """ + current_year = (today or date.today()).year + + if season == current_year: + dynamic = DYNAMIC_THRESHOLDS.get(season, {}) + if category in dynamic: + return dynamic[category] + + return DEFAULT_THRESHOLDS[category] diff --git a/database/db_ingestion.py b/database/db_ingestion.py index 4463ac3..7f7c3f4 100644 --- a/database/db_ingestion.py +++ b/database/db_ingestion.py @@ -23,10 +23,12 @@ import argparse import os import sys +from datetime import date from pathlib import Path import pandas as pd from sqlalchemy import delete from sqlalchemy.orm import Session +from config import DYNAMIC_THRESHOLDS from database.db_models import Base, PassBlockStat, PassRushStat, Team from main import engine, SessionLocal from teams_reference import TEAMS @@ -241,6 +243,17 @@ def main() -> None: f"\nBulk-inserted {pass_rush_rows} pass-rush rows and {pass_block_rows} pass-block rows." ) + # In-progress season's threshold is hand-eyeballed weekly, not + # auto-computed (see config.py's DYNAMIC_THRESHOLDS). + # Nudge rather than silently falling back if this week's ingestion covers + # the current season and nobody's touched it yet. + current_year = date.today().year + if current_year in args.seasons and current_year not in DYNAMIC_THRESHOLDS: + print( + f"Reminder: {current_year} has no entry in config.DYNAMIC_THRESHOLDS yet — " + "falling back to the static default thresholds until you set one." + ) + if __name__ == "__main__": main() diff --git a/frontend/dom.js b/frontend/dom.js index 818597a..c318258 100644 --- a/frontend/dom.js +++ b/frontend/dom.js @@ -35,6 +35,10 @@ export const els = { playersChips: document.getElementById("players-chips"), playersInput: document.getElementById("players-input"), playersDropdown: document.getElementById("players-dropdown"), + // Below-threshold-matches popup — see openBelowThresholdPopup() in filters.js. + belowThresholdOverlay: document.getElementById("below-threshold-overlay"), + belowThresholdList: document.getElementById("below-threshold-list"), + belowThresholdClose: document.getElementById("below-threshold-close"), chart: document.getElementById("chart"), chartPanel: document.querySelector(".chart-panel"), emptyState: document.getElementById("empty-state"), diff --git a/frontend/filters.js b/frontend/filters.js index 3ae2fe3..9ed65d7 100644 --- a/frontend/filters.js +++ b/frontend/filters.js @@ -16,6 +16,8 @@ */ import { els } from "./dom.js"; import { + metadata, + playerPoolCategory, appliedFilters, currentRecords, fetchSlice, @@ -29,7 +31,7 @@ import { teamName, allTeamCodes, } from "./data.js"; -import { searchPlayersExcluding, qualifyingPlayerPool } from "./search.js"; +import { searchPlayersExcluding, qualifyingPlayerPool, fullPlayerPool } from "./search.js"; import { CONFERENCES } from "./config.js"; import { render } from "./render.js"; import { refreshOpenScoutCards } from "./scout-card.js"; @@ -61,6 +63,15 @@ export function setResetThresholdOnNextRange(v) { // Apply snapshots it into appliedFilters.players (see currentFilterState()). export const selectedPlayers = new Map(); +// Below-threshold-matches popup (surfaces a name match that exists but +// doesn't clear the current threshold, rather than leaving that +// indistinguishable from "no such player" — see runPlayersSearch()). A +// generous cap since this is a scrollable popup, not the 8-item autocomplete +// dropdown — just enough to keep a single-letter query from dumping the +// whole pool into it. +const BELOW_THRESHOLD_TOP_K = 20; +let belowThresholdMatches = []; + export function currentFilterState() { return { category: els.category.value, @@ -237,11 +248,24 @@ export function hidePlayersDropdown() { export function renderPlayersDropdown(matches) { els.playersDropdown.innerHTML = ""; - if (!matches.length) { + if (!matches.length && !belowThresholdMatches.length) { hidePlayersDropdown(); return; } + // Sits above the first normal candidate, not among them — it opens a + // popup rather than picking a player, so it's styled/behaves distinctly + // from a .player-option row. Renders only when there's at least one + // below-threshold match; N mirrors whatever runPlayersSearch() just found. + if (belowThresholdMatches.length > 0) { + const toggle = document.createElement("button"); + toggle.type = "button"; + toggle.className = "below-threshold-toggle"; + toggle.textContent = `Below Threshold Matches (${belowThresholdMatches.length})`; + toggle.addEventListener("click", () => openBelowThresholdPopup()); + els.playersDropdown.appendChild(toggle); + } + matches.forEach((record) => { const opt = document.createElement("button"); opt.type = "button"; @@ -293,12 +317,121 @@ export function renderPlayersDropdown(matches) { export function runPlayersSearch() { const query = els.playersInput.value.trim(); if (!query) { + belowThresholdMatches = []; hidePlayersDropdown(); return; } + + // Two independent passes, not one filtered afterward — searchPlayers() + // over qualifyingPlayerPool() stays exactly as it was (the normal + // dropdown's ranking must not shift just because this feature exists, see + // fullPlayerPool()'s header comment). This second pass runs the same + // fuzzy matcher against the unfiltered pool and keeps only what didn't + // already clear the bar, so it can never overlap with the normal results. + if (playerPoolCategory) { + const cat = metadata[playerPoolCategory]; + const minThreshold = Number(els.thresholdNumber.value); + const fullMatches = searchPlayers(query, fullPlayerPool(), BELOW_THRESHOLD_TOP_K); + belowThresholdMatches = fullMatches.filter((r) => r[cat.threshold_field] < minThreshold); + } else { + belowThresholdMatches = []; + } + renderPlayersDropdown(searchPlayers(query, qualifyingPlayerPool())); } +// Single static overlay, repopulated per open — same convention as +// #merge-edit-overlay (see its header comment in index.html): no +// outside-click dismissal, closes only via its own Close button (wired once +// in main.js's attachEvents()). Reads the module-level belowThresholdMatches +// captured by the runPlayersSearch() call that rendered the toggle button, +// rather than taking a matches argument, since it can only ever be opened +// from that button. +export function openBelowThresholdPopup() { + const cat = metadata[playerPoolCategory]; + els.belowThresholdList.innerHTML = ""; + + belowThresholdMatches.forEach((record) => { + const row = document.createElement("div"); + row.className = "below-threshold-row"; + + const info = document.createElement("div"); + info.className = "below-threshold-row-info"; + + const name = document.createElement("span"); + name.className = "player-option-name"; + name.textContent = record.player; + + const team = document.createElement("span"); + team.className = "player-option-team"; + const logo = document.createElement("img"); + logo.className = "player-option-logo"; + logo.src = logoSrc(record.team); + logo.alt = ""; + logo.loading = "lazy"; + logo.onerror = () => logo.replaceWith(teamSwatch(record.team)); + const code = document.createElement("span"); + code.textContent = record.team; + team.append(logo, code); + + info.append(name, team); + + const value = document.createElement("span"); + value.className = "below-threshold-row-value"; + value.textContent = `${record[cat.threshold_field]} ${cat.threshold_field}`; + + const setBtn = document.createElement("button"); + setBtn.type = "button"; + setBtn.className = "below-threshold-row-btn"; + setBtn.textContent = "Set Threshold"; + setBtn.addEventListener("click", () => { + setThresholdTo(record[cat.threshold_field]); + closeBelowThresholdPopup(); + // Query text is left untouched (see els.playersInput.value — nothing + // in this flow writes to it), so this re-run surfaces the + // just-qualified player in the normal dropdown immediately, same as + // if the user had retyped the query. + runPlayersSearch(); + }); + + row.append(info, value, setBtn); + els.belowThresholdList.appendChild(row); + }); + + els.belowThresholdOverlay.hidden = false; +} + +export function closeBelowThresholdPopup() { + els.belowThresholdOverlay.hidden = true; + els.belowThresholdList.innerHTML = ""; +} + +// Sets the threshold to exactly `value` — the filter is an inclusive `>=` +// (see qualifyingPlayerPool()), so a candidate's own metric value is already +// the minimum threshold that surfaces them, no off-by-one adjustment needed. +// Mirrors the manual slider/number-input handlers in main.js's attachEvents() +// (sync both controls, clamp to range, mark the value as an explicit +// override, re-check selections) rather than reimplementing that logic here. +// Deliberately does NOT call runPlayersSearch() itself — every existing +// caller of this same clamp/sync/prune sequence (the manual handlers) leaves +// that to whatever's calling it, since a threshold edit doesn't always +// happen with the Players dropdown open. +export function setThresholdTo(value) { + const min = Number(els.threshold.min); + const max = Number(els.threshold.max); + const step = Number(els.threshold.step) || 1; + const clamped = Math.min(Math.max(value, min), max); + + els.thresholdNumber.value = clamped; + // Slider snaps to the nearest step just to keep the handle in sync + // visually — filtering itself reads thresholdNumber.value (the exact + // value), same split as the manual number-input handler in main.js. + els.threshold.value = min + Math.round((clamped - min) / step) * step; + setResetThresholdOnNextRange(false); + prunePlayerSelections(); + updatePendingState(); +} + // Called whenever the qualifying pool can have shrunk — live threshold // edits, and live category/season/position changes via updatePlayerPool() // (e.g. switching Position from ED to DI drops any ED-only chip immediately, @@ -435,7 +568,7 @@ export function updateThresholdRange() { els.threshold.step = 5; if (resetThresholdOnNextRange) { - const defaultValue = cat.default_threshold ?? 0; + const defaultValue = cat.default_thresholds[els.season.value] ?? 0; els.threshold.value = Math.min(Math.max(defaultValue, 0), max); resetThresholdOnNextRange = false; } else if (Number(els.threshold.value) > max) { @@ -454,8 +587,10 @@ export function updateThresholdRange() { // clamps the user's existing value — the two threshold_field scales (PR Opp // vs Non Spike PB Snaps) aren't comparable, so leaving the outgoing // category's number on screen until Apply would be actively misleading. -// Always snaps to the new category's configured default and never tries to -// preserve whatever the user had set for the outgoing category. The +// Always snaps to the new category's configured default for whatever season +// is currently selected (see config.py's resolve_default_threshold — a +// season may carry a static or dynamic default) and never tries to preserve +// whatever the user had set for the outgoing category. The // accurate slider max (which needs the new category's fetched data) still // gets recomputed at Apply time via loadCurrentSlice()/updateThresholdRange() // — this only fixes what's on screen immediately. Sets both the slider and @@ -463,7 +598,7 @@ export function updateThresholdRange() { // other, same as every other place both controls change at once. export function resetThresholdToCategoryDefault() { const cat = currentCategoryMeta(); - const defaultValue = cat.default_threshold ?? 0; + const defaultValue = cat.default_thresholds[els.season.value] ?? 0; // The outgoing category's slider max may be smaller than the incoming // default (e.g. a narrow DL pool's max sitting below OL's 300 default) — // extend it so the browser doesn't silently clamp the value we're about diff --git a/frontend/index.html b/frontend/index.html index 266530d..3403636 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -507,6 +507,29 @@

Line Summary

+ + + diff --git a/frontend/main.js b/frontend/main.js index 12c3f8b..d1206aa 100644 --- a/frontend/main.js +++ b/frontend/main.js @@ -29,6 +29,7 @@ import { closePlayersPanel, hidePlayersDropdown, runPlayersSearch, + closeBelowThresholdPopup, prunePlayerSelections, updatePendingState, updatePlayerPool, @@ -387,6 +388,11 @@ function attachEvents() { }); els.mergeEditDone.addEventListener("click", closeMergeEditPopup); + // Below Threshold Matches popup — see openBelowThresholdPopup() in + // filters.js. Same "closes only via its own button" convention as the + // merge-edit overlays above. + els.belowThresholdClose.addEventListener("click", closeBelowThresholdPopup); + // Deliberately no "click outside closes the card" handler here, unlike the // Teams/Players dropdowns and the filters drawer below. Those are // transient single-purpose overlays where dismissing on an outside click diff --git a/frontend/search.js b/frontend/search.js index 35875f6..bf7770e 100644 --- a/frontend/search.js +++ b/frontend/search.js @@ -174,6 +174,18 @@ export function qualifyingPlayerPool() { ); } +// Same pool as qualifyingPlayerPool() but without the threshold cut — the +// below-threshold-matches popup fuzzy-matches against this instead, so a +// name that exists but falls under the current threshold can still be told +// apart from a name that doesn't exist at all. Deliberately a separate pool +// rather than threading a "skip the threshold check" flag through +// qualifyingPlayerPool() itself, so the normal dropdown's +// searchPlayers(query, qualifyingPlayerPool()) call site is untouched. +export function fullPlayerPool() { + if (!playerPoolCategory) return []; + return playerPoolRecords.filter((r) => r.position === els.position.value); +} + // Top `topK` matches for `query` among `pool`, excluding any player key in // `excludeKeys`. Search runs against the full "player" field (e.g. "Will // Anderson Jr."), never "abbr_name" ("W. Anderson Jr.") — abbr_name exists diff --git a/frontend/style.css b/frontend/style.css index 84d86f5..2ef9c23 100644 --- a/frontend/style.css +++ b/frontend/style.css @@ -542,13 +542,13 @@ body { /* No flex-grow (unlike .control-slider) — this used to grow to fill whatever space was left in the row, which was wide enough to push Y axis onto a second row even though there was nominally "one row" worth of - controls. Teams/Category/Position all settle at exactly 150px via their - select/button's min-width, with no explicit width needed, because their - content never exceeds it — but a bare has its own UA intrinsic - width (~160px+) that isn't capped by flex-basis when this box's own - width is auto, which used to make Players noticeably wider than its - neighbors before the chip list moved into its own panel. Fixed at the - same 150px explicitly so the collapsed button can't inflate. */ + controls. Teams/Category/Position all settle at exactly 150px via an + explicit width on their select/button (see .control select and + .teams-select-btn) — but a bare has its own UA intrinsic + width (~160px+) that isn't capped by flex-basis alone, which used to make + Players noticeably wider than its neighbors before the chip list moved + into its own panel. Fixed at the same 150px explicitly so the collapsed + button can't inflate. */ .control-players { position: relative; width: 150px; } /* Replaces the plain