Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
60 commits
Select commit Hold shift + click to select a range
cabcf2f
Add a responsive tournament schedule page to the Website
jackshim415 Oct 10, 2026
62d9860
Simplify tournament controls and highlight winning scores
jackshim415 Oct 10, 2026
62d9737
Simplify match times and add elimination navigation
jackshim415 Oct 10, 2026
a53dcf1
Pair match scores and refine tournament schedule layout
jackshim415 Oct 10, 2026
40a783a
Add branded team rows and refine tournament match displays
jackshim415 Oct 10, 2026
25ef6f3
Add tournament alliances and colored elimination bracket
jackshim415 Oct 10, 2026
818a47e
Keep alliances and bracket on the tournament page
jackshim415 Oct 10, 2026
7567a71
Generalize tournament pages and add district event views
jackshim415 Oct 10, 2026
4317d80
Simplify district lists and add town photos to event headers
jackshim415 Oct 10, 2026
1bdefde
Use landmark photos and an FRC fallback for event backgrounds
jackshim415 Oct 10, 2026
5817fde
Add season navigation and improve alliance team displays
jackshim415 Oct 11, 2026
9d18cad
Group season events and add district landmark defaults
jackshim415 Oct 11, 2026
f1fcdf3
Keep Website Docker context limited to required sources
jackshim415 Oct 11, 2026
5266b1d
Distinguish championship divisions and expand event headers
jackshim415 Oct 11, 2026
24c0061
Nest championship divisions and complete district resource links
jackshim415 Oct 11, 2026
d6536fe
Add team season pages and improve event backgrounds
jackshim415 Oct 11, 2026
4c7643e
Keep the season page heading simple
jackshim415 Oct 11, 2026
ab7718c
Expand team events with shared match cards and alliance details
jackshim415 Oct 11, 2026
8fecc61
Align team event summaries and add linked match pages
jackshim415 Oct 11, 2026
41fa68b
Add official match breakdowns and historical season browsing
jackshim415 Oct 11, 2026
53f1716
Add signed-in scouting predictions and tournament team records
jackshim415 Oct 11, 2026
4af7a5c
Center tournament records independently of award icons
jackshim415 Oct 11, 2026
c838c07
Hide match predictions when scouting data is unavailable
jackshim415 Oct 11, 2026
b8728d2
Diagnose rejected prediction authorization without logging credentials
jackshim415 Oct 11, 2026
cebf0cc
Identify missing prediction credentials and verify safe logging
jackshim415 Oct 11, 2026
2e01fcb
Distinguish missing team access from prediction sign-in errors
jackshim415 Oct 11, 2026
4e20935
Add public scouting aggregates and website discovery
jackshim415 Oct 11, 2026
0d27e43
Use an arrow icon for team event dropdowns
jackshim415 Oct 11, 2026
ef2af05
Show scouting role icons and label data sources
jackshim415 Oct 11, 2026
749e664
Match website role icons to Dashboard emblems
jackshim415 Oct 11, 2026
57ad914
Show tied roles beside teams and add match scouting metrics
jackshim415 Oct 11, 2026
c8a6f26
Use robot photos in team headers and simplify match labels
jackshim415 Oct 11, 2026
433c761
Show season percentiles for team scouting metrics
jackshim415 Oct 11, 2026
7802671
Allow team percentiles to finish on a cold cache
jackshim415 Oct 11, 2026
656d713
Align division arrows and add match videos and resource links
jackshim415 Oct 11, 2026
427b4be
Place match videos beneath header resource links
jackshim415 Oct 11, 2026
c39ca64
Group multiple match recordings in a dropdown
jackshim415 Oct 11, 2026
e839d17
Add landmark backgrounds for common event locations
jackshim415 Oct 11, 2026
37e6a89
Add search suggestions and direct team-number navigation
jackshim415 Oct 11, 2026
6ae0109
Assign varied FRC backgrounds consistently by event key
jackshim415 Oct 11, 2026
2872021
Expand tournament district and team image headers
jackshim415 Oct 11, 2026
bc493fb
Blend Worlds portraits into AI-extended backgrounds
jackshim415 Oct 11, 2026
759263f
Blend Hopper background with subtle navy and red fabric
jackshim415 Oct 11, 2026
7387b58
Use FIRST broadcast symbols for Worlds divisions
jackshim415 Oct 11, 2026
782290b
Add configurable team performance graphs and role breakdowns
jackshim415 Oct 11, 2026
b7511e0
Refresh role pie slices when the event filter changes
jackshim415 Oct 11, 2026
c5c850f
Separate schema and worker prerequisites from website changes
jackshim415 Oct 12, 2026
0639d2e
Identify registered Lovat teams on public profiles
jackshim415 Oct 15, 2026
9899b6b
Show compact Lovat badges in tournament team lists
jackshim415 Oct 15, 2026
9a4aec4
Resolve Magnolia legacy peers consistently in website builds
jackshim415 Oct 15, 2026
b5c32e0
Preserve website engine and resolution settings
jackshim415 Oct 15, 2026
2c9edaf
Use the Lovat app icon for team badges
jackshim415 Oct 15, 2026
2689682
Make Lovat team marks subtle and background-free
jackshim415 Oct 15, 2026
ae3624e
Format Lovat badge component
jackshim415 Oct 15, 2026
cca8b80
Add scouting context and consistency to public analytics
jackshim415 Oct 15, 2026
14c061e
Format scouting coverage return type
jackshim415 Oct 15, 2026
0028aef
Show tournament averages and graph context boundaries
jackshim415 Oct 15, 2026
65c2018
Expose scouting gaps and refine public analytics navigation
jackshim415 Oct 15, 2026
e63d398
Use solid lines for tournament boundaries
jackshim415 Oct 15, 2026
ebf0832
Use neutral solid tournament separators
jackshim415 Oct 15, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 64 additions & 0 deletions apps/server/src/handler/tournaments/getDistrict.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import type { Request, Response } from "express";
import prismaClient from "../../prismaClient.js";

export async function getDistrict(req: Request, res: Response): Promise<void> {
const key = req.params.key;

if (typeof key !== "string" || !/^[0-9]{4}[a-z0-9]+$/.test(key)) {
res.status(400).json({ error: "Invalid district key" });
return;
}

try {
const district = await prismaClient.districtSeason.findUnique({
where: { key },
select: {
key: true,
seasonYear: true,
name: true,
abbreviation: true,
tournaments: {
orderBy: [{ startDate: "asc" }, { key: "asc" }],
select: {
key: true,
parentTournamentKey: true,
week: true,
eventType: true,
name: true,
location: true,
startDate: true,
endDate: true,
},
},
teamSeasons: {
orderBy: { teamNumber: "asc" },
select: {
teamNumber: true,
team: {
select: { registeredTeam: { select: { teamApproved: true } } },
},
name: true,
city: true,
stateProvince: true,
},
},
},
});

if (!district) {
res.status(404).json({ error: "District not imported yet" });
return;
}

res.set("Cache-Control", "public, max-age=300");
res.json({
...district,
teamSeasons: district.teamSeasons.map(({ team, ...season }) => ({
...season,
usesLovat: team.registeredTeam?.teamApproved === true,
})),
});
} catch {
res.status(503).json({ error: "District data is temporarily unavailable" });
}
}
40 changes: 40 additions & 0 deletions apps/server/src/handler/tournaments/getMatch.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import type { Request, Response } from "express";
import prismaClient from "../../prismaClient.js";
import { matchVideos } from "./matchVideos.js";

export async function getMatch(req: Request, res: Response): Promise<void> {
const key = req.params.key;

if (
typeof key !== "string" ||
!/^[0-9]{4}[a-z0-9]+_(?:qm[1-9]\d*|(?:ef|qf|sf|f)[1-9]\d*m[1-9]\d*)$/.test(
key,
)
) {
res.status(400).json({ error: "Invalid match key" });
return;
}

try {
const match = await prismaClient.match.findUnique({
where: { key },
select: {
key: true,
alliances: {
orderBy: { color: "asc" },
select: { color: true, scoreBreakdown: true },
},
},
});

if (!match) {
res.status(404).json({ error: "Match not imported yet" });
return;
}

res.set("Cache-Control", "public, max-age=30");
res.json({ ...match, videos: await matchVideos(key) });
} catch {
res.status(503).json({ error: "Match data is temporarily unavailable" });
}
}
113 changes: 113 additions & 0 deletions apps/server/src/handler/tournaments/getRobotPhoto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import type { Request, Response } from "express";
import { z } from "zod";
import { publicCache } from "../../lib/publicCache.js";

const mediaSchema = z.array(
z.object({
type: z.string(),
preferred: z.boolean().optional(),
direct_url: z.string().optional(),
details: z
.object({
image_url_med: z.string().optional(),
image_url: z.string().optional(),
})
.nullish(),
}),
);

export function chooseRobotPhoto(input: unknown): string | null {
const media = mediaSchema
.parse(input)
.filter((item) => ["imgur", "smugmug-photo"].includes(item.type));

media.sort(
(a, b) => Number(Boolean(b.preferred)) - Number(Boolean(a.preferred)),
);

for (const item of media) {
const value =
item.details?.image_url_med || item.direct_url || item.details?.image_url;

if (!value) continue;

try {
const url = new URL(value);
const allowed =
url.hostname === "i.imgur.com" ||
url.hostname === "photos.smugmug.com" ||
url.hostname.endsWith(".smugmug.com");

if (
url.protocol === "https:" &&
!url.username &&
!url.password &&
allowed &&
/\.(?:jpe?g|png|webp)$/i.test(url.pathname)
)
return url.href;
} catch {
// Skip unsupported or malformed provider links.
}
}

return null;
}

export async function getRobotPhoto(
req: Request,
res: Response,
): Promise<void> {
const number = Number(req.params.number);
const year = Number(req.params.year);

if (
!Number.isInteger(number) ||
number <= 0 ||
number > 2_147_483_647 ||
!Number.isInteger(year) ||
year < 1992 ||
year > 9999
) {
res.status(400).json({ error: "Invalid team or year" });
return;
}

try {
const photo = await publicCache(
`robot-photo:${number}:${year}`,
async () => {
if (!process.env.TBA_KEY) return null;

const response = await fetch(
`https://www.thebluealliance.com/api/v3/team/frc${number}/media/${year}`,
{
headers: {
"X-TBA-Auth-Key": process.env.TBA_KEY,
"User-Agent": "Lovat/26.0",
},
signal: AbortSignal.timeout(10_000),
},
);

if (response.status === 404) return null;
if (!response.ok) throw new Error("Robot media unavailable");

const image = chooseRobotPhoto(await response.json());

return image
? {
image,
source: `https://www.thebluealliance.com/team/${number}/${year}`,
}
: null;
},
86400,
);

res.set("Cache-Control", "public, max-age=3600");
res.json({ photo });
} catch {
res.status(503).json({ error: "Robot photo unavailable" });
}
}
61 changes: 61 additions & 0 deletions apps/server/src/handler/tournaments/getSearch.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import type { Request, Response } from "express";
import prisma from "../../prismaClient.js";
import { publicCache } from "../../lib/publicCache.js";

export async function getSearch(req: Request, res: Response): Promise<void> {
const query = typeof req.query.q === "string" ? req.query.q.trim() : "";
const year = Number(req.query.year);

if (
!query ||
query.length > 80 ||
!Number.isInteger(year) ||
year < 1992 ||
year > 9999
) {
res.status(400).json({ error: "Invalid search" });
return;
}

try {
const results = await publicCache(
`search:${year}:${encodeURIComponent(query.toLowerCase())}`,
async () => {
const numeric = /^\d{1,9}$/.test(query) ? Number(query) : undefined;
const [events, teams] = await Promise.all([
prisma.tournament.findMany({
where: {
seasonYear: year,
OR: ["name", "key", "location"].map((field) => ({
[field]: { contains: query, mode: "insensitive" },
})),
},
orderBy: [{ startDate: "asc" }, { key: "asc" }],
take: 20,
select: { key: true, name: true, location: true },
}),
prisma.teamSeason.findMany({
where: {
seasonYear: year,
OR: [
{ name: { contains: query, mode: "insensitive" } },
...(numeric ? [{ teamNumber: numeric }] : []),
],
},
orderBy: { teamNumber: "asc" },
take: 20,
select: { teamNumber: true, name: true, city: true },
}),
]);

return { year, events, teams };
},
60,
);

res.set("Cache-Control", "public, max-age=60");
res.json(results);
} catch {
res.status(503).json({ error: "Search unavailable" });
}
}
58 changes: 58 additions & 0 deletions apps/server/src/handler/tournaments/getSeason.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import type { Request, Response } from "express";
import prismaClient from "../../prismaClient.js";

export async function getSeason(req: Request, res: Response): Promise<void> {
const value = req.params.year;

if (
typeof value !== "string" ||
!/^\d{4}$/.test(value) ||
Number(value) < 1992
) {
res.status(400).json({ error: "Invalid season year" });
return;
}

try {
const season = await prismaClient.season.findUnique({
where: { year: Number(value) },
select: {
year: true,
gameName: true,
districtSeasons: {
orderBy: { name: "asc" },
select: { key: true, abbreviation: true, name: true },
},
tournaments: {
where: { districtSeasonKey: null },
orderBy: [{ startDate: "asc" }, { key: "asc" }],
select: {
key: true,
parentTournamentKey: true,
name: true,
location: true,
startDate: true,
endDate: true,
week: true,
eventType: true,
},
},
},
});

if (!season) {
res.status(404).json({ error: "Season not imported yet" });
return;
}

const seasons = await prismaClient.season.findMany({
orderBy: { year: "desc" },
select: { year: true },
});

res.set("Cache-Control", "public, max-age=300");
res.json({ ...season, seasonYears: seasons.map(({ year }) => year) });
} catch {
res.status(503).json({ error: "Season data is temporarily unavailable" });
}
}
44 changes: 44 additions & 0 deletions apps/server/src/handler/tournaments/getTeamAvatar.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import type { Request, Response } from "express";
import prismaClient from "../../prismaClient.js";

export async function getTeamAvatar(
req: Request,
res: Response,
): Promise<void> {
const teamNumber = Number(req.params.number);
const seasonYear = Number(req.params.year);

if (
!Number.isSafeInteger(teamNumber) ||
teamNumber <= 0 ||
teamNumber > 2_147_483_647 ||
!Number.isSafeInteger(seasonYear) ||
seasonYear < 1992 ||
seasonYear > 9999
) {
res.status(400).end();
return;
}

try {
const team = await prismaClient.teamSeason.findUnique({
where: { teamNumber_seasonYear: { teamNumber, seasonYear } },
select: { avatar: true },
});
const image = team?.avatar?.match(
/^data:(image\/(?:png|jpeg|gif|webp));base64,([A-Za-z0-9+/=]+)$/,
);

if (!image) {
res.status(404).end();
return;
}

res.set("Cache-Control", "public, max-age=86400");
res.set("Content-Type", image[1]!);
res.set("X-Content-Type-Options", "nosniff");
res.send(Buffer.from(image[2]!, "base64"));
} catch {
res.status(503).end();
}
}
Loading
Loading