Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
76716d8
Added players spotlight.
StarsExpress Jul 21, 2026
c48245d
Added CI to dev branch.
StarsExpress Jul 21, 2026
158f64d
Adjusted workflows triggers.
StarsExpress Jul 21, 2026
9ecd3f9
Allowed multi cards and multi player selection.
StarsExpress Jul 25, 2026
9cc38e8
Allowed players cards to be expandable and shrinkable.
StarsExpress Jul 26, 2026
c9c2461
Enhanced layout.
StarsExpress Jul 30, 2026
bab631a
Merge branch 'main' into dev
StarsExpress Jul 30, 2026
5dfbba4
Removed highlightSubtitle off app.js.
StarsExpress Jul 30, 2026
bb68363
Added watermark for downloading PNGs.
StarsExpress Jul 31, 2026
565ef1f
Added player cards merger framework.
StarsExpress Jul 31, 2026
08ba9fa
Refined linemates card and fixed bugs.
StarsExpress Aug 1, 2026
657a141
Refined cards merger.
StarsExpress Aug 2, 2026
19c87e4
Merge branch 'main' into dev
StarsExpress Aug 2, 2026
aefced7
Completed modularization.
StarsExpress Aug 2, 2026
181202e
Adjusted comment for config.py.
StarsExpress Aug 2, 2026
9b534c7
Added freeze panes for merged cards.
StarsExpress Aug 3, 2026
9191339
Enhanced UI.
StarsExpress Aug 4, 2026
1f76bc6
Reformatted code.
StarsExpress Aug 4, 2026
9113cde
Added save card buttons.
StarsExpress Aug 5, 2026
3e804c3
Adjusted README.md.
StarsExpress Aug 5, 2026
d5e0179
Merge branch 'main' into dev
StarsExpress Aug 5, 2026
6436e98
Aligned single cards & merged cards behaviors.
StarsExpress Aug 7, 2026
782fb10
Changed majority of fonts to Oswald.
StarsExpress Aug 7, 2026
ffba1cf
Widened merged cards default width.
StarsExpress Aug 8, 2026
9aacba6
Enhanced UI.
StarsExpress Aug 8, 2026
e6ba26e
Removed paths conditions off black-lint.yml.
StarsExpress Aug 8, 2026
6eeb53e
Enhanced UI.
StarsExpress Aug 8, 2026
c506e76
Added sorting direction indicators for data frames.
StarsExpress Aug 8, 2026
26e3a04
Added BG & text color for percentiles.
StarsExpress Aug 8, 2026
9bcd86b
Added more Neubrutalism.
StarsExpress Aug 9, 2026
8e9d4ed
Merge branch 'main' into dev
StarsExpress Aug 10, 2026
4d2dce0
Added dynamic thresholds for ongoing season.
StarsExpress Aug 30, 2026
7676019
Reformatted by Black lint.
StarsExpress Aug 30, 2026
a56bfc7
Added below threshold match popup.
StarsExpress Aug 30, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -19,3 +19,7 @@ data/*.db-wal
.vscode/

node_modules/

.claude/
CLAUDE.md
BLUEPRINT.md
41 changes: 40 additions & 1 deletion config.py
Original file line number Diff line number Diff line change
@@ -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")
Expand All @@ -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]
13 changes: 13 additions & 0 deletions database/db_ingestion.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
4 changes: 4 additions & 0 deletions frontend/dom.js
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down
147 changes: 141 additions & 6 deletions frontend/filters.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@
*/
import { els } from "./dom.js";
import {
metadata,
playerPoolCategory,
appliedFilters,
currentRecords,
fetchSlice,
Expand All @@ -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";
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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";
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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) {
Expand All @@ -454,16 +587,18 @@ 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
// the number input together so they can never fall out of sync with each
// 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
Expand Down
23 changes: 23 additions & 0 deletions frontend/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -507,6 +507,29 @@ <h4 class="linemate-summary-title">Line Summary</h4>
</div>
</div>

<!-- Below Threshold Matches popup — surfaces a Players-search name match
whose season snap count/opportunities fall under the current threshold,
rather than leaving that indistinguishable from "no such player".
Opened from the "Below Threshold Matches (N)" toggle rendered above the
first candidate in #players-dropdown (see renderPlayersDropdown() in
filters.js), never auto-popped. Same modal pattern as #merge-edit-overlay
above — single static instance, repopulated per open via
openBelowThresholdPopup(), closes only via its own Close button (no
outside-click dismissal). Each row's "Set Threshold" button sets the
threshold to that player's own value (the filter is inclusive, so no
off-by-one is needed) and closes the popup — leaving it open would show a
stale list, since the clicked player (and possibly others) no longer
belong in it. -->
<div class="merge-edit-overlay" id="below-threshold-overlay" hidden>
<div class="merge-edit-box below-threshold-box" role="dialog" aria-modal="true" aria-labelledby="below-threshold-title">
<p class="merge-edit-title" id="below-threshold-title">Below Threshold Matches</p>
<div class="below-threshold-list" id="below-threshold-list"></div>
<div class="merge-edit-actions">
<button type="button" class="merge-edit-done" id="below-threshold-close">Close</button>
</div>
</div>
</div>

<script src="vendor/plotly.min.js?v={{VERSION}}"></script>
<script src="vendor/html2canvas.min.js?v={{VERSION}}"></script>
<script type="module" src="main.js?v={{VERSION}}"></script>
Expand Down
6 changes: 6 additions & 0 deletions frontend/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import {
closePlayersPanel,
hidePlayersDropdown,
runPlayersSearch,
closeBelowThresholdPopup,
prunePlayerSelections,
updatePendingState,
updatePlayerPool,
Expand Down Expand Up @@ -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
Expand Down
12 changes: 12 additions & 0 deletions frontend/search.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading