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 (