From cabcf2f0a7f3609b139deb699f635fef55f1379f Mon Sep 17 00:00:00 2001
From: jackshim415
Date: Sat, 10 Oct 2026 15:35:46 -0700
Subject: [PATCH 01/59] Add a responsive tournament schedule page to the
Website
Expose selected public competition data for 2026cancmp and display match results, inferred breaks, and a searchable team roster using Lovat styling.
---
.../src/handler/tournaments/getTournament.ts | 79 ++
apps/server/src/routes/index.ts | 3 +
apps/server/test/tournamentPage.unit.test.ts | 49 ++
apps/website/src/lib/Navbar.svelte | 3 +
apps/website/src/lib/server/tournament.ts | 55 ++
.../tournaments/2026cancmp/+page.server.ts | 37 +
.../tournaments/2026cancmp/+page.svelte | 689 ++++++++++++++++++
7 files changed, 915 insertions(+)
create mode 100644 apps/server/src/handler/tournaments/getTournament.ts
create mode 100644 apps/server/test/tournamentPage.unit.test.ts
create mode 100644 apps/website/src/lib/server/tournament.ts
create mode 100644 apps/website/src/routes/(with-nav)/tournaments/2026cancmp/+page.server.ts
create mode 100644 apps/website/src/routes/(with-nav)/tournaments/2026cancmp/+page.svelte
diff --git a/apps/server/src/handler/tournaments/getTournament.ts b/apps/server/src/handler/tournaments/getTournament.ts
new file mode 100644
index 00000000..0bb37bf2
--- /dev/null
+++ b/apps/server/src/handler/tournaments/getTournament.ts
@@ -0,0 +1,79 @@
+import type { Request, Response } from "express";
+import prismaClient from "../../prismaClient.js";
+
+export async function getTournament(
+ _req: Request,
+ res: Response,
+): Promise {
+ try {
+ // Public official data only: never include scouting reports or team strategy.
+ const tournament = await prismaClient.tournament.findUnique({
+ where: { key: "2026cancmp" },
+ select: {
+ key: true,
+ name: true,
+ location: true,
+ startDate: true,
+ endDate: true,
+ timezone: true,
+ officialDataUpdatedAt: true,
+ teams: {
+ orderBy: { teamNumber: "asc" },
+ select: { team: { select: { number: true, name: true } } },
+ },
+ matches: {
+ orderBy: [{ displayOrder: "asc" }, { key: "asc" }],
+ select: {
+ key: true,
+ competitionLevel: true,
+ setNumber: true,
+ matchNumber: true,
+ displayOrder: true,
+ scheduledTime: true,
+ predictedTime: true,
+ actualTime: true,
+ status: true,
+ winningAlliance: true,
+ alliances: {
+ orderBy: { color: "asc" },
+ select: { color: true, score: true },
+ },
+ teamSlots: {
+ orderBy: [{ alliance: "asc" }, { station: "asc" }],
+ select: {
+ teamNumber: true,
+ alliance: true,
+ station: true,
+ surrogate: true,
+ disqualified: true,
+ },
+ },
+ },
+ },
+ gaps: {
+ orderBy: { startTime: "asc" },
+ select: {
+ afterMatchKey: true,
+ beforeMatchKey: true,
+ type: true,
+ timingSource: true,
+ startTime: true,
+ endTime: true,
+ },
+ },
+ },
+ });
+
+ if (!tournament) {
+ res.status(404).json({ error: "Tournament not imported yet" });
+ return;
+ }
+
+ res.set("Cache-Control", "public, max-age=30");
+ res.json(tournament);
+ } catch {
+ res
+ .status(503)
+ .json({ error: "Tournament data is temporarily unavailable" });
+ }
+}
diff --git a/apps/server/src/routes/index.ts b/apps/server/src/routes/index.ts
index dbf2bd75..a9def041 100644
--- a/apps/server/src/routes/index.ts
+++ b/apps/server/src/routes/index.ts
@@ -6,6 +6,7 @@ import analysisRouter from "./analysis/analysis.routes.js";
import { onboardingRedirect } from "../handler/slack/onboardingRedirect.js";
import { generateOpenApiDocument } from "../lib/openapi.js";
import swaggerUi from "swagger-ui-express";
+import { getTournament } from "../handler/tournaments/getTournament.js";
const router = Router();
@@ -13,6 +14,8 @@ router.use("/slack", slackRouter);
router.use("/manager", managerRouter);
router.use("/analysis", analysisRouter);
+router.get("/tournaments/2026cancmp", getTournament);
+
router.get("/slack-invite", onboardingRedirect);
// OpenAPI docs
diff --git a/apps/server/test/tournamentPage.unit.test.ts b/apps/server/test/tournamentPage.unit.test.ts
new file mode 100644
index 00000000..b8bceeaf
--- /dev/null
+++ b/apps/server/test/tournamentPage.unit.test.ts
@@ -0,0 +1,49 @@
+import { beforeEach, expect, it, vi } from "vitest";
+import { invoke } from "./helpers/handlerHarness.js";
+
+const findUnique = vi.hoisted(() => vi.fn());
+
+vi.mock("../src/prismaClient.js", () => ({
+ default: { tournament: { findUnique } },
+}));
+
+import { getTournament } from "../src/handler/tournaments/getTournament.js";
+
+beforeEach(() => vi.resetAllMocks());
+
+it("returns explicitly selected public data without report relations", async () => {
+ const tournament = { key: "2026cancmp", matches: [], gaps: [], teams: [] };
+ findUnique.mockResolvedValue(tournament);
+
+ const res = await invoke(getTournament);
+ const query = findUnique.mock.calls[0]![0];
+
+ expect(query.where).toEqual({ key: "2026cancmp" });
+ expect(query.select.matches.select.teamSlots.select).toEqual({
+ teamNumber: true,
+ alliance: true,
+ station: true,
+ surrogate: true,
+ disqualified: true,
+ });
+ expect(JSON.stringify(query)).not.toContain("scoutReports");
+ expect(res.body).toEqual(tournament);
+ expect(res.headers["Cache-Control"]).toBe("public, max-age=30");
+});
+
+it("returns 404 when the event has not been imported", async () => {
+ findUnique.mockResolvedValue(null);
+
+ expect((await invoke(getTournament)).statusCode).toBe(404);
+});
+
+it("does not expose database errors", async () => {
+ findUnique.mockRejectedValue(new Error("private connection details"));
+
+ const res = await invoke(getTournament);
+
+ expect(res.statusCode).toBe(503);
+ expect(res.body).toEqual({
+ error: "Tournament data is temporarily unavailable",
+ });
+});
diff --git a/apps/website/src/lib/Navbar.svelte b/apps/website/src/lib/Navbar.svelte
index 1f4b1667..3f01b4ae 100644
--- a/apps/website/src/lib/Navbar.svelte
+++ b/apps/website/src/lib/Navbar.svelte
@@ -35,6 +35,9 @@
>Learn
+
diff --git a/apps/website/src/lib/server/tournament.ts b/apps/website/src/lib/server/tournament.ts
new file mode 100644
index 00000000..45f865ef
--- /dev/null
+++ b/apps/website/src/lib/server/tournament.ts
@@ -0,0 +1,55 @@
+import { z } from 'zod';
+
+const time = z.iso.datetime().nullable();
+const alliance = z.enum(['RED', 'BLUE']);
+
+export const tournamentSchema = z.object({
+ key: z.literal('2026cancmp'),
+ name: z.string(),
+ location: z.string().nullable(),
+ startDate: time,
+ endDate: time,
+ timezone: z.string().nullable(),
+ officialDataUpdatedAt: time,
+ teams: z.array(z.object({ team: z.object({ number: z.number().int(), name: z.string() }) })),
+ matches: z.array(
+ z.object({
+ key: z.string(),
+ competitionLevel: z.enum([
+ 'QUALIFICATION',
+ 'EIGHTHFINAL',
+ 'QUARTERFINAL',
+ 'SEMIFINAL',
+ 'FINAL'
+ ]),
+ setNumber: z.number().int(),
+ matchNumber: z.number().int(),
+ displayOrder: z.number().int().nullable(),
+ scheduledTime: time,
+ predictedTime: time,
+ actualTime: time,
+ status: z.enum(['SCHEDULED', 'IN_PROGRESS', 'COMPLETED', 'CANCELED']),
+ winningAlliance: alliance.nullable(),
+ alliances: z.array(z.object({ color: alliance, score: z.number().nullable() })),
+ teamSlots: z.array(
+ z.object({
+ teamNumber: z.number().int(),
+ alliance: alliance.nullable(),
+ station: z.number().int().nullable(),
+ surrogate: z.boolean().nullable(),
+ disqualified: z.boolean().nullable()
+ })
+ )
+ })
+ ),
+ gaps: z.array(
+ z.object({
+ afterMatchKey: z.string(),
+ beforeMatchKey: z.string(),
+ type: z.enum(['LUNCH', 'OVERNIGHT', 'PLAYOFF_TRANSITION', 'DELAY', 'BREAK']),
+ timingSource: z.enum(['ACTUAL', 'SCHEDULED', 'PREDICTED']),
+ startTime: z.iso.datetime(),
+ endTime: z.iso.datetime()
+ })
+ )
+});
diff --git a/apps/website/src/routes/(with-nav)/tournaments/2026cancmp/+page.server.ts b/apps/website/src/routes/(with-nav)/tournaments/2026cancmp/+page.server.ts
new file mode 100644
index 00000000..13c49fe2
--- /dev/null
+++ b/apps/website/src/routes/(with-nav)/tournaments/2026cancmp/+page.server.ts
@@ -0,0 +1,37 @@
+import { error } from '@sveltejs/kit';
+import { env } from '$env/dynamic/private';
+import { tournamentSchema } from '$lib/server/tournament';
+import type { PageServerLoad } from './$types';
+
+export const load: PageServerLoad = async ({ fetch, setHeaders }) => {
+ if (!env.LOVAT_API_BASE) error(503, 'Tournament data is not configured');
+
+ let response: Response;
+
+ try {
+ response = await fetch(`${env.LOVAT_API_BASE}/v1/tournaments/2026cancmp`, {
+ signal: AbortSignal.timeout(10_000)
+ });
+ } catch {
+ error(503, 'Tournament data is temporarily unavailable. Please try again shortly.');
+ }
+
+ if (response.status === 404) error(404, 'This tournament has not been imported yet.');
+ if (!response.ok) error(503, 'Tournament data is temporarily unavailable.');
+
+ let payload: unknown;
+
+ try {
+ payload = await response.json();
+ } catch {
+ error(503, 'Tournament data is temporarily unavailable.');
+ }
+
+ const parsed = tournamentSchema.safeParse(payload);
+
+ if (!parsed.success) error(503, 'Tournament data is temporarily unavailable.');
+
+ setHeaders({ 'cache-control': 'public, max-age=30' });
+
+ return { tournament: parsed.data };
+};
diff --git a/apps/website/src/routes/(with-nav)/tournaments/2026cancmp/+page.svelte b/apps/website/src/routes/(with-nav)/tournaments/2026cancmp/+page.svelte
new file mode 100644
index 00000000..97b8845f
--- /dev/null
+++ b/apps/website/src/routes/(with-nav)/tournaments/2026cancmp/+page.svelte
@@ -0,0 +1,689 @@
+
+
+
+ {tournament.name} | Lovat
+
+
+
+
+
+
+
+
{tournament.teams.length}Teams
+
{tournament.matches.length}Matches
+
{completed}Completed
+
{tournament.gaps.length}Inferred breaks
+
+
+
+
+
+
+
+
+
+ {#if view === 'schedule'}
+
+
+
+
Match schedule
+
All times in {timezone}. Actual starts shown when available.
+
+
{filteredMatches.length} matches
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Breaks are inferred from match timing, rather than official agenda announcements.
+
+
+
+ {#each filteredMatches as match, index (match.key)}
+ {#if index === 0 || dateKey(matchTime(filteredMatches[index - 1])) !== dateKey(matchTime(match))}
+
{dayLabel(dateKey(matchTime(match)))}
+ {/if}
+
+
+
+ {#each ['BLUE', 'RED'] as color}
+
+
+ {color === 'RED' ? 'Red' : 'Blue'}{match.alliances.find((alliance) => alliance.color === color)?.score ??
+ '—'}{#if match.winningAlliance === color}WIN{/if}
+
+
+ {#each match.teamSlots.filter((slot) => slot.alliance === color) as slot}
+
+ {/each}
+ {#if !match.teamSlots.some((slot) => slot.alliance === color)}Teams TBD{/if}
+
+
+ {/each}
+
+
+ {@const gap = gapsAfter.get(match.key)}
+ {#if gap && !teamFilter && phase === 'all' && completion === 'all'}
+
+
+ {gapLabels[gap.type]}Inferred · {gap.timingSource.toLowerCase()} timing
+
+
+ {time(gap.startTime)} – {#if dateKey(gap.startTime) !== dateKey(gap.endTime)}{dayLabel(
+ dateKey(gap.endTime)
+ )}
+ {/if}{time(gap.endTime)}
+
+
+ {/if}
+ {:else}
+
+
No matches found
+
Try another day, team number, or match filter.
+
+
+ {/each}
+
+
+ {:else}
+
+
+
+
Teams at this event
+
Select a team to see its matches.
+
+
{filteredTeams.length} teams
+
+
+
+
+
+ {#each filteredTeams as { team }}
{:else}
No teams match your search.
{/each}
+
+
+ {/if}
+
+
+
+
+
From 62d9860d848195238e5e535bd9f41a7d7d2af3f4 Mon Sep 17 00:00:00 2001
From: jackshim415
Date: Sat, 10 Oct 2026 15:38:53 -0700
Subject: [PATCH 02/59] Simplify tournament controls and highlight winning
scores
---
.../tournaments/2026cancmp/+page.svelte | 112 ++++++------------
.../static/assets/event-resources/first.png | Bin 0 -> 74818 bytes
.../static/assets/event-resources/match13.png | Bin 0 -> 6938 bytes
.../assets/event-resources/statbotics.ico | Bin 0 -> 15086 bytes
.../static/assets/event-resources/tba.svg | 32 +++++
5 files changed, 67 insertions(+), 77 deletions(-)
create mode 100644 apps/website/static/assets/event-resources/first.png
create mode 100644 apps/website/static/assets/event-resources/match13.png
create mode 100644 apps/website/static/assets/event-resources/statbotics.ico
create mode 100644 apps/website/static/assets/event-resources/tba.svg
diff --git a/apps/website/src/routes/(with-nav)/tournaments/2026cancmp/+page.svelte b/apps/website/src/routes/(with-nav)/tournaments/2026cancmp/+page.svelte
index 97b8845f..89d0087a 100644
--- a/apps/website/src/routes/(with-nav)/tournaments/2026cancmp/+page.svelte
+++ b/apps/website/src/routes/(with-nav)/tournaments/2026cancmp/+page.svelte
@@ -15,7 +15,6 @@
$: tournament = data.tournament;
$: timezone = validTimezone(tournament.timezone);
$: days = [...new Set(tournament.matches.map((match) => dateKey(matchTime(match))))];
- $: completed = tournament.matches.filter((match) => match.status === 'COMPLETED').length;
$: gapsAfter = new Map(tournament.gaps.map((gap) => [gap.afterMatchKey, gap]));
$: filteredMatches = tournament.matches.filter(
(match) =>
@@ -117,17 +116,30 @@
{/if}
-
-
-
{tournament.teams.length}Teams
-
{tournament.matches.length}Matches
-
{completed}Completed
-
{tournament.gaps.length}Inferred breaks
-
+
@@ -153,7 +165,6 @@
Match schedule
-
All times in {timezone}. Actual starts shown when available.
{filteredMatches.length} matches
@@ -203,10 +214,6 @@
>{/each}
-
- Breaks are inferred from match timing, rather than official agenda announcements.
-
-
{#each filteredMatches as match, index (match.key)}
{#if index === 0 || dateKey(matchTime(filteredMatches[index - 1])) !== dateKey(matchTime(match))}
@@ -237,7 +244,7 @@
class="score"
class:winner={match.winningAlliance === color}
>{match.alliances.find((alliance) => alliance.color === color)?.score ??
- '—'}{#if match.winningAlliance === color}WIN{/if}
@@ -266,9 +273,7 @@
{#if gap && !teamFilter && phase === 'all' && completion === 'all'}
- {gapLabels[gap.type]}Inferred · {gap.timingSource.toLowerCase()} timing
+ {gapLabels[gap.type]}
{time(gap.startTime)} – {#if dateKey(gap.startTime) !== dateKey(gap.endTime)}{dayLabel(
@@ -281,7 +286,6 @@
{:else}
No matches found
-
Try another day, team number, or match filter.
{/if}
-
-
diff --git a/apps/website/src/lib/tournaments/2026cancmp.ts b/apps/website/src/lib/tournaments/2026cancmp.ts
new file mode 100644
index 00000000..548ca901
--- /dev/null
+++ b/apps/website/src/lib/tournaments/2026cancmp.ts
@@ -0,0 +1,244 @@
+// Presentation snapshot for this completed event. Logos: TBA 2026 team avatars.
+// Awards: https://frc-events.firstinspires.org/2026/CANCMP/awards
+// Gradient colors are sampled from the corresponding logo pixels.
+export const eventWinners = new Set([254, 1323, 6238]);
+export const impactWinners = new Set([1671, 1678]);
+
+export const teamBranding: Record
= {
+ '10339': {
+ logo: '/assets/team-logos/10339.png',
+ color: '130, 130, 130'
+ },
+ '1072': {
+ logo: '/assets/team-logos/1072.png',
+ color: '36, 36, 84'
+ },
+ '114': {
+ logo: '/assets/team-logos/114.png',
+ color: '204, 204, 204'
+ },
+ '115': {
+ logo: '/assets/team-logos/115.png',
+ color: '60, 12, 84'
+ },
+ '1323': {
+ logo: '/assets/team-logos/1323.png',
+ color: '12, 12, 60'
+ },
+ '1671': {
+ logo: '/assets/team-logos/1671.png',
+ color: '60, 60, 60'
+ },
+ '1678': {
+ logo: '/assets/team-logos/1678.png',
+ color: '60, 204, 36'
+ },
+ '1868': {
+ logo: '/assets/team-logos/1868.png',
+ color: '132, 204, 252'
+ },
+ '192': {
+ logo: '/assets/team-logos/192.png',
+ color: '252, 12, 12'
+ },
+ '1967': {
+ logo: '/assets/team-logos/1967.png',
+ color: '180, 12, 36'
+ },
+ '2073': {
+ logo: '/assets/team-logos/2073.png',
+ color: '180, 204, 204'
+ },
+ '2135': {
+ logo: '/assets/team-logos/2135.png',
+ color: '252, 204, 12'
+ },
+ '2204': {
+ logo: '/assets/team-logos/2204.png',
+ color: '156, 36, 60'
+ },
+ '253': {
+ logo: '/assets/team-logos/253.png',
+ color: '180, 252, 252'
+ },
+ '254': {
+ logo: '/assets/team-logos/254.png',
+ color: '204, 108, 36'
+ },
+ '2813': {
+ logo: '/assets/team-logos/2813.png',
+ color: '36, 60, 132'
+ },
+ '3045': {
+ logo: '/assets/team-logos/3045.png',
+ color: '12, 60, 156'
+ },
+ '3256': {
+ logo: '/assets/team-logos/3256.png',
+ color: '130, 130, 130'
+ },
+ '3257': {
+ logo: '/assets/team-logos/3257.png',
+ color: '130, 130, 130'
+ },
+ '3501': {
+ logo: '/assets/team-logos/3501.png',
+ color: '228, 36, 36'
+ },
+ '3598': {
+ logo: '/assets/team-logos/3598.png',
+ color: '108, 60, 108'
+ },
+ '3970': {
+ logo: '/assets/team-logos/3970.png',
+ color: '228, 228, 228'
+ },
+ '4135': {
+ logo: '/assets/team-logos/4135.png',
+ color: '12, 12, 156'
+ },
+ '4698': {
+ logo: '/assets/team-logos/4698.png',
+ color: '204, 156, 12'
+ },
+ '4990': {
+ logo: '/assets/team-logos/4990.png',
+ color: '130, 130, 130'
+ },
+ '5026': {
+ logo: '/assets/team-logos/5026.png',
+ color: '132, 84, 12'
+ },
+ '5104': {
+ logo: '/assets/team-logos/5104.png',
+ color: '252, 180, 12'
+ },
+ '5419': {
+ logo: '/assets/team-logos/5419.png',
+ color: '228, 36, 36'
+ },
+ '5507': {
+ logo: '/assets/team-logos/5507.png',
+ color: '156, 36, 36'
+ },
+ '581': {
+ logo: '/assets/team-logos/581.png',
+ color: '228, 108, 60'
+ },
+ '5817': {
+ logo: '/assets/team-logos/5817.png',
+ color: '252, 156, 60'
+ },
+ '5940': {
+ logo: '/assets/team-logos/5940.png',
+ color: '252, 228, 180'
+ },
+ '6036': {
+ logo: '/assets/team-logos/6036.png',
+ color: '12, 12, 132'
+ },
+ '604': {
+ logo: '/assets/team-logos/604.png',
+ color: '252, 180, 36'
+ },
+ '6238': {
+ logo: '/assets/team-logos/6238.png',
+ color: '228, 36, 60'
+ },
+ '6305': {
+ logo: '/assets/team-logos/6305.png',
+ color: '36, 180, 228'
+ },
+ '6418': {
+ logo: '/assets/team-logos/6418.png',
+ color: '108, 132, 204'
+ },
+ '649': {
+ logo: '/assets/team-logos/649.png',
+ color: '12, 228, 36'
+ },
+ '6662': {
+ logo: '/assets/team-logos/6662.png',
+ color: '108, 60, 156'
+ },
+ '6665': {
+ logo: '/assets/team-logos/6665.png',
+ color: '204, 132, 108'
+ },
+ '670': {
+ logo: '/assets/team-logos/670.png',
+ color: '12, 204, 84'
+ },
+ '6814': {
+ logo: '/assets/team-logos/6814.png',
+ color: '204, 204, 252'
+ },
+ '6962': {
+ logo: '/assets/team-logos/6962.png',
+ color: '36, 36, 60'
+ },
+ '701': {
+ logo: '/assets/team-logos/701.png',
+ color: '130, 130, 130'
+ },
+ '7137': {
+ logo: '/assets/team-logos/7137.png',
+ color: '252, 180, 36'
+ },
+ '7419': {
+ logo: '/assets/team-logos/7419.png',
+ color: '252, 180, 36'
+ },
+ '766': {
+ logo: '/assets/team-logos/766.png',
+ color: '204, 108, 60'
+ },
+ '7667': {
+ logo: '/assets/team-logos/7667.png',
+ color: '252, 204, 12'
+ },
+ '8': {
+ logo: '/assets/team-logos/8.png',
+ color: '12, 60, 132'
+ },
+ '8033': {
+ logo: '/assets/team-logos/8033.png',
+ color: '60, 12, 108'
+ },
+ '8048': {
+ logo: '/assets/team-logos/8048.png',
+ color: '132, 156, 252'
+ },
+ '840': {
+ logo: '/assets/team-logos/840.png',
+ color: '130, 130, 130'
+ },
+ '841': {
+ logo: '/assets/team-logos/841.png',
+ color: '252, 252, 12'
+ },
+ '846': {
+ logo: '/assets/team-logos/846.png',
+ color: '252, 228, 12'
+ },
+ '8793': {
+ logo: '/assets/team-logos/8793.png',
+ color: '228, 108, 36'
+ },
+ '9400': {
+ logo: '/assets/team-logos/9400.png',
+ color: '228, 228, 228'
+ },
+ '9470': {
+ logo: '/assets/team-logos/9470.png',
+ color: '84, 84, 108'
+ },
+ '971': {
+ logo: '/assets/team-logos/971.png',
+ color: '252, 204, 12'
+ },
+ '972': {
+ logo: '/assets/team-logos/972.png',
+ color: '252, 84, 12'
+ }
+};
diff --git a/apps/website/src/routes/(with-nav)/tournaments/2026cancmp/+page.svelte b/apps/website/src/routes/(with-nav)/tournaments/2026cancmp/+page.svelte
index d6c7a5f2..f576c716 100644
--- a/apps/website/src/routes/(with-nav)/tournaments/2026cancmp/+page.svelte
+++ b/apps/website/src/routes/(with-nav)/tournaments/2026cancmp/+page.svelte
@@ -1,6 +1,8 @@
-
-
-
-
-
+
diff --git a/apps/website/src/lib/tournaments/2026cancmp.ts b/apps/website/src/lib/tournaments/2026cancmp.ts
index 548ca901..bf673428 100644
--- a/apps/website/src/lib/tournaments/2026cancmp.ts
+++ b/apps/website/src/lib/tournaments/2026cancmp.ts
@@ -242,3 +242,16 @@ export const teamBranding: Record = {
color: '252, 84, 12'
}
};
+
+// Alliance selection order: captain, first pick, second pick.
+// https://frc-events.firstinspires.org/2026/CANCMP/playoffs
+export const eventAlliances = [
+ [254, 1323, 6238],
+ [9470, 1678, 6665],
+ [6036, 5940, 3501],
+ [3045, 581, 846],
+ [972, 971, 3256],
+ [8, 5026, 841],
+ [2813, 4698, 6962],
+ [2073, 604, 1868]
+];
diff --git a/apps/website/src/routes/(with-nav)/tournaments/2026cancmp/+page.server.ts b/apps/website/src/routes/(with-nav)/tournaments/2026cancmp/+layout.server.ts
similarity index 89%
rename from apps/website/src/routes/(with-nav)/tournaments/2026cancmp/+page.server.ts
rename to apps/website/src/routes/(with-nav)/tournaments/2026cancmp/+layout.server.ts
index 13c49fe2..faa79550 100644
--- a/apps/website/src/routes/(with-nav)/tournaments/2026cancmp/+page.server.ts
+++ b/apps/website/src/routes/(with-nav)/tournaments/2026cancmp/+layout.server.ts
@@ -1,9 +1,9 @@
import { error } from '@sveltejs/kit';
import { env } from '$env/dynamic/private';
import { tournamentSchema } from '$lib/server/tournament';
-import type { PageServerLoad } from './$types';
+import type { LayoutServerLoad } from './$types';
-export const load: PageServerLoad = async ({ fetch, setHeaders }) => {
+export const load: LayoutServerLoad = async ({ fetch, setHeaders }) => {
if (!env.LOVAT_API_BASE) error(503, 'Tournament data is not configured');
let response: Response;
diff --git a/apps/website/src/routes/(with-nav)/tournaments/2026cancmp/+page.svelte b/apps/website/src/routes/(with-nav)/tournaments/2026cancmp/+page.svelte
index f576c716..18631680 100644
--- a/apps/website/src/routes/(with-nav)/tournaments/2026cancmp/+page.svelte
+++ b/apps/website/src/routes/(with-nav)/tournaments/2026cancmp/+page.svelte
@@ -130,7 +130,7 @@
const gapLabels = {
LUNCH: 'Break for lunch',
OVERNIGHT: 'End of day / overnight',
- PLAYOFF_TRANSITION: 'Qualification → playoffs',
+ PLAYOFF_TRANSITION: 'Eliminations Begin',
DELAY: 'Delay',
BREAK: 'Break'
};
@@ -207,6 +207,9 @@
teamFilter = '';
}}>Teams
+
diff --git a/apps/website/src/routes/(with-nav)/tournaments/2026cancmp/alliances/+page.svelte b/apps/website/src/routes/(with-nav)/tournaments/2026cancmp/alliances/+page.svelte
new file mode 100644
index 00000000..d4b3633c
--- /dev/null
+++ b/apps/website/src/routes/(with-nav)/tournaments/2026cancmp/alliances/+page.svelte
@@ -0,0 +1,439 @@
+
+
+
Alliances & bracket | {tournament.name} | Lovat
+
+
+ 2026 > FIRST California > Northern State Championship
+ Alliances & bracket
+
+
+
+
+
Alliances
+ {#if selectedAlliance !== null}{/if}
+
+
+ {#each eventAlliances as teams, index}
+
+ {/each}
+
+
+
+
+ Double-elimination bracket
+
+
+
+
+
+
+
diff --git a/apps/website/static/assets/icons/award-symbols.woff2 b/apps/website/static/assets/icons/award-symbols.woff2
deleted file mode 100644
index 04f8f25e40eb9c684e92a23d24ea8bc8eeda8b07..0000000000000000000000000000000000000000
GIT binary patch
literal 0
HcmV?d00001
literal 3064
zcmb_eZERE58Gg^X*LJ>aa~sEotW35qvC*=8G{$z)B-E6Id?;%Y;-qPyB#vVeIy
%#5$D~iD{)uH|Xl?J?Gj4
z4AQhtyT|9=_v5_J`*qH}CPqXSdX6L-YU$~H_`CaGdXq@XgYkAM5Y5%8~okB>&Cl6bDcT*1s6jqlri;=S^ZiHu<)!|AbD
zWTg761NLkV`oI_jroWgQFxEMp
zSiMQBlpw_F+zS%fnVv)ZOX-#Liu4{L%DoUN1pMarW52rj=AgCV6XKaQSFXOVqhGoD
z&lqga!1k9|X(XNnkfkLyONLyy+nk|*NsNmNGA3Gf>uTxjY@sD`Ub~QqP4>_Z==A8N
zCEdG|%E2?rBN-YSYEHC?__nr=4za9d`<5mK{X}wVKjXxo937ix5>~m%jtB(>DWW5O
zNktJnzFU6DUq}>#H3F%gOGm=OoJ*h=kcUFVo3?d?$r6uDkK<&B3<CQw9Om`)=XP!gF|r2mHZGi6=v$T;~sXap@
z_&jGsln6-s$HyZauQ;#Au=V
zdwk@2`0umG^>ue`K40WpQHdln6_w3&MO394Gl2TEKsy0>9N9Uk17j;H=ET?nndsT}
zRl6yji=iK)PhZ1*1Z(lC$6J;74ZJHP?BIOt5-X8UaVcfR5>q6}CHae8%e-21oP8yR53;`}euNuagj%)1aJm6?nlzp^RdbJf^O
z6r0iObyQbBpkTVP&!Fip0lU1gv9-#@W?faS8yA+f*L1Mr+xYzXYRJwdp+jh-Hn4Y-9>X!QcB`Px8m)IFILd)$pmaOs%37E*XuT_ZjT=_
z9jMW&1srmgpM&D#nsj_03uSPE_PchcAzIraJf)#iW9`txRn
z_;E=;<8NVi<6HFr^D&jNV9@1YZ^YQ*6WTYmBeh@VU#j45rMme<>co#bI>bIs;5NY*
zaT?ZQCyNu2X?}@a>;bg4Cg9)XQC`%&j{k-q1|6@ehaHqr>g;fU$4i5o`HlhX-~X
z>MytX`kN~rRLvW$#HZWj8}1&6K$VOT^FvEVGWqd|rg1GGHWj@fum~8%+I9=CJbGYCSDG}k`^+Ra!A=q
zW$L9@$|U{i-N#=jkPE&O{a%5b|E-
\ No newline at end of file
diff --git a/apps/website/static/assets/icons/crown.svg b/apps/website/static/assets/icons/crown.svg
new file mode 100644
index 00000000..e06f124f
--- /dev/null
+++ b/apps/website/static/assets/icons/crown.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
From 818a47e9d63ee49ea47b9d73706447b99434c90d Mon Sep 17 00:00:00 2001
From: jackshim415
Date: Sat, 10 Oct 2026 16:17:05 -0700
Subject: [PATCH 07/59] Keep alliances and bracket on the tournament page
Switch tournament views inline so schedule, teams, and alliances keep the same URL and event header.
---
.../{+layout.server.ts => +page.server.ts} | 4 +-
.../tournaments/2026cancmp/+page.svelte | 8 +-
...page.svelte => TournamentAlliances.svelte} | 266 ++++++++----------
3 files changed, 120 insertions(+), 158 deletions(-)
rename apps/website/src/routes/(with-nav)/tournaments/2026cancmp/{+layout.server.ts => +page.server.ts} (89%)
rename apps/website/src/routes/(with-nav)/tournaments/2026cancmp/{alliances/+page.svelte => TournamentAlliances.svelte} (55%)
diff --git a/apps/website/src/routes/(with-nav)/tournaments/2026cancmp/+layout.server.ts b/apps/website/src/routes/(with-nav)/tournaments/2026cancmp/+page.server.ts
similarity index 89%
rename from apps/website/src/routes/(with-nav)/tournaments/2026cancmp/+layout.server.ts
rename to apps/website/src/routes/(with-nav)/tournaments/2026cancmp/+page.server.ts
index faa79550..13c49fe2 100644
--- a/apps/website/src/routes/(with-nav)/tournaments/2026cancmp/+layout.server.ts
+++ b/apps/website/src/routes/(with-nav)/tournaments/2026cancmp/+page.server.ts
@@ -1,9 +1,9 @@
import { error } from '@sveltejs/kit';
import { env } from '$env/dynamic/private';
import { tournamentSchema } from '$lib/server/tournament';
-import type { LayoutServerLoad } from './$types';
+import type { PageServerLoad } from './$types';
-export const load: LayoutServerLoad = async ({ fetch, setHeaders }) => {
+export const load: PageServerLoad = async ({ fetch, setHeaders }) => {
if (!env.LOVAT_API_BASE) error(503, 'Tournament data is not configured');
let response: Response;
diff --git a/apps/website/src/routes/(with-nav)/tournaments/2026cancmp/+page.svelte b/apps/website/src/routes/(with-nav)/tournaments/2026cancmp/+page.svelte
index 18631680..becccc72 100644
--- a/apps/website/src/routes/(with-nav)/tournaments/2026cancmp/+page.svelte
+++ b/apps/website/src/routes/(with-nav)/tournaments/2026cancmp/+page.svelte
@@ -1,6 +1,7 @@
-Alliances & bracket | {tournament.name} | Lovat
-
-
- 2026 > FIRST California > Northern State Championship
- Alliances & bracket
-