From a60e163e1535c82fe25c962de62cadc4182ec918 Mon Sep 17 00:00:00 2001 From: Chris Hondl Date: Thu, 9 Jul 2026 23:08:33 -0700 Subject: [PATCH 1/6] Fix infinite media-request loop on match pages --- frontend/src/pagesContent/match/[match_id]/imageRow.tsx | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/frontend/src/pagesContent/match/[match_id]/imageRow.tsx b/frontend/src/pagesContent/match/[match_id]/imageRow.tsx index 01f43d49..dd16134c 100644 --- a/frontend/src/pagesContent/match/[match_id]/imageRow.tsx +++ b/frontend/src/pagesContent/match/[match_id]/imageRow.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { useEffect, useState } from "react"; +import React, { useEffect, useMemo, useState } from "react"; import Image from "next/image"; @@ -10,8 +10,10 @@ import { getMediaUrls } from "../../../utils"; const ImageRow = ({ data }: { data: MatchData }) => { const [media, setMedia] = useState(null); - const reverseBlue = [...data?.match?.alliances?.blue?.team_keys].reverse(); - const teams = data?.match?.alliances?.red?.team_keys?.concat(reverseBlue); + const teams = useMemo(() => { + const reverseBlue = [...data?.match?.alliances?.blue?.team_keys].reverse(); + return data?.match?.alliances?.red?.team_keys?.concat(reverseBlue); + }, [data]); const year = data.year.year; useEffect(() => { From 875433fe1518d81cc94638be7041fdf1532c56cf Mon Sep 17 00:00:00 2001 From: Chris Hondl Date: Thu, 9 Jul 2026 23:08:33 -0700 Subject: [PATCH 2/6] Fix Previous/Next match navigation not updating page --- frontend/src/pages/match/[match_id].tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/pages/match/[match_id].tsx b/frontend/src/pages/match/[match_id].tsx index 258e97ae..a90dbfc9 100644 --- a/frontend/src/pages/match/[match_id].tsx +++ b/frontend/src/pages/match/[match_id].tsx @@ -23,7 +23,7 @@ const InnerPage = () => { useEffect(() => { const fetchMatchData = async () => { - if (!match_id || data) return; + if (!match_id || data?.match?.key == match_id) return; try { const matchData = await getMatch(match_id as string); From eb71894e93edbc4439e4428815ad5ee38bea0236 Mon Sep 17 00:00:00 2001 From: Chris Hondl Date: Fri, 10 Jul 2026 08:07:36 -0700 Subject: [PATCH 3/6] Fix Strength of Schedule crash when pre-event EPAs are identical Floor the EPA-percentile Gaussian variance so it is always > 0. When every team at an event shares the same pre-event start EPA (true for all events early in a season, before ratings diverge), epaSd is 0, so the variance (epaSd^2 * 5 / N) is 0 -- or NaN when floating-point error makes the variance argument to Math.sqrt slightly negative. gaussian() throws on variance <= 0, which rejected the un-awaited strengthOfSchedule() promise in the worker and left the SOS table blank; the NaN path instead rendered NaN in the EPA/Composite columns. Both are the same degeneracy. '|| 1e-9' treats 0 and NaN alike, yielding a neutral 0.5 EPA percentile, and leaves every real positive variance untouched. --- frontend/src/pagesContent/event/[event_id]/worker.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/pagesContent/event/[event_id]/worker.ts b/frontend/src/pagesContent/event/[event_id]/worker.ts index 3300ebe8..b82e9205 100644 --- a/frontend/src/pagesContent/event/[event_id]/worker.ts +++ b/frontend/src/pagesContent/event/[event_id]/worker.ts @@ -570,7 +570,7 @@ async function _strengthOfSchedule(data: EventData, simCount: number, postEvent: currTeamOpponents.length; const deltaEPA = epaAvg + 2 * avgPartnerEPA - 3 * avgOpponentEPA; - const distrib = Gaussian(0, (epaSd * epaSd * 5) / N); + const distrib = Gaussian(0, (epaSd * epaSd * 5) / N || 1e-9); const epaPercentile = 1 - distrib.cdf(deltaEPA); const overallPercentile = (rankPercentile + rpPercentile + epaPercentile) / 3; From e690125622a89d31a9e53e77b14bf3a6841f3973 Mon Sep 17 00:00:00 2001 From: Chris Hondl Date: Fri, 10 Jul 2026 08:12:45 -0700 Subject: [PATCH 4/6] Fix EPA column showing 0 on the event Simulation tab Read the team EPA from epa.breakdown.total_points, the field the SOS tab and the simulation worker already use. simulation.tsx read epa.total_points.mean, which matches the stale APITeamEvent type but not the runtime shape: the backend serves epa.total_points as a plain number, so .mean is undefined and every row rendered EPA 0. --- frontend/src/pagesContent/event/[event_id]/simulation.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/src/pagesContent/event/[event_id]/simulation.tsx b/frontend/src/pagesContent/event/[event_id]/simulation.tsx index 9fbd8e36..d12084c9 100644 --- a/frontend/src/pagesContent/event/[event_id]/simulation.tsx +++ b/frontend/src/pagesContent/event/[event_id]/simulation.tsx @@ -137,7 +137,7 @@ const SimulationSection = ({ eventId, data }: { eventId: string; data: EventData rank: i + 1, num: teamEvent.team, team: teamEvent?.team_name, - epa: round(teamEvent?.epa?.total_points?.mean ?? 0, 1), + epa: round(teamEvent?.epa?.breakdown?.total_points ?? 0, 1), rankMean: rankMean[teamEvent.team] ? round(rankMean[teamEvent.team], 2) : "", rank5: rank5[teamEvent.team] ? round(rank5[teamEvent.team], 2) : "", rank50: rank50[teamEvent.team] ? round(rank50[teamEvent.team], 2) : "", @@ -158,7 +158,7 @@ const SimulationSection = ({ eventId, data }: { eventId: string; data: EventData return { num: teamEvent.team, team: teamEvent?.team_name, - epa: round(teamEvent?.epa?.total_points?.mean ?? 0, 1), + epa: round(teamEvent?.epa?.breakdown?.total_points ?? 0, 1), rankMean: rankMean[teamEvent.team] ? round(rankMean[teamEvent.team], 2) : "", RPMean: RPMean[teamEvent.team] ? round(RPMean[teamEvent.team], 2) : "", ...probsObj, From 4b9275cb6d8a87efc2707999bbbda5525a740ff5 Mon Sep 17 00:00:00 2001 From: Chris Hondl Date: Thu, 9 Jul 2026 23:46:25 -0700 Subject: [PATCH 5/6] Sort null-score matches last in noteworthy queries CockroachDB orders NULLs first under ORDER BY ... DESC, so a match whose clean score is null on both alliances (a fully-DQed / placeholder match, e.g. 2026txmca_sf6m1) sorted to the top of every noteworthy list -- ranking a 0/null result as the highest clean score. greatest()/sum() over the no_foul columns yields null when both alliances lack a clean result. Add .nullslast() to every noteworthy order_by so these matches fall to the bottom (out of the top 30) and real high-scoring matches rank first, while legitimate single-DQ matches keep ranking by their scoring alliance. --- backend/src/db/functions/noteworthy_matches.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/backend/src/db/functions/noteworthy_matches.py b/backend/src/db/functions/noteworthy_matches.py index 889b238a..017f22fe 100644 --- a/backend/src/db/functions/noteworthy_matches.py +++ b/backend/src/db/functions/noteworthy_matches.py @@ -55,14 +55,14 @@ def callback(session: SessionType): matches.add_columns( func.greatest(red_score_col, blue_score_col).label("max_score") ) - .order_by(desc("max_score"), asc(MatchORM.time)) # type: ignore + .order_by(desc("max_score").nullslast(), asc(MatchORM.time)) # type: ignore .limit(30) .all() ) combined_score_matches = ( matches.add_columns((red_score_col + blue_score_col).label("sum_score")) # type: ignore - .order_by(desc("sum_score"), asc(MatchORM.time)) # type: ignore + .order_by(desc("sum_score").nullslast(), asc(MatchORM.time)) # type: ignore .limit(30) .all() ) @@ -73,7 +73,7 @@ def callback(session: SessionType): "losing_score" ), ) - .order_by(desc("losing_score"), asc(MatchORM.time)) # type: ignore + .order_by(desc("losing_score").nullslast(), asc(MatchORM.time)) # type: ignore .limit(30) .all() ) @@ -86,7 +86,7 @@ def callback(session: SessionType): "max_auto_score" ) ) - .order_by(desc("max_auto_score"), asc("time")) # type: ignore + .order_by(desc("max_auto_score").nullslast(), asc("time")) # type: ignore .limit(30) .all() ) @@ -97,7 +97,7 @@ def callback(session: SessionType): "max_teleop_score" ) ) - .order_by(desc("max_teleop_score"), asc("time")) # type: ignore + .order_by(desc("max_teleop_score").nullslast(), asc("time")) # type: ignore .limit(30) .all() ) @@ -108,7 +108,7 @@ def callback(session: SessionType): "max_endgame_score" ) ) - .order_by(desc("max_endgame_score"), asc("time")) # type: ignore + .order_by(desc("max_endgame_score").nullslast(), asc("time")) # type: ignore .limit(30) .all() ) From c7c60bbdf412ef3792ae0ca0e24636a0baa7bdf7 Mon Sep 17 00:00:00 2001 From: Chris Hondl Date: Thu, 9 Jul 2026 23:46:25 -0700 Subject: [PATCH 6/6] Dedup in-flight fetches in storage query() On team pages multiple components concurrently request the same event/blob resources. Each caller awaits getWithExpiry(), all miss IndexedDB (nothing is written until a fetch completes), and all issue their own network fetch, so every blob/API resource was downloaded twice per page load. Add a module-level in-flight promise map keyed by storageKey: concurrent callers for the same key now share one fetch, and the entry is cleared once it settles. --- frontend/src/api/storage.tsx | 33 +++++++++++++++++++++++++-------- 1 file changed, 25 insertions(+), 8 deletions(-) diff --git a/frontend/src/api/storage.tsx b/frontend/src/api/storage.tsx index d5b17b19..f7db1ebe 100644 --- a/frontend/src/api/storage.tsx +++ b/frontend/src/api/storage.tsx @@ -37,19 +37,14 @@ export function decompress(buffer: any) { return data; } -async function query( +const inFlight: { [storageKey: string]: Promise } = {}; + +async function fetchAndStore( storageKey: string, apiPath: string, checkBucket: boolean, - minLength: number, expiry: number ) { - const cacheData = await getWithExpiry(storageKey); - if (cacheData && (minLength === 0 || cacheData?.length > minLength)) { - log(`Used Local Storage: ${storageKey}`); - return cacheData; - } - const start = performance.now(); let buffer = null; @@ -85,4 +80,26 @@ async function query( } } +async function query( + storageKey: string, + apiPath: string, + checkBucket: boolean, + minLength: number, + expiry: number +) { + const cacheData = await getWithExpiry(storageKey); + if (cacheData && (minLength === 0 || cacheData?.length > minLength)) { + log(`Used Local Storage: ${storageKey}`); + return cacheData; + } + + if (!inFlight[storageKey]) { + inFlight[storageKey] = fetchAndStore(storageKey, apiPath, checkBucket, expiry).finally(() => { + delete inFlight[storageKey]; + }); + } + + return inFlight[storageKey]; +} + export default query;