diff --git a/backend/pytest.ini b/backend/pytest.ini
new file mode 100644
index 00000000..4584de7e
--- /dev/null
+++ b/backend/pytest.ini
@@ -0,0 +1,3 @@
+[pytest]
+testpaths = tests
+pythonpath = .
diff --git a/backend/src/api/query.py b/backend/src/api/query.py
index b59298ad..5a22788e 100644
--- a/backend/src/api/query.py
+++ b/backend/src/api/query.py
@@ -24,7 +24,7 @@
event_type_query = Query(
None,
- description="One of [`regional`, `district`, `district_cmp`, `champs_div`, or `einstein`].",
+ description="One of [`regional`, `district`, `district_cmp`, `champs_div`, `einstein`, or `offseason`].",
)
limit_query = Query(
diff --git a/backend/src/data/router.py b/backend/src/data/router.py
index 40c7ee19..3cca8e4e 100644
--- a/backend/src/data/router.py
+++ b/backend/src/data/router.py
@@ -1,10 +1,14 @@
+import re
+import time
+
import requests
-from fastapi import APIRouter, BackgroundTasks
+from fastapi import APIRouter, BackgroundTasks, Response
from src.constants import BACKEND_URL, CURR_YEAR
-from src.data.main import refresh_teams, update_curr_year, reset_all_years
+from src.data.main import refresh_teams, reset_all_years, update_curr_year
from src.data.tba import check_year_partial as check_year_partial_tba
-from src.db.read import get_etags as get_etags_db, get_events as get_events_db
+from src.db.read import get_etags as get_etags_db
+from src.db.read import get_events as get_events_db
data_router = APIRouter()
site_router = APIRouter()
@@ -61,3 +65,45 @@ async def update_curr_year_site_endpoint(background_tasks: BackgroundTasks):
background_tasks.add_task(update_curr_year_background)
return {"status": "backgrounded"}
+
+
+# Read-triggered freshness ping.
+#
+# Event pages fire-and-forget GET /v3/site/ping/event/{key} while an event is
+# live. The hot path below is pure in-process memory (no DB, no GCS, no TBA):
+# during the cooldown or while a probe is in flight, a ping costs a regex, a
+# float compare, and a 204. The data service runs a single gunicorn worker by
+# design (structurally single-writer), so module globals are authoritative.
+#
+# A cold ping schedules a background self-HTTP to /v3/site/update_curr_year —
+# the existing cheap probe (TBA etag pre-check, then a backgrounded partial
+# cycle only if something actually changed). The 300s cooldown bounds TBA
+# traffic to one probe per 5 minutes no matter how many viewers pile on.
+PING_COOLDOWN_S = 300
+
+_ping_last_probe: float = float("-inf")
+_ping_inflight: bool = False
+
+
+def _ping_probe():
+ global _ping_inflight
+ try:
+ # Bounded timeout so a hung probe can never wedge _ping_inflight (and
+ # thus disable the fast path) permanently: (connect, read) seconds.
+ requests.get(f"{BACKEND_URL}/v3/site/update_curr_year", timeout=(5, 30))
+ finally:
+ _ping_inflight = False
+
+
+@site_router.get("/ping/event/{event_key}")
+async def ping_event_endpoint(event_key: str, background_tasks: BackgroundTasks):
+ global _ping_last_probe, _ping_inflight
+ if not re.fullmatch(rf"{CURR_YEAR}[a-z0-9]+", event_key):
+ return Response(status_code=204)
+ now = time.monotonic()
+ if _ping_inflight or now - _ping_last_probe < PING_COOLDOWN_S:
+ return Response(status_code=204)
+ _ping_last_probe = now
+ _ping_inflight = True
+ background_tasks.add_task(_ping_probe)
+ return Response(status_code=202)
diff --git a/backend/src/data/wins.py b/backend/src/data/wins.py
index 5db32e84..57199f8f 100644
--- a/backend/src/data/wins.py
+++ b/backend/src/data/wins.py
@@ -5,7 +5,7 @@
from src.constants import CURR_YEAR
from src.data.utils import objs_type
from src.db.models import Event, Match, Team, TeamEvent, TeamYear
-from src.types.enums import MatchStatus, MatchWinner
+from src.types.enums import EventType, MatchStatus, MatchWinner
from src.utils.utils import r
@@ -19,6 +19,7 @@ def winrate(wins: int, ties: int, count: int) -> float:
def process_year(objs: objs_type) -> objs_type:
year_num = objs[0].year
+ event_to_type = {e.key: e.type for e in objs[2].values()}
ty_record: Dict[int, TRecord] = defaultdict(lambda: (0, 0, 0, 0))
te_record: Dict[Tuple[int, str], TRecord] = defaultdict(lambda: (0, 0, 0, 0))
@@ -31,7 +32,11 @@ def process_year(objs: objs_type) -> objs_type:
status = m_obj.status
winner = m_obj.winner
- if status != MatchStatus.COMPLETED or winner is None:
+ if (
+ event_to_type[event] == EventType.OFFSEASON
+ or status != MatchStatus.COMPLETED
+ or winner is None
+ ):
continue
for alliance in ["red", "blue"]:
diff --git a/backend/src/db/functions/noteworthy_matches.py b/backend/src/db/functions/noteworthy_matches.py
index 889b238a..2e4ffddc 100644
--- a/backend/src/db/functions/noteworthy_matches.py
+++ b/backend/src/db/functions/noteworthy_matches.py
@@ -7,7 +7,7 @@
from src.db.main import Session
from src.db.models.event import EventORM
from src.db.models.match import Match, MatchORM
-from src.types.enums import MatchStatus
+from src.types.enums import EventType, MatchStatus
def get_noteworthy_matches(
@@ -29,6 +29,7 @@ def callback(session: SessionType):
(MatchORM.year == year)
& (MatchORM.status == MatchStatus.COMPLETED)
& (MatchORM.event == EventORM.key)
+ & (EventORM.type != EventType.OFFSEASON)
)
if country is not None:
diff --git a/backend/src/models/template.py b/backend/src/models/template.py
index 7854d04e..4c38731e 100644
--- a/backend/src/models/template.py
+++ b/backend/src/models/template.py
@@ -3,7 +3,7 @@
from src.db.models import Event, Match, TeamEvent, TeamYear, Year
from src.models.types import AlliancePred, Attribution, MatchPred
from src.tba.constants import PLACEHOLDER_TEAMS
-from src.types.enums import MatchStatus
+from src.types.enums import EventType, MatchStatus
class Model:
@@ -74,7 +74,8 @@ def process_match(
attributions = self.attribute_match(match, red_pred, blue_pred)
- # Don't update if 1) placeholder match, 2) elim dq, 3) all fouls
+ # Don't update if 1) offseason, 2) placeholder match, 3) elim dq, 4) all fouls
+ offseason_event = event.type == EventType.OFFSEASON
teams = set(match.get_red() + match.get_blue())
placeholder_match = len(set(PLACEHOLDER_TEAMS).intersection(teams)) > 0
elim_dq = match.elim and (
@@ -87,7 +88,7 @@ def process_match(
and match.red_no_foul == 0
and (match.red_foul or 0) > 0
)
- skip_update = placeholder_match or elim_dq or all_fouls
+ skip_update = offseason_event or placeholder_match or elim_dq or all_fouls
epas: Dict[str, Any] = {}
for team, attr in attributions.items():
diff --git a/backend/src/tba/read_tba.py b/backend/src/tba/read_tba.py
index ae6b83ed..3216af7d 100644
--- a/backend/src/tba/read_tba.py
+++ b/backend/src/tba/read_tba.py
@@ -10,6 +10,7 @@
EVENT_BLACKLIST,
EVENT_TYPE_OVERRIDES,
MATCH_BLACKLIST,
+ PLACEHOLDER_TEAMS,
)
from src.tba.main import get_tba
from src.tba.types import EventDict, MatchDict, TeamDict
@@ -95,7 +96,29 @@ def get_events(
event_type_int = int(event["event_type"])
if event_type_int in (99, 100) and key not in EVENT_TYPE_OVERRIDES:
- continue
+ if event_type_int == 100:
+ continue # preseason
+ # offseason events are ingested for 2025+ with quality filters
+ if year < 2025:
+ continue
+ try:
+ event_teams = get_event_teams(key, etag=None, cache=cache)[0]
+ # remove events with less than 6 teams
+ if len(event_teams) < 6:
+ continue
+ if len(set(PLACEHOLDER_TEAMS).intersection(set(event_teams))) > 0:
+ continue
+ matches = get_tba(f"event/{key}/matches", etag=None, cache=cache)[0]
+ end_date = datetime.strptime(event["end_date"], "%Y-%m-%d")
+ if len(matches) == 0 and (datetime.now() - end_date).days >= 1: # type: ignore
+ continue
+ for match in matches: # type: ignore
+ all_teams = match["alliances"]["red"]["team_keys"]
+ all_teams += match["alliances"]["blue"]["team_keys"]
+ all_teams = [int(x[3:]) for x in all_teams] # asserts no B teams
+ except Exception:
+ # remove events with B teams
+ continue
event_type_dict: Dict[int, EventType] = defaultdict(lambda: EventType.INVALID)
event_type_dict[0] = EventType.REGIONAL
@@ -107,6 +130,7 @@ def get_events(
event_type_dict[5] = EventType.DISTRICT_CMP
# rename festival of championships to einsteins
event_type_dict[6] = EventType.EINSTEIN
+ event_type_dict[99] = EventType.OFFSEASON
event_type = event_type_dict[event_type_int]
if key in EVENT_TYPE_OVERRIDES:
@@ -122,6 +146,9 @@ def get_events(
if event_type.is_champs():
event["week"] = 8
+ if event_type == EventType.OFFSEASON:
+ event["week"] = 9
+
# filter out incomplete events
if "week" not in event or event["week"] is None:
continue
diff --git a/backend/tests/test_offseason_epa_freeze.py b/backend/tests/test_offseason_epa_freeze.py
new file mode 100644
index 00000000..5a5643a6
--- /dev/null
+++ b/backend/tests/test_offseason_epa_freeze.py
@@ -0,0 +1,73 @@
+from src.db.models import Event, Match, TeamEvent, TeamYear, Year
+from src.models.template import Model
+from src.models.types import AlliancePred, Attribution
+from src.types.enums import CompLevel, EventType, MatchStatus
+
+
+class RecordingModel(Model):
+ def __init__(self):
+ super().__init__()
+ self.updated = []
+
+ def predict_match(self, match, event):
+ return 0.5, AlliancePred(10.0, None), AlliancePred(10.0, None)
+
+ def attribute_match(self, match, red_pred, blue_pred):
+ return {t: Attribution() for t in match.get_red() + match.get_blue()}
+
+ def update_team(self, team, attrib, match):
+ self.updated.append(team)
+
+
+def mk_match(event_key, week):
+ return Match(
+ key=f"{event_key}_qm1",
+ year=2026,
+ event=event_key,
+ week=week,
+ elim=False,
+ comp_level=CompLevel.QUAL,
+ set_number=1,
+ match_number=1,
+ time=0,
+ status=MatchStatus.COMPLETED,
+ red_1=1,
+ red_2=2,
+ red_3=3,
+ blue_1=4,
+ blue_2=5,
+ blue_3=6,
+ red_dq="",
+ red_surrogate="",
+ blue_dq="",
+ blue_surrogate="",
+ red_score=20,
+ blue_score=10,
+ red_no_foul=20,
+ blue_no_foul=10,
+ )
+
+
+def run_model(event_type, week):
+ model = RecordingModel()
+ model.start_season(Year(year=2026), {}, {})
+ event = Event(key="2026x", year=2026, name="X", type=event_type, week=week)
+ match = mk_match("2026x", week)
+ teams = [1, 2, 3, 4, 5, 6]
+ team_events = {t: TeamEvent(team=t, year=2026, event="2026x") for t in teams}
+ team_years = {t: TeamYear(team=t, year=2026) for t in teams}
+ model.process_match(match, event, team_events, team_years)
+ return model, match
+
+
+def test_offseason_match_skips_epa_update():
+ model, match = run_model(EventType.OFFSEASON, 9)
+ assert model.updated == []
+ # predictions and post-match records are still produced
+ assert match.pre_epas is not None
+ assert match.epas is not None
+
+
+def test_regular_match_updates_epa():
+ model, _ = run_model(EventType.REGIONAL, 1)
+ assert sorted(model.updated) == [1, 2, 3, 4, 5, 6]
diff --git a/backend/tests/test_offseason_ingest.py b/backend/tests/test_offseason_ingest.py
new file mode 100644
index 00000000..89733e8f
--- /dev/null
+++ b/backend/tests/test_offseason_ingest.py
@@ -0,0 +1,177 @@
+from datetime import datetime, timedelta
+
+import src.tba.read_tba as rt
+from src.types.enums import EventType
+
+YEAR = 2026
+FUTURE = (datetime.now() + timedelta(days=30)).strftime("%Y-%m-%d")
+PAST = (datetime.now() - timedelta(days=30)).strftime("%Y-%m-%d")
+
+
+def mk_event(key, event_type, week=None, start_date=PAST, end_date=PAST):
+ return {
+ "key": key,
+ "event_type": event_type,
+ "district": None,
+ "week": week,
+ "name": f"Event {key}",
+ "country": "USA",
+ "state_prov": "IN",
+ "start_date": start_date,
+ "end_date": end_date,
+ "webcasts": [],
+ }
+
+
+def mk_teams(nums):
+ return [{"key": f"frc{n}"} for n in nums]
+
+
+def mk_match(red, blue):
+ return {
+ "alliances": {
+ "red": {"team_keys": [f"frc{t}" for t in red]},
+ "blue": {"team_keys": [f"frc{t}" for t in blue]},
+ }
+ }
+
+
+def patch_tba(monkeypatch, events, extra=None):
+ payloads = {f"events/{YEAR}": events, "events/2024": events, **(extra or {})}
+
+ def _get(path, etag=None, cache=True):
+ return payloads.get(path, []), None
+
+ monkeypatch.setattr(rt, "get_tba", _get)
+
+
+def get_keys(year=YEAR):
+ out, _ = rt.get_events(year)
+ return {e["key"]: e for e in out}
+
+
+def test_offseason_event_ingested_as_week9_offseason(monkeypatch):
+ patch_tba(
+ monkeypatch,
+ [mk_event("2026iri", 99)],
+ {
+ "event/2026iri/teams/simple": mk_teams(range(1, 40)),
+ "event/2026iri/matches": [mk_match([1, 2, 3], [4, 5, 6])],
+ },
+ )
+ events = get_keys()
+ assert "2026iri" in events
+ assert events["2026iri"]["type"] == EventType.OFFSEASON
+ assert events["2026iri"]["week"] == 9
+
+
+def test_preseason_dropped(monkeypatch):
+ patch_tba(
+ monkeypatch,
+ [mk_event("2026week0", 100)],
+ {
+ "event/2026week0/teams/simple": mk_teams(range(1, 40)),
+ "event/2026week0/matches": [mk_match([1, 2, 3], [4, 5, 6])],
+ },
+ )
+ assert "2026week0" not in get_keys()
+
+
+def test_offseason_before_2025_dropped(monkeypatch):
+ patch_tba(
+ monkeypatch,
+ [mk_event("2024cc", 99)],
+ {
+ "event/2024cc/teams/simple": mk_teams(range(1, 40)),
+ "event/2024cc/matches": [mk_match([1, 2, 3], [4, 5, 6])],
+ },
+ )
+ assert "2024cc" not in get_keys(2024)
+
+
+def test_under_6_teams_dropped(monkeypatch):
+ patch_tba(
+ monkeypatch,
+ [mk_event("2026tiny", 99)],
+ {
+ "event/2026tiny/teams/simple": mk_teams([1, 2, 3, 4, 5]),
+ "event/2026tiny/matches": [],
+ },
+ )
+ assert "2026tiny" not in get_keys()
+
+
+def test_placeholder_team_event_dropped(monkeypatch):
+ patch_tba(
+ monkeypatch,
+ [mk_event("2026ph", 99)],
+ {
+ "event/2026ph/teams/simple": mk_teams([1, 2, 3, 4, 5, 9971]),
+ "event/2026ph/matches": [mk_match([1, 2, 3], [4, 5, 9971])],
+ },
+ )
+ assert "2026ph" not in get_keys()
+
+
+def test_b_team_event_dropped(monkeypatch):
+ patch_tba(
+ monkeypatch,
+ [mk_event("2026bt", 99)],
+ {
+ "event/2026bt/teams/simple": mk_teams(range(1, 40)),
+ "event/2026bt/matches": [
+ {
+ "alliances": {
+ "red": {"team_keys": ["frc254B", "frc2", "frc3"]},
+ "blue": {"team_keys": ["frc4", "frc5", "frc6"]},
+ }
+ }
+ ],
+ },
+ )
+ assert "2026bt" not in get_keys()
+
+
+def test_matchless_past_event_dropped(monkeypatch):
+ patch_tba(
+ monkeypatch,
+ [mk_event("2026dead", 99, end_date=PAST)],
+ {
+ "event/2026dead/teams/simple": mk_teams(range(1, 40)),
+ "event/2026dead/matches": [],
+ },
+ )
+ assert "2026dead" not in get_keys()
+
+
+def test_matchless_upcoming_event_kept(monkeypatch):
+ patch_tba(
+ monkeypatch,
+ [mk_event("2026cc", 99, start_date=FUTURE, end_date=FUTURE)],
+ {
+ "event/2026cc/teams/simple": mk_teams(range(1, 40)),
+ "event/2026cc/matches": [],
+ },
+ )
+ events = get_keys()
+ assert "2026cc" in events
+ assert events["2026cc"]["week"] == 9
+
+
+def test_event_type_override_beats_offseason(monkeypatch):
+ # 2026isrtp is a real entry in EVENT_TYPE_OVERRIDES (-> DISTRICT).
+ # It must bypass the offseason path entirely: DISTRICT type, TBA week
+ # (+1 TBA bug adjustment), NOT week 9, and it must not require the
+ # offseason quality-filter TBA calls.
+ patch_tba(monkeypatch, [mk_event("2026isrtp", 99, week=5)])
+ events = get_keys()
+ assert "2026isrtp" in events
+ assert events["2026isrtp"]["type"] == EventType.DISTRICT
+ assert events["2026isrtp"]["week"] == 6
+
+
+def test_regular_regional_unaffected(monkeypatch):
+ patch_tba(monkeypatch, [mk_event("2026gal", 0, week=0)])
+ events = get_keys()
+ assert events["2026gal"]["type"] == EventType.REGIONAL
+ assert events["2026gal"]["week"] == 1
diff --git a/backend/tests/test_offseason_records.py b/backend/tests/test_offseason_records.py
new file mode 100644
index 00000000..777bb8f4
--- /dev/null
+++ b/backend/tests/test_offseason_records.py
@@ -0,0 +1,67 @@
+from src.data.wins import process_year
+from src.db.models import Event, Match, TeamEvent, TeamYear, Year
+from src.types.enums import CompLevel, EventType, MatchStatus, MatchWinner
+
+
+def mk_objs(event_type, week):
+ year = Year(year=2026)
+ event = Event(
+ key="2026x",
+ year=2026,
+ name="X",
+ type=event_type,
+ week=week,
+ start_date="2026-07-16",
+ end_date="2026-07-18",
+ )
+ match = Match(
+ key="2026x_qm1",
+ year=2026,
+ event="2026x",
+ week=week,
+ elim=False,
+ comp_level=CompLevel.QUAL,
+ set_number=1,
+ match_number=1,
+ time=0,
+ status=MatchStatus.COMPLETED,
+ red_1=1,
+ red_2=2,
+ red_3=3,
+ blue_1=4,
+ blue_2=5,
+ blue_3=6,
+ red_dq="",
+ red_surrogate="",
+ blue_dq="",
+ blue_surrogate="",
+ winner=MatchWinner.RED,
+ red_score=20,
+ blue_score=10,
+ )
+ ty = TeamYear(team=1, year=2026)
+ te = TeamEvent(team=1, year=2026, event="2026x")
+ return (
+ year,
+ {"2026_1": ty},
+ {"2026x": event},
+ {"1_2026x": te},
+ {"2026x_qm1": match},
+ {},
+ )
+
+
+def test_offseason_match_excluded_from_records():
+ objs = mk_objs(EventType.OFFSEASON, 9)
+ process_year(objs)
+ ty = objs[1]["2026_1"]
+ te = objs[3]["1_2026x"]
+ assert (ty.wins, ty.losses, ty.ties, ty.count) == (0, 0, 0, 0)
+ assert te.count == 0
+
+
+def test_regular_match_counted():
+ objs = mk_objs(EventType.REGIONAL, 1)
+ process_year(objs)
+ ty = objs[1]["2026_1"]
+ assert (ty.wins, ty.count) == (1, 1)
diff --git a/backend/tests/test_ping.py b/backend/tests/test_ping.py
new file mode 100644
index 00000000..59be4a2e
--- /dev/null
+++ b/backend/tests/test_ping.py
@@ -0,0 +1,61 @@
+import time
+
+from fastapi import FastAPI
+from fastapi.testclient import TestClient
+
+import src.data.router as dr
+from src.constants import CURR_YEAR
+
+
+def make_client(monkeypatch, last_probe=float("-inf"), inflight=False):
+ calls = []
+ monkeypatch.setattr(dr.requests, "get", lambda url, **kw: calls.append(url))
+ monkeypatch.setattr(dr, "_ping_last_probe", last_probe)
+ monkeypatch.setattr(dr, "_ping_inflight", inflight)
+ app = FastAPI()
+ app.include_router(dr.site_router, prefix="/v3/site")
+ return TestClient(app), calls
+
+
+def test_cold_ping_schedules_probe(monkeypatch):
+ client, calls = make_client(monkeypatch)
+ resp = client.get(f"/v3/site/ping/event/{CURR_YEAR}iri")
+ assert resp.status_code == 202
+ # TestClient runs background tasks before returning
+ assert len(calls) == 1
+ assert calls[0].endswith("/v3/site/update_curr_year")
+
+
+def test_ping_within_cooldown_is_noop(monkeypatch):
+ client, calls = make_client(monkeypatch, last_probe=time.monotonic())
+ resp = client.get(f"/v3/site/ping/event/{CURR_YEAR}iri")
+ assert resp.status_code == 204
+ assert calls == []
+
+
+def test_ping_while_inflight_is_noop(monkeypatch):
+ client, calls = make_client(monkeypatch, inflight=True)
+ resp = client.get(f"/v3/site/ping/event/{CURR_YEAR}iri")
+ assert resp.status_code == 204
+ assert calls == []
+
+
+def test_second_ping_hits_cooldown(monkeypatch):
+ client, calls = make_client(monkeypatch)
+ assert client.get(f"/v3/site/ping/event/{CURR_YEAR}iri").status_code == 202
+ assert client.get(f"/v3/site/ping/event/{CURR_YEAR}iri").status_code == 204
+ assert len(calls) == 1
+
+
+def test_non_current_year_key_rejected(monkeypatch):
+ client, calls = make_client(monkeypatch)
+ resp = client.get("/v3/site/ping/event/2019ncwak")
+ assert resp.status_code == 204
+ assert calls == []
+
+
+def test_malformed_key_rejected(monkeypatch):
+ client, calls = make_client(monkeypatch)
+ resp = client.get(f"/v3/site/ping/event/{CURR_YEAR}IRI!")
+ assert resp.status_code == 204
+ assert calls == []
diff --git a/frontend/src/components/filterConstants.tsx b/frontend/src/components/filterConstants.tsx
index 7d1f5b75..0373f9ef 100644
--- a/frontend/src/components/filterConstants.tsx
+++ b/frontend/src/components/filterConstants.tsx
@@ -133,6 +133,7 @@ export const weekOptions = [
{ value: 6, label: "Week 6" },
{ value: 7, label: "Week 7" },
{ value: 8, label: "Week 8" },
+ { value: 9, label: "Offseason" },
];
export const competingOptions = [
diff --git a/frontend/src/pages/event/[event_id].tsx b/frontend/src/pages/event/[event_id].tsx
index 5ae19e0e..706199fa 100644
--- a/frontend/src/pages/event/[event_id].tsx
+++ b/frontend/src/pages/event/[event_id].tsx
@@ -8,6 +8,7 @@ import Link from "next/link";
import { useRouter } from "next/router";
import { getEvent } from "../../api/event";
+import { BACKEND_URL, CURR_YEAR } from "../../constants";
import SiteLayout from "../../layouts/siteLayout";
import Tabs from "../../pagesContent/event/[event_id]/tabs";
import NotFound from "../../pagesContent/shared/notFound";
@@ -41,6 +42,20 @@ const InnerPage = () => {
}
}, [event_id]);
+ useEffect(() => {
+ // Fire-and-forget freshness ping for live current-year events. The
+ // backend absorbs bursts with an in-memory cooldown, so this is safe to
+ // call on every page view.
+ if (!event_id || !data?.event || data.event.year !== CURR_YEAR) return;
+ const today = new Date();
+ const start = new Date(`${data.event.start_date}T00:00:00`);
+ const end = new Date(`${data.event.end_date}T23:59:59`);
+ start.setDate(start.getDate() - 1);
+ end.setDate(end.getDate() + 1);
+ if (today < start || today > end) return;
+ fetch(`${BACKEND_URL}/ping/event/${event_id}`).catch(() => {});
+ }, [event_id, data]);
+
if (!data) {
return ;
}
diff --git a/frontend/src/pagesContent/events/shared.tsx b/frontend/src/pagesContent/events/shared.tsx
index d31cf24a..b30a59bd 100644
--- a/frontend/src/pagesContent/events/shared.tsx
+++ b/frontend/src/pagesContent/events/shared.tsx
@@ -48,8 +48,17 @@ const EventsLayout = ({
// ex: 2024-01-01
const today = new Date().toISOString().split("T")[0];
+ // Any event we have from TBA that is future or ongoing must be listed; only
+ // genuinely finished events go to Completed. Bucket by the date window rather
+ // than status: offseason events often sit in status "Upcoming" on their
+ // load-in day (TBA posts the schedule only once teams are checked in, which
+ // can be shortly before matches start), and keying off status === "Ongoing"
+ // made those events disappear from the list until their schedule posted.
+ const isCompleted = (event: APIEvent) =>
+ event.status === "Completed" || event.end_date < today;
+
const ongoingEvents = sortedData
- ?.filter((event) => event.status === "Ongoing" && event.end_date >= today)
+ ?.filter((event) => !isCompleted(event) && event.start_date <= today)
.sort((a, b) => {
if (a.current_match > 0 && b.current_match === 0) return -1;
if (a.current_match === 0 && b.current_match > 0) return 1;
@@ -58,13 +67,11 @@ const EventsLayout = ({
const ongoingN = ongoingEvents.length;
const upcomingEvents = sortedData?.filter(
- (event) => event.status === "Upcoming" && event.start_date >= today
+ (event) => !isCompleted(event) && event.start_date > today
);
const upcomingN = upcomingEvents.length;
- const completedEvents = sortedData?.filter(
- (event) => event.status === "Completed" || event.end_date < today
- );
+ const completedEvents = sortedData?.filter(isCompleted);
const completedN = completedEvents.length;
return (
diff --git a/frontend/src/pagesContent/events/summary.tsx b/frontend/src/pagesContent/events/summary.tsx
index 6eb617fa..17f9d765 100644
--- a/frontend/src/pagesContent/events/summary.tsx
+++ b/frontend/src/pagesContent/events/summary.tsx
@@ -29,7 +29,7 @@ const EventCard = ({ event }: { event: APIEvent }) => {
return `${startMonth} ${startDate} to ${endMonth} ${endDate}`;
};
- const weekStr = `Week ${event.week}`;
+ const weekStr = event.week === 9 ? "Offseason" : `Week ${event.week}`;
return (