From 90e02e8ee32c07ea4b4d7ad00d6acfc4578e98a2 Mon Sep 17 00:00:00 2001 From: Kaique Date: Mon, 10 Aug 2026 21:16:55 -0300 Subject: [PATCH 1/6] add all tabs on /events/[event_key] --- src/app/events/[event_key]/EmptyTabState.tsx | 35 ++++++++ src/app/events/[event_key]/EventAwardsTab.tsx | 47 +++++++++++ src/app/events/[event_key]/EventKeyClient.tsx | 63 ++++++++------- .../events/[event_key]/EventMatchesTab.tsx | 80 +++++++++++++++++++ .../events/[event_key]/EventRankingsTab.tsx | 61 ++++++++++++++ src/app/events/[event_key]/EventTeamsTab.tsx | 45 +++++++++++ 6 files changed, 301 insertions(+), 30 deletions(-) create mode 100644 src/app/events/[event_key]/EmptyTabState.tsx create mode 100644 src/app/events/[event_key]/EventAwardsTab.tsx create mode 100644 src/app/events/[event_key]/EventMatchesTab.tsx create mode 100644 src/app/events/[event_key]/EventRankingsTab.tsx create mode 100644 src/app/events/[event_key]/EventTeamsTab.tsx diff --git a/src/app/events/[event_key]/EmptyTabState.tsx b/src/app/events/[event_key]/EmptyTabState.tsx new file mode 100644 index 0000000..71a1b3a --- /dev/null +++ b/src/app/events/[event_key]/EmptyTabState.tsx @@ -0,0 +1,35 @@ +/** + * EmptyTabState + * + * Small banner used at the top of the tabs that don't have real TBA + * data wired up yet (Matches / Teams / Rankings / Awards). Sits above + * a skeleton mockup of that tab's future layout so the screen reads + * as "finished, waiting on data" instead of "unfinished". + */ +import { LucideIcon } from "lucide-react"; + +interface EmptyTabStateProps { + icon: LucideIcon; + title: string; + description: string; +} + +export default function EmptyTabState({ icon: Icon, title, description }: EmptyTabStateProps) { + return ( +
+
+ +
+ +
+

+ {title} +

+ +

+ {description} +

+
+
+ ); +} diff --git a/src/app/events/[event_key]/EventAwardsTab.tsx b/src/app/events/[event_key]/EventAwardsTab.tsx new file mode 100644 index 0000000..74b92e8 --- /dev/null +++ b/src/app/events/[event_key]/EventAwardsTab.tsx @@ -0,0 +1,47 @@ +/** + * EventAwardsTab + * + * Design-only mockup for the "Awards" tab. Lays out the awards list + * (trophy icon + award name + recipient) that will be populated from + * TBA's awards endpoint once the event has announced results. + */ +import { Trophy } from "lucide-react"; +import EmptyTabState from "./EmptyTabState"; + +const SKELETON_AWARDS = 4; + +export default function EventAwardsTab() { + return ( +
+ + +
+

+ Awards +

+ +
+ {Array.from({ length: SKELETON_AWARDS }).map((_, i) => ( +
+
+ +
+ +
+
+
+
+
+ ))} +
+
+
+ ); +} diff --git a/src/app/events/[event_key]/EventKeyClient.tsx b/src/app/events/[event_key]/EventKeyClient.tsx index 51a4760..9300b82 100644 --- a/src/app/events/[event_key]/EventKeyClient.tsx +++ b/src/app/events/[event_key]/EventKeyClient.tsx @@ -7,17 +7,24 @@ * and then swaps in a per-tab content block based on the `tab` prop * that `page.tsx` already validated against `EventTab`. * - * Scope note: this only wires up the TAB STRUCTURE. Every tab besides - * "Scout" (which just deep-links into the existing `/scout` flow) is - * a placeholder — hooking each one up to real TBA data (matches, - * teams, rankings, awards) is deliberately left for a follow-up so - * this change stays focused on navigation, not the API layer. + * Scope note: this only wires up the TAB STRUCTURE + DESIGN. Every + * tab besides "Overview" and "Scout" (which just deep-links into the + * existing `/scout` flow) renders a design-finished skeleton mockup + * of its future layout — hooking each one up to real TBA data + * (matches, teams, rankings, awards) is deliberately left for a + * follow-up so this change stays focused on the screen's visuals, + * not the API layer. */ import Link from "next/link"; +import { ArrowRight, ClipboardList } from "lucide-react"; import EventHeader from "@/components/events/event/EventHeader"; import { EventFull, EventTab, EventWebcastType, WebcastUrl} from "@/types/events"; import EventOverview from "./EventOverviewTab"; +import EventMatchesTab from "./EventMatchesTab"; +import EventTeamsTab from "./EventTeamsTab"; +import EventRankingsTab from "./EventRankingsTab"; +import EventAwardsTab from "./EventAwardsTab"; import { EventInfoProps } from "@/components/events/event/EventInfo"; @@ -56,37 +63,22 @@ interface EventKeyProps { event: EventFull; } -/** Simple placeholder shown for tabs that aren't wired to real data yet. */ -function ComingSoon({ label }: { label: string }) { - return ( -
-

- {label} — coming soon -

- -

- This tab will be wired up to TBA data in a follow-up. -

-
- ); -} - function EventTabContent({ eventKey, tab, event}: EventKeyProps) { switch (tab) { case EventTab.Overview: return case EventTab.Matches: - return ; + return ; - case EventTab.Teams: - return ; + case EventTab.Teams: + return ; case EventTab.Rankings: - return ; + return ; case EventTab.Awards: - return ; + return ; case EventTab.Scout: // This one doesn't need TBA data at all — it just hands the @@ -94,16 +86,27 @@ function EventTabContent({ eventKey, tab, event}: EventKeyProps) { // current event pre-selected via the `?event=` search param // that `scoutPageClient.tsx` already reads. return ( -
-

- Ready to scout this event? -

+
+
+ +
+ +
+

+ Ready to scout this event? +

+ +

+ Jump into the scouting flow with this event already selected. +

+
Go to Scout Preparation +
); diff --git a/src/app/events/[event_key]/EventMatchesTab.tsx b/src/app/events/[event_key]/EventMatchesTab.tsx new file mode 100644 index 0000000..61b0616 --- /dev/null +++ b/src/app/events/[event_key]/EventMatchesTab.tsx @@ -0,0 +1,80 @@ +/** + * EventMatchesTab + * + * Design-only mockup for the "Matches" tab. Mirrors the layout the + * real qual/playoff schedule will use (status pill + red/blue alliance + * rows, same shape as `MatchRowCard`) but rendered as skeleton content + * — wiring this up to `/scout` match data is a follow-up. + */ +import { Swords } from "lucide-react"; +import EmptyTabState from "./EmptyTabState"; + +const SKELETON_ROWS = 5; + +export default function EventMatchesTab() { + return ( +
+ + +
+
+

+ Matches +

+ +
+ + Qualification + + + + Playoffs + +
+
+ +
+ {Array.from({ length: SKELETON_ROWS }).map((_, i) => ( +
+
+
+
+
+ +
+ + RED + + +
+
+
+
+
+
+ +
+ + BLUE + + +
+
+
+
+
+
+
+ ))} +
+
+
+ ); +} diff --git a/src/app/events/[event_key]/EventRankingsTab.tsx b/src/app/events/[event_key]/EventRankingsTab.tsx new file mode 100644 index 0000000..30b381f --- /dev/null +++ b/src/app/events/[event_key]/EventRankingsTab.tsx @@ -0,0 +1,61 @@ +/** + * EventRankingsTab + * + * Design-only mockup for the "Rankings" tab. Lays out the qual + * standings table (rank / team / record / RP / avg) that will be + * populated from TBA's rankings endpoint — currently skeleton rows. + */ +import { ListOrdered } from "lucide-react"; +import EmptyTabState from "./EmptyTabState"; + +const SKELETON_ROWS = 8; +const COLUMNS = ["Rank", "Team", "Record", "RP", "Avg"]; + +export default function EventRankingsTab() { + return ( +
+ + +
+
+

+ Rankings +

+
+ +
+ + + + {COLUMNS.map((col) => ( + + ))} + + + + + {Array.from({ length: SKELETON_ROWS }).map((_, i) => ( + + {COLUMNS.map((col) => ( + + ))} + + ))} + +
+ {col} +
+
+
+
+
+
+ ); +} diff --git a/src/app/events/[event_key]/EventTeamsTab.tsx b/src/app/events/[event_key]/EventTeamsTab.tsx new file mode 100644 index 0000000..7298125 --- /dev/null +++ b/src/app/events/[event_key]/EventTeamsTab.tsx @@ -0,0 +1,45 @@ +/** + * EventTeamsTab + * + * Design-only mockup for the "Teams" tab. Lays out the roster grid + * the real team list will use once `event.teamsKeys` is resolved + * against the teams endpoint — currently rendered as skeleton cards. + */ +import { Users } from "lucide-react"; +import EmptyTabState from "./EmptyTabState"; + +const SKELETON_CARDS = 9; + +export default function EventTeamsTab() { + return ( +
+ + +
+

+ Teams +

+ +
+ {Array.from({ length: SKELETON_CARDS }).map((_, i) => ( +
+
+ +
+
+
+
+
+ ))} +
+
+
+ ); +} From dc85017c73e6be8db6b52d94f7c81f7e28acb9c4 Mon Sep 17 00:00:00 2001 From: Kaique Date: Mon, 10 Aug 2026 21:33:07 -0300 Subject: [PATCH 2/6] prepare struture to API --- src/app/events/[event_key]/EventAwardsTab.tsx | 58 ++++++------ .../events/[event_key]/EventMatchesTab.tsx | 79 +++++++--------- .../events/[event_key]/EventRankingsTab.tsx | 70 +++++++-------- src/app/events/[event_key]/EventTeamsTab.tsx | 56 ++++++------ src/app/events/[event_key]/mockData.ts | 89 +++++++++++++++++++ src/components/cards/MatchRowCard.tsx | 14 ++- .../events/event/awards/AwardRow.tsx | 32 +++++++ .../events/event/awards/AwardRowSkeleton.tsx | 22 +++++ .../events/event/awards/AwardsList.tsx | 31 +++++++ .../events/event/matches/MatchesList.tsx | 36 ++++++++ .../events/event/matches/MatchesSkeleton.tsx | 50 +++++++++++ .../events/event/rankings/RankingRow.tsx | 40 +++++++++ .../event/rankings/RankingRowSkeleton.tsx | 20 +++++ .../events/event/rankings/RankingsTable.tsx | 47 ++++++++++ .../events/event/teams/TeamCard.tsx | 43 +++++++++ .../events/event/teams/TeamCardSkeleton.tsx | 18 ++++ .../events/event/teams/TeamsGrid.tsx | 31 +++++++ src/types/events.ts | 64 +++++++++++++ 18 files changed, 652 insertions(+), 148 deletions(-) create mode 100644 src/app/events/[event_key]/mockData.ts create mode 100644 src/components/events/event/awards/AwardRow.tsx create mode 100644 src/components/events/event/awards/AwardRowSkeleton.tsx create mode 100644 src/components/events/event/awards/AwardsList.tsx create mode 100644 src/components/events/event/matches/MatchesList.tsx create mode 100644 src/components/events/event/matches/MatchesSkeleton.tsx create mode 100644 src/components/events/event/rankings/RankingRow.tsx create mode 100644 src/components/events/event/rankings/RankingRowSkeleton.tsx create mode 100644 src/components/events/event/rankings/RankingsTable.tsx create mode 100644 src/components/events/event/teams/TeamCard.tsx create mode 100644 src/components/events/event/teams/TeamCardSkeleton.tsx create mode 100644 src/components/events/event/teams/TeamsGrid.tsx diff --git a/src/app/events/[event_key]/EventAwardsTab.tsx b/src/app/events/[event_key]/EventAwardsTab.tsx index 74b92e8..45179da 100644 --- a/src/app/events/[event_key]/EventAwardsTab.tsx +++ b/src/app/events/[event_key]/EventAwardsTab.tsx @@ -1,46 +1,50 @@ /** * EventAwardsTab * - * Design-only mockup for the "Awards" tab. Lays out the awards list - * (trophy icon + award name + recipient) that will be populated from - * TBA's awards endpoint once the event has announced results. + * Awards tab for `/events/[event_key]`. Defaults to `loading = true` + * (skeleton) since there's no real data source wired up yet — once + * the TBA awards endpoint is adapted into `EventAward[]` + * (`src/types/events.ts`), pass it in: + * + * */ import { Trophy } from "lucide-react"; +import { EventAward } from "@/types/events"; +import AwardsList from "@/components/events/event/awards/AwardsList"; import EmptyTabState from "./EmptyTabState"; -const SKELETON_AWARDS = 4; +interface EventAwardsTabProps { + awards?: EventAward[]; + loading?: boolean; +} + +export default function EventAwardsTab({ awards = [], loading = true }: EventAwardsTabProps) { + const isEmpty = !loading && awards.length === 0; -export default function EventAwardsTab() { return (
- + {(loading || isEmpty) && ( + + )}

Awards

-
- {Array.from({ length: SKELETON_AWARDS }).map((_, i) => ( -
-
- -
- -
-
-
-
-
- ))} -
+ {!isEmpty && ( +
+ +
+ )}
); diff --git a/src/app/events/[event_key]/EventMatchesTab.tsx b/src/app/events/[event_key]/EventMatchesTab.tsx index 61b0616..0e44308 100644 --- a/src/app/events/[event_key]/EventMatchesTab.tsx +++ b/src/app/events/[event_key]/EventMatchesTab.tsx @@ -1,24 +1,39 @@ /** * EventMatchesTab * - * Design-only mockup for the "Matches" tab. Mirrors the layout the - * real qual/playoff schedule will use (status pill + red/blue alliance - * rows, same shape as `MatchRowCard`) but rendered as skeleton content - * — wiring this up to `/scout` match data is a follow-up. + * Matches tab for `/events/[event_key]`. Defaults to `loading = true` + * (skeleton) since there's no real data source wired up yet — once + * the TBA matches endpoint is adapted into `EventMatch[]` + * (`src/types/events.ts`), pass it in: + * + * */ import { Swords } from "lucide-react"; +import { EventMatch } from "@/types/events"; +import MatchesList from "@/components/events/event/matches/MatchesList"; import EmptyTabState from "./EmptyTabState"; -const SKELETON_ROWS = 5; +interface EventMatchesTabProps { + matches?: EventMatch[]; + loading?: boolean; +} + +export default function EventMatchesTab({ matches = [], loading = true }: EventMatchesTabProps) { + const isEmpty = !loading && matches.length === 0; -export default function EventMatchesTab() { return (
- + {(loading || isEmpty) && ( + + )}
@@ -37,43 +52,11 @@ export default function EventMatchesTab() {
-
- {Array.from({ length: SKELETON_ROWS }).map((_, i) => ( -
-
-
-
-
- -
- - RED - - -
-
-
-
-
-
- -
- - BLUE - - -
-
-
-
-
-
-
- ))} -
+ {!isEmpty && ( +
+ +
+ )}
); diff --git a/src/app/events/[event_key]/EventRankingsTab.tsx b/src/app/events/[event_key]/EventRankingsTab.tsx index 30b381f..3e82567 100644 --- a/src/app/events/[event_key]/EventRankingsTab.tsx +++ b/src/app/events/[event_key]/EventRankingsTab.tsx @@ -1,24 +1,39 @@ /** * EventRankingsTab * - * Design-only mockup for the "Rankings" tab. Lays out the qual - * standings table (rank / team / record / RP / avg) that will be - * populated from TBA's rankings endpoint — currently skeleton rows. + * Rankings tab for `/events/[event_key]`. Defaults to `loading = true` + * (skeleton) since there's no real data source wired up yet — once + * the TBA rankings endpoint is adapted into `EventRankingRow[]` + * (`src/types/events.ts`), pass it in: + * + * */ import { ListOrdered } from "lucide-react"; +import { EventRankingRow } from "@/types/events"; +import RankingsTable from "@/components/events/event/rankings/RankingsTable"; import EmptyTabState from "./EmptyTabState"; -const SKELETON_ROWS = 8; -const COLUMNS = ["Rank", "Team", "Record", "RP", "Avg"]; +interface EventRankingsTabProps { + rankings?: EventRankingRow[]; + loading?: boolean; +} + +export default function EventRankingsTab({ rankings = [], loading = true }: EventRankingsTabProps) { + const isEmpty = !loading && rankings.length === 0; -export default function EventRankingsTab() { return (
- + {(loading || isEmpty) && ( + + )}
@@ -27,34 +42,11 @@ export default function EventRankingsTab() {
-
- - - - {COLUMNS.map((col) => ( - - ))} - - - - - {Array.from({ length: SKELETON_ROWS }).map((_, i) => ( - - {COLUMNS.map((col) => ( - - ))} - - ))} - -
- {col} -
-
-
-
+ {!isEmpty && ( +
+ +
+ )}
); diff --git a/src/app/events/[event_key]/EventTeamsTab.tsx b/src/app/events/[event_key]/EventTeamsTab.tsx index 7298125..b4eccb8 100644 --- a/src/app/events/[event_key]/EventTeamsTab.tsx +++ b/src/app/events/[event_key]/EventTeamsTab.tsx @@ -1,44 +1,50 @@ /** * EventTeamsTab * - * Design-only mockup for the "Teams" tab. Lays out the roster grid - * the real team list will use once `event.teamsKeys` is resolved - * against the teams endpoint — currently rendered as skeleton cards. + * Teams tab for `/events/[event_key]`. Defaults to `loading = true` + * (skeleton) since there's no real data source wired up yet — once + * the TBA teams endpoint is adapted into `EventTeamSummary[]` + * (`src/types/events.ts`), pass it in: + * + * */ import { Users } from "lucide-react"; +import { EventTeamSummary } from "@/types/events"; +import TeamsGrid from "@/components/events/event/teams/TeamsGrid"; import EmptyTabState from "./EmptyTabState"; -const SKELETON_CARDS = 9; +interface EventTeamsTabProps { + teams?: EventTeamSummary[]; + loading?: boolean; +} + +export default function EventTeamsTab({ teams = [], loading = true }: EventTeamsTabProps) { + const isEmpty = !loading && teams.length === 0; -export default function EventTeamsTab() { return (
- + {(loading || isEmpty) && ( + + )}

Teams

-
- {Array.from({ length: SKELETON_CARDS }).map((_, i) => ( -
-
- -
-
-
-
-
- ))} -
+ {!isEmpty && ( +
+ +
+ )}
); diff --git a/src/app/events/[event_key]/mockData.ts b/src/app/events/[event_key]/mockData.ts new file mode 100644 index 0000000..9df5e03 --- /dev/null +++ b/src/app/events/[event_key]/mockData.ts @@ -0,0 +1,89 @@ +/** + * Mock fixtures for `/events/[event_key]`'s data-ready tabs. + * + * These exist purely so the tab components (and their real data + * sub-components — `MatchesList`, `TeamsGrid`, `RankingsTable`, + * `AwardsList`) can be exercised with realistic data before the real + * TBA adapters exist. Each fixture matches its `Event*` type 1:1 + * (`src/types/events.ts`), so wiring the real API later is just a + * matter of swapping the fixture import for the adapted API response + * — no shape changes needed downstream. + * + * Not imported anywhere by default — the tabs default to `loading`. + * To preview a tab with data during development: + * + * import { mockMatches } from "./mockData"; + * + */ +import { EventAward, EventMatch, EventRankingRow, EventTeamSummary } from "@/types/events"; + +export const mockMatches: EventMatch[] = [ + { + key: "2026sao_qm1", + match: "Qualification 1", + status: "completed", + red: [{ team: 7563, favorite: true }, { team: 1114 }, { team: 254 }], + blue: [{ team: 118 }, { team: 2056 }, { team: 33 }], + redScore: 112, + blueScore: 98, + scheduledTime: "2026-03-12T13:00:00-03:00", + }, + { + key: "2026sao_qm2", + match: "Qualification 2", + status: "live", + red: [{ team: 1678 }, { team: 195 }, { team: 7563, favorite: true }], + blue: [{ team: 4613 }, { team: 5172 }, { team: 971 }], + redScore: null, + blueScore: null, + scheduledTime: "2026-03-12T13:12:00-03:00", + }, + { + key: "2026sao_qm3", + match: "Qualification 3", + status: "on_field", + red: [{ team: 359 }, { team: 6969 }, { team: 5658 }], + blue: [{ team: 7563, favorite: true }, { team: 4028 }, { team: 4482 }], + redScore: null, + blueScore: null, + scheduledTime: "2026-03-12T13:24:00-03:00", + }, + { + key: "2026sao_qm4", + match: "Qualification 4", + status: "scheduled", + red: [{ team: 6800 }, { team: 3374 }, { team: 172 }], + blue: [{ team: 525 }, { team: 1746 }, { team: 7563, favorite: true }], + redScore: null, + blueScore: null, + scheduledTime: "2026-03-12T13:36:00-03:00", + }, +]; + +export const mockTeams: EventTeamSummary[] = [ + { team_key: "frc7563", number: 7563, name: "SESI SENAI Megazord", city: "Jundiaí", country: "Brazil", favorite: true }, + { team_key: "frc1114", number: 1114, name: "Simbotics", city: "St. Catharines", country: "Canada" }, + { team_key: "frc254", number: 254, name: "The Cheesy Poofs", city: "San Jose", country: "USA" }, + { team_key: "frc118", number: 118, name: "Robonauts", city: "Houston", country: "USA" }, + { team_key: "frc2056", number: 2056, name: "OP Robotics", city: "Ontario", country: "Canada" }, + { team_key: "frc33", number: 33, name: "Killer Bees", city: "Rochester Hills", country: "USA" }, + { team_key: "frc1678", number: 1678, name: "Citrus Circuits", city: "Davis", country: "USA" }, + { team_key: "frc195", number: 195, name: "CyberKnights", city: "Cypress", country: "USA" }, + { team_key: "frc4613", number: 4613, name: "Barrage", city: "Windsor", country: "Canada" }, +]; + +export const mockRankings: EventRankingRow[] = [ + { rank: 1, team_key: "frc7563", number: 7563, wins: 8, losses: 1, ties: 0, rankingPoints: 2.44, average: 118.3 }, + { rank: 2, team_key: "frc1114", number: 1114, wins: 7, losses: 2, ties: 0, rankingPoints: 2.22, average: 112.7 }, + { rank: 3, team_key: "frc254", number: 254, wins: 7, losses: 2, ties: 0, rankingPoints: 2.11, average: 109.5 }, + { rank: 4, team_key: "frc118", number: 118, wins: 6, losses: 3, ties: 0, rankingPoints: 1.89, average: 104.2 }, + { rank: 5, team_key: "frc2056", number: 2056, wins: 6, losses: 3, ties: 0, rankingPoints: 1.78, average: 101.8 }, + { rank: 6, team_key: "frc33", number: 33, wins: 5, losses: 4, ties: 0, rankingPoints: 1.56, average: 97.4 }, +]; + +export const mockAwards: EventAward[] = [ + { id: "2026sao_award_0", name: "Chairman's Award", recipientTeamKey: "frc7563", recipientName: "SESI SENAI Megazord (7563)" }, + { id: "2026sao_award_1", name: "Winner", recipientTeamKey: "frc1114", recipientName: "Simbotics (1114)" }, + { id: "2026sao_award_2", name: "Engineering Inspiration Award", recipientTeamKey: "frc254", recipientName: "The Cheesy Poofs (254)" }, + { id: "2026sao_award_3", name: "Woodie Flowers Finalist Award", recipientTeamKey: null, recipientName: null, awardee: "Jane Doe" }, +]; diff --git a/src/components/cards/MatchRowCard.tsx b/src/components/cards/MatchRowCard.tsx index 514937b..a71f1a2 100644 --- a/src/components/cards/MatchRowCard.tsx +++ b/src/components/cards/MatchRowCard.tsx @@ -7,31 +7,27 @@ * yet — see the TODO in dashboardPageClient.tsx). */ import TeamBadge from "@/components/ui/TeamBadge"; - -interface Team { - team: number; - favorite?: boolean; -} - -type MatchStatus = "live" | "on_field" | "scheduled"; +import { EventAllianceTeam, MatchStatus } from "@/types/events"; interface MatchRowProps { match: string; status?: MatchStatus; - red: Team[]; - blue: Team[]; + red: EventAllianceTeam[]; + blue: EventAllianceTeam[]; } const STATUS_LABEL: Record = { live: "Live", on_field: "On Field", scheduled: "Scheduled", + completed: "Final", }; const STATUS_STYLE: Record = { live: "bg-red-500/10 text-red-400", on_field: "bg-amber-500/10 text-amber-400", scheduled: "bg-slate-500/10 text-slate-400", + completed: "bg-emerald-500/10 text-emerald-400", }; export default function MatchRow({ diff --git a/src/components/events/event/awards/AwardRow.tsx b/src/components/events/event/awards/AwardRow.tsx new file mode 100644 index 0000000..5cf3b67 --- /dev/null +++ b/src/components/events/event/awards/AwardRow.tsx @@ -0,0 +1,32 @@ +/** + * AwardRow + * + * One award entry: trophy icon, award name, and the recipient team or + * individual awardee. + */ +import { Trophy } from "lucide-react"; +import { EventAward } from "@/types/events"; + +interface AwardRowProps { + award: EventAward; +} + +export default function AwardRow({ award }: AwardRowProps) { + return ( +
+
+ +
+ +
+

+ {award.name} +

+ +

+ {award.recipientName ?? award.awardee ?? "Recipient TBD"} +

+
+
+ ); +} diff --git a/src/components/events/event/awards/AwardRowSkeleton.tsx b/src/components/events/event/awards/AwardRowSkeleton.tsx new file mode 100644 index 0000000..9fc8373 --- /dev/null +++ b/src/components/events/event/awards/AwardRowSkeleton.tsx @@ -0,0 +1,22 @@ +/** + * AwardRowSkeleton + * + * Loading placeholder for one `AwardRow` — same trophy-icon + two-line + * shape so the list doesn't reflow once real awards replace it. + */ +import { Trophy } from "lucide-react"; + +export default function AwardRowSkeleton() { + return ( +
+
+ +
+ +
+
+
+
+
+ ); +} diff --git a/src/components/events/event/awards/AwardsList.tsx b/src/components/events/event/awards/AwardsList.tsx new file mode 100644 index 0000000..507d3f3 --- /dev/null +++ b/src/components/events/event/awards/AwardsList.tsx @@ -0,0 +1,31 @@ +/** + * AwardsList + * + * Awards tab content: shows `SKELETON_AWARDS` worth of `AwardRowSkeleton` + * while `loading`, or the real `EventAward[]` as `AwardRow`s once this + * is wired up to real TBA data. + */ +import { EventAward } from "@/types/events"; +import AwardRow from "./AwardRow"; +import AwardRowSkeleton from "./AwardRowSkeleton"; + +const SKELETON_AWARDS = 4; + +interface AwardsListProps { + awards: EventAward[]; + loading: boolean; +} + +export default function AwardsList({ awards, loading }: AwardsListProps) { + return ( +
+ {loading + ? Array.from({ length: SKELETON_AWARDS }).map((_, i) => ( + + )) + : awards.map((award) => ( + + ))} +
+ ); +} diff --git a/src/components/events/event/matches/MatchesList.tsx b/src/components/events/event/matches/MatchesList.tsx new file mode 100644 index 0000000..19cd973 --- /dev/null +++ b/src/components/events/event/matches/MatchesList.tsx @@ -0,0 +1,36 @@ +/** + * MatchesList + * + * Matches tab content: shows `MatchesSkeleton` while `loading`, or the + * real `EventMatch[]` rendered with the same `MatchRow` card already + * used on the dashboard — so matches look identical everywhere in the + * app once this is wired up to real TBA data. + */ +import MatchRow from "@/components/cards/MatchRowCard"; +import { EventMatch } from "@/types/events"; +import MatchesSkeleton from "./MatchesSkeleton"; + +interface MatchesListProps { + matches: EventMatch[]; + loading: boolean; +} + +export default function MatchesList({ matches, loading }: MatchesListProps) { + if (loading) { + return ; + } + + return ( +
+ {matches.map((match) => ( + + ))} +
+ ); +} diff --git a/src/components/events/event/matches/MatchesSkeleton.tsx b/src/components/events/event/matches/MatchesSkeleton.tsx new file mode 100644 index 0000000..557dd89 --- /dev/null +++ b/src/components/events/event/matches/MatchesSkeleton.tsx @@ -0,0 +1,50 @@ +/** + * MatchesSkeleton + * + * Loading placeholder for the Matches tab. Mirrors the exact shape of + * the real `MatchRow` cards (status pill + red/blue alliance chips) + * so the layout doesn't jump once real match data replaces it. + */ +const SKELETON_ROWS = 5; + +export default function MatchesSkeleton() { + return ( +
+ {Array.from({ length: SKELETON_ROWS }).map((_, i) => ( +
+
+
+
+
+ +
+ + RED + + +
+
+
+
+
+
+ +
+ + BLUE + + +
+
+
+
+
+
+
+ ))} +
+ ); +} diff --git a/src/components/events/event/rankings/RankingRow.tsx b/src/components/events/event/rankings/RankingRow.tsx new file mode 100644 index 0000000..8b906e0 --- /dev/null +++ b/src/components/events/event/rankings/RankingRow.tsx @@ -0,0 +1,40 @@ +/** + * RankingRow + * + * One row of the qualification rankings table: rank, team (linked to + * its team page), win/loss/tie record, ranking points, and average. + */ +import Link from "next/link"; +import { EventRankingRow } from "@/types/events"; + +interface RankingRowProps { + row: EventRankingRow; +} + +export default function RankingRow({ row }: RankingRowProps) { + return ( + + + {row.rank} + + + + + {row.number} + + + + + {row.wins}-{row.losses}-{row.ties} + + + + {row.rankingPoints.toFixed(2)} + + + + {row.average.toFixed(1)} + + + ); +} diff --git a/src/components/events/event/rankings/RankingRowSkeleton.tsx b/src/components/events/event/rankings/RankingRowSkeleton.tsx new file mode 100644 index 0000000..c8eb6b8 --- /dev/null +++ b/src/components/events/event/rankings/RankingRowSkeleton.tsx @@ -0,0 +1,20 @@ +/** + * RankingRowSkeleton + * + * Loading placeholder for one `RankingRow` — same 5-column shape + * (Rank / Team / Record / RP / Avg) so the table doesn't reflow once + * real rankings replace it. + */ +const COLUMN_COUNT = 5; + +export default function RankingRowSkeleton() { + return ( + + {Array.from({ length: COLUMN_COUNT }).map((_, i) => ( + +
+ + ))} + + ); +} diff --git a/src/components/events/event/rankings/RankingsTable.tsx b/src/components/events/event/rankings/RankingsTable.tsx new file mode 100644 index 0000000..b5b07ca --- /dev/null +++ b/src/components/events/event/rankings/RankingsTable.tsx @@ -0,0 +1,47 @@ +/** + * RankingsTable + * + * Rankings tab content: renders the table header, then either + * `SKELETON_ROWS` worth of `RankingRowSkeleton` while `loading`, or + * the real `EventRankingRow[]` as `RankingRow`s once this is wired up + * to real TBA data. + */ +import { EventRankingRow } from "@/types/events"; +import RankingRow from "./RankingRow"; +import RankingRowSkeleton from "./RankingRowSkeleton"; + +const COLUMNS = ["Rank", "Team", "Record", "RP", "Avg"]; +const SKELETON_ROWS = 8; + +interface RankingsTableProps { + rankings: EventRankingRow[]; + loading: boolean; +} + +export default function RankingsTable({ rankings, loading }: RankingsTableProps) { + return ( +
+ + + + {COLUMNS.map((col) => ( + + ))} + + + + + {loading + ? Array.from({ length: SKELETON_ROWS }).map((_, i) => ( + + )) + : rankings.map((row) => ( + + ))} + +
+ {col} +
+
+ ); +} diff --git a/src/components/events/event/teams/TeamCard.tsx b/src/components/events/event/teams/TeamCard.tsx new file mode 100644 index 0000000..712cf02 --- /dev/null +++ b/src/components/events/event/teams/TeamCard.tsx @@ -0,0 +1,43 @@ +/** + * TeamCard + * + * One team entry in the event's Teams tab grid: number badge, + * nickname, city/country, and the same favorite-star treatment used + * elsewhere (`EventCard`, `TeamBadge`). Links to `/teams/frc{number}`. + */ +import Link from "next/link"; +import { Star } from "lucide-react"; +import { EventTeamSummary } from "@/types/events"; + +interface TeamCardProps { + team: EventTeamSummary; +} + +export default function TeamCard({ team }: TeamCardProps) { + const location = [team.city, team.country].filter(Boolean).join(", "); + + return ( + +
+ {team.number} +
+ +
+

+ {team.name ?? `Team ${team.number}`} +

+ +

+ {location || "Location unavailable"} +

+
+ + {team.favorite && ( + + )} + + ); +} diff --git a/src/components/events/event/teams/TeamCardSkeleton.tsx b/src/components/events/event/teams/TeamCardSkeleton.tsx new file mode 100644 index 0000000..72294d8 --- /dev/null +++ b/src/components/events/event/teams/TeamCardSkeleton.tsx @@ -0,0 +1,18 @@ +/** + * TeamCardSkeleton + * + * Loading placeholder for one `TeamCard` — same avatar + two-line + * shape so the grid doesn't reflow once real teams replace it. + */ +export default function TeamCardSkeleton() { + return ( +
+
+ +
+
+
+
+
+ ); +} diff --git a/src/components/events/event/teams/TeamsGrid.tsx b/src/components/events/event/teams/TeamsGrid.tsx new file mode 100644 index 0000000..2312381 --- /dev/null +++ b/src/components/events/event/teams/TeamsGrid.tsx @@ -0,0 +1,31 @@ +/** + * TeamsGrid + * + * Teams tab content: shows `SKELETON_CARDS` worth of `TeamCardSkeleton` + * while `loading`, or the real `EventTeamSummary[]` as `TeamCard`s + * once this is wired up to real TBA data. + */ +import { EventTeamSummary } from "@/types/events"; +import TeamCard from "./TeamCard"; +import TeamCardSkeleton from "./TeamCardSkeleton"; + +const SKELETON_CARDS = 9; + +interface TeamsGridProps { + teams: EventTeamSummary[]; + loading: boolean; +} + +export default function TeamsGrid({ teams, loading }: TeamsGridProps) { + return ( +
+ {loading + ? Array.from({ length: SKELETON_CARDS }).map((_, i) => ( + + )) + : teams.map((team) => ( + + ))} +
+ ); +} diff --git a/src/types/events.ts b/src/types/events.ts index b68ecf3..e261477 100644 --- a/src/types/events.ts +++ b/src/types/events.ts @@ -74,4 +74,68 @@ export enum EventTab { Rankings = "rankings", Awards = "awards", Scout = "scout", +} + +/** + * Data-shape types for the `/events/[event_key]` sub-tabs (Matches / + * Teams / Rankings / Awards). + * + * These are app-level shapes — same spirit as `EventFull` above — meant + * to be produced by future adapters in `src/lib/api` from the raw + * `TBAEventRankingRow` / `TBAAward` / `TBAMatchSimple` payloads + * (`src/types/tba/event.ts`, `src/types/tba/match.ts`). Until that + * wiring exists, each tab renders its loading skeleton and these types + * only back local mock fixtures (see `mockData.ts` next to the tabs). + */ + +/** One team's badge within a match alliance. Mirrors `MatchRowCard`'s team shape. */ +export interface EventAllianceTeam { + team: number; + favorite?: boolean; +} + +/** Match status shown as the pill on a match row. */ +export type MatchStatus = "scheduled" | "on_field" | "live" | "completed"; + +/** A single match row for the Matches tab — ready for `MatchRowCard`. */ +export interface EventMatch { + key: string; + match: string; + status: MatchStatus; + red: EventAllianceTeam[]; + blue: EventAllianceTeam[]; + redScore?: number | null; + blueScore?: number | null; + scheduledTime?: string | null; +} + +/** A single team entry for the Teams tab grid. */ +export interface EventTeamSummary { + team_key: string; + number: number; + name?: string | null; + city?: string | null; + country?: string | null; + favorite?: boolean; +} + +/** A single row of the qualification rankings table. */ +export interface EventRankingRow { + rank: number; + team_key: string; + number: number; + wins: number; + losses: number; + ties: number; + rankingPoints: number; + average: number; +} + +/** A single award entry for the Awards tab. */ +export interface EventAward { + id: string; + name: string; + recipientTeamKey?: string | null; + recipientName?: string | null; + awardee?: string | null; } \ No newline at end of file From 19a8eb6fefb4f342e30b8edcef16709477cb55f4 Mon Sep 17 00:00:00 2001 From: Kaique Date: Mon, 10 Aug 2026 22:18:43 -0300 Subject: [PATCH 3/6] Load event teams from TBA and update types Make the Event Teams tab load real data: EventTabContent is now async and calls getTeamSummary to fetch event teams. Added getTeamSummary to src/lib/api/teams.ts which maps TBA team data into EventTeamSummary shapes (with basic error handling). Removed the mockData fixture for event pages. Updated EventTeamSummary in src/types/events.ts to allow nullable fields and require a boolean favorite. Also added a small import adjustment in teams.ts. This wires the Teams tab to the real API instead of fixtures. --- src/app/events/[event_key]/EventKeyClient.tsx | 10 ++- src/app/events/[event_key]/mockData.ts | 89 ------------------- src/lib/api/teams.ts | 37 +++++++- src/types/events.ts | 12 +-- 4 files changed, 48 insertions(+), 100 deletions(-) delete mode 100644 src/app/events/[event_key]/mockData.ts diff --git a/src/app/events/[event_key]/EventKeyClient.tsx b/src/app/events/[event_key]/EventKeyClient.tsx index 9300b82..b79b925 100644 --- a/src/app/events/[event_key]/EventKeyClient.tsx +++ b/src/app/events/[event_key]/EventKeyClient.tsx @@ -26,6 +26,7 @@ import EventTeamsTab from "./EventTeamsTab"; import EventRankingsTab from "./EventRankingsTab"; import EventAwardsTab from "./EventAwardsTab"; import { EventInfoProps } from "@/components/events/event/EventInfo"; +import { getTeamSummary } from "@/lib/api/teams"; export const mockInformation: EventInfoProps = { @@ -63,19 +64,20 @@ interface EventKeyProps { event: EventFull; } -function EventTabContent({ eventKey, tab, event}: EventKeyProps) { +async function EventTabContent({ eventKey, tab, event}: EventKeyProps) { switch (tab) { case EventTab.Overview: return case EventTab.Matches: - return ; + return ; case EventTab.Teams: - return ; + const teams = await getTeamSummary(event.event_key ?? ""); + return ; case EventTab.Rankings: - return ; + return ; case EventTab.Awards: return ; diff --git a/src/app/events/[event_key]/mockData.ts b/src/app/events/[event_key]/mockData.ts deleted file mode 100644 index 9df5e03..0000000 --- a/src/app/events/[event_key]/mockData.ts +++ /dev/null @@ -1,89 +0,0 @@ -/** - * Mock fixtures for `/events/[event_key]`'s data-ready tabs. - * - * These exist purely so the tab components (and their real data - * sub-components — `MatchesList`, `TeamsGrid`, `RankingsTable`, - * `AwardsList`) can be exercised with realistic data before the real - * TBA adapters exist. Each fixture matches its `Event*` type 1:1 - * (`src/types/events.ts`), so wiring the real API later is just a - * matter of swapping the fixture import for the adapted API response - * — no shape changes needed downstream. - * - * Not imported anywhere by default — the tabs default to `loading`. - * To preview a tab with data during development: - * - * import { mockMatches } from "./mockData"; - * - */ -import { EventAward, EventMatch, EventRankingRow, EventTeamSummary } from "@/types/events"; - -export const mockMatches: EventMatch[] = [ - { - key: "2026sao_qm1", - match: "Qualification 1", - status: "completed", - red: [{ team: 7563, favorite: true }, { team: 1114 }, { team: 254 }], - blue: [{ team: 118 }, { team: 2056 }, { team: 33 }], - redScore: 112, - blueScore: 98, - scheduledTime: "2026-03-12T13:00:00-03:00", - }, - { - key: "2026sao_qm2", - match: "Qualification 2", - status: "live", - red: [{ team: 1678 }, { team: 195 }, { team: 7563, favorite: true }], - blue: [{ team: 4613 }, { team: 5172 }, { team: 971 }], - redScore: null, - blueScore: null, - scheduledTime: "2026-03-12T13:12:00-03:00", - }, - { - key: "2026sao_qm3", - match: "Qualification 3", - status: "on_field", - red: [{ team: 359 }, { team: 6969 }, { team: 5658 }], - blue: [{ team: 7563, favorite: true }, { team: 4028 }, { team: 4482 }], - redScore: null, - blueScore: null, - scheduledTime: "2026-03-12T13:24:00-03:00", - }, - { - key: "2026sao_qm4", - match: "Qualification 4", - status: "scheduled", - red: [{ team: 6800 }, { team: 3374 }, { team: 172 }], - blue: [{ team: 525 }, { team: 1746 }, { team: 7563, favorite: true }], - redScore: null, - blueScore: null, - scheduledTime: "2026-03-12T13:36:00-03:00", - }, -]; - -export const mockTeams: EventTeamSummary[] = [ - { team_key: "frc7563", number: 7563, name: "SESI SENAI Megazord", city: "Jundiaí", country: "Brazil", favorite: true }, - { team_key: "frc1114", number: 1114, name: "Simbotics", city: "St. Catharines", country: "Canada" }, - { team_key: "frc254", number: 254, name: "The Cheesy Poofs", city: "San Jose", country: "USA" }, - { team_key: "frc118", number: 118, name: "Robonauts", city: "Houston", country: "USA" }, - { team_key: "frc2056", number: 2056, name: "OP Robotics", city: "Ontario", country: "Canada" }, - { team_key: "frc33", number: 33, name: "Killer Bees", city: "Rochester Hills", country: "USA" }, - { team_key: "frc1678", number: 1678, name: "Citrus Circuits", city: "Davis", country: "USA" }, - { team_key: "frc195", number: 195, name: "CyberKnights", city: "Cypress", country: "USA" }, - { team_key: "frc4613", number: 4613, name: "Barrage", city: "Windsor", country: "Canada" }, -]; - -export const mockRankings: EventRankingRow[] = [ - { rank: 1, team_key: "frc7563", number: 7563, wins: 8, losses: 1, ties: 0, rankingPoints: 2.44, average: 118.3 }, - { rank: 2, team_key: "frc1114", number: 1114, wins: 7, losses: 2, ties: 0, rankingPoints: 2.22, average: 112.7 }, - { rank: 3, team_key: "frc254", number: 254, wins: 7, losses: 2, ties: 0, rankingPoints: 2.11, average: 109.5 }, - { rank: 4, team_key: "frc118", number: 118, wins: 6, losses: 3, ties: 0, rankingPoints: 1.89, average: 104.2 }, - { rank: 5, team_key: "frc2056", number: 2056, wins: 6, losses: 3, ties: 0, rankingPoints: 1.78, average: 101.8 }, - { rank: 6, team_key: "frc33", number: 33, wins: 5, losses: 4, ties: 0, rankingPoints: 1.56, average: 97.4 }, -]; - -export const mockAwards: EventAward[] = [ - { id: "2026sao_award_0", name: "Chairman's Award", recipientTeamKey: "frc7563", recipientName: "SESI SENAI Megazord (7563)" }, - { id: "2026sao_award_1", name: "Winner", recipientTeamKey: "frc1114", recipientName: "Simbotics (1114)" }, - { id: "2026sao_award_2", name: "Engineering Inspiration Award", recipientTeamKey: "frc254", recipientName: "The Cheesy Poofs (254)" }, - { id: "2026sao_award_3", name: "Woodie Flowers Finalist Award", recipientTeamKey: null, recipientName: null, awardee: "Jane Doe" }, -]; diff --git a/src/lib/api/teams.ts b/src/lib/api/teams.ts index 2f745e7..11eca20 100644 --- a/src/lib/api/teams.ts +++ b/src/lib/api/teams.ts @@ -1,6 +1,8 @@ import * as tba from "@/lib/api/tba"; import { Team, TeamListItem } from "@/types/team"; import { TBATeamMedia } from "@/types/tba"; +import { Key } from "lucide-react"; +import { EventTeamSummary } from "@/types/events"; /** * Maps a TBA `social_media` entry's `type` to a URL builder. TBA only @@ -182,4 +184,37 @@ export async function getTeamListItem(year: number = 2025): Promise { + + try { + const teamList = await tba.getEventTeams(event_key); + + + let TeamSummaryList: EventTeamSummary[] = []; + + for (const teamKey of teamList) { + const team = await getTeam(teamKey.key); + if (team) { + TeamSummaryList.push({ + team_key: `frc${team.team_number}`, + number: team.team_number, + name: team.nickname, + city: team.city ? team.city : "", + country: team.country ? team.country : "", + favorite: false, + }); + } + } + return TeamSummaryList; + } + catch { + return null; + } +} + diff --git a/src/types/events.ts b/src/types/events.ts index e261477..cd527f6 100644 --- a/src/types/events.ts +++ b/src/types/events.ts @@ -111,12 +111,12 @@ export interface EventMatch { /** A single team entry for the Teams tab grid. */ export interface EventTeamSummary { - team_key: string; - number: number; - name?: string | null; - city?: string | null; - country?: string | null; - favorite?: boolean; + team_key: string | null; + number: number | null; + name: string | null; + city: string | null; + country: string | null; + favorite: boolean; } /** A single row of the qualification rankings table. */ From 15bc2293c088db4f24ee037d3931c5cd6933caac Mon Sep 17 00:00:00 2001 From: Kaique Date: Tue, 11 Aug 2026 17:25:48 -0300 Subject: [PATCH 4/6] update api start --- src/app/events/[event_key]/EventKeyClient.tsx | 12 ++++++------ src/app/events/[event_key]/page.tsx | 3 +++ 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/src/app/events/[event_key]/EventKeyClient.tsx b/src/app/events/[event_key]/EventKeyClient.tsx index b79b925..bded7d2 100644 --- a/src/app/events/[event_key]/EventKeyClient.tsx +++ b/src/app/events/[event_key]/EventKeyClient.tsx @@ -19,7 +19,7 @@ import Link from "next/link"; import { ArrowRight, ClipboardList } from "lucide-react"; import EventHeader from "@/components/events/event/EventHeader"; -import { EventFull, EventTab, EventWebcastType, WebcastUrl} from "@/types/events"; +import { EventFull, EventTab, EventTeamSummary, EventWebcastType, WebcastUrl} from "@/types/events"; import EventOverview from "./EventOverviewTab"; import EventMatchesTab from "./EventMatchesTab"; import EventTeamsTab from "./EventTeamsTab"; @@ -62,9 +62,10 @@ interface EventKeyProps { eventKey: string; tab: EventTab; event: EventFull; + teams: EventTeamSummary[] | null; } -async function EventTabContent({ eventKey, tab, event}: EventKeyProps) { +async function EventTabContent({ eventKey, tab, event, teams}: EventKeyProps) { switch (tab) { case EventTab.Overview: return @@ -73,8 +74,7 @@ async function EventTabContent({ eventKey, tab, event}: EventKeyProps) { return ; case EventTab.Teams: - const teams = await getTeamSummary(event.event_key ?? ""); - return ; + return ; case EventTab.Rankings: return ; @@ -118,11 +118,11 @@ async function EventTabContent({ eventKey, tab, event}: EventKeyProps) { } } -export default function EventKeyClient({ eventKey, tab, event }: EventKeyProps) { +export default function EventKeyClient({ eventKey, tab, event, teams }: EventKeyProps) { return (
- +
); } diff --git a/src/app/events/[event_key]/page.tsx b/src/app/events/[event_key]/page.tsx index 97238f4..9d6918a 100644 --- a/src/app/events/[event_key]/page.tsx +++ b/src/app/events/[event_key]/page.tsx @@ -20,6 +20,7 @@ import { notFound } from "next/navigation"; import EventKeyClient from "./EventKeyClient"; import { EventTab } from "@/types/events"; import { getEventFull } from "@/lib/api/events"; +import { getTeamSummary } from "@/lib/api/teams"; interface EventPageProps { params: Promise<{ @@ -38,6 +39,7 @@ export default async function EventPage({ const { tab = EventTab.Overview } = await searchParams; const event = await getEventFull(event_key); + const teams = await getTeamSummary(event_key); if (!event) { notFound(); @@ -54,6 +56,7 @@ export default async function EventPage({ eventKey={event_key} event={event} tab={validTab} + teams={teams} /> ); } From a5e3afa23cc9e2e42e25e6c62aa314034b5cdc9f Mon Sep 17 00:00:00 2001 From: Kaique Date: Tue, 11 Aug 2026 17:39:20 -0300 Subject: [PATCH 5/6] feat: add loading skeletons for all routes, parallelize event page fetch --- src/app/events/[event_key]/loading.tsx | 50 ++++++++++++++++++++++++++ src/app/events/[event_key]/page.tsx | 11 ++++-- src/app/events/loading.tsx | 38 ++++++++++++++++++++ src/app/loading.tsx | 28 +++++++++++++++ src/app/scout/2025/auto/loading.tsx | 14 ++++++++ src/app/scout/2025/pit/loading.tsx | 14 ++++++++ src/app/scout/2025/teleop/loading.tsx | 14 ++++++++ src/app/scout/loading.tsx | 41 +++++++++++++++++++++ src/app/teams/loading.tsx | 38 ++++++++++++++++++++ 9 files changed, 246 insertions(+), 2 deletions(-) create mode 100644 src/app/events/[event_key]/loading.tsx create mode 100644 src/app/events/loading.tsx create mode 100644 src/app/loading.tsx create mode 100644 src/app/scout/2025/auto/loading.tsx create mode 100644 src/app/scout/2025/pit/loading.tsx create mode 100644 src/app/scout/2025/teleop/loading.tsx create mode 100644 src/app/scout/loading.tsx create mode 100644 src/app/teams/loading.tsx diff --git a/src/app/events/[event_key]/loading.tsx b/src/app/events/[event_key]/loading.tsx new file mode 100644 index 0000000..c1c3a92 --- /dev/null +++ b/src/app/events/[event_key]/loading.tsx @@ -0,0 +1,50 @@ +/** + * Loading skeleton for `/events/[event_key]`, shown automatically by + * Next.js while `page.tsx` awaits `getEventFull()` + `getTeamSummary()` + * (the slowest fetch in the app — see the API notes shared with this + * change). Shape mirrors `EventHeader` (title + tab strip) and the + * default "Overview" tab layout, so there's no layout shift once real + * content streams in. + */ +const EVENT_TABS = ["Overview", "Matches", "Teams", "Rankings", "Awards", "Scout"]; + +export default function Loading() { + return ( +
+ {/* Header hero: title + quick facts + tab strip */} +
+
+
+
+
+
+ +
+ {Array.from({ length: 4 }).map((_, i) => ( +
+ ))} +
+
+ +
+ {EVENT_TABS.map((tab) => ( +
+ ))} +
+
+ + {/* Overview tab: sidebar info card + main content */} +
+
+
+ ); +} diff --git a/src/app/events/[event_key]/page.tsx b/src/app/events/[event_key]/page.tsx index 9d6918a..02d4d17 100644 --- a/src/app/events/[event_key]/page.tsx +++ b/src/app/events/[event_key]/page.tsx @@ -38,8 +38,15 @@ export default async function EventPage({ const { event_key } = await params; const { tab = EventTab.Overview } = await searchParams; - const event = await getEventFull(event_key); - const teams = await getTeamSummary(event_key); + // `getEventFull` and `getTeamSummary` don't depend on each other, so + // they're fired together instead of chained — this alone was making + // every load wait for two full sequential round-trips instead of one. + // (The bigger cost is inside `getTeamSummary` itself, which is + // off-limits here — see the suggestions shared alongside this change.) + const [event, teams] = await Promise.all([ + getEventFull(event_key), + getTeamSummary(event_key), + ]); if (!event) { notFound(); diff --git a/src/app/events/loading.tsx b/src/app/events/loading.tsx new file mode 100644 index 0000000..d265d5b --- /dev/null +++ b/src/app/events/loading.tsx @@ -0,0 +1,38 @@ +/** + * Loading skeleton for `/events`, shown while `page.tsx` awaits + * `getEventList()`. Mirrors the search bar + week-grouped sections + * layout that `EventsPageClient` renders once data arrives. + */ +const SECTIONS = 3; +const CARDS_PER_SECTION = 4; + +export default function Loading() { + return ( +
+
+ + {/* Search + favorite filter */} +
+
+
+
+ +
+ {Array.from({ length: SECTIONS }).map((_, sectionIdx) => ( +
+
+ +
+ {Array.from({ length: CARDS_PER_SECTION }).map((_, cardIdx) => ( +
+ ))} +
+
+ ))} +
+
+ ); +} diff --git a/src/app/loading.tsx b/src/app/loading.tsx new file mode 100644 index 0000000..5b7420f --- /dev/null +++ b/src/app/loading.tsx @@ -0,0 +1,28 @@ +/** + * Loading skeleton for `/` (dashboard), shown while `page.tsx` awaits + * `getEventOptions()` + `getEventDataDashboard()`. Mirrors + * `DashboardPageClient`'s layout: title, event selector, stat cards, + * progress card, upcoming matches. + */ +export default function Loading() { + return ( +
+
+ +
+ +
+ {Array.from({ length: 4 }).map((_, i) => ( +
+ ))} +
+ +
+ +
+
+ ); +} diff --git a/src/app/scout/2025/auto/loading.tsx b/src/app/scout/2025/auto/loading.tsx new file mode 100644 index 0000000..cfd0707 --- /dev/null +++ b/src/app/scout/2025/auto/loading.tsx @@ -0,0 +1,14 @@ +/** + * Loading skeleton for `/scout/2025/auto`. This page only reads + * `searchParams` today (no TBA fetch yet), so this mostly guards + * against future data-fetching being added here without a loading + * state — keeps the placeholder screens consistent with the rest. + */ +export default function Loading() { + return ( +
+
+
+
+ ); +} diff --git a/src/app/scout/2025/pit/loading.tsx b/src/app/scout/2025/pit/loading.tsx new file mode 100644 index 0000000..8c7daeb --- /dev/null +++ b/src/app/scout/2025/pit/loading.tsx @@ -0,0 +1,14 @@ +/** + * Loading skeleton for `/scout/2025/pit`. This page only reads + * `searchParams` today (no TBA fetch yet), so this mostly guards + * against future data-fetching being added here without a loading + * state — keeps the placeholder screens consistent with the rest. + */ +export default function Loading() { + return ( +
+
+
+
+ ); +} diff --git a/src/app/scout/2025/teleop/loading.tsx b/src/app/scout/2025/teleop/loading.tsx new file mode 100644 index 0000000..279650e --- /dev/null +++ b/src/app/scout/2025/teleop/loading.tsx @@ -0,0 +1,14 @@ +/** + * Loading skeleton for `/scout/2025/teleop`. This page only reads + * `searchParams` today (no TBA fetch yet), so this mostly guards + * against future data-fetching being added here without a loading + * state — keeps the placeholder screens consistent with the rest. + */ +export default function Loading() { + return ( +
+
+
+
+ ); +} diff --git a/src/app/scout/loading.tsx b/src/app/scout/loading.tsx new file mode 100644 index 0000000..39cc0de --- /dev/null +++ b/src/app/scout/loading.tsx @@ -0,0 +1,41 @@ +/** + * Loading skeleton for `/scout`, shown while `page.tsx` awaits + * `getEventOptions()` -> `getMatchOptions()` -> `getMatchAlliances()` + * (a naturally sequential chain, since each step depends on the + * previous selection). Mirrors `ScoutPageClient`'s layout: event + * selector, match selector, red/blue alliance panels. + */ +export default function Loading() { + return ( +
+
+ +
+
+ +
+
+
+ {Array.from({ length: 3 }).map((_, i) => ( +
+ ))} +
+ +
+
+ {Array.from({ length: 3 }).map((_, i) => ( +
+ ))} +
+
+ +
+
+ ); +} diff --git a/src/app/teams/loading.tsx b/src/app/teams/loading.tsx new file mode 100644 index 0000000..b0e557f --- /dev/null +++ b/src/app/teams/loading.tsx @@ -0,0 +1,38 @@ +/** + * Loading skeleton for `/teams`, shown while `page.tsx` awaits + * `getTeamListItem()`. Reuses the same card shape as `TeamCardSkeleton` + * (see `/events/[event_key]`'s Teams tab) so the two stay visually + * consistent. + */ +const CARD_COUNT = 12; + +export default function Loading() { + return ( +
+
+ + {/* Search + sort + favorite filter */} +
+
+
+
+
+ +
+ {Array.from({ length: CARD_COUNT }).map((_, i) => ( +
+
+ +
+
+
+
+
+ ))} +
+
+ ); +} From 69243237af23af50749236b9a17e4a32737a219d Mon Sep 17 00:00:00 2001 From: Kaique Date: Tue, 11 Aug 2026 17:50:11 -0300 Subject: [PATCH 6/6] perf: use TBA teams/simple endpoint for event teams tab, add loading skeletons --- loading-screens-and-fixes.tar.gz | Bin 0 -> 6278 bytes src/lib/api/teams.ts | 40 +++++++++++----------- teams-ts-fix-teams-tab-speed.patch | 51 +++++++++++++++++++++++++++++ 3 files changed, 72 insertions(+), 19 deletions(-) create mode 100644 loading-screens-and-fixes.tar.gz create mode 100644 teams-ts-fix-teams-tab-speed.patch diff --git a/loading-screens-and-fixes.tar.gz b/loading-screens-and-fixes.tar.gz new file mode 100644 index 0000000000000000000000000000000000000000..b2f09aa7f55416e87b926fa9018ca5541cdf5067 GIT binary patch literal 6278 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 literal 0 HcmV?d00001 diff --git a/src/lib/api/teams.ts b/src/lib/api/teams.ts index 11eca20..d4167d8 100644 --- a/src/lib/api/teams.ts +++ b/src/lib/api/teams.ts @@ -193,25 +193,27 @@ export async function getTeamSummary(event_key: string): Promise { try { - const teamList = await tba.getEventTeams(event_key); - - - let TeamSummaryList: EventTeamSummary[] = []; - - for (const teamKey of teamList) { - const team = await getTeam(teamKey.key); - if (team) { - TeamSummaryList.push({ - team_key: `frc${team.team_number}`, - number: team.team_number, - name: team.nickname, - city: team.city ? team.city : "", - country: team.country ? team.country : "", - favorite: false, - }); - } - } - return TeamSummaryList; + // Was: `getEventTeams()` (1 request) + a *sequential* loop calling + // `getTeam()` per team — each of which fires 3 more TBA requests + // (profile, social media, avatar-as-base64). For a ~50-team event + // that's 1 + 50 round-trips done one at a time, which is what made + // the Teams tab so slow to load. + // + // `EventTeamSummary` only needs number/name/city/country — all of + // which TBA's own `/event/{event_key}/teams/simple` endpoint + // already returns for the whole event in a SINGLE request. No need + // to fetch (or wait on) each team's full profile, socials, or + // avatar just to render this summary list. + const teamList = await tba.getEventTeamsSimple(event_key); + + return teamList.map((team) => ({ + team_key: `frc${team.team_number}`, + number: team.team_number, + name: team.nickname ?? team.name, + city: team.city ?? "", + country: team.country ?? "", + favorite: false, + })); } catch { return null; diff --git a/teams-ts-fix-teams-tab-speed.patch b/teams-ts-fix-teams-tab-speed.patch new file mode 100644 index 0000000..a87c3b9 --- /dev/null +++ b/teams-ts-fix-teams-tab-speed.patch @@ -0,0 +1,51 @@ +diff --git a/src/lib/api/teams.ts b/src/lib/api/teams.ts +index 11eca20..d4167d8 100644 +--- a/src/lib/api/teams.ts ++++ b/src/lib/api/teams.ts +@@ -193,25 +193,27 @@ export async function getTeamSummary(event_key: string): + Promise { + + try { +- const teamList = await tba.getEventTeams(event_key); +- +- +- let TeamSummaryList: EventTeamSummary[] = []; +- +- for (const teamKey of teamList) { +- const team = await getTeam(teamKey.key); +- if (team) { +- TeamSummaryList.push({ +- team_key: `frc${team.team_number}`, +- number: team.team_number, +- name: team.nickname, +- city: team.city ? team.city : "", +- country: team.country ? team.country : "", +- favorite: false, +- }); +- } +- } +- return TeamSummaryList; ++ // Was: `getEventTeams()` (1 request) + a *sequential* loop calling ++ // `getTeam()` per team — each of which fires 3 more TBA requests ++ // (profile, social media, avatar-as-base64). For a ~50-team event ++ // that's 1 + 50 round-trips done one at a time, which is what made ++ // the Teams tab so slow to load. ++ // ++ // `EventTeamSummary` only needs number/name/city/country — all of ++ // which TBA's own `/event/{event_key}/teams/simple` endpoint ++ // already returns for the whole event in a SINGLE request. No need ++ // to fetch (or wait on) each team's full profile, socials, or ++ // avatar just to render this summary list. ++ const teamList = await tba.getEventTeamsSimple(event_key); ++ ++ return teamList.map((team) => ({ ++ team_key: `frc${team.team_number}`, ++ number: team.team_number, ++ name: team.nickname ?? team.name, ++ city: team.city ?? "", ++ country: team.country ?? "", ++ favorite: false, ++ })); + } + catch { + return null;