diff --git a/apps/server/src/handler/tournaments/getDistrict.ts b/apps/server/src/handler/tournaments/getDistrict.ts new file mode 100644 index 00000000..5c2353b3 --- /dev/null +++ b/apps/server/src/handler/tournaments/getDistrict.ts @@ -0,0 +1,64 @@ +import type { Request, Response } from "express"; +import prismaClient from "../../prismaClient.js"; + +export async function getDistrict(req: Request, res: Response): Promise { + 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" }); + } +} diff --git a/apps/server/src/handler/tournaments/getMatch.ts b/apps/server/src/handler/tournaments/getMatch.ts new file mode 100644 index 00000000..acf84130 --- /dev/null +++ b/apps/server/src/handler/tournaments/getMatch.ts @@ -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 { + 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" }); + } +} diff --git a/apps/server/src/handler/tournaments/getRobotPhoto.ts b/apps/server/src/handler/tournaments/getRobotPhoto.ts new file mode 100644 index 00000000..5957d256 --- /dev/null +++ b/apps/server/src/handler/tournaments/getRobotPhoto.ts @@ -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 { + 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" }); + } +} diff --git a/apps/server/src/handler/tournaments/getSearch.ts b/apps/server/src/handler/tournaments/getSearch.ts new file mode 100644 index 00000000..4310e035 --- /dev/null +++ b/apps/server/src/handler/tournaments/getSearch.ts @@ -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 { + 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" }); + } +} diff --git a/apps/server/src/handler/tournaments/getSeason.ts b/apps/server/src/handler/tournaments/getSeason.ts new file mode 100644 index 00000000..ca5a63b1 --- /dev/null +++ b/apps/server/src/handler/tournaments/getSeason.ts @@ -0,0 +1,58 @@ +import type { Request, Response } from "express"; +import prismaClient from "../../prismaClient.js"; + +export async function getSeason(req: Request, res: Response): Promise { + 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" }); + } +} diff --git a/apps/server/src/handler/tournaments/getTeamAvatar.ts b/apps/server/src/handler/tournaments/getTeamAvatar.ts new file mode 100644 index 00000000..7c701d60 --- /dev/null +++ b/apps/server/src/handler/tournaments/getTeamAvatar.ts @@ -0,0 +1,44 @@ +import type { Request, Response } from "express"; +import prismaClient from "../../prismaClient.js"; + +export async function getTeamAvatar( + req: Request, + res: Response, +): Promise { + 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(); + } +} diff --git a/apps/server/src/handler/tournaments/getTeamSeason.ts b/apps/server/src/handler/tournaments/getTeamSeason.ts new file mode 100644 index 00000000..3ad4d8bb --- /dev/null +++ b/apps/server/src/handler/tournaments/getTeamSeason.ts @@ -0,0 +1,109 @@ +import type { Request, Response } from "express"; +import prismaClient from "../../prismaClient.js"; + +function teamAwards(awards: unknown, teamNumber: number): string[] { + if (!Array.isArray(awards)) return []; + + return awards.flatMap((award: unknown) => { + if (!award || typeof award !== "object") return []; + + const { name, recipients } = award as Record; + + if (typeof name !== "string" || !Array.isArray(recipients)) return []; + + const received = recipients.some( + (recipient: unknown) => + recipient !== null && + typeof recipient === "object" && + (recipient as Record).teamNumber === teamNumber, + ); + + return received ? [name] : []; + }); +} + +export async function getTeamSeason( + req: Request, + res: Response, +): Promise { + const number = req.params.number; + const year = req.params.year; + + if ( + typeof number !== "string" || + !/^[1-9]\d{0,9}$/.test(number) || + Number(number) > 2_147_483_647 || + typeof year !== "string" || + !/^\d{4}$/.test(year) || + Number(year) < 1992 + ) { + res.status(400).json({ error: "Invalid team or season" }); + return; + } + + const teamNumber = Number(number); + const seasonYear = Number(year); + + try { + const team = await prismaClient.teamSeason.findUnique({ + where: { teamNumber_seasonYear: { teamNumber, seasonYear } }, + select: { + teamNumber: true, + seasonYear: true, + name: true, + city: true, + stateProvince: true, + country: true, + avatar: true, + district: { select: { key: true, abbreviation: true, name: true } }, + team: { + select: { + registeredTeam: { select: { teamApproved: true } }, + seasons: { + orderBy: { seasonYear: "desc" }, + select: { seasonYear: true }, + }, + tournamentTeams: { + where: { tournament: { seasonYear } }, + orderBy: { tournament: { startDate: "asc" } }, + select: { + tournament: { + select: { + key: true, + name: true, + location: true, + week: true, + startDate: true, + endDate: true, + awards: true, + district: { select: { abbreviation: true } }, + }, + }, + }, + }, + }, + }, + }, + }); + + if (!team) { + res.status(404).json({ error: "Team season not imported yet" }); + return; + } + + const { team: history, ...profile } = team; + + res.set("Cache-Control", "public, max-age=300"); + res.json({ + ...profile, + usesLovat: history.registeredTeam?.teamApproved === true, + seasonYears: history.seasons.map((season) => season.seasonYear), + tournaments: history.tournamentTeams.map(({ tournament }) => ({ + ...tournament, + awards: teamAwards(tournament.awards, teamNumber), + })), + }); + } catch { + res.status(503).json({ error: "Team data is temporarily unavailable" }); + } +} diff --git a/apps/server/src/handler/tournaments/getTournament.ts b/apps/server/src/handler/tournaments/getTournament.ts new file mode 100644 index 00000000..fe2e281a --- /dev/null +++ b/apps/server/src/handler/tournaments/getTournament.ts @@ -0,0 +1,114 @@ +import type { Request, Response } from "express"; +import prismaClient from "../../prismaClient.js"; + +export async function getTournament( + req: Request, + res: Response, +): Promise { + const key = req.params.key; + + if (typeof key !== "string" || !/^[0-9]{4}[a-z0-9]+$/.test(key)) { + res.status(400).json({ error: "Invalid tournament key" }); + return; + } + + try { + // Public competition data and registration status; no reports or strategy. + const tournament = await prismaClient.tournament.findUnique({ + where: { key }, + select: { + key: true, + name: true, + seasonYear: true, + district: { select: { key: true, name: true } }, + playoffType: true, + week: true, + allianceSelections: true, + awards: true, + location: true, + startDate: true, + endDate: true, + timezone: true, + officialDataUpdatedAt: true, + teams: { + orderBy: { teamNumber: "asc" }, + select: { + team: { + select: { + number: true, + name: true, + registeredTeam: { select: { teamApproved: true } }, + seasons: { + where: { seasonYear: Number(key.slice(0, 4)) }, + select: { name: true, avatar: 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, + teams: tournament.teams.map(({ team }) => ({ + team: { + number: team.number, + usesLovat: team.registeredTeam?.teamApproved === true, + name: team.seasons[0]?.name ?? team.name, + avatar: team.seasons[0]?.avatar ?? null, + }, + })), + }); + } catch { + res + .status(503) + .json({ error: "Tournament data is temporarily unavailable" }); + } +} diff --git a/apps/server/src/handler/tournaments/matchVideos.ts b/apps/server/src/handler/tournaments/matchVideos.ts new file mode 100644 index 00000000..649e095a --- /dev/null +++ b/apps/server/src/handler/tournaments/matchVideos.ts @@ -0,0 +1,50 @@ +import { z } from "zod"; +import { publicCache } from "../../lib/publicCache.js"; + +const videoSchema = z.object({ + videos: z.array(z.object({ type: z.string(), key: z.string() })), +}); + +export function youtubeVideoIds(input: unknown): string[] { + const parsed = videoSchema.safeParse(input); + + if (!parsed.success) return []; + + return [ + ...new Set( + parsed.data.videos + .filter( + (video) => + video.type === "youtube" && /^[A-Za-z0-9_-]{11}$/.test(video.key), + ) + .map((video) => video.key), + ), + ]; +} + +export async function matchVideos(key: string): Promise { + if (!process.env.TBA_KEY) return []; + + try { + return await publicCache(`match-videos:${key}`, async () => { + const response = await fetch( + `https://www.thebluealliance.com/api/v3/match/${key}`, + { + headers: { + "X-TBA-Auth-Key": process.env.TBA_KEY!, + "User-Agent": "Lovat/26.0", + }, + signal: AbortSignal.timeout(5_000), + }, + ); + + if (response.status === 404) return []; + if (!response.ok) throw new Error("Match videos unavailable"); + + return youtubeVideoIds(await response.json()); + }); + } catch { + // Optional recordings must not prevent official results from loading. + return []; + } +} diff --git a/apps/server/src/handler/tournaments/publicScouting.ts b/apps/server/src/handler/tournaments/publicScouting.ts new file mode 100644 index 00000000..a35f80e4 --- /dev/null +++ b/apps/server/src/handler/tournaments/publicScouting.ts @@ -0,0 +1,656 @@ +import type { Request, Response } from "express"; +import type { User, RobotRole } from "@lovat/db"; +import prisma from "../../prismaClient.js"; +import { publicCache } from "../../lib/publicCache.js"; +import { Metric } from "../analysis/analysisConstants.js"; +import { arrayAndAverageTeams } from "../analysis/coreAnalysis/arrayAndAverageTeams.js"; +import { matchPredictionLogic } from "../analysis/predictions/matchPredictionLogic.js"; + +// Anonymous access is deliberately limited to these aggregate projections. +// Existing authenticated analysis retains each user's source restrictions. +async function publicContext(year: number, teams: number[]): Promise { + const events = await prisma.teamMatchData.findMany({ + where: { + teamNumber: { in: teams }, + tournament: { seasonYear: year }, + scoutReports: { some: {} }, + }, + distinct: ["tournamentKey"], + select: { tournamentKey: true }, + }); + + return { + id: `public-scouting:${year}`, + email: "public@example.invalid", + emailVerified: false, + username: null, + teamNumber: null, + role: "ANALYST", + teamSourceRule: { mode: "EXCLUDE", items: [] }, + tournamentSourceRule: { + mode: "INCLUDE", + items: events.map((event) => event.tournamentKey).sort(), + }, + }; +} + +export function mostCommonRoles( + reports: { robotRoles: RobotRole[] }[], +): RobotRole[] { + const votes = new Map(); + + for (const report of reports) { + for (const role of new Set(report.robotRoles)) + votes.set(role, (votes.get(role) ?? 0) + 1); + } + + const highest = Math.max(0, ...votes.values()); + + return [...votes] + .filter(([, count]) => count === highest) + .map(([role]) => role) + .sort(); +} + +export function scoutingCoverage( + slots: { + teamNumber: number; + alliance: string | null; + scoutReports: unknown[]; + }[], +): { reported: number; expected: number; complete: boolean } { + const participants = slots.filter( + (slot) => slot.alliance === "BLUE" || slot.alliance === "RED", + ); + const reported = new Set( + participants + .filter((slot) => slot.scoutReports.length > 0) + .map((slot) => slot.teamNumber), + ); + const expected = new Set(participants.map((slot) => slot.teamNumber)).size; + + return { + reported: reported.size, + expected, + complete: + expected === 6 && + participants.length === 6 && + reported.size === 6 && + participants.filter((slot) => slot.alliance === "BLUE").length === 3 && + participants.filter((slot) => slot.alliance === "RED").length === 3, + }; +} + +// Midrank percentile: ties share a rank, with the lowest/highest unique values at 0/100. +export function metricPercentile( + value: number, + cohort: number[], +): number | null { + const valid = cohort.filter(Number.isFinite); + if (!Number.isFinite(value) || valid.length < 2) return null; + + const below = valid.filter((entry) => entry < value).length; + const equal = valid.filter((entry) => entry === value).length; + if (!equal) return null; + + return ( + Math.round(((below + (equal - 1) / 2) / (valid.length - 1)) * 1000) / 10 + ); +} + +async function seasonTeamMetrics(year: number) { + return publicCache(`scouting:team-distribution:v1:${year}`, async () => { + const rows = await prisma.teamMatchData.findMany({ + where: { tournament: { seasonYear: year }, scoutReports: { some: {} } }, + distinct: ["teamNumber"], + select: { teamNumber: true }, + orderBy: { teamNumber: "asc" }, + }); + const teams = rows.map((row) => row.teamNumber); + const user = await publicContext(year, teams); + const result: { + number: number; + matches: number; + points: number; + auto: number; + teleop: number; + }[] = []; + + // Bound each analysis query rather than loading every season report at once. + for (let start = 0; start < teams.length; start += 100) { + const batch = teams.slice(start, start + 100); + const values = await Promise.all( + [Metric.totalPoints, Metric.autoPoints, Metric.teleopPoints].map( + (metric) => arrayAndAverageTeams(user, { teams: batch, metric }), + ), + ); + + for (const number of batch) { + const total = values[0]?.[number]; + const auto = values[1]?.[number]; + const teleop = values[2]?.[number]; + + if ( + !total?.timeLine?.length || + !auto?.timeLine?.length || + !teleop?.timeLine?.length || + ![total.average, auto.average, teleop.average].every(Number.isFinite) + ) + continue; + + result.push({ + number, + matches: total.timeLine.length, + points: total.average, + auto: auto.average, + teleop: teleop.average, + }); + } + } + + return result; + }); +} + +export async function getPublicTeamMetrics( + req: Request, + res: Response, +): Promise { + 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 metrics = await publicCache( + `scouting:team:v2:${number}:${year}`, + async () => { + // Lovat's current scoring engine is for REBUILT; never apply it to older games. + if (year !== 2026) return null; + + const cohort = await seasonTeamMetrics(year); + const team = cohort.find((entry) => entry.number === number); + + if (!team) return null; + + return { + matches: team.matches, + points: team.points, + auto: team.auto, + teleop: team.teleop, + percentiles: { + points: metricPercentile( + team.points, + cohort.map((entry) => entry.points), + ), + auto: metricPercentile( + team.auto, + cohort.map((entry) => entry.auto), + ), + teleop: metricPercentile( + team.teleop, + cohort.map((entry) => entry.teleop), + ), + }, + }; + }, + ); + + res.set("Cache-Control", "public, max-age=60"); + res.json({ metrics }); + } catch { + res.status(503).json({ error: "Metrics unavailable" }); + } +} + +export async function getPublicMatchAnalysis( + req: Request, + res: Response, +): Promise { + const key = req.params.key; + + if ( + typeof key !== "string" || + !/^\d{4}[a-z0-9]+_(?:qm\d+|(?:ef|qf|sf|f)\d+m\d+)$/.test(key) + ) { + res.status(400).json({ error: "Invalid match" }); + return; + } + + try { + const analysis = await publicCache(`scouting:match:v3:${key}`, async () => { + const match = await prisma.match.findUnique({ + where: { key }, + select: { + tournament: { select: { seasonYear: true } }, + teamSlots: { + select: { + teamNumber: true, + alliance: true, + scoutReports: { select: { robotRoles: true } }, + }, + }, + }, + }); + + if (!match) return null; + + const roles = match.teamSlots.flatMap((slot) => { + return mostCommonRoles(slot.scoutReports).map((role) => ({ + teamNumber: slot.teamNumber, + role, + })); + }); + const blue = match.teamSlots + .filter((slot) => slot.alliance === "BLUE") + .map((slot) => slot.teamNumber); + const red = match.teamSlots + .filter((slot) => slot.alliance === "RED") + .map((slot) => slot.teamNumber); + let prediction: { blueWinning: number; redWinning: number } | null = null; + + if ( + match.tournament.seasonYear === 2026 && + blue.length === 3 && + red.length === 3 && + new Set([...blue, ...red]).size === 6 + ) { + const user = await publicContext(2026, [...blue, ...red]); + + try { + const result = await matchPredictionLogic(user, { + blue1: blue[0]!, + blue2: blue[1]!, + blue3: blue[2]!, + red1: red[0]!, + red2: red[1]!, + red3: red[2]!, + }); + + prediction = { + blueWinning: result.blueWinning, + redWinning: result.redWinning, + }; + } catch (error) { + if (error !== "not enough data") throw error; + } + } + + return { prediction, roles, coverage: scoutingCoverage(match.teamSlots) }; + }); + + if (!analysis) { + res.status(404).json({ error: "Match not found" }); + return; + } + + res.set("Cache-Control", "public, max-age=60"); + res.json(analysis); + } catch { + res.status(503).json({ error: "Prediction unavailable" }); + } +} + +// Match detail aggregates are requested separately so schedule cards stay light. +export async function getPublicMatchMetrics( + req: Request, + res: Response, +): Promise { + const key = req.params.key; + + if ( + typeof key !== "string" || + !/^\d{4}[a-z0-9]+_(?:qm\d+|(?:ef|qf|sf|f)\d+m\d+)$/.test(key) + ) { + res.status(400).json({ error: "Invalid match" }); + return; + } + + try { + const result = await publicCache( + `scouting:match-metrics:v1:${key}`, + async () => { + const match = await prisma.match.findUnique({ + where: { key }, + select: { + tournament: { select: { key: true, seasonYear: true } }, + teamSlots: { + select: { + key: true, + teamNumber: true, + alliance: true, + _count: { select: { scoutReports: true } }, + }, + }, + }, + }); + + if (!match) return null; + if (match.tournament.seasonYear !== 2026) return { teams: [] }; + + const slots = match.teamSlots.filter( + (slot) => slot._count.scoutReports > 0, + ); + if (!slots.length) return { teams: [] }; + + const user: User = { + id: "public-match-scouting:2026", + email: "public@example.invalid", + emailVerified: false, + username: null, + teamNumber: null, + role: "ANALYST", + teamSourceRule: { mode: "EXCLUDE", items: [] }, + tournamentSourceRule: { + mode: "INCLUDE", + items: [match.tournament.key], + }, + }; + const metrics = [ + Metric.totalPoints, + Metric.autoPoints, + Metric.teleopPoints, + Metric.totalFuelOutputted, + Metric.totalBallsFed, + Metric.totalDefenseTime, + ]; + const values = await Promise.all( + metrics.map((metric) => + arrayAndAverageTeams(user, { + teams: slots.map((slot) => slot.teamNumber), + metric, + }), + ), + ); + + const teams = slots.map((slot) => { + const atMatch = (index: number): number | null => + values[index]?.[slot.teamNumber]?.timeLine?.find( + (entry) => entry.match === slot.key, + )?.dataPoint ?? null; + + return { + teamNumber: slot.teamNumber, + alliance: slot.alliance, + reports: slot._count.scoutReports, + points: atMatch(0), + auto: atMatch(1), + teleop: atMatch(2), + fuelOutput: atMatch(3), + fuelFed: atMatch(4), + defenseSeconds: atMatch(5), + }; + }); + + return { teams }; + }, + ); + + if (!result) { + res.status(404).json({ error: "Match not found" }); + return; + } + + res.set("Cache-Control", "public, max-age=60"); + res.json(result); + } catch { + res.status(503).json({ error: "Scouting metrics unavailable" }); + } +} + +export async function getPublicTeamSeries( + req: Request, + res: Response, +): Promise { + 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 matches = await publicCache( + `scouting:team-series:v3:${number}:${year}`, + async () => { + if (year !== 2026) return []; + + const slots = await prisma.teamMatchData.findMany({ + where: { + teamNumber: number, + tournament: { seasonYear: year }, + }, + select: { + key: true, + matchKey: true, + matchNumber: true, + matchType: true, + tournamentKey: true, + tournament: { select: { name: true, startDate: true } }, + match: { + select: { + displayOrder: true, + actualTime: true, + scheduledTime: true, + teamSlots: { + select: { + teamNumber: true, + alliance: true, + scoutReports: { select: { uuid: true } }, + }, + }, + }, + }, + scoutReports: { + select: { robotRoles: true, robotBrokeDescription: true }, + }, + }, + }); + + if (!slots.length) return []; + + const user = await publicContext(year, [number]); + const values = await Promise.all( + [ + Metric.totalPoints, + Metric.autoPoints, + Metric.teleopPoints, + Metric.totalBallsFed, + ].map((metric) => + arrayAndAverageTeams(user, { teams: [number], metric }), + ), + ); + const timelines = values.map( + (value) => + new Map( + (value[number]?.timeLine ?? []).map((entry) => [ + entry.match, + entry.dataPoint, + ]), + ), + ); + + const eventTime = (slot: (typeof slots)[number]) => + slot.tournament.startDate?.getTime() ?? 0; + const matchTime = (slot: (typeof slots)[number]) => + (slot.match?.actualTime ?? slot.match?.scheduledTime)?.getTime() ?? 0; + + slots.sort( + (a, b) => + eventTime(a) - eventTime(b) || + a.tournamentKey.localeCompare(b.tournamentKey) || + (a.matchType === b.matchType + ? 0 + : a.matchType === "QUALIFICATION" + ? -1 + : 1) || + (a.match?.displayOrder ?? a.matchNumber) - + (b.match?.displayOrder ?? b.matchNumber) || + matchTime(a) - matchTime(b) || + a.key.localeCompare(b.key), + ); + + return slots.map((slot) => { + const value = (index: number) => { + const result = slot.scoutReports.length + ? timelines[index]?.get(slot.key) + : null; + return typeof result === "number" && Number.isFinite(result) + ? result + : null; + }; + + return { + matchKey: slot.matchKey, + reports: slot.scoutReports.length, + elimination: slot.matchType !== "QUALIFICATION", + reportedBroken: slot.scoutReports.some( + (report) => !!report.robotBrokeDescription?.trim(), + ), + coverage: slot.match + ? scoutingCoverage(slot.match.teamSlots ?? []) + : null, + eventKey: slot.tournamentKey, + eventName: slot.tournament.name, + label: `${slot.matchType === "QUALIFICATION" ? "Qualification" : "Elimination"} ${slot.matchNumber}`, + points: value(0), + auto: value(1), + teleop: value(2), + feeding: value(3), + roles: mostCommonRoles(slot.scoutReports), + }; + }); + }, + ); + + res.set("Cache-Control", "public, max-age=60"); + res.json({ matches }); + } catch { + res.status(503).json({ error: "Team analytics unavailable" }); + } +} + +export async function getPublicDistrictLeaderboard( + req: Request, + res: Response, +): Promise { + const key = req.params.key; + + if (typeof key !== "string" || !/^\d{4}[a-z0-9]+$/.test(key)) { + res.status(400).json({ error: "Invalid district" }); + return; + } + + try { + const leaderboard = await publicCache( + `scouting:district:v1:${key}`, + async () => { + const district = await prisma.districtSeason.findUnique({ + where: { key }, + select: { + seasonYear: true, + teamSeasons: { select: { teamNumber: true, name: true } }, + }, + }); + + if (!district) return null; + if (district.seasonYear !== 2026) return []; + + const cohort = await seasonTeamMetrics(district.seasonYear); + const members = new Map( + district.teamSeasons.map((team) => [team.teamNumber, team.name]), + ); + + return cohort + .filter((team) => members.has(team.number)) + .map((team) => ({ + ...team, + name: members.get(team.number)!, + })); + }, + ); + + if (leaderboard === null) { + res.status(404).json({ error: "District not found" }); + return; + } + + res.set("Cache-Control", "public, max-age=60"); + res.json({ teams: leaderboard }); + } catch { + res.status(503).json({ error: "Leaderboard unavailable" }); + } +} + +export async function getPublicPrediction( + req: Request, + res: Response, +): Promise { + const raw = req.query.teams; + const teams = + typeof raw === "string" && /^\d+(,\d+){5}$/.test(raw) + ? raw.split(",").map(Number) + : []; + + if ( + teams.length !== 6 || + new Set(teams).size !== 6 || + teams.some( + (team) => !Number.isInteger(team) || team <= 0 || team > 2_147_483_647, + ) || + req.query.year !== "2026" + ) { + res.status(400).json({ error: "Choose six distinct teams in 2026" }); + return; + } + + try { + const prediction = await publicCache( + `scouting:prediction:v1:2026:${teams.join(",")}`, + async () => { + const user = await publicContext(2026, teams); + + try { + const result = await matchPredictionLogic(user, { + blue1: teams[0]!, + blue2: teams[1]!, + blue3: teams[2]!, + red1: teams[3]!, + red2: teams[4]!, + red3: teams[5]!, + }); + return { + blueWinning: result.blueWinning, + redWinning: result.redWinning, + }; + } catch (error) { + if (error === "not enough data") return null; + throw error; + } + }, + ); + + res.set("Cache-Control", "public, max-age=60"); + res.json({ prediction }); + } catch { + res.status(503).json({ error: "Prediction unavailable" }); + } +} diff --git a/apps/server/src/lib/middleware/requireAuth.ts b/apps/server/src/lib/middleware/requireAuth.ts index 3f42f89b..f75c9473 100644 --- a/apps/server/src/lib/middleware/requireAuth.ts +++ b/apps/server/src/lib/middleware/requireAuth.ts @@ -84,6 +84,22 @@ export const requireAuth = async ( }, ); } catch (error) { + // Keep token contents and claim values out of authentication diagnostics. + const code = + error instanceof Error && + "code" in error && + typeof error.code === "string" && + /^ERR_[A-Z_]+$/.test(error.code) + ? error.code + : "verification_failed"; + const claim = + error instanceof Error && + "claim" in error && + ["aud", "iss", "exp", "nbf"].includes(String(error.claim)) + ? String(error.claim) + : undefined; + + console.warn("Authorization token rejected", { code, claim }); res.status(401).send("Invalid authorization token"); return; } diff --git a/apps/server/src/lib/publicCache.ts b/apps/server/src/lib/publicCache.ts new file mode 100644 index 00000000..024c5e5b --- /dev/null +++ b/apps/server/src/lib/publicCache.ts @@ -0,0 +1,43 @@ +import { kv } from "../redisClient.js"; + +const pending = new Map>(); + +// Public projections have their own namespace and a bounded lifetime. +export async function publicCache( + key: string, + calculate: () => Promise, + seconds = 300, +): Promise { + const cacheKey = `public:v1:${key}`; + const existing = pending.get(cacheKey); + + if (existing) return existing as Promise; + + const request = (async () => { + try { + const saved = await kv.get(cacheKey); + + if (saved) return JSON.parse(saved.toString()) as T; + } catch { + // A cache outage must not expose private caches or prevent public reads. + } + + const result = await calculate(); + + try { + await kv.setEx(cacheKey, JSON.stringify(result), seconds); + } catch { + // Return the calculated public projection even if Redis is unavailable. + } + + return result; + })(); + + pending.set(cacheKey, request); + + try { + return await request; + } finally { + pending.delete(cacheKey); + } +} diff --git a/apps/server/src/routes/index.ts b/apps/server/src/routes/index.ts index dbf2bd75..34e98631 100644 --- a/apps/server/src/routes/index.ts +++ b/apps/server/src/routes/index.ts @@ -6,6 +6,23 @@ 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 { getTeamSeason } from "../handler/tournaments/getTeamSeason.js"; +import { getTeamAvatar } from "../handler/tournaments/getTeamAvatar.js"; +import { getSeason } from "../handler/tournaments/getSeason.js"; +import { getDistrict } from "../handler/tournaments/getDistrict.js"; +import { getTournament } from "../handler/tournaments/getTournament.js"; + +import { getMatch } from "../handler/tournaments/getMatch.js"; +import { getSearch } from "../handler/tournaments/getSearch.js"; +import { getRobotPhoto } from "../handler/tournaments/getRobotPhoto.js"; +import { + getPublicMatchAnalysis, + getPublicMatchMetrics, + getPublicTeamMetrics, + getPublicTeamSeries, + getPublicDistrictLeaderboard, + getPublicPrediction, +} from "../handler/tournaments/publicScouting.js"; const router = Router(); @@ -13,6 +30,21 @@ router.use("/slack", slackRouter); router.use("/manager", managerRouter); router.use("/analysis", analysisRouter); +router.get("/tournaments/:key", getTournament); +router.get("/matches/:key", getMatch); +router.get("/districts/:key", getDistrict); +router.get("/seasons/:year", getSeason); +router.get("/teams/:number/avatar/:year", getTeamAvatar); +router.get("/teams/:number/seasons/:year", getTeamSeason); +router.get("/search", getSearch); +router.get("/public/prediction", getPublicPrediction); +router.get("/public/districts/:key/leaderboard", getPublicDistrictLeaderboard); +router.get("/public/matches/:key", getPublicMatchAnalysis); +router.get("/public/matches/:key/metrics", getPublicMatchMetrics); +router.get("/public/teams/:number/:year/metrics", getPublicTeamMetrics); +router.get("/public/teams/:number/:year/series", getPublicTeamSeries); +router.get("/public/teams/:number/:year/photo", getRobotPhoto); + router.get("/slack-invite", onboardingRedirect); // OpenAPI docs diff --git a/apps/server/test/districtPage.unit.test.ts b/apps/server/test/districtPage.unit.test.ts new file mode 100644 index 00000000..f6c6fd60 --- /dev/null +++ b/apps/server/test/districtPage.unit.test.ts @@ -0,0 +1,101 @@ +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: { districtSeason: { findUnique } }, +})); + +import { getDistrict } from "../src/handler/tournaments/getDistrict.js"; + +beforeEach(() => vi.resetAllMocks()); + +it("returns the requested district with public events and season teams", async () => { + const district = { + key: "2026ca", + name: "FIRST California", + tournaments: [], + teamSeasons: [], + }; + findUnique.mockResolvedValue(district); + + const res = await invoke(getDistrict, { params: { key: "2026ca" } }); + const query = findUnique.mock.calls[0]![0]; + + expect(query.where).toEqual({ key: "2026ca" }); + expect(query.select.teamSeasons.select).toEqual({ + teamNumber: true, + team: { select: { registeredTeam: { select: { teamApproved: true } } } }, + name: true, + city: true, + stateProvince: true, + }); + expect(query.select.tournaments.select).toEqual({ + key: true, + parentTournamentKey: true, + week: true, + eventType: true, + name: true, + location: true, + startDate: true, + endDate: true, + }); + expect(JSON.stringify(query)).not.toContain("scoutReports"); + expect(res.body).toEqual(district); + expect(res.headers["Cache-Control"]).toBe("public, max-age=300"); +}); + +it("returns 404 for an unimported district", async () => { + findUnique.mockResolvedValue(null); + + expect( + (await invoke(getDistrict, { params: { key: "2026missing" } })).statusCode, + ).toBe(404); +}); + +it("rejects invalid keys before querying", async () => { + expect( + (await invoke(getDistrict, { params: { key: "../private" } })).statusCode, + ).toBe(400); + expect(findUnique).not.toHaveBeenCalled(); +}); + +it("does not expose database errors", async () => { + findUnique.mockRejectedValue(new Error("private connection details")); + + const res = await invoke(getDistrict, { params: { key: "2026ca" } }); + + expect(res.statusCode).toBe(503); + expect(res.body).toEqual({ + error: "District data is temporarily unavailable", + }); +}); + +it("projects only the public registration flag for district teams", async () => { + findUnique.mockResolvedValue({ + key: "2026ca", + teamSeasons: [ + { + teamNumber: 8033, + name: "Highlander Robotics", + team: { registeredTeam: { teamApproved: true, code: "private" } }, + }, + { teamNumber: 1, name: "One", team: { registeredTeam: null } }, + { + teamNumber: 2, + name: "Two", + team: { registeredTeam: { teamApproved: false } }, + }, + ], + }); + const response = await invoke(getDistrict, { params: { key: "2026ca" } }); + expect(response.body).toEqual({ + key: "2026ca", + teamSeasons: [ + { teamNumber: 8033, name: "Highlander Robotics", usesLovat: true }, + { teamNumber: 1, name: "One", usesLovat: false }, + { teamNumber: 2, name: "Two", usesLovat: false }, + ], + }); +}); diff --git a/apps/server/test/helpers/handlerHarness.ts b/apps/server/test/helpers/handlerHarness.ts index 8efbf819..dd756d43 100644 --- a/apps/server/test/helpers/handlerHarness.ts +++ b/apps/server/test/helpers/handlerHarness.ts @@ -47,6 +47,7 @@ export const invoke = async ( `attachment; filename="${name}"`; return response; }), + end: vi.fn(() => response), redirect: vi.fn((url: string) => { response.statusCode = 302; response.headers.Location = url; diff --git a/apps/server/test/matchPage.unit.test.ts b/apps/server/test/matchPage.unit.test.ts new file mode 100644 index 00000000..b351d40b --- /dev/null +++ b/apps/server/test/matchPage.unit.test.ts @@ -0,0 +1,66 @@ +import { beforeEach, expect, it, vi } from "vitest"; +import { invoke } from "./helpers/handlerHarness.js"; + +const findUnique = vi.hoisted(() => vi.fn()); +const videos = vi.hoisted(() => vi.fn()); + +vi.mock("../src/handler/tournaments/matchVideos.js", () => ({ + matchVideos: videos, +})); + +vi.mock("../src/prismaClient.js", () => ({ + default: { match: { findUnique } }, +})); + +import { getMatch } from "../src/handler/tournaments/getMatch.js"; + +beforeEach(() => { + vi.resetAllMocks(); + videos.mockResolvedValue(["abcdefghijk"]); +}); + +it("selects only official match breakdowns, without scouting relations", async () => { + const match = { + key: "2026casnf_qm4", + alliances: [{ color: "RED", scoreBreakdown: { autoPoints: 32 } }], + }; + findUnique.mockResolvedValue(match); + + const res = await invoke(getMatch, { params: { key: match.key } }); + + expect(findUnique).toHaveBeenCalledWith({ + where: { key: match.key }, + select: { + key: true, + alliances: { + orderBy: { color: "asc" }, + select: { color: true, scoreBreakdown: true }, + }, + }, + }); + expect(res.body).toEqual({ ...match, videos: ["abcdefghijk"] }); + expect(videos).toHaveBeenCalledWith(match.key); + expect(res.headers["Cache-Control"]).toBe("public, max-age=30"); +}); + +it.each(["../private", "2026casnf", "2026casnf_qm0"])( + "rejects malformed keys before querying: %s", + async (key) => { + expect((await invoke(getMatch, { params: { key } })).statusCode).toBe(400); + expect(findUnique).not.toHaveBeenCalled(); + }, +); + +it("returns 404 for an unimported match", async () => { + findUnique.mockResolvedValue(null); + expect( + (await invoke(getMatch, { params: { key: "2026casnf_sf1m1" } })).statusCode, + ).toBe(404); +}); + +it("hides database errors", async () => { + findUnique.mockRejectedValue(new Error("private database details")); + const res = await invoke(getMatch, { params: { key: "2026casnf_qm4" } }); + expect(res.statusCode).toBe(503); + expect(res.body).toEqual({ error: "Match data is temporarily unavailable" }); +}); diff --git a/apps/server/test/matchVideos.unit.test.ts b/apps/server/test/matchVideos.unit.test.ts new file mode 100644 index 00000000..191ccf31 --- /dev/null +++ b/apps/server/test/matchVideos.unit.test.ts @@ -0,0 +1,65 @@ +import { afterEach, expect, it, vi } from "vitest"; + +vi.mock("../src/lib/publicCache.js", () => ({ + publicCache: async (_key: string, calculate: () => Promise) => + calculate(), +})); + +import { + matchVideos, + youtubeVideoIds, +} from "../src/handler/tournaments/matchVideos.js"; + +afterEach(() => { + vi.unstubAllGlobals(); + vi.unstubAllEnvs(); +}); + +it("projects unique YouTube IDs and rejects unsupported providers or injected URLs", () => { + expect( + youtubeVideoIds({ + videos: [ + { type: "youtube", key: "abcdefghijk" }, + { type: "youtube", key: "abcdefghijk" }, + { type: "tba", key: "ABCDEFGHIJK" }, + { type: "youtube", key: "https://evil.test" }, + { type: "youtube", key: "abc¶m=1" }, + ], + privateData: "hidden", + }), + ).toEqual(["abcdefghijk"]); + expect(youtubeVideoIds({})).toEqual([]); +}); + +it("fetches only the canonical TBA match and returns IDs", async () => { + vi.stubEnv("TBA_KEY", "test-key"); + const fetcher = vi + .fn() + .mockResolvedValue( + Response.json({ videos: [{ type: "youtube", key: "abcdefghijk" }] }), + ); + vi.stubGlobal("fetch", fetcher); + + expect(await matchVideos("2026casnf_qm4")).toEqual(["abcdefghijk"]); + expect(fetcher).toHaveBeenCalledWith( + "https://www.thebluealliance.com/api/v3/match/2026casnf_qm4", + expect.objectContaining({ + headers: { "X-TBA-Auth-Key": "test-key", "User-Agent": "Lovat/26.0" }, + signal: expect.any(AbortSignal), + }), + ); +}); + +it("keeps results available without a key, recording, or upstream service", async () => { + const fetcher = vi.fn(); + vi.stubGlobal("fetch", fetcher); + vi.stubEnv("TBA_KEY", ""); + expect(await matchVideos("2026casnf_qm4")).toEqual([]); + expect(fetcher).not.toHaveBeenCalled(); + + vi.stubEnv("TBA_KEY", "test-key"); + fetcher.mockResolvedValue(new Response(null, { status: 404 })); + expect(await matchVideos("2026casnf_qm4")).toEqual([]); + fetcher.mockRejectedValue(new Error("private upstream details")); + expect(await matchVideos("2026casnf_qm4")).toEqual([]); +}); diff --git a/apps/server/test/publicCache.unit.test.ts b/apps/server/test/publicCache.unit.test.ts new file mode 100644 index 00000000..6371530c --- /dev/null +++ b/apps/server/test/publicCache.unit.test.ts @@ -0,0 +1,39 @@ +import { beforeEach, expect, it, vi } from "vitest"; +const kv = vi.hoisted(() => ({ get: vi.fn(), setEx: vi.fn() })); +vi.mock("../src/redisClient.js", () => ({ kv })); +import { publicCache } from "../src/lib/publicCache.js"; +beforeEach(() => vi.resetAllMocks()); +it("isolates public keys and bounds their lifetime", async () => { + const calculate = vi.fn().mockResolvedValue({ prediction: null }); + await publicCache("match:1", calculate); + expect(kv.get).toHaveBeenCalledWith("public:v1:match:1"); + expect(kv.setEx).toHaveBeenCalledWith( + "public:v1:match:1", + '{"prediction":null}', + 300, + ); +}); +it("coalesces concurrent requests", async () => { + let resolve!: (value: number) => void; + const calculate = vi.fn( + () => + new Promise((done) => { + resolve = done; + }), + ); + const first = publicCache("concurrent", calculate); + const second = publicCache("concurrent", calculate); + await vi.waitFor(() => expect(calculate).toHaveBeenCalledOnce()); + resolve(42); + expect(await Promise.all([first, second])).toEqual([42, 42]); +}); +it("recovers from cache outages and failed calculations", async () => { + kv.get.mockRejectedValue(new Error("offline")); + kv.setEx.mockRejectedValue(new Error("offline")); + await expect( + publicCache("retry", async () => { + throw new Error("failed"); + }), + ).rejects.toThrow("failed"); + await expect(publicCache("retry", async () => 42)).resolves.toBe(42); +}); diff --git a/apps/server/test/publicScouting.unit.test.ts b/apps/server/test/publicScouting.unit.test.ts new file mode 100644 index 00000000..4537c2ba --- /dev/null +++ b/apps/server/test/publicScouting.unit.test.ts @@ -0,0 +1,690 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { invoke } from "./helpers/handlerHarness.js"; + +const mocks = vi.hoisted(() => ({ + match: vi.fn(), + events: vi.fn(), + predict: vi.fn(), + metrics: vi.fn(), + district: vi.fn(), +})); +vi.mock("../src/prismaClient.js", () => ({ + default: { + team: { findMany: vi.fn().mockResolvedValue([]) }, + tournament: { findMany: vi.fn().mockResolvedValue([]) }, + match: { findUnique: mocks.match }, + districtSeason: { findUnique: mocks.district }, + teamMatchData: { findMany: mocks.events }, + }, +})); +vi.mock("../src/lib/publicCache.js", () => ({ + publicCache: async (_key: string, calculate: () => Promise) => + calculate(), +})); +vi.mock("../src/handler/analysis/predictions/matchPredictionLogic.js", () => ({ + matchPredictionLogic: mocks.predict, +})); +vi.mock("../src/handler/analysis/coreAnalysis/arrayAndAverageTeams.js", () => ({ + arrayAndAverageTeams: mocks.metrics, +})); +import { + getPublicMatchAnalysis, + getPublicTeamMetrics, + getPublicTeamSeries, + getPublicMatchMetrics, + mostCommonRoles, + metricPercentile, + scoutingCoverage, + getPublicDistrictLeaderboard, + getPublicPrediction, +} from "../src/handler/tournaments/publicScouting.js"; +import { chooseRobotPhoto } from "../src/handler/tournaments/getRobotPhoto.js"; + +beforeEach(() => { + vi.resetAllMocks(); + mocks.events.mockImplementation(async (args) => + args.distinct[0] === "teamNumber" + ? [{ teamNumber: 8033 }] + : [{ tournamentKey: "2026casnf" }], + ); + mocks.match.mockResolvedValue({ + tournament: { seasonYear: 2026 }, + teamSlots: [1, 2, 3, 4, 5, 6].map((teamNumber) => ({ + teamNumber, + alliance: teamNumber <= 3 ? "BLUE" : "RED", + scoutReports: [{ robotRoles: ["SCORING"] }], + notes: "private", + })), + }); + mocks.predict.mockResolvedValue({ + blueWinning: 0.7, + redWinning: 0.3, + privateNotes: "secret", + }); +}); + +describe("public scouting projections", () => { + it("uses all approved sources within the season and exposes only probabilities and match roles", async () => { + const response = await invoke(getPublicMatchAnalysis, { + params: { key: "2026casnf_qm4" }, + }); + expect(response.body).toEqual({ + coverage: { reported: 6, expected: 6, complete: true }, + prediction: { blueWinning: 0.7, redWinning: 0.3 }, + roles: [1, 2, 3, 4, 5, 6].map((teamNumber) => ({ + teamNumber, + role: "SCORING", + })), + }); + expect(mocks.events.mock.calls[0]![0].where.tournament).toEqual({ + seasonYear: 2026, + }); + expect(mocks.predict.mock.calls[0]![0]).toMatchObject({ + id: "public-scouting:2026", + teamSourceRule: { mode: "EXCLUDE", items: [] }, + tournamentSourceRule: { mode: "INCLUDE", items: ["2026casnf"] }, + }); + expect(mocks.predict.mock.calls[0]![1]).toEqual({ + blue1: 1, + blue2: 2, + blue3: 3, + red1: 4, + red2: 5, + red3: 6, + }); + expect( + mocks.match.mock.calls[0]![0].select.teamSlots.select.scoutReports, + ).toEqual({ select: { robotRoles: true } }); + }); + it("keeps roles when prediction data is insufficient", async () => { + mocks.predict.mockRejectedValue("not enough data"); + const response = await invoke(getPublicMatchAnalysis, { + params: { key: "2026casnf_qm4" }, + }); + expect(response.statusCode).toBe(200); + expect(response.body).toMatchObject({ prediction: null }); + }); + it.each([2025, 2026])( + "does not predict older games or four-team matches (%s)", + async (year) => { + const match = await mocks.match(); + match.tournament.seasonYear = year; + if (year === 2026) + match.teamSlots.push({ + teamNumber: 7, + alliance: "BLUE", + scoutReports: [], + }); + mocks.match.mockResolvedValue(match); + await invoke(getPublicMatchAnalysis, { + params: { key: `${year}casnf_qm4` }, + }); + expect(mocks.predict).not.toHaveBeenCalled(); + }, + ); + it("projects metrics without scouting timelines", async () => { + mocks.metrics.mockResolvedValue({ + 8033: { average: 42, timeLine: [{ match: "private", dataPoint: 42 }] }, + }); + const response = await invoke(getPublicTeamMetrics, { + params: { number: "8033", year: "2026" }, + }); + expect(response.body).toEqual({ + metrics: { + matches: 1, + points: 42, + auto: 42, + teleop: 42, + percentiles: { points: null, auto: null, teleop: null }, + }, + }); + }); + it("rejects malformed keys without querying reports", async () => { + expect( + (await invoke(getPublicMatchAnalysis, { params: { key: "bad" } })) + .statusCode, + ).toBe(400); + expect(mocks.match).not.toHaveBeenCalled(); + }); + it("deduplicates votes and returns every tied top role", () => { + expect( + mostCommonRoles([ + { robotRoles: ["SCORING", "SCORING"] }, + { robotRoles: ["DEFENDING"] }, + ]), + ).toEqual(["DEFENDING", "SCORING"]); + expect( + mostCommonRoles([ + { robotRoles: ["SCORING"] }, + { robotRoles: ["SCORING", "FEEDING"] }, + ]), + ).toEqual(["SCORING"]); + expect(mostCommonRoles([])).toEqual([]); + }); +}); + +describe("season metric percentiles", () => { + it("gives ordered values their rank and ties a shared midpoint", () => { + expect(metricPercentile(10, [10, 20, 30])).toBe(0); + expect(metricPercentile(20, [10, 20, 30])).toBe(50); + expect(metricPercentile(30, [10, 20, 30])).toBe(100); + expect(metricPercentile(20, [10, 20, 20, 30])).toBe(50); + expect(metricPercentile(0, [0, 0, 0])).toBe(50); + expect(metricPercentile(42, [42])).toBeNull(); + expect(metricPercentile(42, [])).toBeNull(); + }); + it("ranks the three metrics independently within the approved season cohort", async () => { + mocks.events.mockImplementation(async (args) => + args.distinct[0] === "teamNumber" + ? [{ teamNumber: 8033 }, { teamNumber: 1 }] + : [{ tournamentKey: "2026casnf" }], + ); + let index = 0; + mocks.metrics.mockImplementation(async () => { + const target = [42, 10, 30][index++]; + return { + 8033: { average: target, timeLine: [{ match: "private" }] }, + 1: { average: 20, timeLine: [{ match: "private" }] }, + }; + }); + const response = await invoke(getPublicTeamMetrics, { + params: { number: "8033", year: "2026" }, + }); + expect(response.body).toEqual({ + metrics: { + matches: 1, + points: 42, + auto: 10, + teleop: 30, + percentiles: { points: 100, auto: 0, teleop: 100 }, + }, + }); + expect(mocks.events.mock.calls[0]![0]).toMatchObject({ + where: { tournament: { seasonYear: 2026 }, scoutReports: { some: {} } }, + distinct: ["teamNumber"], + select: { teamNumber: true }, + }); + expect(mocks.metrics.mock.calls[0]![1].teams).toEqual([8033, 1]); + }); +}); + +describe("public match metrics", () => { + it("returns only the current slot's aggregate, never season averages or raw reports", async () => { + mocks.match.mockResolvedValue({ + tournament: { key: "2026casnf", seasonYear: 2026 }, + teamSlots: [ + { + key: "slot-8033", + teamNumber: 8033, + alliance: "BLUE", + _count: { scoutReports: 2 }, + }, + { + key: "slot-1", + teamNumber: 1, + alliance: "RED", + _count: { scoutReports: 0 }, + }, + ], + }); + mocks.metrics.mockResolvedValue({ + 8033: { + average: 999, + timeLine: [ + { match: "other-slot", dataPoint: 999 }, + { match: "slot-8033", dataPoint: 42 }, + ], + }, + }); + const response = await invoke(getPublicMatchMetrics, { + params: { key: "2026casnf_qm4" }, + }); + expect(response.body).toEqual({ + teams: [ + { + teamNumber: 8033, + alliance: "BLUE", + reports: 2, + points: 42, + auto: 42, + teleop: 42, + fuelOutput: 42, + fuelFed: 42, + defenseSeconds: 42, + }, + ], + }); + expect(mocks.metrics.mock.calls[0]![0]).toMatchObject({ + teamSourceRule: { mode: "EXCLUDE", items: [] }, + tournamentSourceRule: { mode: "INCLUDE", items: ["2026casnf"] }, + }); + expect(mocks.metrics.mock.calls[0]![1].teams).toEqual([8033]); + expect(mocks.match.mock.calls[0]![0].select.teamSlots.select).toEqual({ + key: true, + teamNumber: true, + alliance: true, + _count: { select: { scoutReports: true } }, + }); + }); + it("hides unsupported seasons and unscouted matches", async () => { + mocks.match.mockResolvedValue({ + tournament: { key: "2025casnf", seasonYear: 2025 }, + teamSlots: [], + }); + expect( + ( + await invoke(getPublicMatchMetrics, { + params: { key: "2025casnf_qm4" }, + }) + ).body, + ).toEqual({ teams: [] }); + mocks.match.mockResolvedValue({ + tournament: { key: "2026casnf", seasonYear: 2026 }, + teamSlots: [], + }); + expect( + ( + await invoke(getPublicMatchMetrics, { + params: { key: "2026casnf_qm4" }, + }) + ).body, + ).toEqual({ teams: [] }); + expect(mocks.metrics).not.toHaveBeenCalled(); + }); + it("rejects malformed keys and handles missing matches and failures", async () => { + expect( + (await invoke(getPublicMatchMetrics, { params: { key: "bad" } })) + .statusCode, + ).toBe(400); + mocks.match.mockResolvedValue(null); + expect( + ( + await invoke(getPublicMatchMetrics, { + params: { key: "2026casnf_qm4" }, + }) + ).statusCode, + ).toBe(404); + mocks.match.mockRejectedValue(new Error("secret")); + expect( + ( + await invoke(getPublicMatchMetrics, { + params: { key: "2026casnf_qm4" }, + }) + ).body, + ).toEqual({ error: "Scouting metrics unavailable" }); + }); +}); + +describe("robot media", () => { + it("prefers supported photos over avatars and unsafe links", () => { + expect( + chooseRobotPhoto([ + { + type: "avatar", + preferred: true, + direct_url: "https://i.imgur.com/avatar.png", + }, + { + type: "imgur", + preferred: true, + direct_url: "https://evil.example/robot.jpg", + }, + { type: "imgur", direct_url: "https://i.imgur.com/other.jpg" }, + { + type: "imgur", + preferred: true, + direct_url: "https://i.imgur.com/robot.jpeg", + }, + ]), + ).toBe("https://i.imgur.com/robot.jpeg"); + expect( + chooseRobotPhoto([ + { + type: "imgur", + direct_url: "https://user:secret@i.imgur.com/photo.jpg", + }, + ]), + ).toBeNull(); + }); +}); + +describe("public team series", () => { + it("projects per-match averages and tied roles in event/match order without raw reports", async () => { + const slot = (key: string, matchNumber: number, roles = ["SCORING"]) => ({ + key, + matchNumber, + matchKey: `2026casnf_qm${matchNumber}`, + matchType: "QUALIFICATION", + tournamentKey: "2026casnf", + tournament: { name: "San Francisco", startDate: new Date("2026-03-13") }, + match: { + displayOrder: matchNumber, + actualTime: null, + scheduledTime: null, + }, + scoutReports: roles.map((role) => ({ + robotRoles: [role], + robotBrokeDescription: + role === "FEEDING" ? "private failure details" : null, + notes: "private", + scouterUuid: "private", + })), + }); + mocks.events.mockResolvedValueOnce([ + slot("second", 2), + slot("first", 1, ["FEEDING", "SCORING"]), + ]); + mocks.events.mockResolvedValueOnce([{ tournamentKey: "2026casnf" }]); + mocks.metrics.mockResolvedValue({ + 8033: { + average: 999, + timeLine: [ + { match: "first", dataPoint: 0, notes: "private" }, + { match: "second", dataPoint: 42 }, + { match: "unrelated", dataPoint: 9000 }, + ], + }, + }); + + const response = await invoke(getPublicTeamSeries, { + params: { number: "8033", year: "2026" }, + }); + + expect(response.body).toEqual({ + matches: [ + { + matchKey: "2026casnf_qm1", + reports: 2, + elimination: false, + reportedBroken: true, + coverage: { reported: 0, expected: 0, complete: false }, + eventKey: "2026casnf", + eventName: "San Francisco", + label: "Qualification 1", + points: 0, + auto: 0, + teleop: 0, + feeding: 0, + roles: ["FEEDING", "SCORING"], + }, + { + matchKey: "2026casnf_qm2", + reports: 1, + elimination: false, + reportedBroken: false, + coverage: { reported: 0, expected: 0, complete: false }, + eventKey: "2026casnf", + eventName: "San Francisco", + label: "Qualification 2", + points: 42, + auto: 42, + teleop: 42, + feeding: 42, + roles: ["SCORING"], + }, + ], + }); + expect(mocks.events.mock.calls[0]![0].where).toEqual({ + teamNumber: 8033, + tournament: { seasonYear: 2026 }, + }); + expect(mocks.events.mock.calls[0]![0].select.scoutReports).toEqual({ + select: { robotRoles: true, robotBrokeDescription: true }, + }); + expect(mocks.metrics.mock.calls[0]![0]).toMatchObject({ + teamSourceRule: { mode: "EXCLUDE", items: [] }, + tournamentSourceRule: { mode: "INCLUDE", items: ["2026casnf"] }, + }); + expect(mocks.metrics.mock.calls[0]![1].teams).toEqual([8033]); + }); + + it("keeps missing/non-finite measurements null and supports slots without imported matches", async () => { + mocks.events.mockResolvedValueOnce([ + { + key: "legacy", + matchKey: null, + matchNumber: 3, + matchType: "ELIMINATION", + tournamentKey: "2026casnf", + tournament: { name: "San Francisco", startDate: null }, + match: null, + scoutReports: [], + }, + ]); + mocks.events.mockResolvedValueOnce([{ tournamentKey: "2026casnf" }]); + mocks.metrics.mockResolvedValue({ + 8033: { timeLine: [{ match: "legacy", dataPoint: NaN }] }, + }); + + const response = await invoke(getPublicTeamSeries, { + params: { number: "8033", year: "2026" }, + }); + expect(response.body).toEqual({ + matches: [ + { + matchKey: null, + reports: 0, + elimination: true, + reportedBroken: false, + coverage: null, + eventKey: "2026casnf", + eventName: "San Francisco", + label: "Elimination 3", + points: null, + auto: null, + teleop: null, + feeding: null, + roles: [], + }, + ], + }); + }); + + it("does not run the current scoring engine on other seasons or empty teams", async () => { + expect( + ( + await invoke(getPublicTeamSeries, { + params: { number: "8033", year: "2025" }, + }) + ).body, + ).toEqual({ matches: [] }); + expect(mocks.events).not.toHaveBeenCalled(); + mocks.events.mockResolvedValueOnce([]); + expect( + ( + await invoke(getPublicTeamSeries, { + params: { number: "8033", year: "2026" }, + }) + ).body, + ).toEqual({ matches: [] }); + expect(mocks.metrics).not.toHaveBeenCalled(); + }); + + it("rejects invalid parameters and hides upstream failures", async () => { + expect( + ( + await invoke(getPublicTeamSeries, { + params: { number: "0", year: "2026" }, + }) + ).statusCode, + ).toBe(400); + mocks.events.mockRejectedValueOnce(new Error("private database details")); + const response = await invoke(getPublicTeamSeries, { + params: { number: "8033", year: "2026" }, + }); + expect(response.statusCode).toBe(503); + expect(response.body).toEqual({ error: "Team analytics unavailable" }); + }); +}); + +describe("scouting coverage", () => { + const slots = () => + [1, 2, 3, 4, 5, 6].map((teamNumber) => ({ + teamNumber, + alliance: teamNumber <= 3 ? "BLUE" : "RED", + scoutReports: [{}], + })); + it("requires six distinct participants with a report each, not six reports", () => { + expect(scoutingCoverage(slots())).toEqual({ + reported: 6, + expected: 6, + complete: true, + }); + const partial = slots(); + partial[5]!.scoutReports = []; + partial[0]!.scoutReports = [{}, {}, {}]; + expect(scoutingCoverage(partial)).toEqual({ + reported: 5, + expected: 6, + complete: false, + }); + expect(scoutingCoverage([]).complete).toBe(false); + const duplicate = slots(); + duplicate[5]!.teamNumber = 1; + expect(scoutingCoverage(duplicate).complete).toBe(false); + }); +}); + +describe("district scouting leaderboard", () => { + it("filters cached season metrics to district members only", async () => { + mocks.district.mockResolvedValue({ + seasonYear: 2026, + teamSeasons: [{ teamNumber: 8033, name: "Highlander Robotics" }], + }); + mocks.events.mockImplementation(async (args) => + args.distinct[0] === "teamNumber" + ? [{ teamNumber: 8033 }, { teamNumber: 1 }] + : [{ tournamentKey: "2026casnf" }], + ); + mocks.metrics.mockResolvedValue({ + 8033: { average: 42, timeLine: [{}] }, + 1: { average: 99, timeLine: [{}] }, + }); + const response = await invoke(getPublicDistrictLeaderboard, { + params: { key: "2026ca" }, + }); + expect(response.body).toEqual({ + teams: [ + { + number: 8033, + name: "Highlander Robotics", + matches: 1, + points: 42, + auto: 42, + teleop: 42, + }, + ], + }); + }); + it("handles unsupported seasons, missing districts, invalid keys, and errors", async () => { + expect( + (await invoke(getPublicDistrictLeaderboard, { params: { key: "bad" } })) + .statusCode, + ).toBe(400); + mocks.district.mockResolvedValue(null); + expect( + ( + await invoke(getPublicDistrictLeaderboard, { + params: { key: "2026ca" }, + }) + ).statusCode, + ).toBe(404); + mocks.district.mockResolvedValue({ seasonYear: 2025, teamSeasons: [] }); + expect( + ( + await invoke(getPublicDistrictLeaderboard, { + params: { key: "2025ca" }, + }) + ).body, + ).toEqual({ teams: [] }); + expect(mocks.metrics).not.toHaveBeenCalled(); + mocks.district.mockRejectedValue(new Error("private")); + expect( + ( + await invoke(getPublicDistrictLeaderboard, { + params: { key: "2026ca" }, + }) + ).body, + ).toEqual({ error: "Leaderboard unavailable" }); + }); +}); + +describe("custom public predictor", () => { + it("validates six distinct teams and the supported game before querying", async () => { + for (const query of [ + { teams: "1,1,2,3,4,5", year: "2026" }, + { teams: "1,2,3,4,5,6", year: "2025" }, + { teams: "1,2,3,4,5,2147483648", year: "2026" }, + ]) + expect((await invoke(getPublicPrediction, { query })).statusCode).toBe( + 400, + ); + expect(mocks.predict).not.toHaveBeenCalled(); + }); + it("returns only prediction probabilities and handles unavailable data", async () => { + const query = { teams: "1,2,3,4,5,6", year: "2026" }; + expect((await invoke(getPublicPrediction, { query })).body).toEqual({ + prediction: { blueWinning: 0.7, redWinning: 0.3 }, + }); + mocks.predict.mockRejectedValue("not enough data"); + expect((await invoke(getPublicPrediction, { query })).body).toEqual({ + prediction: null, + }); + mocks.predict.mockRejectedValue(new Error("private")); + expect((await invoke(getPublicPrediction, { query })).body).toEqual({ + error: "Prediction unavailable", + }); + }); +}); + +it("keeps unscouted matches in the series as gaps even if a metric result exists", async () => { + mocks.events.mockResolvedValueOnce([ + { + key: "unscouted", + matchKey: "2026casnf_qm2", + matchNumber: 2, + matchType: "QUALIFICATION", + tournamentKey: "2026casnf", + tournament: { name: "SF", startDate: null }, + match: null, + scoutReports: [], + }, + { + key: "scouted", + matchKey: "2026casnf_qm1", + matchNumber: 1, + matchType: "QUALIFICATION", + tournamentKey: "2026casnf", + tournament: { name: "SF", startDate: null }, + match: null, + scoutReports: [{ robotRoles: [], robotBrokeDescription: null }], + }, + ]); + mocks.events.mockResolvedValueOnce([{ tournamentKey: "2026casnf" }]); + mocks.metrics.mockResolvedValue({ + 8033: { + timeLine: [ + { match: "unscouted", dataPoint: 999 }, + { match: "scouted", dataPoint: 0 }, + ], + }, + }); + const response = await invoke(getPublicTeamSeries, { + params: { number: "8033", year: "2026" }, + }); + expect(response.body).toMatchObject({ + matches: [ + { matchKey: "2026casnf_qm1", points: 0, reports: 1 }, + { + matchKey: "2026casnf_qm2", + points: null, + auto: null, + feeding: null, + reports: 0, + }, + ], + }); + expect(mocks.events.mock.calls[0]![0].where).not.toHaveProperty( + "scoutReports", + ); +}); diff --git a/apps/server/test/requireAuth.unit.test.ts b/apps/server/test/requireAuth.unit.test.ts index 23e2a6cc..cbb34427 100644 --- a/apps/server/test/requireAuth.unit.test.ts +++ b/apps/server/test/requireAuth.unit.test.ts @@ -47,6 +47,7 @@ describe("authentication dependencies and failures", () => { beforeEach(() => { vi.resetAllMocks(); vi.spyOn(console, "error").mockImplementation(() => undefined); + vi.spyOn(console, "warn").mockImplementation(() => undefined); mocks.verify.mockResolvedValue({ payload: { sub: user.id } }); mocks.decode.mockReturnValue({ sub: user.id }); mocks.userInfo.mockResolvedValue({ @@ -80,6 +81,32 @@ describe("authentication dependencies and failures", () => { expect(mocks.findUnique).not.toHaveBeenCalled(); }); + it("logs only the rejection code and claim name, never token or claim values", async () => { + mocks.verify.mockRejectedValue( + Object.assign(new Error("private claim value"), { + code: "ERR_JWT_CLAIM_VALIDATION_FAILED", + claim: "aud", + payload: { aud: "private claim value" }, + }), + ); + + expect( + ( + await request(app).get("/protected").auth("private-token", { + type: "bearer", + }) + ).status, + ).toBe(401); + + expect(console.warn).toHaveBeenCalledWith("Authorization token rejected", { + code: "ERR_JWT_CLAIM_VALIDATION_FAILED", + claim: "aud", + }); + expect(JSON.stringify(vi.mocked(console.warn).mock.calls)).not.toContain( + "private", + ); + }); + it("checks issuer and audience and persists fresh Auth0 user info", async () => { const res = await request(app) .get("/protected") diff --git a/apps/server/test/seasonPage.unit.test.ts b/apps/server/test/seasonPage.unit.test.ts new file mode 100644 index 00000000..aaa5017b --- /dev/null +++ b/apps/server/test/seasonPage.unit.test.ts @@ -0,0 +1,81 @@ +import { beforeEach, expect, it, vi } from "vitest"; +import { invoke } from "./helpers/handlerHarness.js"; + +const { findUnique, findMany } = vi.hoisted(() => ({ + findUnique: vi.fn(), + findMany: vi.fn(), +})); + +vi.mock("../src/prismaClient.js", () => ({ + default: { season: { findUnique, findMany } }, +})); + +import { getSeason } from "../src/handler/tournaments/getSeason.js"; + +beforeEach(() => vi.resetAllMocks()); + +it("returns public season districts and events without team or scouting data", async () => { + const season = { + year: 2026, + gameName: "REBUILT", + districtSeasons: [], + tournaments: [], + }; + findUnique.mockResolvedValue(season); + findMany.mockResolvedValue([{ year: 2026 }, { year: 2025 }, { year: 2024 }]); + + const res = await invoke(getSeason, { params: { year: "2026" } }); + const query = findUnique.mock.calls[0]![0]; + + expect(query.where).toEqual({ year: 2026 }); + expect(query.select.districtSeasons.select).toEqual({ + key: true, + abbreviation: true, + name: true, + }); + expect(query.select.tournaments.where).toEqual({ districtSeasonKey: null }); + expect(query.select.tournaments.select).toEqual({ + key: true, + parentTournamentKey: true, + name: true, + location: true, + startDate: true, + endDate: true, + week: true, + eventType: true, + }); + expect(JSON.stringify(query)).not.toContain("teamSeasons"); + expect(JSON.stringify(query)).not.toContain("scoutReports"); + expect(findMany).toHaveBeenCalledWith({ + orderBy: { year: "desc" }, + select: { year: true }, + }); + expect(res.body).toEqual({ ...season, seasonYears: [2026, 2025, 2024] }); + expect(res.headers["Cache-Control"]).toBe("public, max-age=300"); +}); + +it("returns 404 for an unimported season", async () => { + findUnique.mockResolvedValue(null); + + expect( + (await invoke(getSeason, { params: { year: "2026" } })).statusCode, + ).toBe(404); +}); + +it("rejects invalid keys before querying", async () => { + expect( + (await invoke(getSeason, { params: { year: "../private" } })).statusCode, + ).toBe(400); + expect(findUnique).not.toHaveBeenCalled(); +}); + +it("does not expose database errors", async () => { + findUnique.mockRejectedValue(new Error("private connection details")); + + const res = await invoke(getSeason, { params: { year: "2026" } }); + + expect(res.statusCode).toBe(503); + expect(res.body).toEqual({ + error: "Season data is temporarily unavailable", + }); +}); diff --git a/apps/server/test/teamAvatar.unit.test.ts b/apps/server/test/teamAvatar.unit.test.ts new file mode 100644 index 00000000..e789f681 --- /dev/null +++ b/apps/server/test/teamAvatar.unit.test.ts @@ -0,0 +1,59 @@ +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: { teamSeason: { findUnique } }, +})); + +import { getTeamAvatar } from "../src/handler/tournaments/getTeamAvatar.js"; + +beforeEach(() => vi.resetAllMocks()); + +it("serves only the requested season avatar with a raster content type", async () => { + const image = Buffer.from([255, 216, 255, 224]); + findUnique.mockResolvedValue({ + avatar: `data:image/jpeg;base64,${image.toString("base64")}`, + }); + + const res = await invoke(getTeamAvatar, { + params: { number: "254", year: "2026" }, + }); + + expect(findUnique.mock.calls[0]![0]).toEqual({ + where: { teamNumber_seasonYear: { teamNumber: 254, seasonYear: 2026 } }, + select: { avatar: true }, + }); + expect(res.body).toEqual(image); + expect(res.headers["Content-Type"]).toBe("image/jpeg"); + expect(res.headers["X-Content-Type-Options"]).toBe("nosniff"); +}); + +it("uses 404 for unavailable or unsupported media", async () => { + for (const avatar of [null, "data:image/svg+xml;base64,PHN2Zz4="]) { + findUnique.mockResolvedValue({ avatar }); + expect( + (await invoke(getTeamAvatar, { params: { number: "254", year: "2026" } })) + .statusCode, + ).toBe(404); + } +}); + +it("rejects invalid identifiers without querying", async () => { + expect( + (await invoke(getTeamAvatar, { params: { number: "-1", year: "2026" } })) + .statusCode, + ).toBe(400); + expect(findUnique).not.toHaveBeenCalled(); +}); + +it("keeps database errors private", async () => { + findUnique.mockRejectedValue(new Error("private connection details")); + const res = await invoke(getTeamAvatar, { + params: { number: "254", year: "2026" }, + }); + + expect(res.statusCode).toBe(503); + expect(res.body).toBeUndefined(); +}); diff --git a/apps/server/test/teamPage.unit.test.ts b/apps/server/test/teamPage.unit.test.ts new file mode 100644 index 00000000..8444657c --- /dev/null +++ b/apps/server/test/teamPage.unit.test.ts @@ -0,0 +1,121 @@ +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: { teamSeason: { findUnique } }, +})); + +import { getTeamSeason } from "../src/handler/tournaments/getTeamSeason.js"; + +beforeEach(() => vi.resetAllMocks()); + +it("returns public season data and only this team's awards", async () => { + findUnique.mockResolvedValue({ + teamNumber: 8033, + seasonYear: 2026, + name: "Highlanders", + avatar: null, + district: null, + team: { + registeredTeam: { + teamApproved: true, + code: "private-code", + email: "private@example.com", + }, + seasons: [{ seasonYear: 2026 }, { seasonYear: 2025 }], + tournamentTeams: [ + { + tournament: { + key: "2026cancmp", + awards: [ + { name: "Winner", recipients: [{ teamNumber: 8033 }] }, + { name: "Impact", recipients: [{ teamNumber: 254 }] }, + null, + { name: "Invalid", recipients: null }, + ], + }, + }, + ], + }, + }); + + const res = await invoke(getTeamSeason, { + params: { number: "8033", year: "2026" }, + }); + const query = findUnique.mock.calls[0]![0]; + + expect(query.where).toEqual({ + teamNumber_seasonYear: { teamNumber: 8033, seasonYear: 2026 }, + }); + expect(query.select.team.select.tournamentTeams.where).toEqual({ + tournament: { seasonYear: 2026 }, + }); + expect(JSON.stringify(query)).not.toContain("scoutReports"); + expect(JSON.stringify(query)).not.toContain("matchData"); + expect(res.body).toMatchObject({ + teamNumber: 8033, + usesLovat: true, + seasonYears: [2026, 2025], + tournaments: [{ key: "2026cancmp", awards: ["Winner"] }], + }); + expect(query.select.team.select.registeredTeam).toEqual({ + select: { teamApproved: true }, + }); + expect(res.body).not.toHaveProperty("team"); + expect(JSON.stringify(res.body)).not.toContain("private-code"); + expect(JSON.stringify(res.body)).not.toContain("private@example.com"); + expect(res.headers["Cache-Control"]).toBe("public, max-age=300"); +}); + +it.each([ + ["0", "2026"], + ["1e3", "2026"], + ["2147483648", "2026"], + ["8033", "1991"], + ["8033", "../2026"], +])("rejects invalid keys before querying: %s %s", async (number, year) => { + expect( + (await invoke(getTeamSeason, { params: { number, year } })).statusCode, + ).toBe(400); + expect(findUnique).not.toHaveBeenCalled(); +}); + +it("returns 404 for an unimported season", async () => { + findUnique.mockResolvedValue(null); + + expect( + (await invoke(getTeamSeason, { params: { number: "8033", year: "2026" } })) + .statusCode, + ).toBe(404); +}); + +it("does not expose private database errors", async () => { + findUnique.mockRejectedValue(new Error("private connection details")); + + const res = await invoke(getTeamSeason, { + params: { number: "8033", year: "2026" }, + }); + + expect(res.statusCode).toBe(503); + expect(res.body).toEqual({ error: "Team data is temporarily unavailable" }); +}); + +it.each([null, { teamApproved: false }])( + "does not mark unregistered or unapproved teams as Lovat users: %s", + async (registeredTeam) => { + findUnique.mockResolvedValue({ + teamNumber: 8033, + seasonYear: 2026, + team: { registeredTeam, seasons: [], tournamentTeams: [] }, + }); + + const res = await invoke(getTeamSeason, { + params: { number: "8033", year: "2026" }, + }); + + expect(res.body).toMatchObject({ usesLovat: false }); + expect(res.body).not.toHaveProperty("registeredTeam"); + }, +); diff --git a/apps/server/test/tournamentPage.unit.test.ts b/apps/server/test/tournamentPage.unit.test.ts new file mode 100644 index 00000000..e08f0815 --- /dev/null +++ b/apps/server/test/tournamentPage.unit.test.ts @@ -0,0 +1,139 @@ +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, { params: { key: "2026cancmp" } }); + 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, { params: { key: "2026cancmp" } })).statusCode, + ).toBe(404); +}); + +it("does not expose database errors", async () => { + findUnique.mockRejectedValue(new Error("private connection details")); + + const res = await invoke(getTournament, { params: { key: "2026cancmp" } }); + + expect(res.statusCode).toBe(503); + expect(res.body).toEqual({ + error: "Tournament data is temporarily unavailable", + }); +}); + +it("uses the requested event key", async () => { + findUnique.mockResolvedValue({ key: "2026casj", teams: [] }); + + const res = await invoke(getTournament, { params: { key: "2026casj" } }); + + expect(findUnique.mock.calls[0]![0].where).toEqual({ key: "2026casj" }); + expect(res.body).toEqual({ key: "2026casj", teams: [] }); +}); + +it("rejects invalid keys without querying the database", async () => { + expect( + (await invoke(getTournament, { params: { key: "../private" } })).statusCode, + ).toBe(400); + expect(findUnique).not.toHaveBeenCalled(); +}); + +it("uses the event season name and avatar without exposing season metadata", async () => { + findUnique.mockResolvedValue({ + key: "2025casj", + teams: [ + { + team: { + number: 254, + name: "Current", + seasons: [ + { name: "Season name", avatar: "data:image/png;base64,abc" }, + ], + }, + }, + ], + }); + + const res = await invoke(getTournament, { params: { key: "2025casj" } }); + + expect(res.body).toEqual({ + key: "2025casj", + teams: [ + { + team: { + number: 254, + name: "Season name", + usesLovat: false, + avatar: "data:image/png;base64,abc", + }, + }, + ], + }); +}); + +it.each([true, false, null])( + "projects only a public Lovat status for event teams: %s", + async (approved) => { + findUnique.mockResolvedValue({ + key: "2026casnf", + teams: [ + { + team: { + number: 8033, + name: "Highlander Robotics", + seasons: [], + registeredTeam: + approved === null + ? null + : { + teamApproved: approved, + code: "private-code", + email: "private@example.com", + }, + }, + }, + ], + }); + + const res = await invoke(getTournament, { params: { key: "2026casnf" } }); + + expect( + findUnique.mock.calls[0]![0].select.teams.select.team.select + .registeredTeam, + ).toEqual({ select: { teamApproved: true } }); + expect(res.body).toMatchObject({ + teams: [{ team: { usesLovat: approved === true } }], + }); + expect(JSON.stringify(res.body)).not.toContain("private-code"); + expect(JSON.stringify(res.body)).not.toContain("private@example.com"); + expect(JSON.stringify(res.body)).not.toContain("registeredTeam"); + }, +); diff --git a/apps/website/.npmrc b/apps/website/.npmrc index 0c05da45..a736f268 100644 --- a/apps/website/.npmrc +++ b/apps/website/.npmrc @@ -1,2 +1,5 @@ engine-strict=true resolution-mode=highest + +# Magnolia's Git preparation uses legacy Svelte development peers. +legacy-peer-deps=true diff --git a/apps/website/Dockerfile b/apps/website/Dockerfile index a84f04ab..e22210b1 100644 --- a/apps/website/Dockerfile +++ b/apps/website/Dockerfile @@ -1,7 +1,8 @@ +# Build from the repository root: docker build -f apps/website/Dockerfile . FROM node:20.19.6-bookworm-slim AS build RUN apt-get update && apt-get install -y --no-install-recommends git ca-certificates && rm -rf /var/lib/apt/lists/* WORKDIR /app/apps/website -COPY apps/website/package.json apps/website/package-lock.json ./ +COPY apps/website/package.json apps/website/package-lock.json apps/website/.npmrc ./ RUN npm ci COPY apps/website/ ./ COPY CHANGELOG.md /app/CHANGELOG.md diff --git a/apps/website/README.md b/apps/website/README.md index 7ac4a7a4..b5023772 100644 --- a/apps/website/README.md +++ b/apps/website/README.md @@ -35,3 +35,9 @@ docker build -f apps/website/Dockerfile -t lovat-website . `/changelog` is generated from the root [CHANGELOG.md](../../CHANGELOG.md) during the Website build. Edit that file, using `## Month YYYY` headings, `### Feature` headings, and Markdown bullets. An empty Unreleased section is hidden; feature sections under Unreleased are shown. The Vite plugin reads the file directly, watches it during development, renders Markdown with raw HTML disabled, and rewrites repository-relative links to GitHub. The generated content is bundled and prerendered, so the deployed server requires no Markdown file or GitHub requests at runtime. Rebuild the Website to publish changelog edits. CI and Railway watch the root file as well as Website sources. Netlify's existing app base directory works with the full repository checkout; include root changelog changes if you configure a custom build-ignore rule. + +## Scouting predictions + +Match cards show the existing Lovat scouting predictor after signing in. Predictions use the signed-in user's Server data-source rules; public loaders never return scouting data. Requests use an in-memory Auth0 access token, a same-origin proxy, and private/no-store responses. Only cards near the viewport fetch predictions, with two concurrent requests and per-session deduplication. Signing out clears the prediction cache. + +The default Auth0 domain and public SPA client ID match Dashboard. Override with `PUBLIC_AUTH0_DOMAIN` and `PUBLIC_AUTH0_CLIENT_ID` for a separate Website SPA client. Register each Website origin (including the PR URL and `http://127.0.0.1:8771`) in Auth0's Allowed Callback URLs, Allowed Web Origins, and Allowed Logout URLs. Use the origin itself as the callback URL. Server continues validating the token and enforcing user data-source rules. Auth0 configuration is required before sign-in can work on a new origin. diff --git a/apps/website/package-lock.json b/apps/website/package-lock.json index df23254c..01a5cfa5 100644 --- a/apps/website/package-lock.json +++ b/apps/website/package-lock.json @@ -8,6 +8,7 @@ "name": "lovat-website", "version": "0.0.1", "dependencies": { + "@auth0/auth0-spa-js": "^2.28.3", "@tanstack/svelte-query": "^5.66.11", "magnolia-ui-svelte": "github:highlanderrobotics/magnolia-ui-svelte#production", "qrcode": "^1.5.3", @@ -38,6 +39,28 @@ "vite-imagetools": "^5.0.8" } }, + "node_modules/@auth0/auth0-auth-js": { + "version": "1.16.1", + "resolved": "https://registry.npmjs.org/@auth0/auth0-auth-js/-/auth0-auth-js-1.16.1.tgz", + "integrity": "sha512-dtH12GiKEZ+YGnwUe0LTe7gJtEEAvhE5ztUL3i8iXN7D3siXe/MUssQq735UlXJzasXNqZOQUkMbv1VMXMZj+g==", + "license": "MIT", + "dependencies": { + "jose": "^6.0.8", + "openid-client": "^6.8.0" + } + }, + "node_modules/@auth0/auth0-spa-js": { + "version": "2.28.3", + "resolved": "https://registry.npmjs.org/@auth0/auth0-spa-js/-/auth0-spa-js-2.28.3.tgz", + "integrity": "sha512-W4bqoPdzjakJdtzKiOcWT5yMLciR9jeaHHwGydAKRpAkTD80ld2tGDUVL+sTpzaty8i3SL7lakgERiowcl0WCw==", + "license": "MIT", + "dependencies": { + "@auth0/auth0-auth-js": "^1.16.1", + "browser-tabs-lock": "^1.3.0", + "dpop": "^2.1.1", + "es-cookie": "~1.3.2" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.24.2", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.24.2.tgz", @@ -1906,6 +1929,16 @@ "node": "20 || >=22" } }, + "node_modules/browser-tabs-lock": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/browser-tabs-lock/-/browser-tabs-lock-1.3.0.tgz", + "integrity": "sha512-g6nHaobTiT0eMZ7jh16YpD2kcjAp+PInbiVq3M1x6KKaEIVhT4v9oURNIpZLOZ3LQbQ3XYfNhMAb/9hzNLIWrw==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "lodash": ">=4.17.21" + } + }, "node_modules/buffer": { "version": "5.7.1", "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", @@ -2210,6 +2243,15 @@ "node": ">=6.0.0" } }, + "node_modules/dpop": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/dpop/-/dpop-2.1.2.tgz", + "integrity": "sha512-hN3XjjCcgYWmNL8DFAV1Z3alXIvLolvjis189NI/U4k4Iu/m4rBeUYObbIjnRzQYDQv4zMaXXskjVDqSkmyh7Q==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/emoji-regex": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", @@ -2226,6 +2268,12 @@ "once": "^1.4.0" } }, + "node_modules/es-cookie": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/es-cookie/-/es-cookie-1.3.2.tgz", + "integrity": "sha512-UTlYYhXGLOy05P/vKVT2Ui7WtC7NiRzGtJyAKKn32g5Gvcjn7KAClLPWlipCtxIus934dFg9o9jXiBL0nP+t9Q==", + "license": "MIT" + }, "node_modules/es-errors": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", @@ -3057,6 +3105,15 @@ "dev": true, "license": "ISC" }, + "node_modules/jose": { + "version": "6.2.12", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.12.tgz", + "integrity": "sha512-9NiFmJEex0sy2Dk58j2UGBSHgUs2ypF9eZSu4L6vjOX3Dp96Sw1F3uL+H+D1sx02jZZdzUT0HgvCy59CuvXcWw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/js-yaml": { "version": "4.3.2", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz", @@ -3174,6 +3231,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "license": "MIT" + }, "node_modules/lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", @@ -3345,6 +3408,15 @@ "dev": true, "license": "MIT" }, + "node_modules/oauth4webapi": { + "version": "3.8.8", + "resolved": "https://registry.npmjs.org/oauth4webapi/-/oauth4webapi-3.8.8.tgz", + "integrity": "sha512-8N28E+a/oxfXWBgOMt+ZP/JUf/XR+IFbvkAEPP3gznXOMv9BpAAwiIj0TFNz3tGTPc0ZQ8zmWBNgN1nAys0gng==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", @@ -3355,6 +3427,19 @@ "wrappy": "1" } }, + "node_modules/openid-client": { + "version": "6.8.8", + "resolved": "https://registry.npmjs.org/openid-client/-/openid-client-6.8.8.tgz", + "integrity": "sha512-ZsucJA5Ad04Uv7YN4ql+s4GXNmb9uAYQwyJTsJx7CH/MX3JZioLE2pVKi1SC+YrbSLA4Px3Gi30dMjgOZtb6pA==", + "license": "MIT", + "dependencies": { + "jose": "^6.2.12", + "oauth4webapi": "^3.8.8" + }, + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", diff --git a/apps/website/package.json b/apps/website/package.json index 37d5c653..753b2a1b 100644 --- a/apps/website/package.json +++ b/apps/website/package.json @@ -12,7 +12,8 @@ "lint": "eslint .", "format": "prettier --write .", "format:check": "prettier --check .", - "start": "node build" + "start": "node build", + "test:prediction": "node --test scripts/prediction.test.mjs" }, "devDependencies": { "@sveltejs/adapter-netlify": "^4.0.0", @@ -39,6 +40,7 @@ }, "type": "module", "dependencies": { + "@auth0/auth0-spa-js": "^2.28.3", "@tanstack/svelte-query": "^5.66.11", "magnolia-ui-svelte": "github:highlanderrobotics/magnolia-ui-svelte#production", "qrcode": "^1.5.3", diff --git a/apps/website/scripts/analytics.test.mjs b/apps/website/scripts/analytics.test.mjs new file mode 100644 index 00000000..0ca68ba1 --- /dev/null +++ b/apps/website/scripts/analytics.test.mjs @@ -0,0 +1,48 @@ +import assert from 'node:assert/strict'; +import { after, test } from 'node:test'; +import { createServer } from 'vite'; +const vite = await createServer({ + server: { middlewareMode: true, hmr: false }, + appType: 'custom' +}); +after(() => vite.close()); +const { consistency, rollingValues, tournamentRanges } = await vite.ssrLoadModule( + '/src/lib/tournaments/teamAnalytics.ts' +); +test('consistency includes zero and ignores missing/non-finite values', () => { + assert.deepEqual(consistency([null, 0, 10, 20, 30, NaN]), { + average: 15, + median: 15, + lower: 7.5, + upper: 22.5, + best: 30, + recent: 15, + recentCount: 4, + count: 4 + }); + assert.equal(consistency([null, NaN]), null); + assert.equal(consistency([100, 0, 1, 2, 3, 4]).recent, 2); +}); +test('rolling averages preserve missing matches', () => { + assert.deepEqual(rollingValues([0, null, 10], 3), [0, null, 5]); +}); + +test('rolling values restart when tournaments change', () => { + assert.deepEqual(rollingValues([100, 10, 20], 3, ['a', 'b', 'b']), [100, 10, 15]); +}); + +test('tournament averages and elimination boundaries follow filtered raw match values', () => { + const matches = [ + { eventKey: '2026a', eventName: 'A', points: 0, elimination: false, reportedBroken: false }, + { eventKey: '2026a', eventName: 'A', points: 100, elimination: true, reportedBroken: true }, + { eventKey: '2026b', eventName: 'B', points: null, elimination: true, reportedBroken: false }, + { eventKey: '2026b', eventName: 'B', points: 20, elimination: true, reportedBroken: false } + ]; + assert.deepEqual(tournamentRanges(matches, 'points', true), [ + { key: '2026a', name: 'A', start: 0, end: 1, average: 50, eliminationIndex: 1 }, + { key: '2026b', name: 'B', start: 2, end: 3, average: 20, eliminationIndex: 2 } + ]); + assert.equal(tournamentRanges(matches, 'points', false)[0].average, 0); + assert.deepEqual(tournamentRanges([], 'points', true), []); + assert.equal(tournamentRanges([matches[2]], 'points', true)[0].average, null); +}); diff --git a/apps/website/scripts/prediction.test.mjs b/apps/website/scripts/prediction.test.mjs new file mode 100644 index 00000000..dbe8a281 --- /dev/null +++ b/apps/website/scripts/prediction.test.mjs @@ -0,0 +1,195 @@ +import assert from 'node:assert/strict'; +import { after, test } from 'node:test'; +import { createServer } from 'vite'; + +process.env.LOVAT_API_BASE = 'https://api.example.test'; +const vite = await createServer({ + server: { middlewareMode: true, hmr: false }, + appType: 'custom' +}); +after(() => vite.close()); +const { POST } = await vite.ssrLoadModule('/src/routes/api/prediction/+server.ts'); +const { teamRecords } = await vite.ssrLoadModule('/src/lib/tournaments/teamRecords.ts'); +const teams = { blue: [1, 2, 3], red: [4, 5, 6] }; + +function event({ + origin = 'https://website.example.test', + authorization = 'Bearer test-token', + body = teams, + upstream +} = {}) { + return { + url: new URL('https://website.example.test/api/prediction'), + request: new Request('https://website.example.test/api/prediction', { + method: 'POST', + headers: { + origin, + ...(authorization ? { authorization } : {}), + 'content-type': 'application/json' + }, + body: JSON.stringify(body) + }), + fetch: + upstream ?? + (() => { + throw new Error('Upstream must not be called'); + }) + }; +} + +test('rejects anonymous, cross-origin and duplicate team requests before upstream', async () => { + assert.equal((await POST(event({ authorization: '' }))).status, 401); + assert.equal((await POST(event({ origin: 'https://other.example.test' }))).status, 403); + assert.equal((await POST(event({ body: { blue: [1, 1, 2], red: [3, 4, 5] } }))).status, 400); +}); + +test('forwards the user token, returns only probabilities and forbids shared caching', async () => { + const response = await POST( + event({ + upstream: async (url, options) => { + assert.equal(options.headers.authorization, 'Bearer test-token'); + assert.equal(new URL(url).searchParams.get('blue3'), '3'); + return Response.json({ + blueWinning: 0.25, + redWinning: 0.75, + redAlliance: { privateData: 'hidden' } + }); + } + }) + ); + + assert.equal(response.headers.get('cache-control'), 'private, no-store'); + assert.equal(response.headers.get('vary'), 'Authorization'); + assert.deepEqual(await response.json(), { prediction: { blueWinning: 0.25, redWinning: 0.75 } }); +}); + +test('handles no scouting data, expired authorization, outages and invalid probabilities', async () => { + const noData = await POST(event({ upstream: async () => new Response('not enough data') })); + assert.deepEqual(await noData.json(), { prediction: null }); + assert.equal( + (await POST(event({ upstream: async () => new Response('', { status: 401 }) }))).status, + 401 + ); + assert.equal( + ( + await POST( + event({ + upstream: async () => { + throw new Error('secret'); + } + }) + ) + ).status, + 503 + ); + const invalid = await POST( + event({ upstream: async () => Response.json({ blueWinning: 0.9, redWinning: 0.9 }) }) + ); + assert.deepEqual(await invalid.json(), { prediction: null }); +}); + +test('distinguishes a missing verified team from an expired session', async () => { + for (const [body, status] of [ + ['No team', 401], + ['Your team has not been verified yet', 403] + ]) { + const response = await POST(event({ upstream: async () => new Response(body, { status }) })); + + assert.equal(response.status, 403); + assert.deepEqual(await response.json(), { error: 'Verified team required' }); + } +}); + +test('records count wins, losses and ties; skip surrogates, unplayed and unresolved matches; DQs lose', () => { + const match = { + status: 'COMPLETED', + winningAlliance: 'BLUE', + alliances: [ + { color: 'BLUE', score: 100 }, + { color: 'RED', score: 50 } + ], + teamSlots: [ + { teamNumber: 1, alliance: 'BLUE' }, + { teamNumber: 2, alliance: 'RED' }, + { teamNumber: 3, alliance: 'BLUE', surrogate: true } + ] + }; + const records = teamRecords([ + match, + { + ...match, + winningAlliance: null, + alliances: [ + { color: 'BLUE', score: 50 }, + { color: 'RED', score: 50 } + ] + }, + { ...match, status: 'SCHEDULED' }, + { ...match, winningAlliance: null, alliances: [] }, + { ...match, teamSlots: [{ teamNumber: 1, alliance: 'BLUE', disqualified: true }] } + ]); + assert.deepEqual(records.get(1), { wins: 1, losses: 1, ties: 1 }); + assert.deepEqual(records.get(2), { wins: 0, losses: 1, ties: 1 }); + assert.equal(records.has(3), false); +}); + +const { GET: publicMatch } = await vite.ssrLoadModule( + '/src/routes/api/public/matches/[key]/+server.ts' +); +test('public predictions omit credentials and strip all fields outside the approved projection', async () => { + const response = await publicMatch({ + params: { key: '2026casnf_qm4' }, + fetch: async (_url, options) => { + assert.equal(options.credentials, 'omit'); + assert.equal(options.headers, undefined); + return Response.json({ + prediction: { blueWinning: 0.6, redWinning: 0.4, notes: 'private' }, + roles: [{ teamNumber: 8033, role: 'SCORING', scouter: 'private' }], + reports: ['private'] + }); + } + }); + assert.equal(response.headers.get('cache-control'), 'public, max-age=60'); + assert.deepEqual(await response.json(), { + prediction: { blueWinning: 0.6, redWinning: 0.4 }, + roles: [{ teamNumber: 8033, role: 'SCORING' }] + }); +}); +test('public predictions reject malformed matches and provider failures', async () => { + const fetch = async () => { + throw new Error('private provider details'); + }; + assert.equal((await publicMatch({ params: { key: 'invalid' }, fetch })).status, 400); + const response = await publicMatch({ params: { key: '2026casnf_qm4' }, fetch }); + assert.equal(response.status, 503); + assert.deepEqual(await response.json(), { error: 'Prediction unavailable' }); +}); + +const { GET: publicMetrics } = await vite.ssrLoadModule( + '/src/routes/api/public/matches/[key]/metrics/+server.ts' +); +test('public match metrics return approved aggregates without reports or source identities', async () => { + const team = { + teamNumber: 8033, + alliance: 'BLUE', + reports: 2, + points: 42, + auto: 10, + teleop: 30, + fuelOutput: 50, + fuelFed: 20, + defenseSeconds: 12 + }; + const response = await publicMetrics({ + params: { key: '2026casnf_qm4' }, + fetch: async (_url, options) => { + assert.equal(options.credentials, 'omit'); + return Response.json({ + teams: [{ ...team, scoutReports: ['private'], notes: 'private' }], + sourceTeams: ['private'] + }); + } + }); + assert.deepEqual(await response.json(), { teams: [team] }); + assert.equal(response.headers.get('cache-control'), 'public, max-age=60'); +}); diff --git a/apps/website/scripts/team-analytics.test.mjs b/apps/website/scripts/team-analytics.test.mjs new file mode 100644 index 00000000..9768bee6 --- /dev/null +++ b/apps/website/scripts/team-analytics.test.mjs @@ -0,0 +1,83 @@ +import assert from 'node:assert/strict'; +import { after, test } from 'node:test'; +import { createServer } from 'vite'; + +process.env.LOVAT_API_BASE = 'https://api.example.test'; +const vite = await createServer({ + server: { middlewareMode: true, hmr: false }, + appType: 'custom' +}); +after(() => vite.close()); + +const { rollingValues, roleDistribution } = await vite.ssrLoadModule( + '/src/lib/tournaments/teamAnalytics.ts' +); +const { GET } = await vite.ssrLoadModule( + '/src/routes/api/public/teams/[number]/[year]/series/+server.ts' +); + +test('rolling averages preserve zeros and gaps, using only known values', () => { + assert.deepEqual(rollingValues([0, 10, null, 20, 30], 3), [0, 5, null, 15, 25]); + assert.deepEqual(rollingValues([0, null, 5], 1), [0, null, 5]); +}); + +test('role pie weights a tied match once and percentages total 100', () => { + const result = roleDistribution([ + { roles: ['CYCLING'] }, + { roles: ['SCORING', 'FEEDING', 'SCORING'] }, + { roles: [] } + ]); + assert.deepEqual(result, [ + { role: 'CYCLING', percentage: 50 }, + { role: 'SCORING', percentage: 25 }, + { role: 'FEEDING', percentage: 25 } + ]); + assert.equal( + result.reduce((sum, entry) => sum + entry.percentage, 0), + 100 + ); + assert.deepEqual(roleDistribution([]), []); +}); + +test('series proxy exposes only public aggregates and sends no credentials', async () => { + const match = { + matchKey: '2026casnf_qm4', + eventKey: '2026casnf', + eventName: 'San Francisco', + label: 'Qualification 4', + points: 0, + auto: null, + teleop: 0, + feeding: 20, + roles: ['FEEDING'] + }; + const response = await GET({ + params: { number: '8033', year: '2026' }, + fetch: async (url, options) => { + assert.equal(url, 'https://api.example.test/v1/public/teams/8033/2026/series'); + assert.equal(options.credentials, 'omit'); + return Response.json({ + matches: [{ ...match, notes: 'private', scoutReports: ['private'] }], + sources: ['private'] + }); + } + }); + assert.deepEqual(await response.json(), { matches: [match] }); + assert.equal(response.headers.get('cache-control'), 'public, max-age=60'); +}); + +test('series proxy validates team IDs and handles unsupported data safely', async () => { + const invalid = await GET({ + params: { number: '0', year: '2026' }, + fetch: () => { + throw new Error('Should not call'); + } + }); + assert.equal(invalid.status, 400); + const unavailable = await GET({ + params: { number: '8033', year: '2026' }, + fetch: async () => Response.json({ matches: [{ notes: 'private' }] }) + }); + assert.equal(unavailable.status, 503); + assert.deepEqual(await unavailable.json(), { error: 'Team analytics unavailable' }); +}); diff --git a/apps/website/scripts/wallpaper.test.mjs b/apps/website/scripts/wallpaper.test.mjs new file mode 100644 index 00000000..e07a6e38 --- /dev/null +++ b/apps/website/scripts/wallpaper.test.mjs @@ -0,0 +1,54 @@ +import assert from 'node:assert/strict'; +import { after, test } from 'node:test'; +import { readFile, access } from 'node:fs/promises'; +import { createServer } from 'vite'; + +const vite = await createServer({ + server: { middlewareMode: true, hmr: false }, + appType: 'custom' +}); + +after(() => vite.close()); + +const { eventWallpaper, districtWallpaper } = await vite.ssrLoadModule( + '/src/lib/tournaments/wallpaper.ts' +); + +test('event keys keep their fallback photo independently of lookup order', () => { + const keys = ['2026alhu', '2026arli', '2026azva', '2026cofr']; + const before = keys.map((key) => eventWallpaper(undefined, null, key)); + const after = [...keys].reverse().map((key) => eventWallpaper(undefined, null, key)); + + assert.deepEqual(before, after.reverse()); + assert.deepEqual(eventWallpaper(undefined, 'Unknown town', keys[0]), before[0]); +}); + +test('unmapped competitions use a variety of fallback photos', () => { + const images = new Set( + Array.from( + { length: 50 }, + (_, index) => eventWallpaper(undefined, null, `2026regional${index}`).image + ) + ); + + assert.ok(images.size >= 5, `Expected variety, found ${images.size} images`); +}); + +test('event, location, and district overrides retain their precedence', () => { + assert.equal(eventWallpaper('ca', 'Houston', '2026cur').image, '/assets/worlds/icons/cur.svg'); + assert.equal( + eventWallpaper('ont', 'Rochester', '2026nyro').image, + '/assets/cities/rochester.jpg' + ); + assert.deepEqual(eventWallpaper('ont', 'Unknown town', '2026unknown'), districtWallpaper('ont')); +}); + +test('every fallback image is local and includes its license credits', async () => { + const photos = JSON.parse(await readFile('src/lib/event-backgrounds.json', 'utf8')); + + for (const photo of photos) { + assert.match(photo.image, /^\/assets\/frc\/[a-z-]+\.jpg$/); + assert.ok(photo.author && photo.license && photo.source); + await access(`static${photo.image}`); + } +}); diff --git a/apps/website/src/hooks.server.ts b/apps/website/src/hooks.server.ts index a9187ec3..30d194de 100644 --- a/apps/website/src/hooks.server.ts +++ b/apps/website/src/hooks.server.ts @@ -23,7 +23,13 @@ const csrf = const response = await resolve(event); // Operational pages include verification side effects and live pit data. // Static assets are served separately by the adapter. - response.headers.set('cache-control', 'private, no-store'); + if ( + event.request.method !== 'GET' || + !event.url.pathname.startsWith('/api/public/') || + response.status !== 200 + ) { + response.headers.set('cache-control', 'private, no-store'); + } return response; }; function isContentType(request: Request, ...types: string[]) { diff --git a/apps/website/src/lib/AwardMarker.svelte b/apps/website/src/lib/AwardMarker.svelte new file mode 100644 index 00000000..1a26c147 --- /dev/null +++ b/apps/website/src/lib/AwardMarker.svelte @@ -0,0 +1,31 @@ + + + + + diff --git a/apps/website/src/lib/Footer.svelte b/apps/website/src/lib/Footer.svelte new file mode 100644 index 00000000..3b3e05b1 --- /dev/null +++ b/apps/website/src/lib/Footer.svelte @@ -0,0 +1,59 @@ + + + + + diff --git a/apps/website/src/lib/LovatBadge.svelte b/apps/website/src/lib/LovatBadge.svelte new file mode 100644 index 00000000..ce570539 --- /dev/null +++ b/apps/website/src/lib/LovatBadge.svelte @@ -0,0 +1,42 @@ + + + + + {#if !compact}Uses Lovat{/if} + + + diff --git a/apps/website/src/lib/Navbar.svelte b/apps/website/src/lib/Navbar.svelte index 1f4b1667..b516f9d1 100644 --- a/apps/website/src/lib/Navbar.svelte +++ b/apps/website/src/lib/Navbar.svelte @@ -1,7 +1,31 @@ + +
+
+
+ + + = 0 + ? `site-search-option-${selected}` + : undefined} + autocomplete="off" + on:focus={() => (focused = true)} + on:input={() => (focused = true)} + on:blur={() => (focused = false)} + on:keydown={keydown} + required + /> + +
+ {#if expanded} + + {/if} +
+
+ + diff --git a/apps/website/src/lib/default-event-wallpaper.json b/apps/website/src/lib/default-event-wallpaper.json new file mode 100644 index 00000000..fb137f3b --- /dev/null +++ b/apps/website/src/lib/default-event-wallpaper.json @@ -0,0 +1,8 @@ +{ + "image": "/assets/frc-match.jpg", + "source": "https://commons.wikimedia.org/wiki/File:FIRST_Championship_Detroit_2019_%E2%80%93_FRC_match_10.jpg", + "author": "Stilfehler", + "license": "CC BY-SA 4.0", + "licenseUrl": "https://creativecommons.org/licenses/by-sa/4.0", + "bytes": 319316 +} diff --git a/apps/website/src/lib/district-backgrounds.json b/apps/website/src/lib/district-backgrounds.json new file mode 100644 index 00000000..9eabb9be --- /dev/null +++ b/apps/website/src/lib/district-backgrounds.json @@ -0,0 +1,114 @@ +{ + "ca": { + "image": "/assets/cities/san-francisco.jpg", + "source": "https://commons.wikimedia.org/wiki/File:Golden_Gate_Bridge_as_seen_from_Battery_East.jpg", + "author": "Frank Schulenburg", + "license": "CC BY-SA 4.0", + "licenseUrl": "https://creativecommons.org/licenses/by-sa/4.0", + "bytes": 252236 + }, + "ont": { + "image": "/assets/districts/ont.jpg", + "source": "https://commons.wikimedia.org/wiki/File:Toronto_Skyline.jpg", + "author": "Bill Wrigley", + "license": "CC BY-SA 3.0", + "licenseUrl": "http://creativecommons.org/licenses/by-sa/3.0/", + "bytes": 82207 + }, + "fch": { + "image": "/assets/districts/fch.jpg", + "source": "https://commons.wikimedia.org/wiki/File:Chesapeake_Bay_Bridge_from_Annapolis_to_Maryland%27s_Eastern_Shore_LCCN2011632486-2.jpg", + "author": "Carol M. Highsmith", + "license": "Public domain", + "licenseUrl": "", + "bytes": 334895 + }, + "fim": { + "image": "/assets/districts/fim.jpg", + "source": "https://commons.wikimedia.org/wiki/File:Renaissance_Center,_Detroit,_Michigan_from_S_2014-12-07.jpg", + "author": "Crisco 1492", + "license": "Public domain", + "licenseUrl": "", + "bytes": 257414 + }, + "fit": { + "image": "/assets/districts/fit.jpg", + "source": "https://commons.wikimedia.org/wiki/File:TexasStateCapitol-2010-01.JPG", + "author": "LoneStarMike", + "license": "CC BY 3.0", + "licenseUrl": "https://creativecommons.org/licenses/by/3.0", + "bytes": 357572 + }, + "isr": { + "image": "/assets/districts/isr.jpg", + "source": "https://commons.wikimedia.org/wiki/File:The_Bahai_Temple_in_Haifa_Israel.jpg", + "author": "Dani Lavi 0007", + "license": "CC BY-SA 4.0", + "licenseUrl": "https://creativecommons.org/licenses/by-sa/4.0", + "bytes": 480465 + }, + "fma": { + "image": "/assets/districts/fma.jpg", + "source": "https://commons.wikimedia.org/wiki/File:Philadelphia_city_hall.jpg", + "author": "Toniklemm", + "license": "CC BY-SA 4.0", + "licenseUrl": "https://creativecommons.org/licenses/by-sa/4.0", + "bytes": 770119 + }, + "fnc": { + "image": "/assets/districts/fnc.jpg", + "source": "https://commons.wikimedia.org/wiki/File:Uptown_Charlotte_2018_taking_by_DJI_Phantom_4_pro.jpg", + "author": "Precisionviews", + "license": "CC BY-SA 4.0", + "licenseUrl": "https://creativecommons.org/licenses/by-sa/4.0", + "bytes": 419619 + }, + "fsc": { + "image": "/assets/districts/fsc.jpg", + "source": "https://commons.wikimedia.org/wiki/File:Rainbow_Row_Panorama.jpg", + "author": "Something Original (talk)", + "license": "CC BY-SA 3.0", + "licenseUrl": "https://creativecommons.org/licenses/by-sa/3.0", + "bytes": 148183 + }, + "win": { + "image": "/assets/districts/win.jpg", + "source": "https://commons.wikimedia.org/wiki/File:Milwaukee_Skyline_w_New_NW_Mutual_Building_2025_by_Isaac_Rowlett.jpg", + "author": "Bfkenney", + "license": "CC BY-SA 4.0", + "licenseUrl": "https://creativecommons.org/licenses/by-sa/4.0", + "bytes": 377950 + }, + "fin": { + "image": "/assets/districts/fin.jpg", + "source": "https://commons.wikimedia.org/wiki/File:Indianapolis_skyline_-_2014_April_-_Chris_Bowman.jpg", + "author": "Chris Bowman", + "license": "CC BY 2.0", + "licenseUrl": "https://creativecommons.org/licenses/by/2.0", + "bytes": 235510 + }, + "ne": { + "image": "/assets/districts/ne.jpg", + "source": "https://commons.wikimedia.org/wiki/File:Boston_skyline_from_Longfellow_Bridge_September_2017_panorama_2.jpg", + "author": "King of Hearts", + "license": "CC BY-SA 4.0", + "licenseUrl": "https://creativecommons.org/licenses/by-sa/4.0", + "bytes": 196387 + }, + "pnw": { + "image": "/assets/districts/pnw.jpg", + "source": "https://commons.wikimedia.org/wiki/File:Rainier_Aerial_West_Close_Washington_Mar23_R16_07149.jpg", + "author": "Timothy A. Gonsalves", + "license": "CC BY-SA 4.0", + "licenseUrl": "https://creativecommons.org/licenses/by-sa/4.0", + "bytes": 175139 + }, + "pch": { + "image": "/assets/districts/pch.jpg", + "source": "https://commons.wikimedia.org/wiki/File:Atlanta_skyline_from_Jackson_Street_Bridge_2020.jpg", + "author": "Marc Merlin", + "license": "CC BY-SA 4.0", + "licenseUrl": "https://creativecommons.org/licenses/by-sa/4.0", + "bytes": 269657 + } +} diff --git a/apps/website/src/lib/district-wallpapers.json b/apps/website/src/lib/district-wallpapers.json new file mode 100644 index 00000000..f3796d8f --- /dev/null +++ b/apps/website/src/lib/district-wallpapers.json @@ -0,0 +1,268 @@ +{ + "Berkeley": { + "image": "/assets/cities/berkeley.jpg", + "source": "https://commons.wikimedia.org/wiki/File:Campanile_Hi-Res_-_reduced_file_size.jpg", + "author": "Ryan Schwark", + "license": "CC0", + "licenseUrl": "http://creativecommons.org/publicdomain/zero/1.0/deed.en", + "bytes": 202198, + "position": "50% 22%" + }, + "San Diego": { + "image": "/assets/cities/san-diego.jpg", + "source": "https://commons.wikimedia.org/wiki/File:California_tower_gardens_at_Balboa_Park_2022.jpg", + "author": "Librarybell", + "license": "CC0", + "licenseUrl": "http://creativecommons.org/publicdomain/zero/1.0/deed.en", + "bytes": 809522, + "position": "50% 22%" + }, + "San Francisco": { + "image": "/assets/cities/san-francisco.jpg", + "source": "https://commons.wikimedia.org/wiki/File:Golden_Gate_Bridge_as_seen_from_Battery_East.jpg", + "author": "Frank Schulenburg", + "license": "CC BY-SA 4.0", + "licenseUrl": "https://creativecommons.org/licenses/by-sa/4.0", + "bytes": 252236 + }, + "Anaheim": { + "image": "/assets/cities/anaheim.jpg", + "source": "https://commons.wikimedia.org/wiki/File:Sleeping_beauty_castle_dlr_2019.jpg", + "author": "CrispyCream27", + "license": "CC BY-SA 4.0", + "licenseUrl": "https://creativecommons.org/licenses/by-sa/4.0", + "bytes": 424206 + }, + "Glendale": { + "image": "/assets/cities/glendale.jpg", + "source": "https://commons.wikimedia.org/wiki/File:AmericanaBrandSign.jpg", + "author": "Minnaert", + "license": "CC BY-SA 3.0", + "licenseUrl": "https://creativecommons.org/licenses/by-sa/3.0", + "bytes": 266452, + "position": "50% 25%" + }, + "Pittsburg": { + "image": "/assets/cities/pittsburg.jpg", + "source": "https://commons.wikimedia.org/wiki/File:Pittsburg_Boardwalk.jpg", + "author": "Thomas Savage", + "license": "CC BY-SA 2.0", + "licenseUrl": "https://creativecommons.org/licenses/by-sa/2.0", + "bytes": 171565 + }, + "Clovis": { + "image": "/assets/cities/clovis.jpg", + "source": "https://commons.wikimedia.org/wiki/File:Old_Town_Clovis%2C_California%2C_Pollasky_Ave%2C_2026.jpg", + "author": "Aaron T Harris", + "license": "CC BY-SA 4.0", + "licenseUrl": "https://creativecommons.org/licenses/by-sa/4.0", + "bytes": 278831 + }, + "El Segundo": { + "image": "/assets/cities/el-segundo.jpg", + "source": "https://commons.wikimedia.org/wiki/File:New_El_Segundo_welcome_sign.jpg", + "author": "EEJCC", + "license": "CC0", + "licenseUrl": "http://creativecommons.org/publicdomain/zero/1.0/deed.en", + "bytes": 272016 + }, + "Lancaster": { + "image": "/assets/cities/lancaster.jpg", + "source": "https://commons.wikimedia.org/wiki/File:California_Poppies1.jpg", + "author": "Boris D (talk · contribs)", + "license": "CC BY-SA 3.0", + "licenseUrl": "http://creativecommons.org/licenses/by-sa/3.0/", + "bytes": 351163, + "position": "50% 65%" + }, + "Rosemead": { + "image": "/assets/cities/rosemead.jpg", + "source": "https://commons.wikimedia.org/wiki/File:Dinsmoor_Heritage_House_and_Museum_Rosemead_California.jpg", + "author": "Ghostgrrrl626", + "license": "CC BY-SA 4.0", + "licenseUrl": "https://creativecommons.org/licenses/by-sa/4.0", + "bytes": 483486 + }, + "Mission Viejo": { + "image": "/assets/cities/mission-viejo.jpg", + "source": "https://commons.wikimedia.org/wiki/File:Snow_at_Lake_Mission_Viejo_005.jpg", + "author": "User:Wikpe44", + "license": "Public domain", + "licenseUrl": "", + "bytes": 431615 + }, + "Elk Grove": { + "image": "/assets/cities/elk-grove.jpg", + "source": "https://commons.wikimedia.org/wiki/File:Elk_Grove_Historic_District_-_Sarah_Stierch_-_2.jpg", + "author": "SarahStierch", + "license": "CC BY 4.0", + "licenseUrl": "https://creativecommons.org/licenses/by/4.0", + "bytes": 368903 + }, + "Hollister": { + "image": "/assets/cities/hollister.jpg", + "source": "https://commons.wikimedia.org/wiki/File:DSC00736_east_side_of_San_Benito_Street_at_Fifth_Street%2C_Hollister%2C_California%2C_June_16%2C_2007.JPG", + "author": "BrendelSignature", + "license": "CC BY-SA 3.0", + "licenseUrl": "http://creativecommons.org/licenses/by-sa/3.0/", + "bytes": 261489 + }, + "Daly City": { + "image": "/assets/cities/daly-city.jpg", + "source": "https://commons.wikimedia.org/wiki/File:Cow_Palace_(front).jpg", + "author": "Dante Alighieri", + "license": "CC BY-SA 3.0", + "licenseUrl": "http://creativecommons.org/licenses/by-sa/3.0/", + "bytes": 234214, + "position": "50% 32%" + }, + "Half Moon Bay": { + "image": "/assets/cities/half-moon-bay.jpg", + "source": "https://commons.wikimedia.org/wiki/File:2009_aerial_view_of_Pillar_Point_Harbor_and_Bluff.jpg", + "author": "Doc Searls", + "license": "CC BY-SA 2.0", + "licenseUrl": "https://creativecommons.org/licenses/by-sa/2.0", + "bytes": 242412 + }, + "Woodside": { + "image": "/assets/cities/woodside.jpg", + "source": "https://commons.wikimedia.org/wiki/File:Filoli_Main_House_Entrance.jpg", + "author": "Coolcaesar", + "license": "CC BY 4.0", + "licenseUrl": "https://creativecommons.org/licenses/by/4.0", + "bytes": 384295 + }, + "Port Hueneme": { + "image": "/assets/cities/port-hueneme.jpg", + "source": "https://commons.wikimedia.org/wiki/File:Point_Hueneme_Lighthouse.jpg", + "author": "Thomas from USA", + "license": "CC BY 2.0", + "licenseUrl": "https://creativecommons.org/licenses/by/2.0", + "bytes": 263171 + }, + "Ventura": { + "image": "/assets/cities/ventura.jpg", + "source": "https://commons.wikimedia.org/wiki/File:Aerial_view_of_the_pier%2C_Ventura%2C_California_LCCN2013631292.tiff", + "author": "Carol M. Highsmith", + "license": "Public domain", + "licenseUrl": "", + "bytes": 274226 + }, + "Houston": { + "source": "https://commons.wikimedia.org/wiki/File:Houston_panorama.jpg", + "author": "Henry Han", + "license": "CC BY-SA 3.0", + "licenseUrl": "https://creativecommons.org/licenses/by-sa/3.0", + "position": "50% 45%", + "image": "/assets/cities/houston.jpg", + "bytes": 96996 + }, + "Flint": { + "source": "https://commons.wikimedia.org/wiki/File:Flint,_Michigan_Skyline.jpg", + "author": "WeaponizingArchitecture", + "license": "CC BY-SA 4.0", + "licenseUrl": "https://creativecommons.org/licenses/by-sa/4.0", + "position": "50% 75%", + "image": "/assets/cities/flint.jpg", + "bytes": 84646 + }, + "University Center": { + "source": "https://commons.wikimedia.org/wiki/File:Saginaw_Valley_State_University%27s_Regional_Education_Center_(4323596316).jpg", + "author": "Saginaw Future Inc.", + "license": "CC BY 2.0", + "licenseUrl": "https://creativecommons.org/licenses/by/2.0", + "position": "50% 40%", + "image": "/assets/cities/university-center.jpg", + "bytes": 257470 + }, + "Istanbul": { + "source": "https://commons.wikimedia.org/wiki/File:Bosphorus_Bridge,_%C4%B0stanbul,_Turkey,_2010_-_panoramio.jpg", + "author": "Tugrul Kagan Ates", + "license": "CC BY 3.0", + "licenseUrl": "https://creativecommons.org/licenses/by/3.0", + "position": "50% 45%", + "image": "/assets/cities/istanbul.jpg", + "bytes": 226126 + }, + "İstanbul": { + "source": "https://commons.wikimedia.org/wiki/File:Bosphorus_Bridge,_%C4%B0stanbul,_Turkey,_2010_-_panoramio.jpg", + "author": "Tugrul Kagan Ates", + "license": "CC BY 3.0", + "licenseUrl": "https://creativecommons.org/licenses/by/3.0", + "position": "50% 45%", + "image": "/assets/cities/istanbul.jpg", + "bytes": 226126 + }, + "Başakşehir / İstanbul": { + "source": "https://commons.wikimedia.org/wiki/File:Bosphorus_Bridge,_%C4%B0stanbul,_Turkey,_2010_-_panoramio.jpg", + "author": "Tugrul Kagan Ates", + "license": "CC BY 3.0", + "licenseUrl": "https://creativecommons.org/licenses/by/3.0", + "position": "50% 45%", + "image": "/assets/cities/istanbul.jpg", + "bytes": 226126 + }, + "Bakırköy / İstanbul": { + "source": "https://commons.wikimedia.org/wiki/File:Bosphorus_Bridge,_%C4%B0stanbul,_Turkey,_2010_-_panoramio.jpg", + "author": "Tugrul Kagan Ates", + "license": "CC BY 3.0", + "licenseUrl": "https://creativecommons.org/licenses/by/3.0", + "position": "50% 45%", + "image": "/assets/cities/istanbul.jpg", + "bytes": 226126 + }, + "Rochester": { + "source": "https://commons.wikimedia.org/wiki/File:Highfallsrochester.jpg", + "author": "Evilarry", + "license": "CC BY-SA 3.0", + "licenseUrl": "https://creativecommons.org/licenses/by-sa/3.0", + "position": "50% 45%", + "image": "/assets/cities/rochester.jpg", + "bytes": 255271 + }, + "Wilsonville": { + "source": "https://commons.wikimedia.org/wiki/File:Wilsonville_railroad_bridge.jpg", + "author": "Cjdaniel", + "license": "CC BY-SA 3.0", + "licenseUrl": "https://creativecommons.org/licenses/by-sa/3.0", + "position": "50% 45%", + "image": "/assets/cities/wilsonville.jpg", + "bytes": 330089 + }, + "Hamilton": { + "source": "https://commons.wikimedia.org/wiki/File:HamiltonOntarioSkylineC.JPG", + "author": "Rick Cordeiro", + "license": "Public domain", + "licenseUrl": "", + "position": "50% 40%", + "image": "/assets/cities/hamilton.jpg", + "bytes": 374482 + }, + "Niagara Falls": { + "source": "https://commons.wikimedia.org/wiki/File:NiagaraFallsSkylon.jpg", + "author": "Cimosteve", + "license": "CC0", + "licenseUrl": "http://creativecommons.org/publicdomain/zero/1.0/deed.en", + "position": "50% 50%", + "image": "/assets/cities/niagara-falls.jpg", + "bytes": 173255 + }, + "Hadera": { + "source": "https://commons.wikimedia.org/wiki/File:PikiWiki_Israel_72716_hadera_river_park.jpg", + "author": "Israel Preker / Heritage Conservation Outside The City / Pikiwiki Israel", + "license": "CC BY 2.5", + "licenseUrl": "https://creativecommons.org/licenses/by/2.5", + "position": "50% 50%", + "image": "/assets/cities/hadera.jpg", + "bytes": 267355 + }, + "West Springfield": { + "source": "https://commons.wikimedia.org/wiki/File:Storrowton_Green,_Eastern_States_Exposition,_West_Springfield_MA.jpg", + "author": "John Phelan", + "license": "CC BY 3.0", + "licenseUrl": "https://creativecommons.org/licenses/by/3.0", + "position": "50% 40%", + "image": "/assets/cities/west-springfield.jpg", + "bytes": 433937 + } +} diff --git a/apps/website/src/lib/event-backgrounds.json b/apps/website/src/lib/event-backgrounds.json new file mode 100644 index 00000000..e8d673e5 --- /dev/null +++ b/apps/website/src/lib/event-backgrounds.json @@ -0,0 +1,47 @@ +[ + { + "image": "/assets/frc/detroit-match.jpg", + "source": "https://commons.wikimedia.org/wiki/File:FIRST_Championship_Detroit_2019_%E2%80%93_FRC_match_1.jpg", + "author": "Stilfehler", + "license": "CC BY-SA 4.0", + "licenseUrl": "https://creativecommons.org/licenses/by-sa/4.0", + "position": "50% 50%", + "bytes": 433897 + }, + { + "image": "/assets/frc/detroit-field.jpg", + "source": "https://commons.wikimedia.org/wiki/File:FIRST_Championship_Detroit_2019_%E2%80%93_FRC_match_5.jpg", + "author": "Stilfehler", + "license": "CC BY-SA 4.0", + "licenseUrl": "https://creativecommons.org/licenses/by-sa/4.0", + "position": "50% 50%", + "bytes": 433819 + }, + { + "image": "/assets/frc/aerial-assist.jpg", + "source": "https://commons.wikimedia.org/wiki/File:UTC_-_FIRST_Robotics_Competition_(13571274695).jpg", + "author": "Barbara Bresnahan from Connecticut, United States", + "license": "CC BY-SA 2.0", + "licenseUrl": "https://creativecommons.org/licenses/by-sa/2.0", + "position": "50% 50%", + "bytes": 358041 + }, + { + "image": "/assets/frc/breakaway.jpg", + "source": "https://commons.wikimedia.org/wiki/File:First_Robotics_Competition_(835838).jpg", + "author": "NASA/Paul E. Alers", + "license": "Public domain", + "licenseUrl": "", + "position": "50% 50%", + "bytes": 343665 + }, + { + "image": "/assets/frc/crescendo.jpg", + "source": "https://commons.wikimedia.org/wiki/File:FIRST_Robotics_Competition_(FRC)_2024_Crescendo_Playing_Field.jpg", + "author": "Dmhll", + "license": "CC BY 4.0", + "licenseUrl": "https://creativecommons.org/licenses/by/4.0", + "position": "50% 50%", + "bytes": 241623 + } +] diff --git a/apps/website/src/lib/event-wallpaper-overrides.json b/apps/website/src/lib/event-wallpaper-overrides.json new file mode 100644 index 00000000..57ba4ed2 --- /dev/null +++ b/apps/website/src/lib/event-wallpaper-overrides.json @@ -0,0 +1,110 @@ +{ + "2026arc": { + "image": "/assets/worlds/icons/arc.svg", + "source": "https://www.youtube.com/watch?v=265TvNiCV94", + "author": "Lovat (matching FIRST broadcast symbol)", + "license": "Original SVG", + "licenseUrl": "", + "streamSource": "https://www.youtube.com/watch?v=265TvNiCV94", + "background": "linear-gradient(110deg, #242328, #353026)", + "position": "center", + "fit": "contain", + "symbol": true + }, + "2026cur": { + "image": "/assets/worlds/icons/cur.svg", + "source": "https://github.com/FortAwesome/Font-Awesome/blob/6.x/svgs/solid/radiation.svg", + "author": "Font Awesome (recolored by Lovat)", + "license": "CC BY 4.0", + "licenseUrl": "https://creativecommons.org/licenses/by/4.0/", + "streamSource": "https://www.youtube.com/watch?v=0ZeAiI7iPPA", + "background": "linear-gradient(110deg, #242328, #353026)", + "position": "center", + "fit": "contain", + "symbol": true + }, + "2026dal": { + "image": "/assets/worlds/icons/dal.svg", + "source": "https://github.com/FortAwesome/Font-Awesome/blob/6.x/svgs/solid/flask.svg", + "author": "Font Awesome (recolored by Lovat)", + "license": "CC BY 4.0", + "licenseUrl": "https://creativecommons.org/licenses/by/4.0/", + "streamSource": "https://www.youtube.com/watch?v=MF80hJ8VsKs", + "background": "linear-gradient(110deg, #242328, #353026)", + "position": "center", + "fit": "contain", + "symbol": true + }, + "2026gal": { + "image": "/assets/worlds/icons/gal.svg", + "source": "https://www.youtube.com/watch?v=g1iPzNURUgY", + "author": "Lovat (matching FIRST broadcast symbol)", + "license": "Original SVG", + "licenseUrl": "", + "streamSource": "https://www.youtube.com/watch?v=g1iPzNURUgY", + "background": "linear-gradient(110deg, #242328, #353026)", + "position": "center", + "fit": "contain", + "symbol": true + }, + "2026hop": { + "image": "/assets/worlds/icons/hop.svg", + "source": "https://github.com/FortAwesome/Font-Awesome/blob/6.x/svgs/solid/bug.svg", + "author": "Font Awesome (recolored by Lovat)", + "license": "CC BY 4.0", + "licenseUrl": "https://creativecommons.org/licenses/by/4.0/", + "streamSource": "https://www.youtube.com/watch?v=rLmphTvtQ7w", + "background": "linear-gradient(110deg, #242328, #353026)", + "position": "center", + "fit": "contain", + "symbol": true + }, + "2026joh": { + "image": "/assets/worlds/icons/joh.svg", + "source": "https://github.com/FortAwesome/Font-Awesome/blob/6.x/svgs/solid/rocket.svg", + "author": "Font Awesome (recolored by Lovat)", + "license": "CC BY 4.0", + "licenseUrl": "https://creativecommons.org/licenses/by/4.0/", + "streamSource": "https://www.youtube.com/watch?v=PqyFF_0QMTE", + "background": "linear-gradient(110deg, #242328, #353026)", + "position": "center", + "fit": "contain", + "symbol": true + }, + "2026mil": { + "image": "/assets/worlds/icons/mil.svg", + "source": "https://github.com/FortAwesome/Font-Awesome/blob/6.x/svgs/solid/staff-snake.svg", + "author": "Font Awesome (recolored by Lovat)", + "license": "CC BY 4.0", + "licenseUrl": "https://creativecommons.org/licenses/by/4.0/", + "streamSource": "https://www.youtube.com/watch?v=b0MqgiYCM1k", + "background": "linear-gradient(110deg, #242328, #353026)", + "position": "center", + "fit": "contain", + "symbol": true + }, + "2026new": { + "image": "/assets/worlds/icons/new.svg", + "source": "https://github.com/FortAwesome/Font-Awesome/blob/6.x/svgs/solid/apple-whole.svg", + "author": "Font Awesome (recolored by Lovat)", + "license": "CC BY 4.0", + "licenseUrl": "https://creativecommons.org/licenses/by/4.0/", + "streamSource": "https://www.youtube.com/watch?v=_BBiWP_tyK8", + "background": "linear-gradient(110deg, #242328, #353026)", + "position": "center", + "fit": "contain", + "symbol": true + }, + "2026cmptx": { + "image": "/assets/worlds/icons/cmptx.svg", + "source": "https://github.com/FortAwesome/Font-Awesome/blob/6.x/svgs/solid/atom.svg", + "author": "Font Awesome (recolored by Lovat)", + "license": "CC BY 4.0", + "licenseUrl": "https://creativecommons.org/licenses/by/4.0/", + "streamSource": "https://www.youtube.com/watch?v=EjF9we707DA", + "background": "linear-gradient(110deg, #242328, #353026)", + "position": "center", + "fit": "contain", + "symbol": true + } +} diff --git a/apps/website/src/lib/scoutingSession.ts b/apps/website/src/lib/scoutingSession.ts new file mode 100644 index 00000000..b13a5f49 --- /dev/null +++ b/apps/website/src/lib/scoutingSession.ts @@ -0,0 +1,81 @@ +import { goto } from '$app/navigation'; +import { browser } from '$app/environment'; +import { env } from '$env/dynamic/public'; +import { writable } from 'svelte/store'; +import type { Auth0Client } from '@auth0/auth0-spa-js'; + +export const scoutingSession = writable({ signedIn: false, revision: 0 }); + +let client: Promise | undefined; +let revision = 0; + +function getClient() { + if (!browser) throw new Error('Sign-in requires a browser'); + + client ??= import('@auth0/auth0-spa-js').then( + ({ Auth0Client }) => + new Auth0Client({ + domain: env.PUBLIC_AUTH0_DOMAIN || 'lovat.us.auth0.com', + clientId: env.PUBLIC_AUTH0_CLIENT_ID || 'FR7SUG9t0wXb5ZVo51G0ewnsK7PPSyJ9', + cacheLocation: 'memory', + authorizationParams: { + audience: 'https://api.lovat.app', + scope: 'openid profile email', + redirect_uri: window.location.origin + } + }) + ); + + return client; +} + +export async function restoreScoutingSession() { + try { + const auth = await getClient(); + + const callback = new URL(window.location.href); + + if (callback.searchParams.has('code') && callback.searchParams.has('state')) { + const result = await auth.handleRedirectCallback<{ returnTo?: string }>(); + const target = result.appState?.returnTo ?? '/'; + const safeTarget = + target.startsWith('/') && + !target.startsWith('//') && + new URL(target, window.location.origin).origin === window.location.origin + ? target + : '/'; + + scoutingSession.set({ signedIn: await auth.isAuthenticated(), revision: ++revision }); + await goto(safeTarget, { replaceState: true }); + return; + } + + await auth.checkSession(); + scoutingSession.set({ signedIn: await auth.isAuthenticated(), revision }); + } catch { + // Public browsing remains available when authentication is unavailable. + } +} + +export async function signIn() { + const auth = await getClient(); + + await auth.loginWithRedirect({ + appState: { returnTo: window.location.pathname + window.location.search } + }); +} + +export async function signOut() { + const auth = await getClient(); + + scoutingSession.set({ signedIn: false, revision: ++revision }); + await auth.logout({ logoutParams: { returnTo: window.location.origin } }); +} + +export async function scoutingToken() { + const token = await (await getClient()).getTokenSilently(); + + if (!token) throw new Error('Sign in required'); + + return token; +} diff --git a/apps/website/src/lib/server/district.ts b/apps/website/src/lib/server/district.ts new file mode 100644 index 00000000..47b9fb76 --- /dev/null +++ b/apps/website/src/lib/server/district.ts @@ -0,0 +1,29 @@ +import { z } from 'zod'; + +export const districtSchema = z.object({ + key: z.string(), + name: z.string(), + abbreviation: z.string(), + seasonYear: z.number().int(), + tournaments: z.array( + z.object({ + key: z.string(), + parentTournamentKey: z.string().nullable(), + week: z.number().int().nullable(), + eventType: z.number().int().nullable(), + name: z.string(), + location: z.string().nullable(), + startDate: z.iso.datetime().nullable(), + endDate: z.iso.datetime().nullable() + }) + ), + teamSeasons: z.array( + z.object({ + teamNumber: z.number().int(), + usesLovat: z.boolean().default(false), + name: z.string(), + city: z.string().nullable(), + stateProvince: z.string().nullable() + }) + ) +}); diff --git a/apps/website/src/lib/server/publicAnalytics.ts b/apps/website/src/lib/server/publicAnalytics.ts new file mode 100644 index 00000000..acc198fc --- /dev/null +++ b/apps/website/src/lib/server/publicAnalytics.ts @@ -0,0 +1,131 @@ +import { z } from 'zod'; +import { env } from '$env/dynamic/private'; + +const coverageSchema = z.object({ + reported: z.number().int().nonnegative(), + expected: z.number().int().nonnegative(), + complete: z.boolean() +}); + +export const publicMatchSchema = z.object({ + coverage: coverageSchema.optional(), + prediction: z + .object({ + blueWinning: z.number().finite().min(0).max(1), + redWinning: z.number().finite().min(0).max(1) + }) + .refine((p) => Math.abs(p.blueWinning + p.redWinning - 1) < 0.000001) + .nullable(), + roles: z.array( + z.object({ + teamNumber: z.number().int().positive(), + role: z.enum(['CYCLING', 'SCORING', 'FEEDING', 'DEFENDING', 'IMMOBILE']) + }) + ) +}); + +export const publicMatchMetricsSchema = z.object({ + teams: z.array( + z.object({ + teamNumber: z.number().int().positive(), + alliance: z.enum(['BLUE', 'RED']).nullable(), + reports: z.number().int().positive(), + points: z.number().finite().nullable(), + auto: z.number().finite().nullable(), + teleop: z.number().finite().nullable(), + fuelOutput: z.number().finite().nullable(), + fuelFed: z.number().finite().nullable(), + defenseSeconds: z.number().finite().nullable() + }) + ) +}); + +export const metricsSchema = z.object({ + metrics: z + .object({ + matches: z.number().int().positive(), + points: z.number().finite(), + auto: z.number().finite(), + teleop: z.number().finite(), + percentiles: z + .object({ + points: z.number().finite().min(0).max(100).nullable(), + auto: z.number().finite().min(0).max(100).nullable(), + teleop: z.number().finite().min(0).max(100).nullable() + }) + .optional() + }) + .nullable() +}); +export const photoSchema = z.object({ + photo: z + .object({ + image: z + .string() + .url() + .refine((value) => { + const url = new URL(value); + return ( + url.protocol === 'https:' && + !url.username && + !url.password && + (url.hostname === 'i.imgur.com' || + url.hostname === 'photos.smugmug.com' || + url.hostname.endsWith('.smugmug.com')) + ); + }), + source: z.string().regex(/^https:\/\/www\.thebluealliance\.com\/team\/\d+\/\d{4}$/) + }) + .nullable() +}); + +export async function fetchPublic( + fetcher: typeof fetch, + path: string, + schema: z.ZodType, + timeoutMs = 20_000 +): Promise { + if (!env.LOVAT_API_BASE) throw new Error('Public data unavailable'); + + const response = await fetcher(`${env.LOVAT_API_BASE}/v1/public/${path}`, { + credentials: 'omit', + signal: AbortSignal.timeout(timeoutMs) + }); + + if (!response.ok) throw new Error('Public data unavailable'); + + return schema.parse(await response.json()); +} + +export const teamSeriesSchema = z.object({ + matches: z.array( + z.object({ + matchKey: z.string().nullable(), + reports: z.number().int().nonnegative().default(1), + elimination: z.boolean().default(false), + reportedBroken: z.boolean().default(false), + coverage: coverageSchema.nullable().default(null), + eventKey: z.string(), + eventName: z.string(), + label: z.string(), + points: z.number().finite().nullable(), + auto: z.number().finite().nullable(), + teleop: z.number().finite().nullable(), + feeding: z.number().finite().nullable(), + roles: z.array(z.enum(['CYCLING', 'SCORING', 'FEEDING', 'DEFENDING', 'IMMOBILE'])) + }) + ) +}); + +export const leaderboardSchema = z.object({ + teams: z.array( + z.object({ + number: z.number().int().positive(), + name: z.string(), + matches: z.number().int().positive(), + points: z.number().finite(), + auto: z.number().finite(), + teleop: z.number().finite() + }) + ) +}); diff --git a/apps/website/src/lib/server/search.ts b/apps/website/src/lib/server/search.ts new file mode 100644 index 00000000..63a7cc3f --- /dev/null +++ b/apps/website/src/lib/server/search.ts @@ -0,0 +1,28 @@ +import { env } from '$env/dynamic/private'; +import { error } from '@sveltejs/kit'; +import { z } from 'zod'; + +const schema = z.object({ + year: z.number().int(), + events: z.array(z.object({ key: z.string(), name: z.string(), location: z.string().nullable() })), + teams: z.array( + z.object({ teamNumber: z.number().int(), name: z.string(), city: z.string().nullable() }) + ) +}); + +export async function search(query: string, year: number, fetcher: typeof fetch) { + if (!env.LOVAT_API_BASE) error(503, 'Search unavailable'); + + try { + const response = await fetcher( + `${env.LOVAT_API_BASE}/v1/search?${new URLSearchParams({ q: query, year: String(year) })}`, + { signal: AbortSignal.timeout(15_000) } + ); + + if (!response.ok) error(503, 'Search unavailable'); + + return schema.parse(await response.json()); + } catch { + error(503, 'Search unavailable'); + } +} diff --git a/apps/website/src/lib/server/tournament.ts b/apps/website/src/lib/server/tournament.ts new file mode 100644 index 00000000..415b3cf9 --- /dev/null +++ b/apps/website/src/lib/server/tournament.ts @@ -0,0 +1,90 @@ +import { z } from 'zod'; + +const time = z.iso.datetime().nullable(); +const alliance = z.enum(['RED', 'BLUE']); + +export const tournamentSchema = z.object({ + key: z.string(), + name: z.string(), + seasonYear: z.number().int().nullable(), + district: z.object({ key: z.string(), name: z.string() }).nullable(), + week: z.number().int().nullable(), + playoffType: z.number().int().nullable(), + allianceSelections: z + .array( + z.object({ + teams: z.array(z.number().int()), + backup: z.object({ in: z.number().int(), out: z.number().int() }).nullable() + }) + ) + .nullable(), + awards: z + .array( + z.object({ + type: z.number().int(), + name: z.string(), + recipients: z.array( + z.object({ teamNumber: z.number().int().nullable(), name: z.string().nullable() }) + ) + }) + ) + .nullable(), + 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(), + usesLovat: z.boolean().default(false), + name: z.string(), + avatar: z + .string() + .regex(/^data:image\/(png|jpeg|gif|webp);base64,[A-Za-z0-9+/=]+$/) + .nullable() + }) + }) + ), + 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/lib/tournaments/DistrictLeaderboard.svelte b/apps/website/src/lib/tournaments/DistrictLeaderboard.svelte new file mode 100644 index 00000000..58e5ada5 --- /dev/null +++ b/apps/website/src/lib/tournaments/DistrictLeaderboard.svelte @@ -0,0 +1,161 @@ + + +
+
+

Lovat leaderboard

+ +
+

Season scouting averages · {teams.length} teams with reports

+ {#if loading}

Loading leaderboard…

{:else if failed}

+ Leaderboard unavailable. +

+ {:else if !teams.length}

No scouting metrics for this district and season.

+ {:else}
+ + {#each columns as column}{/each} + {#each sorted as team, index}{/each} + +
RankTeam
{index > 0 && team[metric] === sorted[index - 1][metric] + ? sorted.findIndex((t) => t[metric] === team[metric]) + 1 + : index + 1}{team.number} · {team.name}{team.matches}{team.points.toFixed(1)}{team.auto.toFixed(1)}{team.teleop.toFixed(1)}
+
{/if} +
+ + diff --git a/apps/website/src/lib/tournaments/MatchCard.svelte b/apps/website/src/lib/tournaments/MatchCard.svelte new file mode 100644 index 00000000..ef1f13d6 --- /dev/null +++ b/apps/website/src/lib/tournaments/MatchCard.svelte @@ -0,0 +1,315 @@ + + +
+
+
+

+ {#if linkToMatch}{matchLabel(match, playoffType)}{:else}{matchLabel(match, playoffType)}{/if} +

+ {match.status.replaceAll('_', ' ')} +
+
+ {#each ['BLUE', 'RED'] as color} + + {match.alliances.find((alliance) => alliance.color === color)?.score ?? '—'} + + {#if color === 'BLUE'}{/if} + {/each} +
+
+ Scheduled time {time(match.scheduledTime)} + {#if match.actualTime}Actual time {time(match.actualTime)}{/if} +
+
+ + updateRoles(event.detail)} + blue={blueTeams} + red={redTeams} + /> + +
+ {#each ['BLUE', 'RED'] as color} +
+
+ {#each match.teamSlots.filter((slot) => slot.alliance === color) as slot} + onTeamSelect?.(slot.teamNumber)} + aria-label={`${onTeamSelect ? 'Show matches for team' : 'View team'} ${slot.teamNumber}${slot.disqualified ? ', disqualified' : ''}${roles.has(slot.teamNumber) ? `, Lovat scouting role: ${roles.get(slot.teamNumber)?.join(', ').toLowerCase()}` : ''}`} + > + {slot.teamNumber} + {#each roles.get(slot.teamNumber) ?? [] as role} + + {/each} + + {#if teamNames.has(slot.teamNumber)}{teamNames.get(slot.teamNumber)}{/if}{#if slot.surrogate || slot.disqualified}{#if slot.surrogate}S{/if}{#if slot.surrogate && slot.disqualified} + · + {/if}{#if slot.disqualified}DQ{/if}{/if} + {/each} + {#if !match.teamSlots.some((slot) => slot.alliance === color)}Teams TBD{/if} +
+
+ {/each} +
+
+ + diff --git a/apps/website/src/lib/tournaments/MatchPredictor.svelte b/apps/website/src/lib/tournaments/MatchPredictor.svelte new file mode 100644 index 00000000..0880b982 --- /dev/null +++ b/apps/website/src/lib/tournaments/MatchPredictor.svelte @@ -0,0 +1,173 @@ + + +
+ {#if heading}

Match predictor

{/if} +
+
+ {#each ['Blue', 'Red'] as color, side}
+ {color} alliance +
+ {#each [0, 1, 2] as station}{/each} +
+
{/each} +
+ +
+
+ {#if prediction}
+
+
+ Blue {Math.round(prediction.blueWinning * 100)}%Red {Math.round(prediction.redWinning * 100)}% +
+
{/if}{#if message}

{message}

{/if} +
+
+ + diff --git a/apps/website/src/lib/tournaments/MatchScouting.svelte b/apps/website/src/lib/tournaments/MatchScouting.svelte new file mode 100644 index 00000000..f6106a99 --- /dev/null +++ b/apps/website/src/lib/tournaments/MatchScouting.svelte @@ -0,0 +1,161 @@ + + +{#if teams.length} +
+

Lovat scouting

+
+ {#each ['BLUE', 'RED'] as alliance} + {@const rows = teams.filter((team) => team.alliance === alliance)} + {#if rows.length} +
+

{alliance === 'BLUE' ? 'Blue' : 'Red'}

+
+ + + + {#each rows as team} + + + + + {/each} + +
TeamAutoTeleopPointsFuel outputFedDefense
{team.teamNumber}{team.reports} {team.reports === 1 ? 'report' : 'reports'}{value(team.auto)}{value(team.teleop)}{value(team.points)}{value(team.fuelOutput)}{value(team.fuelFed)}{value(team.defenseSeconds, 's')}
+
+
+ {/if} + {/each} +
+
+{/if} + + diff --git a/apps/website/src/lib/tournaments/PredictionBar.svelte b/apps/website/src/lib/tournaments/PredictionBar.svelte new file mode 100644 index 00000000..6ee60b19 --- /dev/null +++ b/apps/website/src/lib/tournaments/PredictionBar.svelte @@ -0,0 +1,145 @@ + + +
+ {#if coverage && coverage.expected > 0} +
+ {coverage.complete + ? 'Fully scouted' + : `${coverage.reported}/${coverage.expected} teams scouted`} +
+ {/if} + {#if prediction} + + {/if} +
+ + diff --git a/apps/website/src/lib/tournaments/RoleIcon.svelte b/apps/website/src/lib/tournaments/RoleIcon.svelte new file mode 100644 index 00000000..02de8527 --- /dev/null +++ b/apps/website/src/lib/tournaments/RoleIcon.svelte @@ -0,0 +1,35 @@ + + +{#if icon} + +{/if} + + diff --git a/apps/website/src/lib/tournaments/ScoreBreakdown.svelte b/apps/website/src/lib/tournaments/ScoreBreakdown.svelte new file mode 100644 index 00000000..ec64748e --- /dev/null +++ b/apps/website/src/lib/tournaments/ScoreBreakdown.svelte @@ -0,0 +1,216 @@ + + +{#if details.length} +
+ {#if scoring.length}
+

Official score breakdown

+ + + {#each scoring as [name, ...keys]}{/each} + +
PointsBlueRed
{name}{display(first(blue, keys))}{display(first(red, keys))}
+
{/if} + {#if fuel.length}
+

Official fuel scored

+ + + {#each fuel as [name, key]}{/each} + +
PhaseBlueRed
{name}{display(value(blue, ['hubScore', key]))}{display(value(red, ['hubScore', key]))}
+
{/if} +
+
+ Full official breakdown + + + {#each details as key}{/each} + +
ResultBlueRed
{label(key)}{display(value(blue, key.split('.')))}{display(value(red, key.split('.')))}
+
+{/if} + + diff --git a/apps/website/src/lib/tournaments/TeamAnalytics.svelte b/apps/website/src/lib/tournaments/TeamAnalytics.svelte new file mode 100644 index 00000000..58763c8d --- /dev/null +++ b/apps/website/src/lib/tournaments/TeamAnalytics.svelte @@ -0,0 +1,693 @@ + + +
+

Lovat analytics

+ {#if loading}

Loading analytics…

+ {:else if failed}

Analytics unavailable.

+ {:else if year !== 2026} +

Analytics unavailable for this season.

+ {:else if !matches.length}

No scouting data for this season.

+ {:else} +
+ + + + +
+ + {#if statistics} +
+
Average{statistics.average.toFixed(1)}
+
Median{statistics.median.toFixed(1)}
+
+ Spread (25–75%){statistics.lower.toFixed(1)}–{statistics.upper.toFixed(1)} +
+
+ Best match{#if bestMatch}{statistics.best.toFixed(1)}{:else}{statistics.best.toFixed(1)}{/if} +
+
+ Recent {statistics.recentCount}{statistics.recent.toFixed(1)} +
+
+ {/if} +
+

{metrics[metric]} per match

+ {#if values.some((value) => value !== null)} +
+ + {#each [0, 1, 2, 3, 4] as tick} + + {((ceiling * tick) / 4).toLocaleString('en-US', { + maximumFractionDigits: 1 + })} + {/each} + {#each tournaments as tournament, index} + + {#if index > 0} + New tournament: {tournament.name} + {/if} + {#if tournament.eliminationIndex !== null} + Eliminations begin: {tournament.name} + {/if} + {#if tournament.average !== null} + {tournament.key} average: {tournament.average.toFixed(1)} + {metrics[metric]} + {/if} + {tournament.name}{tournament.key} + Avg {tournament.average?.toFixed(1) ?? '—'} + {/each} + {#each filtered as match, index} + {#if match.reportedBroken}Reported broken: {match.label}{/if} + {/each} + {#if chart === 'line'}{/if} + {#each values as value, index} + {#if filtered[index].reports === 0} + (active = index)} + on:mouseenter={() => (active = index)} + >{filtered[index].label} · Unscouted + {/if} + {#if value !== null} + (active = index)} + on:mouseenter={() => (active = index)} + > + {#if chart === 'bar'}{/if} + {filtered[index].label} · {filtered[index].eventName}: {value.toFixed(1)} + {metrics[metric]} + + {/if} + {#if index % Math.max(1, Math.ceil(filtered.length / (chartWidth < 500 ? 4 : 8))) === 0 || index === filtered.length - 1}{index + 1}{/if} + {/each} + +
+
+ ● Qualifications● Eliminations┆ Reported broken│ New tournament┆ Eliminations begin— Tournament average○ Unscouted +
+
+ {#if selected}{selected.label} · {selected.eventName} + {selected.reports === 0 ? 'Unscouted · ' : ''}{selected.reportedBroken + ? 'Reported broken · ' + : ''}{selected.coverage?.complete + ? 'Fully scouted' + : selected.coverage + ? `${selected.coverage.reported}/${selected.coverage.expected} teams scouted` + : ''}{values[active]?.toFixed(1)} {metrics[metric]}{:else}Matches{/if} +
+ {:else}

No values available.

{/if} +
+
+

Roles

+ {#if distribution.length} +
+ + {#each distribution as entry, index} + {#if distribution.length === 1}{roles[entry.role].label}: {entry.percentage.toFixed(1)}% + {:else}{roles[entry.role].label}: {entry.percentage.toFixed(1)}%{/if} + {/each} + {#each labels as label} + + {roles[label.role].label} + {label.percentage.toFixed(1)}% + {/each} + +
+ {:else}

No roles available.

{/if} +
+ Match predictor → +
+ Match values +
+ + {#each filtered as match}{/each} +
MatchEventContext{metrics[metric]}
{match.label}{match.eventName}{match.elimination ? 'Eliminations' : 'Qualifications'}{match.reports === 0 + ? ' · Unscouted' + : ''}{match.reportedBroken ? ' · Reported broken' : ''}{match.coverage?.complete + ? ' · Fully scouted' + : match.coverage + ? ` · ${match.coverage.reported}/${match.coverage.expected} scouted` + : ''}{match[metric]?.toFixed(1) ?? '—'}
+
+
+ {/if} +
+ + diff --git a/apps/website/src/lib/tournaments/TeamOverview.svelte b/apps/website/src/lib/tournaments/TeamOverview.svelte new file mode 100644 index 00000000..43649fb3 --- /dev/null +++ b/apps/website/src/lib/tournaments/TeamOverview.svelte @@ -0,0 +1,104 @@ + + +{#if metrics} +
+ {#if metrics}
+

Lovat scouting metrics

+
+ {#each [['Points', metrics.points, metrics.percentiles?.points], ['Auto', metrics.auto, metrics.percentiles?.auto], ['Teleop', metrics.teleop, metrics.percentiles?.teleop]] as [label, value, percentile]}
+ {Number(value).toFixed(1)}{label} + {#if typeof percentile === 'number'}{ordinal(percentile)} percentile{/if} +
{/each} +
+
{/if} +
+{/if} + + diff --git a/apps/website/src/lib/tournaments/branding.ts b/apps/website/src/lib/tournaments/branding.ts new file mode 100644 index 00000000..72eec2ff --- /dev/null +++ b/apps/website/src/lib/tournaments/branding.ts @@ -0,0 +1,71 @@ +const colors = new Map(); + +// Read the imported raster avatar so roster gradients follow each season's team branding. +export function avatarGradient(node: HTMLElement, selector = 'img') { + const image = node.querySelector(selector); + + if (!image) return; + + function apply() { + if (!image || !image.naturalWidth) return; + + let color = colors.get(image.src); + + if (!color) { + const canvas = document.createElement('canvas'); + canvas.width = canvas.height = 32; + const context = canvas.getContext('2d'); + + if (!context) return; + + context.drawImage(image, 0, 0, 32, 32); + const pixels = context.getImageData(0, 0, 32, 32).data; + const buckets = new Map(); + + for (let i = 0; i < pixels.length; i += 4) { + const rgb = [pixels[i], pixels[i + 1], pixels[i + 2]]; + const high = Math.max(...rgb); + const low = Math.min(...rgb); + + if (pixels[i + 3] < 64 || high < 45 || low > 220) continue; + + const weight = (pixels[i + 3] / 255) * (1 + (high - low) / 128); + const key = rgb.map((channel) => Math.floor(channel / 32)).join(','); + const bucket = buckets.get(key) ?? { sum: [0, 0, 0], weight: 0 }; + bucket.weight += weight; + rgb.forEach((channel, index) => (bucket.sum[index] += channel * weight)); + buckets.set(key, bucket); + } + + const dominant = [...buckets.values()].sort((a, b) => b.weight - a.weight)[0]; + color = dominant + ? dominant.sum.map((value) => Math.round(value / dominant.weight)).join(', ') + : '130, 130, 130'; + colors.set(image.src, color); + } + + node.style.setProperty('--team-color', color); + } + + image.addEventListener('load', apply); + apply(); + + return { destroy: () => image.removeEventListener('load', apply) }; +} + +// Defer avatar requests until their row approaches the viewport. +export function lazyAvatar(image: HTMLImageElement) { + const observer = new IntersectionObserver( + (entries) => { + if (!entries.some((entry) => entry.isIntersecting)) return; + + image.src = image.dataset.src ?? ''; + observer.disconnect(); + }, + { rootMargin: '200px' } + ); + + observer.observe(image); + + return { destroy: () => observer.disconnect() }; +} diff --git a/apps/website/src/lib/tournaments/eventHierarchy.ts b/apps/website/src/lib/tournaments/eventHierarchy.ts new file mode 100644 index 00000000..8a590b38 --- /dev/null +++ b/apps/website/src/lib/tournaments/eventHierarchy.ts @@ -0,0 +1,39 @@ +type Event = { + key: string; + name: string; + parentTournamentKey: string | null; +}; + +export function nestedEvents(events: T[]) { + const keys = new Set(events.map((event) => event.key)); + const children = new Map(); + + for (const event of events) { + if ( + event.parentTournamentKey && + keys.has(event.parentTournamentKey) && + event.parentTournamentKey !== event.key + ) { + const siblings = children.get(event.parentTournamentKey) ?? []; + + siblings.push(event); + children.set(event.parentTournamentKey, siblings); + } + } + + return events + .filter( + (event) => + !event.parentTournamentKey || + !keys.has(event.parentTournamentKey) || + event.parentTournamentKey === event.key + ) + .flatMap((event) => [ + { event, nested: false, name: event.name }, + ...(children.get(event.key) ?? []).map((child) => ({ + event: child, + nested: true, + name: child.name.split(' - ').at(-1) ?? child.name + })) + ]); +} diff --git a/apps/website/src/lib/tournaments/matchLabel.ts b/apps/website/src/lib/tournaments/matchLabel.ts new file mode 100644 index 00000000..713a60d3 --- /dev/null +++ b/apps/website/src/lib/tournaments/matchLabel.ts @@ -0,0 +1,42 @@ +import type { z } from 'zod'; +import type { tournamentSchema } from '$lib/server/tournament'; + +type Match = z.infer['matches'][number]; + +export function matchLabel(match: Match, playoffType: number | null) { + if (match.competitionLevel === 'QUALIFICATION') return `Qualification ${match.matchNumber}`; + + if (match.competitionLevel === 'FINAL') { + return match.matchNumber === 3 ? 'Finals Tiebreaker' : `Finals Match ${match.matchNumber}`; + } + + // Only apply double-elimination labels to that format. + if (match.competitionLevel === 'SEMIFINAL' && playoffType === 10) { + const bracketNames: Record = { + 1: 'Upper Bracket Round 1', + 2: 'Upper Bracket Round 1', + 3: 'Upper Bracket Round 1', + 4: 'Upper Bracket Round 1', + 5: 'Lower Bracket Round 1', + 6: 'Lower Bracket Round 1', + 7: 'Upper Bracket Round 2', + 8: 'Upper Bracket Round 2', + 9: 'Lower Bracket Round 2', + 10: 'Lower Bracket Round 2', + 11: 'Upper Bracket Final', + 12: 'Lower Bracket Semifinal', + 13: 'Lower Bracket Final' + }; + + return `${bracketNames[match.setNumber]} · Match ${match.setNumber}`; + } + + const stage = + match.competitionLevel === 'SEMIFINAL' + ? 'Semifinal' + : match.competitionLevel === 'QUARTERFINAL' + ? 'Quarterfinal' + : 'Eighthfinal'; + + return `${stage} ${match.setNumber} · Match ${match.matchNumber}`; +} diff --git a/apps/website/src/lib/tournaments/publicAnalysis.ts b/apps/website/src/lib/tournaments/publicAnalysis.ts new file mode 100644 index 00000000..d3942dae --- /dev/null +++ b/apps/website/src/lib/tournaments/publicAnalysis.ts @@ -0,0 +1,46 @@ +export type PublicAnalysis = { + coverage?: { reported: number; expected: number; complete: boolean }; + prediction: { blueWinning: number; redWinning: number } | null; + roles: { teamNumber: number; role: string }[]; +}; + +const cache = new Map }>(); +let active = 0; +const queue: (() => void)[] = []; + +export function publicAnalysis(key: string): Promise { + const existing = cache.get(key); + + if (existing && existing.expires > Date.now()) return existing.result; + + const result = (async () => { + if (active >= 2) await new Promise((resolve) => queue.push(resolve)); + else active++; + + try { + const response = await fetch(`/api/public/matches/${encodeURIComponent(key)}`, { + signal: AbortSignal.timeout(25_000) + }); + + if (!response.ok) throw new Error('Prediction unavailable'); + + return (await response.json()) as PublicAnalysis; + } finally { + const next = queue.shift(); + + if (next) next(); + else active--; + } + })(); + + if (cache.size >= 500) cache.delete(cache.keys().next().value!); + + const entry = { expires: Date.now() + 60_000, result }; + + cache.set(key, entry); + result.catch(() => { + if (cache.get(key) === entry) cache.delete(key); + }); + + return result; +} diff --git a/apps/website/src/lib/tournaments/scoutingPrediction.ts b/apps/website/src/lib/tournaments/scoutingPrediction.ts new file mode 100644 index 00000000..56233426 --- /dev/null +++ b/apps/website/src/lib/tournaments/scoutingPrediction.ts @@ -0,0 +1,76 @@ +import { browser } from '$app/environment'; +import { scoutingSession } from '$lib/scoutingSession'; + +type Prediction = { blueWinning: number; redWinning: number } | null; + +const cache = new Map>(); +let currentRevision = -1; +let active = 0; +const queue: (() => void)[] = []; + +if (browser) { + scoutingSession.subscribe(({ revision }) => { + cache.clear(); + currentRevision = revision; + }); +} + +export async function getPrediction( + revision: number, + blue: number[], + red: number[], + token: () => Promise +) { + if (revision !== currentRevision) { + cache.clear(); + currentRevision = revision; + } + + const key = JSON.stringify([ + revision, + [...blue].sort((a, b) => a - b), + [...red].sort((a, b) => a - b) + ]); + const existing = cache.get(key); + + if (existing) return existing; + + const result = (async () => { + if (active >= 2) await new Promise((resolve) => queue.push(resolve)); + + active++; + + try { + if (currentRevision !== revision) return null; + + const authorization = `Bearer ${await token()}`; + + if (currentRevision !== revision) return null; + + const response = await fetch('/api/prediction', { + method: 'POST', + headers: { authorization, 'content-type': 'application/json' }, + body: JSON.stringify({ blue, red }), + signal: AbortSignal.timeout(25_000) + }); + + if (!response.ok) throw new Error('Prediction unavailable'); + + const result: { prediction: Prediction } = await response.json(); + + return result.prediction; + } finally { + active--; + queue.shift()?.(); + } + })(); + + if (cache.size >= 500) cache.delete(cache.keys().next().value!); + + cache.set(key, result); + result.catch(() => { + if (cache.get(key) === result) cache.delete(key); + }); + + return result; +} diff --git a/apps/website/src/lib/tournaments/teamAnalytics.ts b/apps/website/src/lib/tournaments/teamAnalytics.ts new file mode 100644 index 00000000..2fd467ed --- /dev/null +++ b/apps/website/src/lib/tournaments/teamAnalytics.ts @@ -0,0 +1,99 @@ +import type { z } from 'zod'; +import type { teamSeriesSchema } from '$lib/server/publicAnalytics'; + +export type SeriesMatch = z.infer['matches'][number]; +export type GraphMetric = 'points' | 'auto' | 'teleop' | 'feeding'; + +export function rollingValues(values: (number | null)[], window: number, groups?: string[]) { + return values.map((value, index) => { + if (value === null) return null; + + const sample = values + .slice(Math.max(0, index - window + 1), index + 1) + .filter( + (entry, offset): entry is number => + entry !== null && + (!groups || groups[Math.max(0, index - window + 1) + offset] === groups[index]) + ); + + return sample.reduce((sum, entry) => sum + entry, 0) / sample.length; + }); +} + +export function roleDistribution(matches: Pick[]) { + const totals = new Map(); + + for (const match of matches) { + const roles = [...new Set(match.roles)]; + + for (const role of roles) totals.set(role, (totals.get(role) ?? 0) + 1 / roles.length); + } + + const total = [...totals.values()].reduce((sum, value) => sum + value, 0); + + return [...totals].map(([role, value]) => ({ role, percentage: (value / total) * 100 })); +} + +export function consistency(values: (number | null)[]) { + const sample = values.filter( + (value): value is number => value !== null && Number.isFinite(value) + ); + if (!sample.length) return null; + const sorted = [...sample].sort((a, b) => a - b); + const quantile = (fraction: number) => { + const index = (sorted.length - 1) * fraction; + const lower = Math.floor(index); + return sorted[lower] + (sorted[Math.ceil(index)] - sorted[lower]) * (index - lower); + }; + const recent = sample.slice(-5); + return { + average: sample.reduce((a, b) => a + b, 0) / sample.length, + median: quantile(0.5), + lower: quantile(0.25), + upper: quantile(0.75), + best: sorted[sorted.length - 1], + recent: recent.reduce((a, b) => a + b, 0) / recent.length, + recentCount: recent.length, + count: sample.length + }; +} + +export function tournamentRanges( + matches: SeriesMatch[], + metric: GraphMetric, + includeBroken: boolean +) { + const ranges: { + key: string; + name: string; + start: number; + end: number; + average: number | null; + eliminationIndex: number | null; + }[] = []; + + for (let start = 0; start < matches.length;) { + const first = matches[start]; + let end = start + 1; + + while (end < matches.length && matches[end].eventKey === first.eventKey) end++; + + const sample = matches.slice(start, end); + const statistics = consistency( + sample.map((match) => (!includeBroken && match.reportedBroken ? null : match[metric])) + ); + const elimination = sample.findIndex((match) => match.elimination); + + ranges.push({ + key: first.eventKey, + name: first.eventName, + start, + end: end - 1, + average: statistics?.average ?? null, + eliminationIndex: elimination === -1 ? null : start + elimination + }); + start = end; + } + + return ranges; +} diff --git a/apps/website/src/lib/tournaments/teamRecords.ts b/apps/website/src/lib/tournaments/teamRecords.ts new file mode 100644 index 00000000..e22f92f1 --- /dev/null +++ b/apps/website/src/lib/tournaments/teamRecords.ts @@ -0,0 +1,33 @@ +import type { z } from 'zod'; +import type { tournamentSchema } from '$lib/server/tournament'; + +type Match = z.infer['matches'][number]; + +export function teamRecords(matches: Match[]) { + const records = new Map(); + + for (const match of matches) { + if (match.status !== 'COMPLETED') continue; + + const blue = match.alliances.find((alliance) => alliance.color === 'BLUE')?.score; + const red = match.alliances.find((alliance) => alliance.color === 'RED')?.score; + const tie = blue != null && red != null && blue === red; + + if (!match.winningAlliance && !tie) continue; + + for (const slot of match.teamSlots) { + if (!slot.alliance || slot.surrogate) continue; + + const record = records.get(slot.teamNumber) ?? { wins: 0, losses: 0, ties: 0 }; + + if (slot.disqualified) record.losses++; + else if (tie) record.ties++; + else if (match.winningAlliance === slot.alliance) record.wins++; + else record.losses++; + + records.set(slot.teamNumber, record); + } + } + + return records; +} diff --git a/apps/website/src/lib/tournaments/wallpaper.ts b/apps/website/src/lib/tournaments/wallpaper.ts new file mode 100644 index 00000000..a480fe2b --- /dev/null +++ b/apps/website/src/lib/tournaments/wallpaper.ts @@ -0,0 +1,78 @@ +import townPhotos from '$lib/district-wallpapers.json'; +import districtPhotos from '$lib/district-backgrounds.json'; +import eventOverrides from '$lib/event-wallpaper-overrides.json'; +import matchPhoto from '$lib/default-event-wallpaper.json'; +import eventPhotos from '$lib/event-backgrounds.json'; + +type Wallpaper = { + image: string; + source: string; + author: string; + license: string; + licenseUrl: string; + position?: string; + fit?: string; + background?: string; + symbol?: boolean; +}; + +const wallpapers: Record = townPhotos; + +const districts: Record = districtPhotos; +const overrides: Record = eventOverrides; +const fallbackPhotos: Wallpaper[] = [matchPhoto, ...eventPhotos]; + +function photoScore(key: string, image: string) { + let hash = 2166136261; + + for (const character of `${key}\0${image}`) { + hash = Math.imul(hash ^ character.charCodeAt(0), 16777619); + } + + return hash >>> 0; +} + +function eventFallback(key: string) { + // Score each image independently so adding a photo only moves events that prefer it. + const photo = fallbackPhotos.reduce((best, candidate) => + photoScore(key, candidate.image) > photoScore(key, best.image) ? candidate : best + ); + + return { + ...photo, + label: 'FRC match', + position: photo.position ?? 'center', + fit: photo.fit ?? 'cover' + }; +} + +export function districtWallpaper(district: string) { + const photo = districts[district]; + + return photo + ? { + ...photo, + label: district.toUpperCase(), + position: photo.position ?? 'center', + fit: photo.fit ?? 'cover' + } + : { ...matchPhoto, label: 'FRC match', position: 'center', fit: 'cover' }; +} + +export function eventWallpaper( + district: string | undefined, + city: string | null, + key?: string +): Wallpaper & { label: string; position: string } { + const override = key ? overrides[key] : undefined; + + if (override) return { ...override, label: key!, position: override.position ?? 'center' }; + + const custom = city ? wallpapers[city] : undefined; + + if (custom) return { ...custom, label: city!, position: custom.position ?? 'center' }; + + if (district && districts[district]) return districtWallpaper(district); + + return eventFallback(key ?? city ?? district ?? 'event'); +} diff --git a/apps/website/src/routes/(with-nav)/[district]/[year]/+page.server.ts b/apps/website/src/routes/(with-nav)/[district]/[year]/+page.server.ts new file mode 100644 index 00000000..4428f064 --- /dev/null +++ b/apps/website/src/routes/(with-nav)/[district]/[year]/+page.server.ts @@ -0,0 +1,43 @@ +import { error } from '@sveltejs/kit'; +import { env } from '$env/dynamic/private'; +import { districtSchema } from '$lib/server/district'; +import type { PageServerLoad } from './$types'; + +export const load: PageServerLoad = async ({ params, fetch, setHeaders }) => { + const key = `${params.year}${params.district}`; + + if (!/^[a-z][a-z0-9]{0,9}$/.test(params.district) || !/^\d{4}$/.test(params.year)) + error(404, 'District not found'); + + if (!env.LOVAT_API_BASE) error(503, 'District data is not configured'); + + let response: Response; + + try { + response = await fetch(`${env.LOVAT_API_BASE}/v1/districts/${encodeURIComponent(key)}`, { + signal: AbortSignal.timeout(10_000) + }); + } catch { + error(503, 'District data is temporarily unavailable'); + } + + if (response.status === 404) error(404, 'This district has not been imported yet'); + if (!response.ok) error(503, 'District data is temporarily unavailable'); + + let payload: unknown; + + try { + payload = await response.json(); + } catch { + error(503, 'District data is temporarily unavailable'); + } + + const parsed = districtSchema.safeParse(payload); + + if (!parsed.success || parsed.data.key !== key) + error(503, 'District data is temporarily unavailable'); + + setHeaders({ 'cache-control': 'public, max-age=300' }); + + return { district: parsed.data }; +}; diff --git a/apps/website/src/routes/(with-nav)/[district]/[year]/+page.svelte b/apps/website/src/routes/(with-nav)/[district]/[year]/+page.svelte new file mode 100644 index 00000000..b7364319 --- /dev/null +++ b/apps/website/src/routes/(with-nav)/[district]/[year]/+page.svelte @@ -0,0 +1,490 @@ + + + + {district.name} {district.seasonYear} | Lovat + + + +
+
+ +
+ + +
+

{district.name}

+
+ +
+ + + + + +
+ + {#if view === 'leaderboard'} + {#key district.key}{/key} + {:else} +
+
+

{view === 'events' ? 'Events' : 'Teams'}

+ {view === 'events' ? events.length : teams.length} + {view === 'events' ? 'events' : 'teams'} +
+ +
+ {#if view === 'events'} + {#each eventGroups as group (group.label)} +
+

{group.label}

+ {#each nestedEvents(group.events) as { event, nested, name } (event.key)} + {@const photo = eventWallpaper(district.abbreviation, event.location, event.key)} + + {#if photo}{/if} +
+ {name}{event.location ?? ''} +
+ + {date(event.startDate)} + {#if event.endDate && event.endDate !== event.startDate} + – + {date(event.endDate)} + {/if} + +
+ {/each} +
+ {:else}

No events found.

{/each} + {:else} + {#each teams as team (`${district.key}:${team.teamNumber}`)} + + +
+
+ {team.teamNumber}{#if team.usesLovat}{/if} +
+ {team.name} +
+
+ {:else}

No teams found.

{/each} + {/if} +
+ {#if view === 'events' && photoCredits.length} +
+ Photo credits + {#each photoCredits as photo}

+ {photo.label} · {photo.author} · {#if photo.licenseUrl}{photo.license}{:else}{photo.license}{/if} +

{/each} +
+ {/if} +
+ {/if} +
+ + diff --git a/apps/website/src/routes/(with-nav)/[year]/+page.server.ts b/apps/website/src/routes/(with-nav)/[year]/+page.server.ts new file mode 100644 index 00000000..ddde2799 --- /dev/null +++ b/apps/website/src/routes/(with-nav)/[year]/+page.server.ts @@ -0,0 +1,61 @@ +import { error } from '@sveltejs/kit'; +import { env } from '$env/dynamic/private'; +import { z } from 'zod'; +import type { PageServerLoad } from './$types'; + +const seasonSchema = z.object({ + year: z.number().int(), + seasonYears: z.array(z.number().int()).optional(), + gameName: z.string().nullable(), + districtSeasons: z.array( + z.object({ key: z.string(), abbreviation: z.string(), name: z.string() }) + ), + tournaments: z.array( + z.object({ + key: z.string(), + parentTournamentKey: z.string().nullable(), + name: z.string(), + location: z.string().nullable(), + startDate: z.iso.datetime().nullable(), + endDate: z.iso.datetime().nullable(), + week: z.number().int().nullable(), + eventType: z.number().int().nullable() + }) + ) +}); + +export const load: PageServerLoad = async ({ params, fetch, setHeaders }) => { + if (!/^\d{4}$/.test(params.year) || Number(params.year) < 1992) error(404, 'Season not found'); + + if (!env.LOVAT_API_BASE) error(503, 'Season data is not configured'); + + let response: Response; + + try { + response = await fetch(`${env.LOVAT_API_BASE}/v1/seasons/${params.year}`, { + signal: AbortSignal.timeout(10_000) + }); + } catch { + error(503, 'Season data is temporarily unavailable'); + } + + if (response.status === 404) error(404, 'This season has not been imported yet'); + if (!response.ok) error(503, 'Season data is temporarily unavailable'); + + let payload: unknown; + + try { + payload = await response.json(); + } catch { + error(503, 'Season data is temporarily unavailable'); + } + + const parsed = seasonSchema.safeParse(payload); + + if (!parsed.success || parsed.data.year !== Number(params.year)) + error(503, 'Season data is temporarily unavailable'); + + setHeaders({ 'cache-control': 'public, max-age=300' }); + + return { season: parsed.data }; +}; diff --git a/apps/website/src/routes/(with-nav)/[year]/+page.svelte b/apps/website/src/routes/(with-nav)/[year]/+page.svelte new file mode 100644 index 00000000..35bbf531 --- /dev/null +++ b/apps/website/src/routes/(with-nav)/[year]/+page.svelte @@ -0,0 +1,373 @@ + + + + {season.year} Season | Lovat + + + +
+
+
+

{season.year} Season

+ +
+ {#if season.gameName}

{season.gameName}

{/if} +
+ +
+

Districts

+
+ {#each season.districtSeasons as district (district.key)} + {@const photo = districtWallpaper(district.abbreviation)} + + + {district.name} + + {:else}

No districts imported yet.

{/each} +
+
+ +
+
+

Events

+ +
+
+ {#each eventGroups as group (group.label)} +
+

{group.label}

+ {#each nestedEvents(group.events) as { event, nested, name } (event.key)} + {@const photo = eventWallpaper(undefined, event.location, event.key)} + + +
+ {name}{event.location ?? ''} +
+ + {date(event.startDate)} + {#if event.endDate && event.endDate !== event.startDate} + –{date(event.endDate)} + {/if} + +
+ {/each} +
+ {:else}

{search ? 'No events found.' : 'No events imported yet.'}

{/each} +
+
+
+ Media credits + {#each photoCredits as photo}

+ {photo.label} · {photo.author} · + {#if photo.licenseUrl}{photo.license}{:else}{photo.license}{/if} +

{/each} +
+
+ + diff --git a/apps/website/src/routes/(with-nav)/matches/[key]/+page.server.ts b/apps/website/src/routes/(with-nav)/matches/[key]/+page.server.ts new file mode 100644 index 00000000..f3703df4 --- /dev/null +++ b/apps/website/src/routes/(with-nav)/matches/[key]/+page.server.ts @@ -0,0 +1,95 @@ +import { error } from '@sveltejs/kit'; +import { env } from '$env/dynamic/private'; +import { z } from 'zod'; +import { tournamentSchema } from '$lib/server/tournament'; +import type { PageServerLoad } from './$types'; + +export const load: PageServerLoad = async ({ params, fetch, setHeaders }) => { + const key = params.key.match( + /^([0-9]{4}[a-z0-9]+)_(?:qm[1-9]\d*|(?:ef|qf|sf|f)[1-9]\d*m[1-9]\d*)$/ + ); + + if (!key) error(404, 'Match not found'); + if (!env.LOVAT_API_BASE) error(503, 'Match data is not configured'); + + let response: Response; + + try { + response = await fetch(`${env.LOVAT_API_BASE}/v1/tournaments/${key[1]}`, { + signal: AbortSignal.timeout(10_000) + }); + } catch { + error(503, 'Match data is temporarily unavailable'); + } + + if (response.status === 404) error(404, 'Match not found'); + if (!response.ok) error(503, 'Match data is temporarily unavailable'); + + let payload: unknown; + + try { + payload = await response.json(); + } catch { + error(503, 'Match data is temporarily unavailable'); + } + + const parsed = tournamentSchema.safeParse(payload); + + if (!parsed.success || parsed.data.key !== key[1]) + error(503, 'Match data is temporarily unavailable'); + + const tournament = parsed.data; + const index = tournament.matches.findIndex((match) => match.key === params.key); + + if (index < 0) error(404, 'Match not found'); + + setHeaders({ 'cache-control': 'public, max-age=30' }); + + let breakdowns: { color: 'BLUE' | 'RED'; scoreBreakdown: Record | null }[] = []; + let videos: string[] = []; + + try { + const breakdownResponse = await fetch(`${env.LOVAT_API_BASE}/v1/matches/${params.key}`, { + signal: AbortSignal.timeout(10_000) + }); + + if (breakdownResponse.ok) { + const result = z + .object({ + key: z.literal(params.key), + videos: z.array(z.string().regex(/^[A-Za-z0-9_-]{11}$/)).optional(), + alliances: z.array( + z.object({ + color: z.enum(['BLUE', 'RED']), + scoreBreakdown: z.record(z.string(), z.unknown()).nullable() + }) + ) + }) + .safeParse(await breakdownResponse.json()); + + if (result.success) { + breakdowns = result.data.alliances; + videos = result.data.videos ?? []; + } + } + } catch { + // Keep the match result available while breakdown data is unavailable. + } + + return { + videos, + breakdowns, + match: tournament.matches[index], + previous: tournament.matches[index - 1] ?? null, + next: tournament.matches[index + 1] ?? null, + tournament: { + key: tournament.key, + name: tournament.name, + seasonYear: tournament.seasonYear ?? Number(key[1].slice(0, 4)), + district: tournament.district, + timezone: tournament.timezone, + playoffType: tournament.playoffType, + teams: tournament.teams.map(({ team }) => ({ number: team.number, name: team.name })) + } + }; +}; diff --git a/apps/website/src/routes/(with-nav)/matches/[key]/+page.svelte b/apps/website/src/routes/(with-nav)/matches/[key]/+page.svelte new file mode 100644 index 00000000..83e41347 --- /dev/null +++ b/apps/website/src/routes/(with-nav)/matches/[key]/+page.svelte @@ -0,0 +1,326 @@ + + + + {label} · {tournament.name} | Lovat + + + +
+
+
+ + +
+
+

{label}

+ {#if data.videos.length} + + {/if} +
+ {#if matchDate}

{matchDate}

{/if} +
+ + + + {#key data.match.key} + + {/key} + + + + +
+ + diff --git a/apps/website/src/routes/(with-nav)/predictor/+page.svelte b/apps/website/src/routes/(with-nav)/predictor/+page.svelte new file mode 100644 index 00000000..e47db9a4 --- /dev/null +++ b/apps/website/src/routes/(with-nav)/predictor/+page.svelte @@ -0,0 +1,45 @@ + + +Match predictor · 2026 | Lovat +
+ +

Match predictor

+ {#key team}{/key} +
+ + diff --git a/apps/website/src/routes/(with-nav)/search/+page.server.ts b/apps/website/src/routes/(with-nav)/search/+page.server.ts new file mode 100644 index 00000000..bf9417fb --- /dev/null +++ b/apps/website/src/routes/(with-nav)/search/+page.server.ts @@ -0,0 +1,15 @@ +import { error, redirect } from '@sveltejs/kit'; +import { search } from '$lib/server/search'; +import type { PageServerLoad } from './$types'; + +export const load: PageServerLoad = async ({ url, fetch }) => { + const query = (url.searchParams.get('q') ?? '').trim(); + const year = Number(url.searchParams.get('year') ?? new Date().getFullYear()); + + if (!Number.isInteger(year) || year < 1992 || year > 9999 || query.length > 80) + error(400, 'Invalid search'); + if (!query) return { query, year, events: [], teams: [] }; + if (/^\d+$/.test(query) && Number(query) > 0) redirect(303, `/teams/${Number(query)}/${year}`); + + return { query, ...(await search(query, year, fetch)) }; +}; diff --git a/apps/website/src/routes/(with-nav)/search/+page.svelte b/apps/website/src/routes/(with-nav)/search/+page.svelte new file mode 100644 index 00000000..80f95dd8 --- /dev/null +++ b/apps/website/src/routes/(with-nav)/search/+page.svelte @@ -0,0 +1,133 @@ + + +Search | Lovat + +
+

Search

+
+ + + +
+ {#if data.query} +
+

Teams

+
+ {#each data.teams as team}{team.teamNumber} +
{team.name}{team.city ?? ''}
{:else}

No teams found.

{/each} +
+
+
+

Events

+
+ {#each data.events as event}
{event.name}{event.location ?? ''}
{:else}

No events found.

{/each} +
+
+ {/if} +
+ + diff --git a/apps/website/src/routes/(with-nav)/teams/[number]/[year]/+page.server.ts b/apps/website/src/routes/(with-nav)/teams/[number]/[year]/+page.server.ts new file mode 100644 index 00000000..4a04123d --- /dev/null +++ b/apps/website/src/routes/(with-nav)/teams/[number]/[year]/+page.server.ts @@ -0,0 +1,81 @@ +import { error } from '@sveltejs/kit'; +import { env } from '$env/dynamic/private'; +import { z } from 'zod'; +import type { PageServerLoad } from './$types'; + +const teamSchema = z.object({ + teamNumber: z.number().int(), + seasonYear: z.number().int(), + name: z.string(), + usesLovat: z.boolean().default(false), + city: z.string().nullable(), + stateProvince: z.string().nullable(), + country: z.string().nullable(), + avatar: z + .string() + .regex(/^data:image\/(png|jpeg|gif|webp);base64,[A-Za-z0-9+/=]+$/) + .nullable(), + district: z.object({ key: z.string(), abbreviation: z.string(), name: z.string() }).nullable(), + seasonYears: z.array(z.number().int()), + tournaments: z.array( + z.object({ + key: z.string(), + name: z.string(), + location: z.string().nullable(), + week: z.number().int().nullable(), + startDate: z.iso.datetime().nullable(), + endDate: z.iso.datetime().nullable(), + awards: z.array(z.string()), + district: z.object({ abbreviation: z.string() }).nullable() + }) + ) +}); + +export const load: PageServerLoad = async ({ params, fetch, setHeaders }) => { + if ( + !/^[1-9]\d{0,9}$/.test(params.number) || + Number(params.number) > 2_147_483_647 || + !/^\d{4}$/.test(params.year) || + Number(params.year) < 1992 + ) + error(404, 'Team not found'); + + if (!env.LOVAT_API_BASE) error(503, 'Team data is not configured'); + + let response: Response; + + try { + response = await fetch( + `${env.LOVAT_API_BASE}/v1/teams/${params.number}/seasons/${params.year}`, + { + signal: AbortSignal.timeout(10_000) + } + ); + } catch { + error(503, 'Team data is temporarily unavailable'); + } + + if (response.status === 404) error(404, 'This team season has not been imported yet'); + if (!response.ok) error(503, 'Team data is temporarily unavailable'); + + let payload: unknown; + + try { + payload = await response.json(); + } catch { + error(503, 'Team data is temporarily unavailable'); + } + + const parsed = teamSchema.safeParse(payload); + + if ( + !parsed.success || + parsed.data.seasonYear !== Number(params.year) || + parsed.data.teamNumber !== Number(params.number) + ) + error(503, 'Team data is temporarily unavailable'); + + setHeaders({ 'cache-control': 'public, max-age=300' }); + + return { team: parsed.data }; +}; diff --git a/apps/website/src/routes/(with-nav)/teams/[number]/[year]/+page.svelte b/apps/website/src/routes/(with-nav)/teams/[number]/[year]/+page.svelte new file mode 100644 index 00000000..3bd8b9d2 --- /dev/null +++ b/apps/website/src/routes/(with-nav)/teams/[number]/[year]/+page.svelte @@ -0,0 +1,660 @@ + + + + {team.teamNumber} {team.name} · {team.seasonYear} | Lovat + + + +
+ {#key `${team.teamNumber}:${team.seasonYear}`} +
+ {#if robotPhoto} (robotPhoto = null)} + />{/if} +
+ + +
+
+ +
+
+ {team.teamNumber} + {#if team.usesLovat}{/if} +
+

{team.name}

+

{location}

+
+
+
+ {/key} + {#key `${team.teamNumber}:${team.seasonYear}`} (robotPhoto = event.detail)} + />{/key} + +
+
+ + + + +
+ +
+ {#if view === 'analytics'} + {#key `${team.teamNumber}:${team.seasonYear}`}{/key} + {:else} +
+
+

Events

+
+
+ {#each team.tournaments as event (event.key)} + {@const photo = eventWallpaper(event.district?.abbreviation, event.location, event.key)} +
{ + if (e.currentTarget.open) void loadEvent(event.key); + }} + > + + +
+ {event.name}{[event.location, event.week !== null ? `Week ${event.week + 1}` : null] + .filter(Boolean) + .join(' · ')} +
+ {date(event.startDate)}{#if event.endDate && event.endDate !== event.startDate}–{date(event.endDate)}{/if} + +
+
+ View tournament → +
+
+

Awards

+ {#if event.awards.length} +
+ {#each event.awards as award}{award}{/each} +
+ {:else}

No awards.

{/if} +
+
+

Alliance

+ {#if eventDetails[event.key]?.loading} +

Loading…

+ {:else if eventDetails[event.key]?.error} +

Temporarily unavailable.

+ + {:else if eventDetails[event.key]?.data} + {@const alliance = eventDetails[event.key].data!.alliance} + {#if alliance} +
+ Alliance {alliance.number} + {#each alliance.teams as number}{number}{/each} +
+ {#if alliance.backup}

+ Backup: {alliance.backup.in} + replacing {alliance.backup.out} +

{/if} + {:else}

No alliance selection.

{/if} + {/if} +
+
+ {#if eventDetails[event.key]?.data} + {@const details = eventDetails[event.key].data!} +

Matches

+
+ {#each details.matches as match (match.key)} + [entry.number, entry.name]))} + highlightedTeam={team.teamNumber} + /> + {:else}

No matches imported yet.

{/each} +
+ {/if} +
+
+ {:else}

No events imported for this season.

{/each} +
+
+ {/if} + {#if photoCredits.length}
+ Photo credits{#each photoCredits as photo}

+ {photo.label} · {photo.author} · {#if photo.licenseUrl}{photo.license}{:else}{photo.license}{/if} +

{/each} +
{/if} +
+ + diff --git a/apps/website/src/routes/(with-nav)/teams/[number]/[year]/events/[key]/+server.ts b/apps/website/src/routes/(with-nav)/teams/[number]/[year]/events/[key]/+server.ts new file mode 100644 index 00000000..4785fda1 --- /dev/null +++ b/apps/website/src/routes/(with-nav)/teams/[number]/[year]/events/[key]/+server.ts @@ -0,0 +1,63 @@ +import { error, isHttpError, json } from '@sveltejs/kit'; +import { env } from '$env/dynamic/private'; +import { tournamentSchema } from '$lib/server/tournament'; +import type { RequestHandler } from './$types'; + +export const GET: RequestHandler = async ({ params, fetch }) => { + const teamNumber = Number(params.number); + + if ( + !/^[1-9]\d{0,9}$/.test(params.number) || + teamNumber > 2_147_483_647 || + !/^\d{4}$/.test(params.year) || + Number(params.year) < 1992 || + !/^[0-9]{4}[a-z0-9]+$/.test(params.key) || + !params.key.startsWith(params.year) + ) + error(404, 'Tournament not found'); + + if (!env.LOVAT_API_BASE) error(503, 'Tournament data is not configured'); + + try { + const response = await fetch(`${env.LOVAT_API_BASE}/v1/tournaments/${params.key}`, { + signal: AbortSignal.timeout(10_000) + }); + + if (response.status === 404) error(404, 'Tournament not found'); + if (!response.ok) error(503, 'Tournament data is temporarily unavailable'); + + const parsed = tournamentSchema.safeParse(await response.json()); + + if (!parsed.success || parsed.data.key !== params.key) + error(503, 'Tournament data is temporarily unavailable'); + + const tournament = parsed.data; + const selection = + tournament.allianceSelections?.findIndex( + (alliance) => alliance.teams.includes(teamNumber) || alliance.backup?.in === teamNumber + ) ?? -1; + + return json( + { + playoffType: tournament.playoffType, + timezone: tournament.timezone, + teams: tournament.teams.map(({ team }) => ({ number: team.number, name: team.name })), + matches: tournament.matches.filter((match) => + match.teamSlots.some((slot) => slot.teamNumber === teamNumber) + ), + alliance: + selection < 0 + ? null + : { + number: selection + 1, + ...tournament.allianceSelections![selection] + } + }, + { headers: { 'cache-control': 'public, max-age=30' } } + ); + } catch (cause) { + if (isHttpError(cause)) throw cause; + + error(503, 'Tournament data is temporarily unavailable'); + } +}; diff --git a/apps/website/src/routes/(with-nav)/tournaments/[key]/+page.server.ts b/apps/website/src/routes/(with-nav)/tournaments/[key]/+page.server.ts new file mode 100644 index 00000000..d9a4cc24 --- /dev/null +++ b/apps/website/src/routes/(with-nav)/tournaments/[key]/+page.server.ts @@ -0,0 +1,43 @@ +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, params }) => { + if (!/^[0-9]{4}[a-z0-9]+$/.test(params.key)) error(404, 'Tournament not found'); + + 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/${encodeURIComponent(params.key)}`, + { + 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 || parsed.data.key !== params.key) + 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/[key]/+page.svelte b/apps/website/src/routes/(with-nav)/tournaments/[key]/+page.svelte new file mode 100644 index 00000000..459041fb --- /dev/null +++ b/apps/website/src/routes/(with-nav)/tournaments/[key]/+page.svelte @@ -0,0 +1,734 @@ + + + + {tournament.name} | Lovat + + + +
+
+ {#if wallpaper}{/if} +
+
+ + +
+

{tournament.name}

+

+ {tournament.location ?? 'Location unavailable'} + {#if tournament.week !== null} + · Week {tournament.week + 1}{/if} + {#if tournament.startDate && tournament.endDate} + · {dayLabel(tournament.startDate.slice(0, 10))} – {dayLabel( + tournament.endDate.slice(0, 10) + )} + {/if} +

+
+
+ +
+ + + + + +
+ + {#if view === 'schedule'} +
+
+
+

Match schedule

+
+ {filteredMatches.length} matches +
+ +
+
+ +
+
+ +
+
+ +
+ + +
+ +
+
+ +
+ {#each filteredMatches as match, index (match.key)} + {#if index === 0 || dateKey(matchTime(filteredMatches[index - 1])) !== dateKey(matchTime(match))} +

{dayLabel(dateKey(matchTime(match)))}

+ {/if} + (teamFilter = String(number))} + id={match.competitionLevel !== 'QUALIFICATION' && + !filteredMatches + .slice(0, index) + .some((previous) => previous.competitionLevel !== 'QUALIFICATION') + ? 'eliminations' + : undefined} + /> + {@const gap = gapsAfter.get(match.key)} + {#if gap && !teamFilter && phase === 'all' && completion === 'all'} +
+
+ {gapLabels[gap.type]} +
+
+ {#if dateKey(gap.startTime) !== dateKey(gap.endTime)} + {dayLabel(dateKey(gap.startTime))} + {time(gap.startTime)} – {dayLabel(dateKey(gap.endTime))} + {time(gap.endTime)} + {:else} + {time(gap.startTime)} – {time(gap.endTime)} + {/if} +
+
+ {/if} + {:else} +
+

No matches found

+ +
+ {/each} +
+
+ {:else if view === 'alliances'} + + {:else} +
+
+
+

Teams at this event

+
+ {filteredTeams.length} teams +
+
+ +
+ +
+ {/if} + {#if wallpaper} +
+ Photo credit +

+ {wallpaper.label} · {wallpaper.author} · + {#if wallpaper.licenseUrl}{wallpaper.license}{:else}{wallpaper.license}{/if} +

+
+ {/if} +
+ + diff --git a/apps/website/src/routes/(with-nav)/tournaments/[key]/TournamentAlliances.svelte b/apps/website/src/routes/(with-nav)/tournaments/[key]/TournamentAlliances.svelte new file mode 100644 index 00000000..5ed1acd8 --- /dev/null +++ b/apps/website/src/routes/(with-nav)/tournaments/[key]/TournamentAlliances.svelte @@ -0,0 +1,422 @@ + + +
+
+

Alliances

+ {#if selectedAlliance !== null}{/if} +
+
+ {#each eventAlliances as alliance, index} + {@const lineup = [ + ...new Set([...alliance.teams, ...(alliance.backup ? [alliance.backup.in] : [])]) + ]} + + {:else} +

No alliances selected yet.

+ {/each} +
+
+ +{#if tournament.playoffType === 10 && eventAlliances.length === 8} +
+

Double-elimination bracket

+ + +
+
+ +

Upper bracket

+

Lower bracket

+ {#each nodes as node} + {@const match = eliminationMatches.get(node.match)} + {#if match} + +

Match {node.match}

+ {#each ['RED', 'BLUE'] as color} +
+ Alliance {allianceFor(match, color) ?? 'TBD'}{match.alliances.find((alliance) => alliance.color === color)?.score ?? + '—'} +
+ {/each} +
+ {/if} + {/each} +
finalist.alliance === selectedAlliance)} + style="left:1170px;top:455px" + > +

Finals

+ {#each finalists as finalist}
= 2} + > + Alliance {finalist.alliance}{finalist.wins} +
{/each} + {#each finals as match} + + {match.matchNumber === 3 ? 'Tiebreaker' : `Match ${match.matchNumber}`} + + {match.alliances.find((alliance) => alliance.color === 'RED')?.score ?? + '—'} + – + {match.alliances.find((alliance) => alliance.color === 'BLUE')?.score ?? + '—'} + + + {/each} +
+
+
+
+{/if} + + diff --git a/apps/website/src/routes/+layout.svelte b/apps/website/src/routes/+layout.svelte index f56be56a..2fc7ca36 100644 --- a/apps/website/src/routes/+layout.svelte +++ b/apps/website/src/routes/+layout.svelte @@ -1,12 +1,39 @@
- +
+
+
+
+ + diff --git a/apps/website/src/routes/api/prediction/+server.ts b/apps/website/src/routes/api/prediction/+server.ts new file mode 100644 index 00000000..8264b521 --- /dev/null +++ b/apps/website/src/routes/api/prediction/+server.ts @@ -0,0 +1,93 @@ +import { json } from '@sveltejs/kit'; +import { env } from '$env/dynamic/private'; +import { z } from 'zod'; +import type { RequestHandler } from './$types'; + +const teamsSchema = z + .object({ + blue: z.array(z.number().int().positive().max(2_147_483_647)).length(3), + red: z.array(z.number().int().positive().max(2_147_483_647)).length(3) + }) + .refine(({ blue, red }) => new Set([...blue, ...red]).size === 6); + +const predictionSchema = z + .object({ + blueWinning: z.number().finite().min(0).max(1), + redWinning: z.number().finite().min(0).max(1) + }) + .refine(({ blueWinning, redWinning }) => Math.abs(blueWinning + redWinning - 1) < 0.000001); + +export const POST: RequestHandler = async ({ request, fetch, url }) => { + const reply = (body: unknown, status = 200) => + json(body, { + status, + headers: { 'cache-control': 'private, no-store', vary: 'Authorization' } + }); + + if (request.headers.get('origin') !== url.origin) return reply({ error: 'Forbidden' }, 403); + + const authorization = request.headers.get('authorization'); + + if (!authorization?.match(/^Bearer [A-Za-z0-9._~-]+$/)) + return reply({ error: 'Sign in required' }, 401); + if (!env.LOVAT_API_BASE) return reply({ error: 'Predictions unavailable' }, 503); + + let teams: z.infer; + + try { + const body = await request.text(); + + if (body.length > 2048) return reply({ error: 'Invalid teams' }, 400); + + teams = teamsSchema.parse(JSON.parse(body)); + } catch { + return reply({ error: 'Invalid teams' }, 400); + } + + const query = new URLSearchParams(); + + for (const color of ['blue', 'red'] as const) { + teams[color].forEach((team, index) => query.set(`${color}${index + 1}`, String(team))); + } + + try { + const response = await fetch(`${env.LOVAT_API_BASE}/v1/analysis/matchprediction?${query}`, { + headers: { authorization }, + signal: AbortSignal.timeout(20_000) + }); + + if (response.status === 401 || response.status === 403) { + const body = await response.text(); + const teamRequired = body === 'No team' || body === 'Your team has not been verified yet'; + const reason = teamRequired + ? 'Verified team required' + : ['No authorization token provided', 'Invalid authorization token'].includes(body) + ? body + : 'Unknown authorization rejection'; + + console.warn('Prediction authorization rejected by API', { + status: response.status, + reason, + redirected: response.redirected + }); + return reply( + { error: teamRequired ? 'Verified team required' : 'Sign in required' }, + teamRequired ? 403 : 401 + ); + } + if (!response.ok) return reply({ error: 'Predictions unavailable' }, 503); + + const body = await response.text(); + + if (body === 'not enough data') return reply({ prediction: null }); + + const prediction = predictionSchema.safeParse(JSON.parse(body)); + + if (!prediction.success) return reply({ prediction: null }); + + // Return only probabilities, never the private alliance analysis payload. + return reply({ prediction: prediction.data }); + } catch { + return reply({ error: 'Predictions unavailable' }, 503); + } +}; diff --git a/apps/website/src/routes/api/public/districts/[key]/leaderboard/+server.ts b/apps/website/src/routes/api/public/districts/[key]/leaderboard/+server.ts new file mode 100644 index 00000000..26769527 --- /dev/null +++ b/apps/website/src/routes/api/public/districts/[key]/leaderboard/+server.ts @@ -0,0 +1,18 @@ +import { json } from '@sveltejs/kit'; +import { fetchPublic, leaderboardSchema } from '$lib/server/publicAnalytics'; +import type { RequestHandler } from './$types'; + +export const GET: RequestHandler = async ({ params, fetch }) => { + if (!/^\d{4}[a-z0-9]+$/.test(params.key)) + return json({ error: 'Invalid district' }, { status: 400 }); + try { + return json( + await fetchPublic(fetch, `districts/${params.key}/leaderboard`, leaderboardSchema), + { + headers: { 'cache-control': 'public, max-age=60' } + } + ); + } catch { + return json({ error: 'Leaderboard unavailable' }, { status: 503 }); + } +}; diff --git a/apps/website/src/routes/api/public/matches/[key]/+server.ts b/apps/website/src/routes/api/public/matches/[key]/+server.ts new file mode 100644 index 00000000..ec55ceb7 --- /dev/null +++ b/apps/website/src/routes/api/public/matches/[key]/+server.ts @@ -0,0 +1,16 @@ +import { json } from '@sveltejs/kit'; +import { fetchPublic, publicMatchSchema } from '$lib/server/publicAnalytics'; +import type { RequestHandler } from './$types'; + +export const GET: RequestHandler = async ({ params, fetch }) => { + if (!/^\d{4}[a-z0-9]+_(?:qm\d+|(?:ef|qf|sf|f)\d+m\d+)$/.test(params.key)) + return json({ error: 'Invalid match' }, { status: 400 }); + + try { + return json(await fetchPublic(fetch, `matches/${params.key}`, publicMatchSchema), { + headers: { 'cache-control': 'public, max-age=60' } + }); + } catch { + return json({ error: 'Prediction unavailable' }, { status: 503 }); + } +}; diff --git a/apps/website/src/routes/api/public/matches/[key]/metrics/+server.ts b/apps/website/src/routes/api/public/matches/[key]/metrics/+server.ts new file mode 100644 index 00000000..a5ecafac --- /dev/null +++ b/apps/website/src/routes/api/public/matches/[key]/metrics/+server.ts @@ -0,0 +1,19 @@ +import { json } from '@sveltejs/kit'; +import { fetchPublic, publicMatchMetricsSchema } from '$lib/server/publicAnalytics'; +import type { RequestHandler } from './$types'; + +export const GET: RequestHandler = async ({ params, fetch }) => { + if (!/^\d{4}[a-z0-9]+_(?:qm\d+|(?:ef|qf|sf|f)\d+m\d+)$/.test(params.key)) + return json({ error: 'Invalid match' }, { status: 400 }); + + try { + return json( + await fetchPublic(fetch, `matches/${params.key}/metrics`, publicMatchMetricsSchema), + { + headers: { 'cache-control': 'public, max-age=60' } + } + ); + } catch { + return json({ error: 'Scouting metrics unavailable' }, { status: 503 }); + } +}; diff --git a/apps/website/src/routes/api/public/prediction/+server.ts b/apps/website/src/routes/api/public/prediction/+server.ts new file mode 100644 index 00000000..a492265e --- /dev/null +++ b/apps/website/src/routes/api/public/prediction/+server.ts @@ -0,0 +1,28 @@ +import { json } from '@sveltejs/kit'; +import { fetchPublic, publicMatchSchema } from '$lib/server/publicAnalytics'; +import type { RequestHandler } from './$types'; + +export const GET: RequestHandler = async ({ url, fetch }) => { + const teams = url.searchParams.get('teams') ?? ''; + const year = url.searchParams.get('year'); + const numbers = teams.split(',').map(Number); + if ( + !/^\d+(,\d+){5}$/.test(teams) || + new Set(numbers).size !== 6 || + numbers.some((n) => !Number.isInteger(n) || n <= 0 || n > 2147483647) || + year !== '2026' + ) + return json({ error: 'Choose six distinct teams in 2026' }, { status: 400 }); + try { + return json( + await fetchPublic( + fetch, + `prediction?year=2026&teams=${encodeURIComponent(teams)}`, + publicMatchSchema.pick({ prediction: true }) + ), + { headers: { 'cache-control': 'public, max-age=60' } } + ); + } catch { + return json({ error: 'Prediction unavailable' }, { status: 503 }); + } +}; diff --git a/apps/website/src/routes/api/public/search/+server.ts b/apps/website/src/routes/api/public/search/+server.ts new file mode 100644 index 00000000..f303f402 --- /dev/null +++ b/apps/website/src/routes/api/public/search/+server.ts @@ -0,0 +1,15 @@ +import { error, json } from '@sveltejs/kit'; +import { search } from '$lib/server/search'; +import type { RequestHandler } from './$types'; + +export const GET: RequestHandler = async ({ url, fetch }) => { + const query = (url.searchParams.get('q') ?? '').trim(); + const year = Number(url.searchParams.get('year')); + + if (!query || query.length > 80 || !Number.isInteger(year) || year < 1992 || year > 9999) + error(400, 'Invalid search'); + + return json(await search(query, year, fetch), { + headers: { 'Cache-Control': 'public, max-age=60' } + }); +}; diff --git a/apps/website/src/routes/api/public/teams/[number]/[year]/+server.ts b/apps/website/src/routes/api/public/teams/[number]/[year]/+server.ts new file mode 100644 index 00000000..2f740f40 --- /dev/null +++ b/apps/website/src/routes/api/public/teams/[number]/[year]/+server.ts @@ -0,0 +1,28 @@ +import { json } from '@sveltejs/kit'; +import { fetchPublic, metricsSchema, photoSchema } from '$lib/server/publicAnalytics'; +import type { RequestHandler } from './$types'; + +export const GET: RequestHandler = async ({ params, fetch }) => { + if ( + !/^[1-9]\d{0,9}$/.test(params.number) || + Number(params.number) > 2_147_483_647 || + !/^\d{4}$/.test(params.year) || + Number(params.year) < 1992 + ) + return json({ error: 'Invalid team' }, { status: 400 }); + + const path = `teams/${params.number}/${params.year}`; + const [metrics, photo] = await Promise.allSettled([ + // The shared season percentile distribution may need a cold-cache calculation. + fetchPublic(fetch, `${path}/metrics`, metricsSchema, 45_000), + fetchPublic(fetch, `${path}/photo`, photoSchema) + ]); + + return json( + { + metrics: metrics.status === 'fulfilled' ? metrics.value.metrics : null, + photo: photo.status === 'fulfilled' ? photo.value.photo : null + }, + { headers: { 'cache-control': 'public, max-age=60' } } + ); +}; diff --git a/apps/website/src/routes/api/public/teams/[number]/[year]/series/+server.ts b/apps/website/src/routes/api/public/teams/[number]/[year]/series/+server.ts new file mode 100644 index 00000000..83b3b678 --- /dev/null +++ b/apps/website/src/routes/api/public/teams/[number]/[year]/series/+server.ts @@ -0,0 +1,24 @@ +import { json } from '@sveltejs/kit'; +import { fetchPublic, teamSeriesSchema } from '$lib/server/publicAnalytics'; +import type { RequestHandler } from './$types'; + +export const GET: RequestHandler = async ({ params, fetch }) => { + if ( + !/^[1-9]\d{0,9}$/.test(params.number) || + Number(params.number) > 2_147_483_647 || + !/^\d{4}$/.test(params.year) || + Number(params.year) < 1992 + ) + return json({ error: 'Invalid team' }, { status: 400 }); + + try { + return json( + await fetchPublic(fetch, `teams/${params.number}/${params.year}/series`, teamSeriesSchema), + { + headers: { 'cache-control': 'public, max-age=60' } + } + ); + } catch { + return json({ error: 'Team analytics unavailable' }, { status: 503 }); + } +}; diff --git a/apps/website/src/routes/team-avatars/[year]/[number]/+server.ts b/apps/website/src/routes/team-avatars/[year]/[number]/+server.ts new file mode 100644 index 00000000..98dc7005 --- /dev/null +++ b/apps/website/src/routes/team-avatars/[year]/[number]/+server.ts @@ -0,0 +1,32 @@ +import { env } from '$env/dynamic/private'; +import type { RequestHandler } from './$types'; + +export const GET: RequestHandler = async ({ params, fetch }) => { + if (!/^\d{4}$/.test(params.year) || !/^\d{1,10}$/.test(params.number)) + return new Response(null, { status: 404 }); + + if (!env.LOVAT_API_BASE) return new Response(null, { status: 503 }); + + try { + const response = await fetch( + `${env.LOVAT_API_BASE}/v1/teams/${params.number}/avatar/${params.year}`, + { signal: AbortSignal.timeout(10_000) } + ); + + if (!response.ok) return new Response(null, { status: response.status }); + + const type = response.headers.get('content-type') ?? ''; + + if (!/^image\/(png|jpeg|gif|webp)(;|$)/.test(type)) return new Response(null, { status: 502 }); + + return new Response(response.body, { + headers: { + 'content-type': type, + 'cache-control': 'public, max-age=86400', + 'x-content-type-options': 'nosniff' + } + }); + } catch { + return new Response(null, { status: 503 }); + } +}; diff --git a/apps/website/static/assets/cities/anaheim.jpg b/apps/website/static/assets/cities/anaheim.jpg new file mode 100644 index 00000000..94c2a05e Binary files /dev/null and b/apps/website/static/assets/cities/anaheim.jpg differ diff --git a/apps/website/static/assets/cities/berkeley.jpg b/apps/website/static/assets/cities/berkeley.jpg new file mode 100644 index 00000000..d2e42546 Binary files /dev/null and b/apps/website/static/assets/cities/berkeley.jpg differ diff --git a/apps/website/static/assets/cities/clovis.jpg b/apps/website/static/assets/cities/clovis.jpg new file mode 100644 index 00000000..8c8b2b1a Binary files /dev/null and b/apps/website/static/assets/cities/clovis.jpg differ diff --git a/apps/website/static/assets/cities/daly-city.jpg b/apps/website/static/assets/cities/daly-city.jpg new file mode 100644 index 00000000..cd1077cb Binary files /dev/null and b/apps/website/static/assets/cities/daly-city.jpg differ diff --git a/apps/website/static/assets/cities/el-segundo.jpg b/apps/website/static/assets/cities/el-segundo.jpg new file mode 100644 index 00000000..65c5514d Binary files /dev/null and b/apps/website/static/assets/cities/el-segundo.jpg differ diff --git a/apps/website/static/assets/cities/elk-grove.jpg b/apps/website/static/assets/cities/elk-grove.jpg new file mode 100644 index 00000000..5275829c Binary files /dev/null and b/apps/website/static/assets/cities/elk-grove.jpg differ diff --git a/apps/website/static/assets/cities/flint.jpg b/apps/website/static/assets/cities/flint.jpg new file mode 100644 index 00000000..740ec07f Binary files /dev/null and b/apps/website/static/assets/cities/flint.jpg differ diff --git a/apps/website/static/assets/cities/glendale.jpg b/apps/website/static/assets/cities/glendale.jpg new file mode 100644 index 00000000..45cba6ae Binary files /dev/null and b/apps/website/static/assets/cities/glendale.jpg differ diff --git a/apps/website/static/assets/cities/hadera.jpg b/apps/website/static/assets/cities/hadera.jpg new file mode 100644 index 00000000..d98f550f Binary files /dev/null and b/apps/website/static/assets/cities/hadera.jpg differ diff --git a/apps/website/static/assets/cities/half-moon-bay.jpg b/apps/website/static/assets/cities/half-moon-bay.jpg new file mode 100644 index 00000000..b588d05f Binary files /dev/null and b/apps/website/static/assets/cities/half-moon-bay.jpg differ diff --git a/apps/website/static/assets/cities/hamilton.jpg b/apps/website/static/assets/cities/hamilton.jpg new file mode 100644 index 00000000..14e579b3 Binary files /dev/null and b/apps/website/static/assets/cities/hamilton.jpg differ diff --git a/apps/website/static/assets/cities/hollister.jpg b/apps/website/static/assets/cities/hollister.jpg new file mode 100644 index 00000000..51bf47d3 Binary files /dev/null and b/apps/website/static/assets/cities/hollister.jpg differ diff --git a/apps/website/static/assets/cities/houston.jpg b/apps/website/static/assets/cities/houston.jpg new file mode 100644 index 00000000..65c87d1c Binary files /dev/null and b/apps/website/static/assets/cities/houston.jpg differ diff --git a/apps/website/static/assets/cities/istanbul.jpg b/apps/website/static/assets/cities/istanbul.jpg new file mode 100644 index 00000000..79106541 Binary files /dev/null and b/apps/website/static/assets/cities/istanbul.jpg differ diff --git a/apps/website/static/assets/cities/lancaster.jpg b/apps/website/static/assets/cities/lancaster.jpg new file mode 100644 index 00000000..62cd7a51 Binary files /dev/null and b/apps/website/static/assets/cities/lancaster.jpg differ diff --git a/apps/website/static/assets/cities/mission-viejo.jpg b/apps/website/static/assets/cities/mission-viejo.jpg new file mode 100644 index 00000000..4f2d60f8 Binary files /dev/null and b/apps/website/static/assets/cities/mission-viejo.jpg differ diff --git a/apps/website/static/assets/cities/niagara-falls.jpg b/apps/website/static/assets/cities/niagara-falls.jpg new file mode 100644 index 00000000..cd09bac5 Binary files /dev/null and b/apps/website/static/assets/cities/niagara-falls.jpg differ diff --git a/apps/website/static/assets/cities/pittsburg.jpg b/apps/website/static/assets/cities/pittsburg.jpg new file mode 100644 index 00000000..62aa018e Binary files /dev/null and b/apps/website/static/assets/cities/pittsburg.jpg differ diff --git a/apps/website/static/assets/cities/port-hueneme.jpg b/apps/website/static/assets/cities/port-hueneme.jpg new file mode 100644 index 00000000..31bf3c29 Binary files /dev/null and b/apps/website/static/assets/cities/port-hueneme.jpg differ diff --git a/apps/website/static/assets/cities/rochester.jpg b/apps/website/static/assets/cities/rochester.jpg new file mode 100644 index 00000000..13a7454d Binary files /dev/null and b/apps/website/static/assets/cities/rochester.jpg differ diff --git a/apps/website/static/assets/cities/rosemead.jpg b/apps/website/static/assets/cities/rosemead.jpg new file mode 100644 index 00000000..0c313968 Binary files /dev/null and b/apps/website/static/assets/cities/rosemead.jpg differ diff --git a/apps/website/static/assets/cities/san-diego.jpg b/apps/website/static/assets/cities/san-diego.jpg new file mode 100644 index 00000000..bfee0efe Binary files /dev/null and b/apps/website/static/assets/cities/san-diego.jpg differ diff --git a/apps/website/static/assets/cities/san-francisco.jpg b/apps/website/static/assets/cities/san-francisco.jpg new file mode 100644 index 00000000..6b6e956d Binary files /dev/null and b/apps/website/static/assets/cities/san-francisco.jpg differ diff --git a/apps/website/static/assets/cities/university-center.jpg b/apps/website/static/assets/cities/university-center.jpg new file mode 100644 index 00000000..1ad65d8d Binary files /dev/null and b/apps/website/static/assets/cities/university-center.jpg differ diff --git a/apps/website/static/assets/cities/ventura.jpg b/apps/website/static/assets/cities/ventura.jpg new file mode 100644 index 00000000..622d3b31 Binary files /dev/null and b/apps/website/static/assets/cities/ventura.jpg differ diff --git a/apps/website/static/assets/cities/west-springfield.jpg b/apps/website/static/assets/cities/west-springfield.jpg new file mode 100644 index 00000000..3339eec4 Binary files /dev/null and b/apps/website/static/assets/cities/west-springfield.jpg differ diff --git a/apps/website/static/assets/cities/wilsonville.jpg b/apps/website/static/assets/cities/wilsonville.jpg new file mode 100644 index 00000000..03f25999 Binary files /dev/null and b/apps/website/static/assets/cities/wilsonville.jpg differ diff --git a/apps/website/static/assets/cities/woodside.jpg b/apps/website/static/assets/cities/woodside.jpg new file mode 100644 index 00000000..8ed0e6b9 Binary files /dev/null and b/apps/website/static/assets/cities/woodside.jpg differ diff --git a/apps/website/static/assets/districts/fch.jpg b/apps/website/static/assets/districts/fch.jpg new file mode 100644 index 00000000..18032af2 Binary files /dev/null and b/apps/website/static/assets/districts/fch.jpg differ diff --git a/apps/website/static/assets/districts/fim.jpg b/apps/website/static/assets/districts/fim.jpg new file mode 100644 index 00000000..6e7a94c8 Binary files /dev/null and b/apps/website/static/assets/districts/fim.jpg differ diff --git a/apps/website/static/assets/districts/fin.jpg b/apps/website/static/assets/districts/fin.jpg new file mode 100644 index 00000000..a7f588ab Binary files /dev/null and b/apps/website/static/assets/districts/fin.jpg differ diff --git a/apps/website/static/assets/districts/fit.jpg b/apps/website/static/assets/districts/fit.jpg new file mode 100644 index 00000000..090f479f Binary files /dev/null and b/apps/website/static/assets/districts/fit.jpg differ diff --git a/apps/website/static/assets/districts/fma.jpg b/apps/website/static/assets/districts/fma.jpg new file mode 100644 index 00000000..5826ee91 Binary files /dev/null and b/apps/website/static/assets/districts/fma.jpg differ diff --git a/apps/website/static/assets/districts/fnc.jpg b/apps/website/static/assets/districts/fnc.jpg new file mode 100644 index 00000000..c2f63f35 Binary files /dev/null and b/apps/website/static/assets/districts/fnc.jpg differ diff --git a/apps/website/static/assets/districts/fsc.jpg b/apps/website/static/assets/districts/fsc.jpg new file mode 100644 index 00000000..c88a26c2 Binary files /dev/null and b/apps/website/static/assets/districts/fsc.jpg differ diff --git a/apps/website/static/assets/districts/isr.jpg b/apps/website/static/assets/districts/isr.jpg new file mode 100644 index 00000000..6075de3a Binary files /dev/null and b/apps/website/static/assets/districts/isr.jpg differ diff --git a/apps/website/static/assets/districts/ne.jpg b/apps/website/static/assets/districts/ne.jpg new file mode 100644 index 00000000..ce54176a Binary files /dev/null and b/apps/website/static/assets/districts/ne.jpg differ diff --git a/apps/website/static/assets/districts/ont.jpg b/apps/website/static/assets/districts/ont.jpg new file mode 100644 index 00000000..2ce17195 Binary files /dev/null and b/apps/website/static/assets/districts/ont.jpg differ diff --git a/apps/website/static/assets/districts/pch.jpg b/apps/website/static/assets/districts/pch.jpg new file mode 100644 index 00000000..4c2d8202 Binary files /dev/null and b/apps/website/static/assets/districts/pch.jpg differ diff --git a/apps/website/static/assets/districts/pnw.jpg b/apps/website/static/assets/districts/pnw.jpg new file mode 100644 index 00000000..4e0b3c11 Binary files /dev/null and b/apps/website/static/assets/districts/pnw.jpg differ diff --git a/apps/website/static/assets/districts/win.jpg b/apps/website/static/assets/districts/win.jpg new file mode 100644 index 00000000..3ee61973 Binary files /dev/null and b/apps/website/static/assets/districts/win.jpg differ diff --git a/apps/website/static/assets/event-resources/first.png b/apps/website/static/assets/event-resources/first.png new file mode 100644 index 00000000..190415ee Binary files /dev/null and b/apps/website/static/assets/event-resources/first.png differ diff --git a/apps/website/static/assets/event-resources/match13.png b/apps/website/static/assets/event-resources/match13.png new file mode 100644 index 00000000..3e568576 Binary files /dev/null and b/apps/website/static/assets/event-resources/match13.png differ diff --git a/apps/website/static/assets/event-resources/statbotics.ico b/apps/website/static/assets/event-resources/statbotics.ico new file mode 100644 index 00000000..967df22d Binary files /dev/null and b/apps/website/static/assets/event-resources/statbotics.ico differ diff --git a/apps/website/static/assets/event-resources/tba.svg b/apps/website/static/assets/event-resources/tba.svg new file mode 100644 index 00000000..7528cd48 --- /dev/null +++ b/apps/website/static/assets/event-resources/tba.svg @@ -0,0 +1,32 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/website/static/assets/frc-match.jpg b/apps/website/static/assets/frc-match.jpg new file mode 100644 index 00000000..5bca5155 Binary files /dev/null and b/apps/website/static/assets/frc-match.jpg differ diff --git a/apps/website/static/assets/frc/aerial-assist.jpg b/apps/website/static/assets/frc/aerial-assist.jpg new file mode 100644 index 00000000..b8b29260 Binary files /dev/null and b/apps/website/static/assets/frc/aerial-assist.jpg differ diff --git a/apps/website/static/assets/frc/breakaway.jpg b/apps/website/static/assets/frc/breakaway.jpg new file mode 100644 index 00000000..0db33c04 Binary files /dev/null and b/apps/website/static/assets/frc/breakaway.jpg differ diff --git a/apps/website/static/assets/frc/crescendo.jpg b/apps/website/static/assets/frc/crescendo.jpg new file mode 100644 index 00000000..cd46fcbc Binary files /dev/null and b/apps/website/static/assets/frc/crescendo.jpg differ diff --git a/apps/website/static/assets/frc/detroit-field.jpg b/apps/website/static/assets/frc/detroit-field.jpg new file mode 100644 index 00000000..a755e38e Binary files /dev/null and b/apps/website/static/assets/frc/detroit-field.jpg differ diff --git a/apps/website/static/assets/frc/detroit-match.jpg b/apps/website/static/assets/frc/detroit-match.jpg new file mode 100644 index 00000000..64f21b16 Binary files /dev/null and b/apps/website/static/assets/frc/detroit-match.jpg differ diff --git a/apps/website/static/assets/icons/LICENSE.txt b/apps/website/static/assets/icons/LICENSE.txt new file mode 100644 index 00000000..7a4a3ea2 --- /dev/null +++ b/apps/website/static/assets/icons/LICENSE.txt @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. \ No newline at end of file diff --git a/apps/website/static/assets/icons/bookmark.svg b/apps/website/static/assets/icons/bookmark.svg new file mode 100644 index 00000000..0d59d447 --- /dev/null +++ b/apps/website/static/assets/icons/bookmark.svg @@ -0,0 +1 @@ + \ 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 diff --git a/apps/website/static/assets/icons/lovat-mark.png b/apps/website/static/assets/icons/lovat-mark.png new file mode 100644 index 00000000..6bcf273b Binary files /dev/null and b/apps/website/static/assets/icons/lovat-mark.png differ diff --git a/apps/website/static/assets/icons/role-cycling.svg b/apps/website/static/assets/icons/role-cycling.svg new file mode 100644 index 00000000..0c51cc8a --- /dev/null +++ b/apps/website/static/assets/icons/role-cycling.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/website/static/assets/icons/role-defending.svg b/apps/website/static/assets/icons/role-defending.svg new file mode 100644 index 00000000..042badf1 --- /dev/null +++ b/apps/website/static/assets/icons/role-defending.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/website/static/assets/icons/role-feeding.svg b/apps/website/static/assets/icons/role-feeding.svg new file mode 100644 index 00000000..a598bfeb --- /dev/null +++ b/apps/website/static/assets/icons/role-feeding.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/website/static/assets/icons/role-immobile.svg b/apps/website/static/assets/icons/role-immobile.svg new file mode 100644 index 00000000..196a44bb --- /dev/null +++ b/apps/website/static/assets/icons/role-immobile.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/website/static/assets/icons/role-scoring.svg b/apps/website/static/assets/icons/role-scoring.svg new file mode 100644 index 00000000..191ca88f --- /dev/null +++ b/apps/website/static/assets/icons/role-scoring.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/website/static/assets/worlds/icons/LICENSE.txt b/apps/website/static/assets/worlds/icons/LICENSE.txt new file mode 100644 index 00000000..e69c5e39 --- /dev/null +++ b/apps/website/static/assets/worlds/icons/LICENSE.txt @@ -0,0 +1,165 @@ +Fonticons, Inc. (https://fontawesome.com) + +-------------------------------------------------------------------------------- + +Font Awesome Free License + +Font Awesome Free is free, open source, and GPL friendly. You can use it for +commercial projects, open source projects, or really almost whatever you want. +Full Font Awesome Free license: https://fontawesome.com/license/free. + +-------------------------------------------------------------------------------- + +# Icons: CC BY 4.0 License (https://creativecommons.org/licenses/by/4.0/) + +The Font Awesome Free download is licensed under a Creative Commons +Attribution 4.0 International License and applies to all icons packaged +as SVG and JS file types. + +-------------------------------------------------------------------------------- + +# Fonts: SIL OFL 1.1 License + +In the Font Awesome Free download, the SIL OFL license applies to all icons +packaged as web and desktop font files. + +Copyright (c) 2024 Fonticons, Inc. (https://fontawesome.com) +with Reserved Font Name: "Font Awesome". + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + +SIL OPEN FONT LICENSE +Version 1.1 - 26 February 2007 + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting — in part or in whole — any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. + +-------------------------------------------------------------------------------- + +# Code: MIT License (https://opensource.org/licenses/MIT) + +In the Font Awesome Free download, the MIT license applies to all non-font and +non-icon files. + +Copyright 2024 Fonticons, Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in the +Software without restriction, including without limitation the rights to use, copy, +modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, +and to permit persons to whom the Software is furnished to do so, subject to the +following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, +INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +-------------------------------------------------------------------------------- + +# Attribution + +Attribution is required by MIT, SIL OFL, and CC BY licenses. Downloaded Font +Awesome Free files already contain embedded comments with sufficient +attribution, so you shouldn't need to do anything additional when using these +files normally. + +We've kept attribution comments terse, so we ask that you do not actively work +to remove them from files, especially code. They're a great way for folks to +learn about Font Awesome. + +-------------------------------------------------------------------------------- + +# Brand Icons + +All brand icons are trademarks of their respective owners. The use of these +trademarks does not indicate endorsement of the trademark holder by Font +Awesome, nor vice versa. **Please do not use brand logos for any purpose except +to represent the company, product, or service to which they refer.** diff --git a/apps/website/static/assets/worlds/icons/arc.svg b/apps/website/static/assets/worlds/icons/arc.svg new file mode 100644 index 00000000..82378285 --- /dev/null +++ b/apps/website/static/assets/worlds/icons/arc.svg @@ -0,0 +1 @@ + diff --git a/apps/website/static/assets/worlds/icons/cmptx.svg b/apps/website/static/assets/worlds/icons/cmptx.svg new file mode 100644 index 00000000..ea03a51c --- /dev/null +++ b/apps/website/static/assets/worlds/icons/cmptx.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/website/static/assets/worlds/icons/cur.svg b/apps/website/static/assets/worlds/icons/cur.svg new file mode 100644 index 00000000..98221639 --- /dev/null +++ b/apps/website/static/assets/worlds/icons/cur.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/website/static/assets/worlds/icons/dal.svg b/apps/website/static/assets/worlds/icons/dal.svg new file mode 100644 index 00000000..cf756f7c --- /dev/null +++ b/apps/website/static/assets/worlds/icons/dal.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/website/static/assets/worlds/icons/gal.svg b/apps/website/static/assets/worlds/icons/gal.svg new file mode 100644 index 00000000..534495b1 --- /dev/null +++ b/apps/website/static/assets/worlds/icons/gal.svg @@ -0,0 +1 @@ + diff --git a/apps/website/static/assets/worlds/icons/hop.svg b/apps/website/static/assets/worlds/icons/hop.svg new file mode 100644 index 00000000..66871acb --- /dev/null +++ b/apps/website/static/assets/worlds/icons/hop.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/website/static/assets/worlds/icons/joh.svg b/apps/website/static/assets/worlds/icons/joh.svg new file mode 100644 index 00000000..0bec5981 --- /dev/null +++ b/apps/website/static/assets/worlds/icons/joh.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/website/static/assets/worlds/icons/mil.svg b/apps/website/static/assets/worlds/icons/mil.svg new file mode 100644 index 00000000..adc7c480 --- /dev/null +++ b/apps/website/static/assets/worlds/icons/mil.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/website/static/assets/worlds/icons/new.svg b/apps/website/static/assets/worlds/icons/new.svg new file mode 100644 index 00000000..3785267c --- /dev/null +++ b/apps/website/static/assets/worlds/icons/new.svg @@ -0,0 +1 @@ + \ No newline at end of file