diff --git a/CLAUDE.md b/CLAUDE.md
index 1da9d0a..7d416e0 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -24,6 +24,9 @@ Before any task, read `playbook/INVARIANTS.md`. It holds the working rules (git,
PFF data, frontend limits, versioning) and the invariants that must stay true. Then use the table below to find the
detail for your task.
+Read it again in full at the start of every new session and after every context compaction, before editing any file: a summary
+of an earlier conversation is not a substitute for the rules, and a rule recalled from memory may be incomplete.
+
To find a work item or an architecture file, do not open whole folders. First read the top of each file (the title and one-line summary,
or the "Load when" line), then open only the ones that apply. The commands are in `playbook/ROSTER.md`.
diff --git a/README.md b/README.md
index b5f63cd..eac85ed 100644
--- a/README.md
+++ b/README.md
@@ -24,7 +24,7 @@


-
+


diff --git a/config.py b/config.py
index c697e34..d64f997 100644
--- a/config.py
+++ b/config.py
@@ -20,6 +20,13 @@ class League(str, Enum):
NCAAF = "ncaaf"
+class CareerStatus(str, Enum):
+ """Who is on the college Career page. The value is what the table stores and the page shows."""
+
+ JOINED_NFL = "Joined NFL"
+ PROSPECTS = "Prospects"
+
+
LEAGUE_DATA_PATHS: dict[League, str] = {
League.NFL: NFL_DATA_PATH,
League.NCAAF: NCAAF_DATA_PATH,
@@ -39,9 +46,9 @@ class League(str, Enum):
"Allowed Havoc Rate = (Sacks + QB Hits) / Non Spike Pass Block Snaps."
)
-PRP_NOTE = "PRP = (Sacks + 0.75 x (QB Hits + Hurries)) / Pass Rush Snaps x 100."
+PRP_NOTE = "PRP = (Sacks + 0.5 x (QB Hits + Hurries)) / Pass Rush Opportunities x 100."
PASS_COV_PERCENT_NOTE = "Pass Cov Percent = 100 - Pass Rush Percent, where Pass Rush Percent = Pass Rush Snaps / Pass Play Snaps x 100."
-PBE_NOTE = "PBE = 100 - (Sacks + 0.75 x (QB Hits + Hurries)) / Pass Block Snaps."
+PBE_NOTE = "PBE = 100 - (Sacks + 0.5 x (QB Hits + Hurries)) / Non Spike Pass Block Snaps x 100."
# Shared across every "TPS "-prefixed metric — not a per-metric formula, so
# it lives once here rather than duplicated into a TPS_*_NOTE per metric.
@@ -80,8 +87,41 @@ class League(str, Enum):
}
+# NCAAF thresholds. The NFL values do not fit: the NFL's 230 pass rush opportunities
+# lets only 12 to 17% of the college ED and DI players through (about 36% in the NFL),
+# so the college value is about 140; the NFL's 300 pass block snaps already fits the OL.
+# Starting values, confirmed by trying the real page; change them if they feel wrong.
+NCAAF_DEFAULT_THRESHOLDS: dict[str, int] = {
+ "pass_rush": 230, # Min PR Opp for pass rush filter.
+ "pass_block": 360, # Min Non Spike PB Snaps for pass block filter.
+}
+
+# Set BY HAND each week, like the NFL ones above. The 2026 entry is a placeholder
+# of about 2/3 of the static values (a college season has about 12 games).
+NCAAF_DYNAMIC_THRESHOLDS: dict[int, dict[str, int]] = {
+ 2026: {
+ "pass_rush": 120, # Min PR Opp for pass rush filter.
+ "pass_block": 205, # Min Non Spike PB Snaps for pass block filter.
+ },
+}
+
+LEAGUE_DEFAULT_THRESHOLDS: dict[League, dict[str, int]] = {
+ League.NFL: DEFAULT_THRESHOLDS,
+ League.NCAAF: NCAAF_DEFAULT_THRESHOLDS,
+}
+
+LEAGUE_DYNAMIC_THRESHOLDS: dict[League, dict[int, dict[str, int]]] = {
+ League.NFL: DYNAMIC_THRESHOLDS,
+ League.NCAAF: NCAAF_DYNAMIC_THRESHOLDS,
+}
+
+
def resolve_default_threshold(
- category: str, season: int, *, today: date | None = None
+ category: str,
+ season: int,
+ league: League = League.NFL,
+ *,
+ 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
@@ -92,8 +132,8 @@ def resolve_default_threshold(
current_year = (today or date.today()).year
if season == current_year:
- dynamic = DYNAMIC_THRESHOLDS.get(season, {})
+ dynamic = LEAGUE_DYNAMIC_THRESHOLDS[league].get(season, {})
if category in dynamic:
return dynamic[category]
- return DEFAULT_THRESHOLDS[category]
+ return LEAGUE_DEFAULT_THRESHOLDS[league][category]
diff --git a/config/__init__.py b/config/__init__.py
new file mode 100644
index 0000000..939e395
--- /dev/null
+++ b/config/__init__.py
@@ -0,0 +1,71 @@
+"""All configurations, split by what they describe.
+
+- `leagues`: the leagues, who is on the college Career page, and the data paths.
+- `metrics`: position names, rounding, and the notes that explain each metric.
+- `thresholds`: the default thresholds per league and the rule that picks one.
+
+Every name is re-exported here, so code writes `from config import League` and does
+not care which file defines it.
+"""
+
+from config.leagues import (
+ BASE_PATH,
+ DATA_FOLDER_PATH,
+ LEAGUE_DATA_PATHS,
+ NCAAF_DATA_PATH,
+ NFL_DATA_PATH,
+ CareerStatus,
+ League,
+)
+from config.metrics import (
+ ALLOWED_HAVOC_RATE_NOTE,
+ DISPLAY_DECIMALS,
+ FRONT_7_NAMES,
+ HAVOC_RATE_NOTE,
+ OL_NAMES,
+ PASS_COV_PERCENT_NOTE,
+ PBE_NOTE,
+ PRP_NOTE,
+ ROUNDING_DECIMALS,
+ TPS_NOTE,
+ TPS_PROPORTION_NOTE,
+)
+from config.thresholds import (
+ DEFAULT_THRESHOLDS,
+ DYNAMIC_THRESHOLDS,
+ LEAGUE_DEFAULT_THRESHOLDS,
+ LEAGUE_DYNAMIC_THRESHOLDS,
+ NCAAF_CAREER_DEFAULT_THRESHOLDS,
+ NCAAF_DEFAULT_THRESHOLDS,
+ NCAAF_DYNAMIC_THRESHOLDS,
+ resolve_default_threshold,
+)
+
+__all__ = [
+ "ALLOWED_HAVOC_RATE_NOTE",
+ "BASE_PATH",
+ "DATA_FOLDER_PATH",
+ "DEFAULT_THRESHOLDS",
+ "DISPLAY_DECIMALS",
+ "DYNAMIC_THRESHOLDS",
+ "FRONT_7_NAMES",
+ "HAVOC_RATE_NOTE",
+ "LEAGUE_DATA_PATHS",
+ "LEAGUE_DEFAULT_THRESHOLDS",
+ "LEAGUE_DYNAMIC_THRESHOLDS",
+ "NCAAF_CAREER_DEFAULT_THRESHOLDS",
+ "NCAAF_DATA_PATH",
+ "NCAAF_DEFAULT_THRESHOLDS",
+ "NCAAF_DYNAMIC_THRESHOLDS",
+ "NFL_DATA_PATH",
+ "OL_NAMES",
+ "PASS_COV_PERCENT_NOTE",
+ "PBE_NOTE",
+ "PRP_NOTE",
+ "ROUNDING_DECIMALS",
+ "TPS_NOTE",
+ "TPS_PROPORTION_NOTE",
+ "CareerStatus",
+ "League",
+ "resolve_default_threshold",
+]
diff --git a/config/leagues.py b/config/leagues.py
new file mode 100644
index 0000000..c5a6d50
--- /dev/null
+++ b/config/leagues.py
@@ -0,0 +1,34 @@
+"""The leagues, who is on the college Career page, and where the data lives."""
+
+import os
+from enum import Enum
+
+# Repo root: this file sits one folder below it, in config/.
+BASE_PATH = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+DATA_FOLDER_PATH = os.path.join(BASE_PATH, "data")
+
+# Each league keeps its files in its own subfolder:
+# data/{nfl,ncaaf}/{front_7_pass_rush,ol_pass_block}/.
+# DATA_FOLDER_PATH stays root of all data.
+NFL_DATA_PATH = os.path.join(DATA_FOLDER_PATH, "nfl")
+NCAAF_DATA_PATH = os.path.join(DATA_FOLDER_PATH, "ncaaf")
+
+
+class League(str, Enum):
+ """The leagues the data pipeline knows. The value is the data folder name."""
+
+ NFL = "nfl"
+ NCAAF = "ncaaf"
+
+
+class CareerStatus(str, Enum):
+ """Who is on the college Career page. The value is what the table stores and the page shows."""
+
+ JOINED_NFL = "Joined NFL"
+ PROSPECTS = "Prospects"
+
+
+LEAGUE_DATA_PATHS: dict[League, str] = {
+ League.NFL: NFL_DATA_PATH,
+ League.NCAAF: NCAAF_DATA_PATH,
+}
diff --git a/config/metrics.py b/config/metrics.py
new file mode 100644
index 0000000..21d0854
--- /dev/null
+++ b/config/metrics.py
@@ -0,0 +1,29 @@
+"""Position names, rounding, and the notes that explain each metric."""
+
+ROUNDING_DECIMALS = 3 # Better precision alleviates overlaps in plots.
+DISPLAY_DECIMALS = 1 # For UI displays only.
+
+
+FRONT_7_NAMES = {"DI": "Defensive Interior", "ED": "Edge", "LB": "Linebacker"}
+OL_NAMES = {"T": "Offensive Tackles", "G": "Guards", "C": "Centers"}
+
+
+HAVOC_RATE_NOTE = "Havoc Rate = (Sacks + QB Hits) / Pass Rush Opportunities."
+ALLOWED_HAVOC_RATE_NOTE = (
+ "Allowed Havoc Rate = (Sacks + QB Hits) / Non Spike Pass Block Snaps."
+)
+
+PRP_NOTE = "PRP = (Sacks + 0.5 x (QB Hits + Hurries)) / Pass Rush Opportunities x 100."
+PASS_COV_PERCENT_NOTE = "Pass Cov Percent = 100 - Pass Rush Percent, where Pass Rush Percent = Pass Rush Snaps / Pass Play Snaps x 100."
+TPS_PROPORTION_NOTE = "TPS Proportion % = TPS Non Spike Pass Block Snaps / Non Spike Pass Block Snaps x 100."
+PBE_NOTE = "PBE = 100 - (Sacks + 0.5 x (QB Hits + Hurries)) / Non Spike Pass Block Snaps x 100."
+
+# Shared across every "TPS "-prefixed metric — not a per-metric formula, so
+# it lives once here rather than duplicated into a TPS_*_NOTE per metric.
+# The frontend appends this to whatever a TPS metric's own note/formula
+# already says, keyed off the "TPS " prefix rather than a per-metric flag
+# (see main.py's /api/metadata: exposed once at the top level, not per metric).
+TPS_NOTE = (
+ "True Pass Set: excludes plays with less than 4 rushers, play action, "
+ "screens, short dropbacks and time-to-throws under 2 seconds."
+)
diff --git a/config/thresholds.py b/config/thresholds.py
new file mode 100644
index 0000000..5eb8c89
--- /dev/null
+++ b/config/thresholds.py
@@ -0,0 +1,97 @@
+"""The default thresholds per league, and the rule that picks one for a season."""
+
+from datetime import date
+from config.leagues import CareerStatus, League
+
+# Default "historical seasons'" thresholds applied on page load.
+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": 95, # Min PR Opp for pass rush filter.
+ "pass_block": 105, # Min Non Spike PB Snaps for pass block filter.
+ },
+}
+
+
+# NCAAF thresholds, their own because the NFL values left too many or too few players on
+# the college chart: the front 7 uses the same 230 as the NFL, the OL a stricter 360.
+# Set by the owner after trying the real page; change them if they feel wrong.
+NCAAF_DEFAULT_THRESHOLDS: dict[str, int] = {
+ "pass_rush": 230, # Min PR Opp for pass rush filter.
+ "pass_block": 360, # Min Non Spike PB Snaps for pass block filter.
+}
+
+# Set BY HAND each week, like the NFL ones above. The 2026 entry is a placeholder
+# of about 2/3 of the static values (a college season has about 12 games).
+NCAAF_DYNAMIC_THRESHOLDS: dict[int, dict[str, int]] = {
+ 2026: {
+ "pass_rush": 120, # Min PR Opp for pass rush filter.
+ "pass_block": 205, # Min Non Spike PB Snaps for pass block filter.
+ },
+}
+
+# The college Career page has no season, so no dynamic entry, and two thresholds per
+# category: a Joined NFL player must have played more college snaps than a Prospect,
+# whose few snaps (a young player) still make a projection worth showing. The
+# numbers count the merged career (summed PR Opp, summed Non Spike PB Snaps). The owner
+# tunes them by running the page locally; the front 7 pair was set that way, the OL pair
+# is still a starting point.
+NCAAF_CAREER_DEFAULT_THRESHOLDS: dict[str, dict[str, int]] = {
+ "pass_rush": {
+ CareerStatus.JOINED_NFL.value: 400,
+ CareerStatus.PROSPECTS.value: 300,
+ },
+ "pass_block": {
+ CareerStatus.JOINED_NFL.value: 650,
+ CareerStatus.PROSPECTS.value: 500,
+ },
+}
+
+LEAGUE_DEFAULT_THRESHOLDS: dict[League, dict[str, int]] = {
+ League.NFL: DEFAULT_THRESHOLDS,
+ League.NCAAF: NCAAF_DEFAULT_THRESHOLDS,
+}
+
+LEAGUE_DYNAMIC_THRESHOLDS: dict[League, dict[int, dict[str, int]]] = {
+ League.NFL: DYNAMIC_THRESHOLDS,
+ League.NCAAF: NCAAF_DYNAMIC_THRESHOLDS,
+}
+
+
+def resolve_default_threshold(
+ category: str,
+ season: int,
+ league: League = League.NFL,
+ *,
+ 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 = LEAGUE_DYNAMIC_THRESHOLDS[league].get(season, {})
+ if category in dynamic:
+ return dynamic[category]
+
+ return LEAGUE_DEFAULT_THRESHOLDS[league][category]
diff --git a/database/career.py b/database/career.py
new file mode 100644
index 0000000..6e83ec6
--- /dev/null
+++ b/database/career.py
@@ -0,0 +1,127 @@
+"""The college Career page's members, rebuilt from the season tables.
+
+Career Stats lists only two kinds of college player, and the two never overlap:
+
+- Joined NFL: the `player_id` also occurs in the NFL table of the same category.
+ A college player keeps his PFF id in the NFL files, so the id is the link.
+- Prospects: the player has a row in the latest college season and is
+ not Joined NFL.
+
+The membership is precomputed into one small table per category, so the website
+never runs the join. It holds no stat and no percentile: the Career row of each
+member is merged from the season tables at request time, and the browser ranks.
+
+Rebuilt whole (delete, then insert) at the end of every `--league ncaaf` ingestion,
+never by the NFL run, so a member added by the NFL tables since the last college
+run shows up only at the next college run. Run the NFL ingestion first when both
+run on the same day.
+"""
+
+from __future__ import annotations
+from dataclasses import dataclass
+from sqlalchemy import delete, func, select
+from sqlalchemy.orm import Session
+from config import CareerStatus
+from database.db_models import (
+ NCAAFPassBlockCareerMember,
+ NCAAFPassBlockStat,
+ NCAAFPassRushCareerMember,
+ NCAAFPassRushStat,
+ NFLPassBlockStat,
+ NFLPassRushStat,
+)
+
+
+@dataclass(frozen=True)
+class _CareerTables:
+ """The three tables one category's membership is built from and written to."""
+
+ label: str # Wording of the printed line.
+ college_model: type
+ nfl_model: type
+ member_model: type
+
+
+CAREER_TABLES = (
+ _CareerTables(
+ "pass rush",
+ NCAAFPassRushStat,
+ NFLPassRushStat,
+ NCAAFPassRushCareerMember,
+ ),
+ _CareerTables(
+ "pass block",
+ NCAAFPassBlockStat,
+ NFLPassBlockStat,
+ NCAAFPassBlockCareerMember,
+ ),
+)
+
+
+def rebuild_career_members(sess: Session, tables: _CareerTables) -> dict[str, int]:
+ """Replace one category's members and return how many there are per status."""
+ college_model = tables.college_model
+
+ joined_ids = set(
+ sess.scalars(
+ select(college_model.player_id)
+ .where(college_model.player_id.in_(select(tables.nfl_model.player_id)))
+ .distinct()
+ )
+ )
+
+ # The latest season comes from the table itself, never from the seasons just
+ # ingested, so back-filling an old season cannot change who is a Prospect.
+ latest_season = sess.scalar(select(func.max(college_model.season)))
+ latest_ids = set()
+
+ if latest_season is not None:
+ latest_ids = set(
+ sess.scalars(
+ select(college_model.player_id)
+ .where(college_model.season == latest_season)
+ .distinct()
+ )
+ )
+
+ prospect_ids = latest_ids - joined_ids
+
+ sess.execute(delete(tables.member_model))
+ sess.add_all(
+ [
+ tables.member_model(
+ player_id=player_id, status=CareerStatus.JOINED_NFL.value
+ )
+ for player_id in sorted(joined_ids)
+ ]
+ )
+ sess.add_all(
+ [
+ tables.member_model(
+ player_id=player_id, status=CareerStatus.PROSPECTS.value
+ )
+ for player_id in sorted(prospect_ids)
+ ]
+ )
+ sess.commit()
+
+ return {
+ CareerStatus.JOINED_NFL.value: len(joined_ids),
+ CareerStatus.PROSPECTS.value: len(prospect_ids),
+ }
+
+
+def rebuild_all_career_members(sess: Session) -> None:
+ """Rebuild the members of every category and print what each now holds."""
+ for tables in CAREER_TABLES:
+ counts = rebuild_career_members(sess, tables)
+ summary = ", ".join(f"{count} {status}" for status, count in counts.items())
+ print(f" career members, {tables.label}: {summary}")
+
+ nfl_row_count = sess.scalar(select(func.count()).select_from(tables.nfl_model))
+
+ if nfl_row_count == 0:
+ print(
+ f" Warning: the NFL {tables.label} table is empty, so nobody can be "
+ "Joined NFL. Run the NFL ingestion first."
+ )
diff --git a/database/career_merge.py b/database/career_merge.py
new file mode 100644
index 0000000..3399fe9
--- /dev/null
+++ b/database/career_merge.py
@@ -0,0 +1,239 @@
+"""One Career row per member, merged from the college season tables.
+
+Career Stats pools a player's seasons into a single row, so there is no season to
+rank in and no percentile stored: the browser ranks the merged rows. The merge is
+a `GROUP BY player_id` over every season the player has in the category's table.
+
+- Games and the opportunity or snap counts are summed.
+- A rate is a weighted average over its own denominator (PR Opp for the front 7,
+ Non Spike PB Snaps for the OL; the TPS versions use the TPS counts). The tables
+ hold rates, not counts, so `SUM(rate * weight) / SUM(weight)` is the rate of the
+ pooled counts, within the rounding of the stored rates.
+- Pass Cov Percent cannot be weighted by PR Opp, because PR Opp is not Pass Rush
+ Snaps. It is rebuilt from the four snap counts the college pass-rush table keeps:
+ 100 - SUM(pass rush snaps) / SUM(pass play snaps) x 100.
+
+The name, position, team and conference are those of the player's last college season.
+The columns come from `database/columns.py`, so a metric added there is merged too.
+"""
+
+from __future__ import annotations
+from dataclasses import dataclass
+from sqlalchemy import and_, case, func, select
+from sqlalchemy.orm import Session
+from config import ROUNDING_DECIMALS
+from database.columns import (
+ PASS_BLOCK_DATA_COLUMNS,
+ PASS_RUSH_DATA_COLUMNS,
+ DataColumn,
+ with_tps_proportion,
+)
+from database.db_models import (
+ NCAAFPassBlockCareerMember,
+ NCAAFPassBlockStat,
+ NCAAFPassRushCareerMember,
+ NCAAFPassRushStat,
+)
+
+
+@dataclass(frozen=True)
+class CareerSpec:
+ """What one category's Career merge reads, and how each column is combined."""
+
+ college_model: type
+ member_model: type
+ data_columns: tuple[DataColumn, ...]
+ weight_attr: str # The denominator of the rates.
+ tps_weight_attr: str # The denominator of the TPS rates.
+ sum_attrs: tuple[str, ...] # Columns that are plainly summed.
+ order_attr: str # The count a record is ordered by, descending.
+ # (column attr, rush snaps attr, pass play snaps attr) of each Pass Cov Percent column.
+ complement_snaps: tuple[tuple[str, str, str], ...] = ()
+ # Whether the record gets TPS Proportion % (the OL only), from its summed snap counts.
+ adds_tps_proportion: bool = False
+
+
+PASS_RUSH_CAREER = CareerSpec(
+ college_model=NCAAFPassRushStat,
+ member_model=NCAAFPassRushCareerMember,
+ data_columns=PASS_RUSH_DATA_COLUMNS,
+ weight_attr="pr_opp",
+ tps_weight_attr="tps_pr_opp",
+ sum_attrs=("games", "pr_opp", "tps_pr_opp"),
+ order_attr="pr_opp",
+ complement_snaps=(
+ ("pass_cov_percent", "pass_rush_snaps", "pass_play_snaps"),
+ ("tps_pass_cov_percent", "tps_pass_rush_snaps", "tps_pass_play_snaps"),
+ ),
+)
+
+PASS_BLOCK_CAREER = CareerSpec(
+ college_model=NCAAFPassBlockStat,
+ member_model=NCAAFPassBlockCareerMember,
+ data_columns=PASS_BLOCK_DATA_COLUMNS,
+ weight_attr="non_spike_pb_snaps",
+ tps_weight_attr="tps_non_spike_pb_snaps",
+ sum_attrs=("games", "non_spike_pb_snaps", "tps_non_spike_pb_snaps"),
+ order_attr="non_spike_pb_snaps",
+ adds_tps_proportion=True,
+)
+
+
+def _aggregate_expressions(spec: CareerSpec) -> dict:
+ """The SQL expression that merges each data column, keyed by its API key."""
+ model = spec.college_model
+ snaps_of = {attr: (rush, play) for attr, rush, play in spec.complement_snaps}
+ expressions = dict()
+
+ for column in spec.data_columns:
+ attr = getattr(model, column.attr)
+
+ if column.attr in spec.sum_attrs:
+ expressions[column.api_key] = func.sum(attr)
+
+ elif column.attr in snaps_of:
+ rush_attr, play_attr = snaps_of[column.attr]
+ rush_snaps = func.sum(getattr(model, rush_attr))
+ play_snaps = func.sum(getattr(model, play_attr))
+ expressions[column.api_key] = 100.0 - 100.0 * rush_snaps / func.nullif(
+ play_snaps, 0
+ )
+
+ else:
+ weight_name = (
+ spec.tps_weight_attr
+ if column.attr.startswith("tps_")
+ else spec.weight_attr
+ )
+
+ weight = getattr(model, weight_name)
+
+ # A season with no opportunities can hold an infinite rate (a pressure on a zero
+ # denominator). Infinity times zero is NaN on Postgres, so such seasons are skipped.
+ weighted_rate = case((weight > 0, attr * weight), else_=0)
+ expressions[column.api_key] = func.sum(weighted_rate) / func.nullif(
+ func.sum(weight), 0
+ )
+
+ return expressions
+
+
+def _last_season_rows(sess: Session, spec: CareerSpec) -> dict:
+ """Each member's row of his last college season, with his status.
+
+ A player can hold two rows in one season (two positions); the one with more
+ opportunities stands for him.
+ """
+ model = spec.college_model
+ member_model = spec.member_model
+
+ last_season = (
+ select(model.player_id, func.max(model.season).label("last_season"))
+ .group_by(model.player_id)
+ .subquery()
+ )
+
+ rows = sess.execute(
+ select(model, member_model.status)
+ .join(
+ last_season,
+ and_(
+ model.player_id == last_season.c.player_id,
+ model.season == last_season.c.last_season,
+ ),
+ )
+ .join(member_model, member_model.player_id == model.player_id)
+ ).all()
+
+ chosen = dict()
+
+ for row, status in rows:
+ current = chosen.get(row.player_id)
+ count = getattr(row, spec.order_attr) or 0
+
+ if current is None or count > (getattr(current[0], spec.order_attr) or 0):
+ chosen[row.player_id] = (row, status)
+
+ return chosen
+
+
+def last_season_conferences(sess: Session, spec: CareerSpec) -> dict[int, str]:
+ """The conference of each school a member last played for, for the Teams checklist."""
+ return {
+ row.team_code: row.conference
+ for row, _status in _last_season_rows(sess, spec).values()
+ }
+
+
+def merged_career_rows(
+ sess: Session, spec: CareerSpec, positions: tuple[str, ...]
+) -> list[dict]:
+ """The Career payload of every member whose last college position is in `positions`.
+
+ Records come ordered by the category's count, most first. A member with no row
+ left after the position filter (or none at all) is simply not listed.
+ """
+ model = spec.college_model
+ expressions = _aggregate_expressions(spec)
+ api_keys = list(expressions)
+ sum_api_keys = {
+ column.api_key for column in spec.data_columns if column.attr in spec.sum_attrs
+ }
+
+ aggregate_rows = sess.execute(
+ select(
+ model.player_id,
+ func.min(model.season),
+ func.max(model.season),
+ func.count(func.distinct(model.season)),
+ *expressions.values(),
+ )
+ .join(spec.member_model, spec.member_model.player_id == model.player_id)
+ .group_by(model.player_id)
+ ).all()
+
+ last_rows = _last_season_rows(sess, spec)
+ records = []
+
+ for player_id, first_season, last_season, season_count, *values in aggregate_rows:
+ last_row, status = last_rows[player_id]
+
+ if last_row.position not in positions:
+ continue
+
+ record = {
+ "id": player_id,
+ "player_id": player_id,
+ "player": last_row.player,
+ "abbr_name": last_row.abbr_name,
+ "position": last_row.position,
+ "team": last_row.team_code,
+ "conference": last_row.conference,
+ "status": status,
+ "first_season": first_season,
+ "last_season": last_season,
+ "seasons": season_count,
+ }
+
+ for api_key, value in zip(api_keys, values):
+ if value is None:
+ record[api_key] = None
+
+ elif api_key in sum_api_keys:
+ record[api_key] = int(value)
+
+ else:
+ record[api_key] = round(float(value), ROUNDING_DECIMALS)
+
+ if spec.adds_tps_proportion:
+ # A ratio of the summed counts, never the mean of the seasons' ratios.
+ with_tps_proportion(record)
+
+ records.append(record)
+
+ order_key = next(
+ column.api_key for column in spec.data_columns if column.attr == spec.order_attr
+ )
+ records.sort(key=lambda record: (-(record[order_key] or 0), record["player_id"]))
+
+ return records
diff --git a/database/columns.py b/database/columns.py
index d19c6dd..5487b21 100644
--- a/database/columns.py
+++ b/database/columns.py
@@ -77,6 +77,46 @@ class DataColumn:
)
+# The four snap counts behind Pass Cov Percent, kept only for the college Career merge
+# (Career Pass Cov Percent = 100 - the sum of the rush snaps over the sum of the pass plays,
+# which the stored percentage alone cannot rebuild). They are never part of the /api payload,
+# so they are not in PASS_RUSH_DATA_COLUMNS; the ingestion reads them as extra columns.
+NCAAF_PASS_RUSH_SNAP_COLUMNS = (
+ DataColumn("PR Snaps", "pass_rush_snaps", "PR Snaps", "int"),
+ DataColumn("Snap Counts Pass Play", "pass_play_snaps", "Pass Play Snaps", "int"),
+ DataColumn("TPS PR Snaps", "tps_pass_rush_snaps", "TPS PR Snaps", "int"),
+ DataColumn(
+ "TPS Snap Counts Pass Play",
+ "tps_pass_play_snaps",
+ "TPS Pass Play Snaps",
+ "int",
+ ),
+)
+
+
+# The OL-only share of pass block snaps that were true pass sets. It is never stored: the API
+# computes it from the two snap counts, so the season and Career payloads stay in step.
+TPS_PROPORTION_API_KEY = "TPS Proportion %"
+TPS_PROPORTION_DECIMALS = 1
+
+
+def tps_proportion_pct(tps_snaps: int | None, snaps: int | None) -> float | None:
+ """TPS Non Spike PB Snaps / Non Spike PB Snaps x 100, or None when it is undefined."""
+ if tps_snaps is None or not snaps:
+ return None
+
+ return round(100.0 * tps_snaps / snaps, TPS_PROPORTION_DECIMALS)
+
+
+def with_tps_proportion(payload: dict) -> dict:
+ """The pass block payload with its TPS Proportion % added, from its two snap counts."""
+ payload[TPS_PROPORTION_API_KEY] = tps_proportion_pct(
+ payload.get("TPS Non Spike PB Snaps"), payload.get("Non Spike PB Snaps")
+ )
+
+ return payload
+
+
def required_excel_headers(data_columns: tuple[DataColumn, ...]) -> tuple[str, ...]:
"""The headers a sheet must have: the identity columns plus every data column."""
return IDENTITY_EXCEL_HEADERS + tuple(
diff --git a/database/db_ingestion.py b/database/db_ingestion.py
index ad0dfff..7722804 100644
--- a/database/db_ingestion.py
+++ b/database/db_ingestion.py
@@ -1,10 +1,14 @@
"""Ingest preprocessed PFF Excel workbooks into database.
-Reads `nfl/front_7_pass_rush/{season}.xlsx` and
-`nfl/ol_pass_block/{season}.xlsx` files (under the data root) that
+Reads `{league}/front_7_pass_rush/{season}.xlsx` and
+`{league}/ol_pass_block/{season}.xlsx` files (under the data root) that
`preprocessing/front_7.py` and `preprocessing/offensive_line.py` already
-produce, and bulk-inserts them into the `nfl_pass_rush_stats` /
-`nfl_pass_block_stats` tables.
+produce, and bulk-inserts them into that league's tables: `nfl_pass_rush_stats` /
+`nfl_pass_block_stats` or `ncaaf_pass_rush_stats` / `ncaaf_pass_block_stats`.
+The league is always named (`--league`), so the wrong one is never loaded by accident.
+
+The college run (`--league ncaaf`) also rebuilds the Career page's member tables
+(`database/career.py`) after loading, so run the NFL ingestion first when both run.
Strict: a sheet that lacks any column the tables need, or a row without a
`Player Id`, stops the run with an error instead of loading NULLs.
@@ -16,17 +20,18 @@
Usage:
1. Must always go from project root.
2. After running two preprocessing scripts.
-3. python -m database.db_ingestion
+3. python -m database.db_ingestion --league nfl
For Railway, set DATABASE_URL to Postgres URL Railway assigns.
Point LINESHINES_REPO_ROOT at LinesShines project root so scripts can
-locate xlsx files under $LINESHINES_REPO_ROOT/data/nfl/.
+locate xlsx files under $LINESHINES_REPO_ROOT/data/{nfl,ncaaf}/.
"""
from __future__ import annotations
import argparse
import os
from dataclasses import dataclass
+from typing import Callable
import re
import sys
from datetime import date
@@ -34,22 +39,31 @@
import pandas as pd
from sqlalchemy import delete
from sqlalchemy.orm import Session
-from config import DYNAMIC_THRESHOLDS
+from config import LEAGUE_DYNAMIC_THRESHOLDS, League
+from database.career import rebuild_all_career_members
from database.columns import (
+ NCAAF_PASS_RUSH_SNAP_COLUMNS,
PASS_BLOCK_DATA_COLUMNS,
PASS_RUSH_DATA_COLUMNS,
required_excel_headers,
)
-from database.db_models import Base, NFLPassBlockStat, NFLPassRushStat, NFLTeam
+from database.db_models import (
+ Base,
+ NCAAFPassBlockStat,
+ NCAAFPassRushStat,
+ NCAAFTeam,
+ NFLPassBlockStat,
+ NFLPassRushStat,
+ NFLTeam,
+)
from database.session import engine, SessionLocal
+from teams.ncaaf import SCHOOL_NAMES
from teams.nfl import TEAMS
FRONT_7_POSITIONS = ("DI", "ED", "LB")
OL_POSITIONS = ("T", "G", "C")
-# The data root holds one folder per league; the NFL files are under data/nfl/.
-NFL_SUBDIR = "nfl"
-
+# The data root holds one folder per league (the League value): data/nfl/, data/ncaaf/.
SEASON_CATEGORY_DIRS = ("front_7_pass_rush", "ol_pass_block")
@@ -64,7 +78,7 @@ def _find_project_root() -> Path:
def _repo_root() -> Path:
- """The data root: the folder that holds `nfl/` (and later `ncaaf/`).
+ """The data root: the folder that holds `nfl/` and `ncaaf/`.
Order of precedence:
1. `--data-dir` CLI flag.
@@ -83,9 +97,9 @@ def _repo_root() -> Path:
return _find_project_root() / "data"
-def _discover_seasons(data_dir: Path) -> list[int]:
+def _discover_seasons(data_dir: Path, league: League = League.NFL) -> list[int]:
"""Union of seasons with a `{season}.xlsx` file under either category
- subdir (`front_7_pass_rush`, `ol_pass_block`) of `data_dir/nfl`.
+ subdir (`front_7_pass_rush`, `ol_pass_block`) of `data_dir/{league}`.
Union, not intersection — OL and DL are independent categories with no
joint table, so it's fine for one to have a season's file before the other does.
@@ -98,16 +112,13 @@ def _discover_seasons(data_dir: Path) -> list[int]:
seasons = set()
for category_dir in SEASON_CATEGORY_DIRS:
- for path in (data_dir / NFL_SUBDIR / category_dir).glob("*.xlsx"):
+ for path in (data_dir / league.value / category_dir).glob("*.xlsx"):
if re.fullmatch(r"\d{4}", path.stem):
seasons.add(int(path.stem))
return list(seasons)
-DEFAULT_SEASONS = _discover_seasons(_repo_root())
-
-
def _safe_int(val):
return None if pd.isna(val) else int(val)
@@ -137,7 +148,7 @@ def _require_columns(df: pd.DataFrame, columns, path, sheet) -> None:
raise ValueError(f"{path} sheet {sheet} is missing columns: {missing}")
-def upsert_teams(sess: Session) -> None:
+def upsert_nfl_teams(sess: Session) -> None:
existing = {t.code for t in sess.query(NFLTeam).all()}
for code, (full_name, color) in TEAMS.items():
@@ -152,6 +163,20 @@ def upsert_teams(sess: Session) -> None:
sess.commit()
+def upsert_ncaaf_teams(sess: Session) -> None:
+ """One `ncaaf_teams` row per scoped school; colours are left as they are (unset)."""
+ existing = {team.franchise_id for team in sess.query(NCAAFTeam).all()}
+
+ for franchise_id, full_name in SCHOOL_NAMES.items():
+ if franchise_id in existing:
+ sess.get(NCAAFTeam, franchise_id).full_name = full_name
+
+ else:
+ sess.add(NCAAFTeam(franchise_id=franchise_id, full_name=full_name))
+
+ sess.commit()
+
+
def _read_value(kind: str, value):
"""Read one Excel cell the way its column's kind says."""
if kind == "int":
@@ -170,6 +195,7 @@ def _read_value(kind: str, value):
class _Category:
"""Everything the ingestion needs to load one category into its table."""
+ league: League
model: type
folder: str
positions: tuple[str, ...]
@@ -177,9 +203,16 @@ class _Category:
snap_header: str # The column a row must have a value in (dropna subset).
skip_label: str # Wording of the "file not found" line.
row_label: str # Wording of the per-position "N rows" line.
+ team_header: str = "Team" # The column that holds the team key.
+ team_code_type: Callable = str # The NFL key is a code, the college key an id.
+ extra_columns: tuple[tuple[str, str], ...] = () # (model attribute, Excel header).
+ hidden_columns: (
+ tuple
+ ) = () # Read like data columns, but not part of the API payload.
_PASS_RUSH = _Category(
+ league=League.NFL,
model=NFLPassRushStat,
folder="front_7_pass_rush",
positions=FRONT_7_POSITIONS,
@@ -190,6 +223,7 @@ class _Category:
)
_PASS_BLOCK = _Category(
+ league=League.NFL,
model=NFLPassBlockStat,
folder="ol_pass_block",
positions=OL_POSITIONS,
@@ -199,20 +233,65 @@ class _Category:
row_label="pass_block",
)
+# The college rows key the team by franchise_id and carry the conference attached at conversion.
+_NCAAF_PASS_RUSH = _Category(
+ league=League.NCAAF,
+ model=NCAAFPassRushStat,
+ folder="front_7_pass_rush",
+ positions=FRONT_7_POSITIONS,
+ data_columns=PASS_RUSH_DATA_COLUMNS,
+ snap_header="PR Opp",
+ skip_label="ncaaf pass rush",
+ row_label="ncaaf_pass_rush",
+ team_header="Franchise Id",
+ team_code_type=int,
+ extra_columns=(("conference", "Conference"),),
+ hidden_columns=NCAAF_PASS_RUSH_SNAP_COLUMNS,
+)
+
+_NCAAF_PASS_BLOCK = _Category(
+ league=League.NCAAF,
+ model=NCAAFPassBlockStat,
+ folder="ol_pass_block",
+ positions=OL_POSITIONS,
+ data_columns=PASS_BLOCK_DATA_COLUMNS,
+ snap_header="Non Spike PB Snaps",
+ skip_label="ncaaf pass block",
+ row_label="ncaaf_pass_block",
+ team_header="Franchise Id",
+ team_code_type=int,
+ extra_columns=(("conference", "Conference"),),
+)
+
+LEAGUE_CATEGORIES: dict[League, tuple[_Category, ...]] = {
+ League.NFL: (_PASS_RUSH, _PASS_BLOCK),
+ League.NCAAF: (_NCAAF_PASS_RUSH, _NCAAF_PASS_BLOCK),
+}
+
+LEAGUE_TEAM_UPSERTS: dict[League, Callable[[Session], None]] = {
+ League.NFL: upsert_nfl_teams,
+ League.NCAAF: upsert_ncaaf_teams,
+}
+
def ingest_category(
sess: Session, data_dir: Path, seasons: list[int], category: _Category
) -> int:
"""Load one category's seasons into its table, replacing each slice first.
- The body is shared by the two categories (and later the two leagues); the
- column reads come from the declaration in `database/columns.py`.
+ The body is shared by the categories of both leagues; the column reads come
+ from the declaration in `database/columns.py`.
"""
- required = required_excel_headers(category.data_columns)
+ required = required_excel_headers(
+ category.data_columns + category.hidden_columns
+ ) + (
+ category.team_header,
+ *(header for _, header in category.extra_columns),
+ )
total = 0
for season in seasons:
- path = data_dir / NFL_SUBDIR / category.folder / f"{season}.xlsx"
+ path = data_dir / category.league.value / category.folder / f"{season}.xlsx"
if not path.exists():
print(f"Skip {category.skip_label} {season}: {path} not found")
@@ -240,16 +319,19 @@ def ingest_category(
values = {
"season": season,
"position": position,
- "team_code": row["Team"],
+ "team_code": category.team_code_type(row[category.team_header]),
"player_id": _player_id(row, path, position),
"player": row["Player"],
"abbr_name": row["Abbr Name"],
}
- for column in category.data_columns:
+ for column in category.data_columns + category.hidden_columns:
values[column.attr] = _read_value(
column.kind, row.get(column.excel_header)
)
+ for attr, header in category.extra_columns:
+ values[attr] = row[header]
+
rows.append(category.model(**values))
sess.add_all(rows)
@@ -268,15 +350,27 @@ def ingest_pass_block(sess: Session, data_dir: Path, seasons: list[int]) -> int:
return ingest_category(sess, data_dir, seasons, _PASS_BLOCK)
+def ingest_ncaaf_pass_rush(sess: Session, data_dir: Path, seasons: list[int]) -> int:
+ return ingest_category(sess, data_dir, seasons, _NCAAF_PASS_RUSH)
+
+
+def ingest_ncaaf_pass_block(sess: Session, data_dir: Path, seasons: list[int]) -> int:
+ return ingest_category(sess, data_dir, seasons, _NCAAF_PASS_BLOCK)
+
+
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
+ parser.add_argument(
+ "--league", required=True, choices=[league.value for league in League]
+ )
+
parser.add_argument(
"--seasons",
type=int,
nargs="+",
- default=DEFAULT_SEASONS,
- help=f"Seasons to ingest (default: {DEFAULT_SEASONS})",
+ default=None,
+ help="Seasons to ingest (default: every season with an xlsx file for the league).",
)
parser.add_argument(
@@ -297,27 +391,38 @@ def main() -> None:
)
sys.exit(1)
- print(f"Reading xlsx from: {data_dir}")
+ league = League(args.league)
+ seasons = args.seasons or _discover_seasons(data_dir, league)
+ print(f"Reading {league.value} xlsx from: {data_dir}")
Base.metadata.create_all(engine)
with SessionLocal() as sess:
- upsert_teams(sess)
- pass_rush_rows = ingest_pass_rush(sess, data_dir, args.seasons)
- pass_block_rows = ingest_pass_block(sess, data_dir, args.seasons)
+ LEAGUE_TEAM_UPSERTS[league](sess)
+ pass_rush_category, pass_block_category = LEAGUE_CATEGORIES[league]
+ pass_rush_rows = ingest_category(sess, data_dir, seasons, pass_rush_category)
+ pass_block_rows = ingest_category(sess, data_dir, seasons, pass_block_category)
+
+ # The Career members read both leagues' tables, so only the college run rebuilds them.
+ if league is League.NCAAF:
+ rebuild_all_career_members(sess)
print(
- f"\nBulk-inserted {pass_rush_rows} pass-rush rows and {pass_block_rows} pass-block rows."
+ f"\nBulk-inserted {pass_rush_rows} {league.value} pass-rush rows and "
+ f"{pass_block_rows} pass-block rows."
)
# In-progress season's threshold is hand-eyeballed weekly, not
- # auto-computed (see config.py's DYNAMIC_THRESHOLDS).
+ # auto-computed (see config/thresholds.py's DYNAMIC_THRESHOLDS and NCAAF_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:
+ if (
+ current_year in seasons
+ and current_year not in LEAGUE_DYNAMIC_THRESHOLDS[league]
+ ):
print(
- f"Reminder: {current_year} has no entry in config.DYNAMIC_THRESHOLDS yet — "
- "falling back to the static default thresholds until you set one."
+ f"Reminder: {current_year} has no {league.value} entry in the dynamic thresholds "
+ "of config/thresholds.py yet — falling back to the static default thresholds until you set one."
)
diff --git a/database/db_models.py b/database/db_models.py
index dd4a986..a4d0744 100644
--- a/database/db_models.py
+++ b/database/db_models.py
@@ -34,6 +34,7 @@
PASS_BLOCK_DATA_COLUMNS,
PASS_RUSH_DATA_COLUMNS,
row_to_payload,
+ with_tps_proportion,
)
@@ -146,6 +147,82 @@ class NFLPassBlockStat(StatIdentityMixin, PassBlockMetricsMixin, Base):
)
+class NCAAFTeam(Base):
+ __tablename__ = "ncaaf_teams"
+
+ franchise_id = Column(Integer, primary_key=True) # PFF's stable school id.
+ full_name = Column(String, nullable=False)
+ primary_color = Column(String(7)) # Hex like "#0085CA"; may be unset for now.
+
+ pass_rush_stats = relationship("NCAAFPassRushStat", back_populates="team")
+ pass_block_stats = relationship("NCAAFPassBlockStat", back_populates="team")
+
+
+class NCAAFPassRushStat(StatIdentityMixin, PassRushMetricsMixin, Base):
+ __tablename__ = "ncaaf_pass_rush_stats"
+
+ # College team key is franchise_id, not a short code.
+ team_code = Column(Integer, ForeignKey("ncaaf_teams.franchise_id"), nullable=False)
+ conference = Column(String, nullable=False) # Attached at conversion; in scope.
+
+ # Behind Pass Cov Percent, for Career merge only; never in API payload.
+ pass_rush_snaps = Column(Integer) # PR Snaps.
+ pass_play_snaps = Column(Integer) # Snap Counts Pass Play.
+ tps_pass_rush_snaps = Column(Integer) # TPS PR Snaps.
+ tps_pass_play_snaps = Column(Integer) # TPS Snap Counts Pass Play.
+
+ team = relationship("NCAAFTeam", back_populates="pass_rush_stats")
+
+ __table_args__ = (
+ UniqueConstraint(
+ "season",
+ "position",
+ "player_id",
+ name="uq_ncaaf_pr_season_position_player_id",
+ ),
+ Index("idx_ncaaf_pr_filter", "season", "position"),
+ Index("idx_ncaaf_pr_player_id", "player_id"),
+ )
+
+
+class NCAAFPassBlockStat(StatIdentityMixin, PassBlockMetricsMixin, Base):
+ __tablename__ = "ncaaf_pass_block_stats"
+
+ team_code = Column(Integer, ForeignKey("ncaaf_teams.franchise_id"), nullable=False)
+ conference = Column(String, nullable=False)
+
+ team = relationship("NCAAFTeam", back_populates="pass_block_stats")
+
+ __table_args__ = (
+ UniqueConstraint(
+ "season",
+ "position",
+ "player_id",
+ name="uq_ncaaf_pb_season_position_player_id",
+ ),
+ Index("idx_ncaaf_pb_filter", "season", "position"),
+ Index("idx_ncaaf_pb_player_id", "player_id"),
+ )
+
+
+class NCAAFPassRushCareerMember(Base):
+ """Who is on the college Career page for the front 7 (see `database/career.py`)."""
+
+ __tablename__ = "ncaaf_pass_rush_career_members"
+
+ player_id = Column(Integer, primary_key=True, autoincrement=False)
+ status = Column(String, nullable=False) # A `CareerStatus` value.
+
+
+class NCAAFPassBlockCareerMember(Base):
+ """Who is on the college Career page for the OL (see `database/career.py`)."""
+
+ __tablename__ = "ncaaf_pass_block_career_members"
+
+ player_id = Column(Integer, primary_key=True, autoincrement=False)
+ status = Column(String, nullable=False) # A `CareerStatus` value.
+
+
# Serialization helpers used by main.py to keep API payload shape
# identical to what frontend already expects (space-and-mixed-case keys
# preserved so no client-side renaming is needed). Both read the one
@@ -157,4 +234,15 @@ def pass_rush_row_to_dict(row: NFLPassRushStat) -> dict:
def pass_block_row_to_dict(row: NFLPassBlockStat) -> dict:
- return row_to_payload(row, PASS_BLOCK_DATA_COLUMNS)
+ return with_tps_proportion(row_to_payload(row, PASS_BLOCK_DATA_COLUMNS))
+
+
+def ncaaf_pass_rush_row_to_dict(row: NCAAFPassRushStat) -> dict:
+ return {**row_to_payload(row, PASS_RUSH_DATA_COLUMNS), "conference": row.conference}
+
+
+def ncaaf_pass_block_row_to_dict(row: NCAAFPassBlockStat) -> dict:
+ return {
+ **with_tps_proportion(row_to_payload(row, PASS_BLOCK_DATA_COLUMNS)),
+ "conference": row.conference,
+ }
diff --git a/database/midseason_trades.py b/database/midseason_trades.py
new file mode 100644
index 0000000..697ac28
--- /dev/null
+++ b/database/midseason_trades.py
@@ -0,0 +1,74 @@
+"""Point a traded player at his new team mid-season, between weekly ingests.
+
+The NFL trade deadline is always in early November, so the season is simply the
+current calendar year and needs no argument. Like `database/db_ingestion.py`,
+this updates whichever database `DATABASE_URL` points at (the local SQLite file
+by default, or Railway Postgres when the env var is set), so export it first.
+
+Usage:
+ export DATABASE_URL=... # omit to update the local SQLite file
+ python -c "from database.midseason_trades import update_traded_player_team as move; move('Jonathan Allen', 'MIN')"
+
+Several at once, each an independent (player, team) pair:
+ python -c "from database.midseason_trades import update_traded_players as moves; moves(('Jonathan Allen', 'MIN'), ('Za Darius Smith', 'PHI'))"
+"""
+
+from __future__ import annotations
+
+from datetime import date
+
+from sqlalchemy import update
+
+from database.db_models import NFLPassBlockStat, NFLPassRushStat
+from database.session import SessionLocal
+from teams.nfl import TEAMS
+
+# A player sits in exactly one category, so updating both touches only his rows.
+_STAT_MODELS = (NFLPassRushStat, NFLPassBlockStat)
+
+
+def update_traded_player_team(player_name: str, new_team: str) -> None:
+ """Set a player's team for the current season; print and change nothing if off.
+
+ The season is the current calendar year. A wrong team code, a wrong player
+ name, or a season not ingested yet all end the same way: a message is printed
+ and no row is changed.
+ """
+ season = date.today().year
+
+ if new_team not in TEAMS:
+ print(f"{new_team!r} is not a team code; nothing updated.")
+ return
+
+ with SessionLocal() as sess:
+ updated_rows = 0
+ for model in _STAT_MODELS:
+ result = sess.execute(
+ update(model)
+ .where(model.season == season, model.player == player_name)
+ .values(team_code=new_team)
+ )
+ updated_rows += result.rowcount
+
+ if updated_rows == 0:
+ print(f"No {season} player named {player_name!r}; nothing updated.")
+ return
+
+ sess.commit()
+
+ full_name = TEAMS[new_team][0]
+ print(
+ f"Moved {player_name!r} to {new_team} ({full_name}) for {season}: "
+ f"{updated_rows} row(s) updated."
+ )
+
+
+def update_traded_players(*trades: tuple[str, str]) -> None:
+ """Apply several trades in one call, each an independent (player, team) pair.
+
+ A bad entry (unknown team, or no such player this season) is reported and
+ skipped while the rest still run, the same continue-on-error habit as the
+ preprocessing command line.
+ """
+ for player_name, new_team in trades:
+ update_traded_player_team(player_name, new_team)
diff --git a/frontend/config.js b/frontend/config.js
index f74cbbe..55a1bc7 100644
--- a/frontend/config.js
+++ b/frontend/config.js
@@ -55,7 +55,7 @@ export const MERGE_QUOTA = 8;
export const LINEMATE_POSITIONS = { pass_block: ["T", "G", "C"], pass_rush: ["ED", "DI"] };
export const LINEMATE_SIZE_REMINDER_AT = { pass_block: 5, pass_rush: 7 };
-// Mirrors config.py's DISPLAY_DECIMALS — keep in sync. UI-display rounding
+// Mirrors config/metrics.py's DISPLAY_DECIMALS — keep in sync. UI-display rounding
// only: plots (render.js's xVals/yVals, drawn straight from record[key])
// stay full-precision, since rounding coordinates would visibly shift point
// positions. This only governs formatValue()'s rendered stat-table text.
diff --git a/frontend/data.js b/frontend/data.js
index 768b80c..abd0e6b 100644
--- a/frontend/data.js
+++ b/frontend/data.js
@@ -10,10 +10,30 @@
* assignment, same order, same timing as the original app.js.
*/
import { els } from "./dom.js";
+import { thresholdSpec, meetsThreshold, JOINED_NFL, PROSPECTS } from "./pinned-impact.js";
-export const LOGO_PATH = (team) => `logos/nfl/${team}.png`;
+// The page's league (NFL or NCAAF), filled into index.html by main.py: the API
+// prefix, the logo folder, how the Teams checklist is grouped and how a team is
+// named in text. The NFL values are the fallback if the page was served raw.
+const NFL_LEAGUE = {
+ key: "nfl",
+ name: "NFL",
+ apiPrefix: "/api/nfl",
+ logoDir: "nfl",
+ teamGroups: "static",
+ teamLabel: "code",
+};
+export const LEAGUE = window.LEAGUE || NFL_LEAGUE;
-export let metadata = null; // /api/metadata payload
+// The Career page pools every college season of a player into one row, so it has
+// no season (its one "season" is the word below), a Status Filter instead, and
+// two threshold bars, one per status. A Season page is the plain case.
+export const IS_CAREER = LEAGUE.mode === "career";
+export const CAREER_SEASON = "Career";
+
+export const LOGO_PATH = (team) => `logos/${LEAGUE.logoDir}/${team}.png`;
+
+export let metadata = null; // /api/{league}/metadata payload
export const sliceCache = new Map(); // key = `${category}:${season}:${position}` → records[]
export let currentRecords = []; // records for the current slice (all threshold values)
export let currentFiltered = []; // records >= threshold (what the chart shows)
@@ -47,12 +67,60 @@ export let playerPoolCategory = null;
export let appliedFilters = null;
// Decoded Image objects, keyed by team code, filled once at page load so
-// filter changes never wait on the browser to re-resolve/decode /logos/nfl/*.png
+// filter changes never wait on the browser to re-resolve/decode /logos/{league}/*.png
// again — layout.images and the scout card both just point at these.
export const logoCache = {};
export function setMetadata(m) {
metadata = m;
+
+ if (IS_CAREER) {
+ // Shape the Career payload like a Season one, so the controls stay shared: one
+ // "season" and one flat conference map. The Career statuses and their two
+ // default bars stay as the API sent them.
+ ["pass_rush", "pass_block"].forEach((key) => {
+ metadata[key].seasons = [CAREER_SEASON];
+ });
+ if (metadata.team_conferences && !(CAREER_SEASON in metadata.team_conferences)) {
+ metadata.team_conferences = { [CAREER_SEASON]: metadata.team_conferences };
+ }
+ }
+}
+
+// The threshold in force, as a number (Season) or { joined, prospect } (Career).
+// `appliedThreshold()` reads the last-applied state, `pendingThreshold()` the
+// controls as they are right now.
+export function appliedThreshold() {
+ return thresholdSpec(appliedFilters.threshold, appliedFilters.prospectThreshold);
+}
+
+export function pendingThreshold() {
+ return thresholdSpec(els.thresholdNumber.value, IS_CAREER ? els.thresholdProspectNumber.value : undefined);
+}
+
+// "300" for a bar given as a plain number.
+export function thresholdPhrase(spec) {
+ return String(spec);
+}
+
+// The threshold as clauses a card can print: one ("≥ 300 pass rush opportunities") on
+// a Season page, one per status on Career ("≥ 450 pass rush opportunities (Joined NFL)",
+// "≥ 350 pass rush opportunities (Prospects)"). `onlyStatus` keeps just that
+// status's bar, unlabelled, for a card whose whole roster has that one status.
+export function thresholdClauses(spec, fieldLabel, onlyStatus = null) {
+ if (typeof spec === "number") return [`≥ ${spec} ${fieldLabel}`];
+
+ if (onlyStatus === JOINED_NFL) return [`≥ ${spec.joined} ${fieldLabel}`];
+
+ if (onlyStatus === PROSPECTS) return [`≥ ${spec.prospect} ${fieldLabel}`];
+
+ return [`≥ ${spec.joined} ${fieldLabel} (${JOINED_NFL})`, `≥ ${spec.prospect} ${fieldLabel} (${PROSPECTS})`];
+}
+
+// Which statuses are lit on the Career page (empty on a Season page). A status
+// that is off only dims its players; it never leaves the percentile pool.
+export function litStatuses(statusesText) {
+ return new Set(statusesText ? statusesText.split(",") : []);
}
export function setCurrentSlice(records, category) {
@@ -117,7 +185,7 @@ export function currentCategoryMeta() {
// threshold — an ongoing season (DYNAMIC_THRESHOLDS) against a finalized one
// (DEFAULT_THRESHOLDS), in either direction. Two finalized seasons share one
// static default, so the user's value stays. Reads the default each season
-// carries in /api/metadata, so it follows whatever config.py resolved.
+// carries in /api/metadata, so it follows whatever config/thresholds.py resolved.
export function seasonSwitchChangesThresholdDefault(fromSeason, toSeason) {
const defaults = currentCategoryMeta().default_thresholds;
return defaults[fromSeason] !== defaults[toSeason];
@@ -125,7 +193,7 @@ export function seasonSwitchChangesThresholdDefault(fromSeason, toSeason) {
// True for whatever season is still in progress this calendar year, false
// for every finalized/historical one — same Calendar Year comparison
-// config.py's resolve_default_threshold() routes the static-vs-dynamic
+// config/thresholds.py's resolve_default_threshold() routes the static-vs-dynamic
// threshold default on, backed by metadata.current_year (see main.py's
// /api/metadata) rather than a separate date.today().year call here, so
// the frontend can never classify a season differently than the backend
@@ -163,7 +231,8 @@ export function appliedCategoryMeta() {
export async function fetchSlice(category, season) {
const key = `${category}:${season}`;
if (!sliceCache.has(key)) {
- const url = `/api/${category}?season=${season}`;
+ // The Career route has no season: it sends every member.
+ const url = IS_CAREER ? `${LEAGUE.apiPrefix}/${category}` : `${LEAGUE.apiPrefix}/${category}?season=${season}`;
const res = await fetch(url);
if (!res.ok) throw new Error(`GET ${url} → ${res.status}`);
const data = await res.json();
@@ -179,8 +248,8 @@ export async function fetchSlice(category, season) {
// position instead of being locked to appliedFilters.position.
export function positionPool(position) {
const cat = appliedCategoryMeta();
- const minThreshold = Number(appliedFilters.threshold);
- return currentRecords.filter((r) => r.position === position && r[cat.threshold_field] >= minThreshold);
+ const threshold = appliedThreshold();
+ return currentRecords.filter((r) => r.position === position && meetsThreshold(r, cat.threshold_field, threshold));
}
export function median(values) {
@@ -225,6 +294,13 @@ export function teamName(code) {
return (t && t.full_name) || code;
}
+// How a team reads in text (rows, badges, table cells): the NFL shows its short
+// code (BUF), while a college team's key is only a franchise id, so it shows the
+// school's name. Keys and logo file names always stay the raw key.
+export function teamLabel(team) {
+ return LEAGUE.teamLabel === "name" ? teamName(team) : team;
+}
+
// Fallback swatch for the teams-dropdown checklist when a team's logo file
// 404s — mirrors openScoutCard()'s logoImg.onerror treatment.
export function teamSwatch(code) {
diff --git a/frontend/dom.js b/frontend/dom.js
index 81c7f3c..20b5db5 100644
--- a/frontend/dom.js
+++ b/frontend/dom.js
@@ -12,10 +12,28 @@ export const els = {
xMetric: document.getElementById("x-metric-select"),
yMetric: document.getElementById("y-metric-select"),
heroInstallSlot: document.getElementById("hero-install-slot"),
+ leagueNav: document.querySelector(".league-nav"),
+ leagueNavToggle: document.getElementById("league-nav-toggle"),
xAxisInfoSlot: document.getElementById("x-axis-info-slot"),
yAxisInfoSlot: document.getElementById("y-axis-info-slot"),
threshold: document.getElementById("threshold-slider"),
thresholdNumber: document.getElementById("threshold-number"),
+ // Career page only: the second bar (Prospects) and the Status Filter.
+ thresholdProspect: document.getElementById("threshold-prospect-slider"),
+ thresholdProspectNumber: document.getElementById("threshold-prospect-number"),
+ thresholdControl: document.getElementById("threshold-control"),
+ thresholdProspectControl: document.getElementById("prospect-threshold-control"),
+ thresholdStatusLabel: document.getElementById("threshold-status-label"),
+ thresholdProspectFieldLabel: document.getElementById("threshold-prospect-field-label"),
+ seasonControl: document.querySelector(".control-season"),
+ statusControl: document.getElementById("status-control"),
+ statusBtn: document.getElementById("status-toggle-btn"),
+ statusSummary: document.getElementById("status-select-summary"),
+ statusDropdown: document.getElementById("status-dropdown"),
+ statusInfoSlot: document.getElementById("status-info-slot"),
+ statusEnabled: document.getElementById("status-enabled"),
+ statusJoined: document.getElementById("status-joined"),
+ statusProspect: document.getElementById("status-prospect"),
applyBtn: document.getElementById("apply-filters"),
savePngBtn: document.getElementById("save-png-btn"),
thresholdFieldLabel: document.getElementById("threshold-field-label"),
diff --git a/frontend/filters.js b/frontend/filters.js
index 1ba42cd..ce5448a 100644
--- a/frontend/filters.js
+++ b/frontend/filters.js
@@ -31,8 +31,14 @@ import {
logoSrc,
teamSwatch,
teamName,
+ teamLabel,
+ LEAGUE,
allTeamCodes,
findRecordByPlayer,
+ IS_CAREER,
+ CAREER_SEASON,
+ appliedThreshold,
+ pendingThreshold,
} from "./data.js";
import { searchPlayersExcluding, qualifyingPlayerPool, fullPlayerPool } from "./search.js";
import { CONFERENCES, LINEMATE_POSITIONS } from "./config.js";
@@ -59,6 +65,9 @@ import {
partitionByEligibility,
removedPlayersNotice,
resolveCarriedRecord,
+ meetsThreshold,
+ JOINED_NFL,
+ PROSPECTS,
} from "./pinned-impact.js";
// True right after page load and right after a category switch — both cases
@@ -104,6 +113,9 @@ export function currentFilterState() {
xMetric: els.xMetric.value,
yMetric: els.yMetric.value,
threshold: els.thresholdNumber.value,
+ // Career only: the second bar and which statuses are lit. Left out on a Season
+ // page, so its state keeps its old shape.
+ ...(IS_CAREER ? { prospectThreshold: els.thresholdProspectNumber.value, statuses: litStatusesText() } : {}),
// Joined into a comparable string (not a bare array) so the `!==`
// check in filtersArePending() works the same way it does for every
// other primitive-valued control — two different array references
@@ -113,6 +125,99 @@ export function currentFilterState() {
};
}
+// The lit Career statuses as a sorted comma string (comparable with `!==`, like teams).
+// Empty while "Enable Status" is off: Status is then out of the highlight union.
+export function litStatusesText() {
+ if (!els.statusEnabled.checked) return "";
+
+ const lit = [];
+ if (els.statusJoined.checked) lit.push(JOINED_NFL);
+ if (els.statusProspect.checked) lit.push(PROSPECTS);
+ return lit.sort().join(",");
+}
+
+// One entry per threshold bar: the single bar of a Season page, or the Joined NFL
+// and Prospects pair on the Career page. `status` is what the bar judges
+// (undefined on a Season page, where it judges every player).
+function thresholdBars() {
+ const bars = [{ slider: els.threshold, number: els.thresholdNumber, status: IS_CAREER ? JOINED_NFL : undefined }];
+ if (IS_CAREER) {
+ bars.push({ slider: els.thresholdProspect, number: els.thresholdProspectNumber, status: PROSPECTS });
+ }
+ return bars;
+}
+
+// The configured default of one bar: the season's number, or on Career the one of the bar's status.
+function defaultThresholdOf(cat, bar) {
+ const key = IS_CAREER ? bar.status : els.season.value;
+ return cat.default_thresholds[key] ?? 0;
+}
+
+// Slider ceiling of one bar for a category's records at the pending position: the
+// largest value of the bar's players, rounded up to ten (Career: of its own status).
+function barMax(records, cat, bar) {
+ const values = records
+ .filter((r) => r.position === els.position.value && (bar.status === undefined || r.status === bar.status))
+ .map((r) => r[cat.threshold_field])
+ .filter((v) => v != null);
+ const maxVal = values.length ? Math.max(...values) : 100;
+ return Math.ceil(maxVal / 10) * 10;
+}
+
+// The season value the controls hold: a number, or the word Career.
+function seasonValue() {
+ return IS_CAREER ? CAREER_SEASON : Number(els.season.value);
+}
+
+// The Status button's label, like the Teams summary.
+export function updateStatusSummary() {
+ const joined = els.statusJoined.checked;
+ const prospect = els.statusProspect.checked;
+ if (!els.statusEnabled.checked) els.statusSummary.textContent = "Off";
+ else if (joined && prospect) els.statusSummary.textContent = "All Status";
+ else if (joined) els.statusSummary.textContent = JOINED_NFL;
+ else if (prospect) els.statusSummary.textContent = PROSPECTS;
+ else els.statusSummary.textContent = "No Status";
+
+ // The button's name is its visible label, which no longer says "Status".
+ els.statusBtn.setAttribute("aria-label", `Status: ${els.statusSummary.textContent}`);
+}
+
+export function openStatusDropdown() {
+ els.statusDropdown.hidden = false;
+ els.statusBtn.setAttribute("aria-expanded", "true");
+}
+
+export function closeStatusDropdown() {
+ els.statusDropdown.hidden = true;
+ els.statusBtn.setAttribute("aria-expanded", "false");
+}
+
+// The two bars do not follow the Status Filter: both stay fully usable whatever is lit,
+// since a status that is off only dims its players and its bar still counts in the pool.
+// With "Enable Status" off the dropdown is shut and greyed; its ticks are kept.
+export function syncStatusBars() {
+ if (!IS_CAREER) return;
+ updateStatusSummary();
+
+ const enabled = els.statusEnabled.checked;
+ els.statusBtn.disabled = !enabled;
+ if (!enabled) closeStatusDropdown();
+}
+
+// True when a pending bar differs from the applied one (either bar on Career).
+function thresholdsChanged() {
+ return (
+ els.thresholdNumber.value !== appliedFilters.threshold ||
+ (IS_CAREER && els.thresholdProspectNumber.value !== appliedFilters.prospectThreshold)
+ );
+}
+
+// The state the pool checks judge, from the applied snapshot: its threshold as a number or pair.
+function appliedPoolState() {
+ return { ...appliedFilters, threshold: appliedThreshold() };
+}
+
export function selectedTeamCodes() {
return Array.from(els.teamsChecklist.querySelectorAll("input[type=checkbox]:checked")).map((cb) => cb.value);
}
@@ -129,17 +234,18 @@ export function selectedTeamCodes() {
function resetTeamsIfAllSelected() {
if (selectedTeamCodes().length !== allTeamCodes().length) return;
els.teamsChecklist.querySelectorAll("input[type=checkbox]").forEach((cb) => (cb.checked = false));
+ markNoTeamsSelected();
updateTeamsSummary();
}
-export function teamOptionRow(code) {
+export function teamOptionRow(code, checked = true) {
const label = document.createElement("label");
label.className = "team-option";
const cb = document.createElement("input");
cb.type = "checkbox";
cb.value = code;
- cb.checked = true;
+ cb.checked = checked;
const logo = document.createElement("img");
logo.className = "team-option-logo";
@@ -149,38 +255,127 @@ export function teamOptionRow(code) {
logo.onerror = () => logo.replaceWith(teamSwatch(code));
const text = document.createElement("span");
- text.textContent = code;
+ text.textContent = teamLabel(code);
label.append(cb, logo, text);
return label;
}
-export function populateTeamsChecklist() {
+// The checklist's groups for a season: [{ title, sections: [{ title, codes }] }].
+// The NFL's conferences and divisions are fixed (config.js); a college
+// conference changes with the season (realignment), so those groups come from
+// the metadata's per-season membership, one section per conference, with the
+// schools by name. A conference with no school that season has no group.
+function teamGroups(season) {
+ if (LEAGUE.teamGroups === "static") {
+ return Object.entries(CONFERENCES).map(([conference, divisions]) => ({
+ title: conference,
+ sections: Object.entries(divisions).map(([division, codes]) => ({ title: division, codes })),
+ }));
+ }
+
+ const conferenceOfTeam = (metadata.team_conferences && metadata.team_conferences[season]) || {};
+ return metadata.conference_order
+ .map((conference) => ({
+ title: conference,
+ sections: [
+ {
+ title: null,
+ codes: Object.keys(conferenceOfTeam)
+ .filter((code) => conferenceOfTeam[code] === conference)
+ .sort((a, b) => teamName(a).localeCompare(teamName(b))),
+ },
+ ],
+ }))
+ .filter((group) => group.sections[0].codes.length > 0);
+}
+
+// What the user chose is kept across rebuilds, because a college season lists
+// only its own schools. knownTeams/uncheckedTeams hold each seen school's last
+// state; selectNewByDefault is whether a school shown for the first time starts
+// checked -- the "All Teams" vs "none" mode. It is flipped ONLY by an explicit
+// all/none action (the Teams "Select all"/"None" buttons and the player-pick
+// auto-clear, through markAllTeamsSelected()/markNoTeamsSelected()), never
+// inferred from how many boxes happen to be ticked. Inferring it from the DOM
+// used to turn on schools the user never touched: a season whose every visible
+// school happened to be ticked read as "All Teams", so moving to a bigger season
+// ticked its new schools too.
+const uncheckedTeams = new Set();
+const knownTeams = new Set();
+let selectNewByDefault = true;
+
+// The Teams "None" button and the player-pick auto-clear: nothing is selected
+// now, including schools this season does not list and ones not yet seen.
+export function markNoTeamsSelected() {
+ selectNewByDefault = false;
+ knownTeams.forEach((key) => uncheckedTeams.add(key));
+}
+
+// The Teams "Select all" button: every school is selected, including ones not
+// yet seen.
+export function markAllTeamsSelected() {
+ selectNewByDefault = true;
+ uncheckedTeams.clear();
+}
+
+export function populateTeamsChecklist(season) {
+ // Capture the individual ticks the user made on the outgoing season's boxes
+ // (those fire no event the sets listen to). An explicit all/none action has
+ // already updated the sets through the markers above.
+ els.teamsChecklist.querySelectorAll("input[type=checkbox]").forEach((cb) => {
+ knownTeams.add(cb.value);
+ if (cb.checked) uncheckedTeams.delete(cb.value);
+ else uncheckedTeams.add(cb.value);
+ });
els.teamsChecklist.innerHTML = "";
- Object.entries(CONFERENCES).forEach(([conference, divisions]) => {
+ const bySeason = LEAGUE.teamGroups === "by_season";
+ els.teamsDropdown.classList.toggle("teams-dropdown--by-season", bySeason);
+
+ teamGroups(season).forEach(({ title, sections }) => {
const column = document.createElement("div");
column.className = "teams-column";
- const columnHeader = document.createElement("div");
+ // A college conference header is a button that selects or clears its whole
+ // conference; the NFL's headers stay plain text.
+ const columnHeader = document.createElement(bySeason ? "button" : "div");
columnHeader.className = "teams-column-header";
- columnHeader.textContent = conference;
+ columnHeader.textContent = title;
column.appendChild(columnHeader);
- Object.entries(divisions).forEach(([division, codes]) => {
+ const groupBoxes = [];
+ sections.forEach((section) => {
const group = document.createElement("div");
group.className = "teams-division";
- const divisionHeader = document.createElement("div");
- divisionHeader.className = "teams-division-header";
- divisionHeader.textContent = division;
- group.appendChild(divisionHeader);
-
- // Codes are already listed ascending within each division above.
- codes.forEach((code) => group.appendChild(teamOptionRow(code)));
+ if (section.title) {
+ const divisionHeader = document.createElement("div");
+ divisionHeader.className = "teams-division-header";
+ divisionHeader.textContent = section.title;
+ group.appendChild(divisionHeader);
+ }
+
+ // Codes are already listed in order within each section above.
+ section.codes.forEach((code) => {
+ const key = String(code);
+ const checked = knownTeams.has(key) ? !uncheckedTeams.has(key) : selectNewByDefault;
+ const row = teamOptionRow(code, checked);
+ groupBoxes.push(row.querySelector("input"));
+ group.appendChild(row);
+ });
column.appendChild(group);
});
+ if (bySeason) {
+ columnHeader.type = "button";
+ columnHeader.addEventListener("click", () => {
+ const allChecked = groupBoxes.every((cb) => cb.checked);
+ groupBoxes.forEach((cb) => (cb.checked = !allChecked));
+ updateTeamsSummary();
+ updatePendingState();
+ });
+ }
+
els.teamsChecklist.appendChild(column);
});
@@ -286,8 +481,8 @@ export function renderPlayersDropdown(matches) {
toggle.className = "below-threshold-toggle";
toggle.textContent = `Below Threshold Matches (${belowThresholdMatches.length})`;
toggle.addEventListener("click", () =>
- openBelowThresholdPopup(belowThresholdMatches, metadata[playerPoolCategory], (value) => {
- quickSetPlayersThreshold(value);
+ openBelowThresholdPopup(belowThresholdMatches, metadata[playerPoolCategory], (value, record) => {
+ quickSetPlayersThreshold(value, record);
runPlayersSearch();
})
);
@@ -318,7 +513,7 @@ export function renderPlayersDropdown(matches) {
logo.onerror = () => logo.replaceWith(teamSwatch(record.team));
const code = document.createElement("span");
- code.textContent = record.team;
+ code.textContent = teamLabel(record.team);
team.append(logo, code);
opt.append(name, team);
@@ -358,9 +553,9 @@ export function runPlayersSearch() {
// 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 threshold = pendingThreshold();
const fullMatches = searchPlayers(query, fullPlayerPool(), BELOW_THRESHOLD_TOP_K);
- belowThresholdMatches = fullMatches.filter((r) => r[cat.threshold_field] < minThreshold);
+ belowThresholdMatches = fullMatches.filter((r) => !meetsThreshold(r, cat.threshold_field, threshold));
} else {
belowThresholdMatches = [];
}
@@ -407,7 +602,7 @@ export function openBelowThresholdPopup(matches, cat, onSetThreshold) {
logo.loading = "lazy";
logo.onerror = () => logo.replaceWith(teamSwatch(record.team));
const code = document.createElement("span");
- code.textContent = record.team;
+ code.textContent = teamLabel(record.team);
team.append(logo, code);
info.append(name, team);
@@ -424,7 +619,7 @@ export function openBelowThresholdPopup(matches, cat, onSetThreshold) {
// Query text is left untouched (nothing in this flow writes to the
// triggering box's own input) — onSetThreshold's own rerun surfaces
// the just-qualified player the same way retyping the query would.
- onSetThreshold(record[cat.threshold_field]);
+ onSetThreshold(record[cat.threshold_field], record);
closeBelowThresholdPopup();
});
@@ -450,17 +645,19 @@ export function closeBelowThresholdPopup() {
// override) rather than reimplementing that logic per caller. Neither
// caller's own appliedFilters/pool-refresh handling lives here — that's each
// one's own job, since they differ (stay pending vs. commit now).
-function syncThresholdControls(value) {
- const min = Number(els.threshold.min);
- const max = Number(els.threshold.max);
- const step = Number(els.threshold.step) || 1;
+function syncThresholdControls(value, record) {
+ // On Career the bar that moves is the one of the player's own status.
+ const bar = thresholdBars().find((b) => b.status === (record && record.status)) || thresholdBars()[0];
+ const min = Number(bar.slider.min);
+ const max = Number(bar.slider.max);
+ const step = Number(bar.slider.step) || 1;
const clamped = Math.min(Math.max(value, min), max);
- els.thresholdNumber.value = clamped;
+ bar.number.value = clamped;
// Slider snaps to the nearest step just to keep the handle in sync
- // visually — filtering itself reads thresholdNumber.value (the exact
+ // visually — filtering itself reads the number input's 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;
+ bar.slider.value = min + Math.round((clamped - min) / step) * step;
setResetThresholdOnNextRange(false);
}
@@ -496,8 +693,8 @@ export function setThresholdTo(value) {
// pending edit sitting unapplied in the drawer right now) and refreshes
// every already-open card in place instead of invalidating the Workspace
// the way a normal Apply's threshold change does.
-export function applyThresholdImmediately(value) {
- syncThresholdControls(value);
+export function applyThresholdImmediately(value, record) {
+ syncThresholdControls(value, record);
commitPendingThreshold();
}
@@ -510,7 +707,11 @@ function commitPendingThreshold() {
// currentFilterState() would have produced — that's what keeps
// filtersArePending() from lighting the Apply button for a threshold that
// in fact just got committed.
- setAppliedFilters({ ...appliedFilters, threshold: els.thresholdNumber.value });
+ setAppliedFilters({
+ ...appliedFilters,
+ threshold: els.thresholdNumber.value,
+ ...(IS_CAREER ? { prospectThreshold: els.thresholdProspectNumber.value } : {}),
+ });
prunePlayerSelections();
render();
reconcileCarriedCards();
@@ -530,8 +731,8 @@ function commitPendingThreshold() {
// field untouched. Even then it must pass evaluatePinnedImpact(); "reset"
// (a pinned player would fall under the bar) also falls back to staging, so
// the normal Apply flow shows its usual confirm.
-function quickSetPlayersThreshold(value) {
- syncThresholdControls(value);
+function quickSetPlayersThreshold(value, record) {
+ syncThresholdControls(value, record);
prunePlayerSelections();
const poolMatchesApplied =
els.category.value === appliedFilters.category &&
@@ -539,7 +740,7 @@ function quickSetPlayersThreshold(value) {
els.position.value === appliedFilters.position;
if (poolMatchesApplied && playerPoolCategory === appliedFilters.category) {
const impact = evaluatePinnedImpact(
- appliedFilters,
+ appliedPoolState(),
pendingPoolState(),
distinctWorkspacePlayers(),
playerPoolRecords,
@@ -565,7 +766,7 @@ function pendingPoolState() {
category: els.category.value,
season: els.season.value,
position: els.position.value,
- threshold: Number(els.thresholdNumber.value),
+ threshold: pendingThreshold(),
};
}
@@ -577,11 +778,11 @@ function pendingPoolState() {
// guaranteed to resolve — evaluatePinnedImpact() checked them first.
function reconcileCarriedCards() {
const field = appliedCategoryMeta().threshold_field;
- const threshold = Number(appliedFilters.threshold);
+ const threshold = appliedThreshold();
// Pinned players are judged at the applied position only.
const resolve = (name) => {
const r = currentRecords.find((x) => x.player === name && x.position === appliedFilters.position);
- return r && r[field] != null && r[field] >= threshold ? r : null;
+ return r && meetsThreshold(r, field, threshold) ? r : null;
};
// A Player Card opened from a Linemate Card's "Card" button, and a Linemate
// Card anchored on such a player, can belong to a teammate at another
@@ -646,7 +847,7 @@ export function prunePlayerSelections({ notify = true } = {}) {
Array.from(selectedPlayers.keys()),
hasPool ? playerPoolRecords : [],
els.position.value,
- Number(els.thresholdNumber.value),
+ pendingThreshold(),
hasPool ? metadata[playerPoolCategory].threshold_field : null
);
if (!removed.length) return [];
@@ -735,11 +936,12 @@ export function populateCategoryDependentControls() {
}
els.thresholdFieldLabel.textContent = thresholdFieldLabel(cat);
+ els.thresholdProspectFieldLabel.textContent = thresholdFieldLabel(cat);
}
export async function loadCurrentSlice() {
const category = els.category.value;
- const season = Number(els.season.value);
+ const season = seasonValue();
// setCurrentSlice() replaces the original's direct `currentRecords =`/
// `currentSliceCategory =` assignments — data.js owns that state now, and
// ES modules don't let an importer reassign an imported `let` (see
@@ -762,7 +964,7 @@ export async function loadCurrentSlice() {
// own comment for why.
export async function updatePlayerPool() {
const category = els.category.value;
- const season = Number(els.season.value);
+ const season = seasonValue();
// setPlayerPool() — see the comment in loadCurrentSlice() above.
setPlayerPool(await fetchSlice(category, season), category);
updatePendingThresholdMax();
@@ -809,87 +1011,75 @@ export async function updatePlayerPool() {
function updatePendingThresholdMax() {
if (!playerPoolCategory) return;
const cat = metadata[playerPoolCategory];
- const positionRecords = playerPoolRecords.filter((r) => r.position === els.position.value);
- const values = positionRecords.map((r) => r[cat.threshold_field]).filter((v) => v != null);
- const maxVal = values.length ? Math.max(...values) : 100;
- const max = Math.ceil(maxVal / 10) * 10;
-
- if (resetThresholdOnNextRange) {
- const defaultValue = cat.default_thresholds[els.season.value] ?? 0;
- els.threshold.max = max;
- els.thresholdNumber.max = max;
- els.threshold.value = Math.min(Math.max(defaultValue, 0), max);
- els.thresholdNumber.value = els.threshold.value;
- return;
- }
- // Read the number box's own current value *before* touching .max below —
- // a native 's value auto-clamps to a lowered max the
- // instant it's assigned, so checking els.threshold.value afterward would
- // silently compare against an already-corrected number and skip
- // clamping thresholdNumber (caught via manual testing: the slider ended
- // up right, but the number box was left stuck at the old, too-high
- // value). thresholdNumber has no such auto-clamp, so it's the only
- // reliable "what was actually set before this ran" source here.
- const currentValue = Number(els.thresholdNumber.value);
- if (currentValue > max) {
- const defaultValue = cat.default_thresholds[els.season.value] ?? 0;
- const fallback = Math.min(Math.max(defaultValue, 0), max);
- els.threshold.max = max;
- els.thresholdNumber.max = max;
- els.threshold.value = fallback;
- els.thresholdNumber.value = fallback;
- }
+ // Each bar is judged alone (a Season page has one, the Career page two).
+ thresholdBars().forEach((bar) => {
+ const max = barMax(playerPoolRecords, cat, bar);
+
+ if (resetThresholdOnNextRange) {
+ const defaultValue = defaultThresholdOf(cat, bar);
+ bar.slider.max = max;
+ bar.number.max = max;
+ bar.slider.value = Math.min(Math.max(defaultValue, 0), max);
+ bar.number.value = bar.slider.value;
+ return;
+ }
+
+ // Read the number box's own current value *before* touching .max below —
+ // a native 's value auto-clamps to a lowered max the
+ // instant it's assigned, so checking the slider's value afterward would
+ // silently compare against an already-corrected number and skip clamping
+ // the number box. The number box has no such auto-clamp, so it's the only
+ // reliable "what was actually set before this ran" source here.
+ const currentValue = Number(bar.number.value);
+ if (currentValue > max) {
+ const defaultValue = defaultThresholdOf(cat, bar);
+ const fallback = Math.min(Math.max(defaultValue, 0), max);
+ bar.slider.max = max;
+ bar.number.max = max;
+ bar.slider.value = fallback;
+ bar.number.value = fallback;
+ }
+ });
}
export function updateThresholdRange() {
const cat = currentCategoryMeta();
- // currentRecords now holds every position in the category (see
- // fetchSlice) — scope to the pending position before computing the
- // slider's max, otherwise switching to a smaller-pool position (e.g. DI)
- // would inherit a max sized for a bigger one (e.g. ED).
- const positionRecords = currentRecords.filter((r) => r.position === els.position.value);
- const values = positionRecords.map((r) => r[cat.threshold_field]).filter((v) => v != null);
- const maxVal = values.length ? Math.max(...values) : 100;
- const max = Math.ceil(maxVal / 10) * 10;
-
- els.threshold.min = 0;
- els.threshold.step = 5;
- els.thresholdNumber.min = 0;
-
- // .max is only reassigned inside the two branches below, not
- // unconditionally up front — same "don't move the bar unless the value
- // actually needs to" rule as updatePendingThresholdMax()'s live
- // counterpart (see its comment for the full reasoning). A no-op Apply
- // (e.g. one historical season swapped for another, both sharing
- // DEFAULT_THRESHOLDS' one static value) now leaves the slider's fill
- // percentage exactly where it was instead of jumping to match
- // whatever slightly different real max the new slice happens to have.
- if (resetThresholdOnNextRange) {
- const defaultValue = cat.default_thresholds[els.season.value] ?? 0;
- els.threshold.max = max;
- els.thresholdNumber.max = max;
- els.threshold.value = Math.min(Math.max(defaultValue, 0), max);
- resetThresholdOnNextRange = false;
- } else if (Number(els.threshold.value) > max) {
- // Every other Season/Position change (Position always; Season only
- // when it isn't an ongoing↔historical transition — main.js's season
- // "change" handler already forces resetThresholdOnNextRange for that
- // case via resetThresholdToCategoryDefault(), so this branch won't even
- // run for it) — keep the user's value if it still fits; otherwise fall
- // back to this season's configured default rather than the new slice's
- // raw max (see updatePendingThresholdMax()'s matching comment — same
- // bug, same fix, just the Apply-time copy of it).
- const defaultValue = cat.default_thresholds[els.season.value] ?? 0;
- els.threshold.max = max;
- els.thresholdNumber.max = max;
- els.threshold.value = Math.min(Math.max(defaultValue, 0), max);
- }
- // else: the current value still fits under the new max, and nothing
- // forced a default reset — leave .max/.thresholdNumber.max exactly as
- // they are so the bar doesn't move for a change that isn't there.
+ const resetting = resetThresholdOnNextRange;
+
+ thresholdBars().forEach((bar) => {
+ // currentRecords holds every position in the category (see fetchSlice) —
+ // barMax() scopes to the pending position (and, on Career, to the bar's
+ // status), otherwise switching to a smaller-pool position would inherit a
+ // max sized for a bigger one.
+ const max = barMax(currentRecords, cat, bar);
+
+ bar.slider.min = 0;
+ bar.slider.step = 5;
+ bar.number.min = 0;
+
+ // .max is only reassigned inside the two branches below, not
+ // unconditionally up front: the bar must not move unless the value
+ // actually needs to (see updatePendingThresholdMax()).
+ if (resetting) {
+ const defaultValue = defaultThresholdOf(cat, bar);
+ bar.slider.max = max;
+ bar.number.max = max;
+ bar.slider.value = Math.min(Math.max(defaultValue, 0), max);
+ } else if (Number(bar.slider.value) > max) {
+ // The user's value no longer fits: fall back to the configured default
+ // rather than the new slice's raw max.
+ const defaultValue = defaultThresholdOf(cat, bar);
+ bar.slider.max = max;
+ bar.number.max = max;
+ bar.slider.value = Math.min(Math.max(defaultValue, 0), max);
+ }
+ // else: the current value still fits and nothing forced a reset, so the bar stays.
- els.thresholdNumber.value = els.threshold.value;
+ bar.number.value = bar.slider.value;
+ });
+
+ if (resetting) resetThresholdOnNextRange = false;
}
// Fired immediately on a Category switch only (main.js's attachEvents()) —
@@ -906,7 +1096,7 @@ export function updateThresholdRange() {
// it could pin the slider at the far-right edge for however long the fetch
// took, then visibly snap back once corrected). 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
+// (see config/thresholds.py's resolve_default_threshold — a season may carry a static
// or dynamic default) and never tries to preserve whatever the user had
// set beforehand. The accurate slider max (which needs the new category's
// fetched data) still gets corrected as soon as it's available via
@@ -917,17 +1107,22 @@ export function updateThresholdRange() {
// other, same as every other place both controls change at once.
export function resetThresholdToCategoryDefault() {
const cat = currentCategoryMeta();
- 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
- // to set. loadCurrentSlice() overwrites this with the real max at Apply.
- if (Number(els.threshold.max) < defaultValue) {
- els.threshold.max = defaultValue;
- els.thresholdNumber.max = defaultValue;
- }
- els.threshold.value = defaultValue;
- els.thresholdNumber.value = defaultValue;
+
+ thresholdBars().forEach((bar) => {
+ const defaultValue = defaultThresholdOf(cat, bar);
+
+ // 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 default) —
+ // extend it so the browser doesn't silently clamp the value we're about
+ // to set. loadCurrentSlice() overwrites this with the real max at Apply.
+ if (Number(bar.slider.max) < defaultValue) {
+ bar.slider.max = defaultValue;
+ bar.number.max = defaultValue;
+ }
+
+ bar.slider.value = defaultValue;
+ bar.number.value = defaultValue;
+ });
}
export function closeFiltersDrawer() {
@@ -956,7 +1151,7 @@ export async function applyFilters() {
if (
els.category.value === appliedFilters.category &&
els.position.value === appliedFilters.position &&
- (els.season.value !== appliedFilters.season || els.thresholdNumber.value !== appliedFilters.threshold)
+ (els.season.value !== appliedFilters.season || thresholdsChanged())
) {
await updatePlayerPool();
}
@@ -965,7 +1160,7 @@ export async function applyFilters() {
// (see pinned-impact.js and CLAUDE.md's "Pool-change invalidation"). Axes /
// Teams / Players never participate — none affect positionPool().
const impact = evaluatePinnedImpact(
- appliedFilters,
+ appliedPoolState(),
pendingPoolState(),
pinned,
playerPoolRecords,
diff --git a/frontend/images/metric_formulas/pbe.svg b/frontend/images/metric_formulas/pbe.svg
index f0da1d6..d1cad90 100644
--- a/frontend/images/metric_formulas/pbe.svg
+++ b/frontend/images/metric_formulas/pbe.svg
@@ -1,16 +1,16 @@
-