diff --git a/src/handler/analysis/coreAnalysis/arrayAndAverageTeams.ts b/src/handler/analysis/coreAnalysis/arrayAndAverageTeams.ts index 649a51f..8f17877 100644 --- a/src/handler/analysis/coreAnalysis/arrayAndAverageTeams.ts +++ b/src/handler/analysis/coreAnalysis/arrayAndAverageTeams.ts @@ -298,6 +298,22 @@ const config: AnalysisFunctionConfig = { }; break; + case Metric.totalBallsFed: + srSelect = { + events: { + select: { action: true, quantity: true }, + }, + } as any; + matchAggregationFunction = (reports) => { + const perReportTotals = reports.map((r) => { + return (r.events ?? []) + .filter((e) => e.action === "STOP_FEEDING") + .reduce((acc, cur) => acc + (cur.quantity ?? 0), 0); + }); + return avg(perReportTotals); + }; + break; + case Metric.outpostIntakes: srSelect = { events: { @@ -516,18 +532,16 @@ const config: AnalysisFunctionConfig = { let matchValue = 0; if (metric === Metric.fuelPerSecond) { - // Mirror averageManyFast: use total SCORING duration across all reports + // Total fuel across all reports / total duration across all reports + const totalFuel = (row.scoutReports as any) + .flatMap((r: any) => r.events ?? []) + .filter((e: any) => e.action === "STOP_SCORING") + .reduce((acc: number, cur: any) => acc + (cur.quantity ?? 0), 0); const totalDuration = calculateTimeMetric( row.scoutReports as any, "SCORING", ).reduce((a, b) => a + b, 0); - const perReportRates = (row.scoutReports as any).map((r: any) => { - const totalFuel = (r.events ?? []) - .filter((e: any) => e.action === "STOP_SCORING") - .reduce((acc: number, cur: any) => acc + (cur.quantity ?? 0), 0); - return totalDuration > 0 ? totalFuel / totalDuration : 0; - }); - matchValue = avg(perReportRates); + matchValue = totalDuration > 0 ? totalFuel / totalDuration : 0; } else { matchValue = matchAggregationFunction!(row.scoutReports as any); } diff --git a/src/handler/analysis/coreAnalysis/averageManyFast.ts b/src/handler/analysis/coreAnalysis/averageManyFast.ts index 75ffb3a..4279351 100644 --- a/src/handler/analysis/coreAnalysis/averageManyFast.ts +++ b/src/handler/analysis/coreAnalysis/averageManyFast.ts @@ -107,16 +107,25 @@ const config: AnalysisFunctionConfig = { const finalResults: Record> = {}; for (const metric of args.metrics) { - const resultsByTeam: Record = {}; - for (const team of args.teams) resultsByTeam[team] = []; + // Group by team -> tournament -> match values + const resultsByTeamAndTournament: Record< + number, + Record + > = {}; + for (const team of args.teams) resultsByTeamAndTournament[team] = {}; for (const row of tmd) { if (!row.scoutReports.length) continue; const team = row.teamNumber; + const tnmt = row.tournamentKey; const sr = row.scoutReports; - let tournamentValue = 0; + if (!resultsByTeamAndTournament[team][tnmt]) { + resultsByTeamAndTournament[team][tnmt] = []; + } + + let matchValue = 0; switch (metric) { case Metric.autoClimbStartTime: { @@ -128,12 +137,15 @@ const config: AnalysisFunctionConfig = { ); }); const nonNullTimes = times.filter((t): t is number => t !== null); - if (nonNullTimes.length === 0) { tournamentValue = -1; break; } + if (nonNullTimes.length === 0) { + matchValue = -1; + break; + } const adjustedTimes = nonNullTimes.map((t) => { const remaining = autoEnd - t; return remaining >= 0 ? remaining : 0; }); - tournamentValue = avg(adjustedTimes.length ? adjustedTimes : [0]); + matchValue = avg(adjustedTimes.length ? adjustedTimes : [0]); break; } @@ -151,91 +163,88 @@ const config: AnalysisFunctionConfig = { if (r.endgameClimb !== required) return null; return firstEventTime( r.events, - (e) => e.action === "CLIMB" && e.time > autoEnd && e.time <= 158, + (e) => + e.action === "CLIMB" && e.time > autoEnd && e.time <= 158, ); }); const nonNullTimes = times.filter((t): t is number => t !== null); - if (nonNullTimes.length === 0) { tournamentValue = -1; break; } + if (nonNullTimes.length === 0) { + matchValue = -1; + break; + } const adjustedTimes = nonNullTimes.map((t) => { const remaining = 158 - t; return remaining >= 0 ? remaining : 0; }); - tournamentValue = avg(adjustedTimes.length ? adjustedTimes : [0]); + matchValue = avg(adjustedTimes.length ? adjustedTimes : [0]); break; } case Metric.contactDefenseTime: case Metric.campingDefenseTime: case Metric.totalDefenseTime: { - const perMatch = sr.map((r) => { - const contact = (tournamentValue = avg( - calculateTimeMetric(sr, "DEFENDING"), - )); - const camping = (tournamentValue = avg( - calculateTimeMetric(sr, "CAMPING"), - )); - if (metric === Metric.contactDefenseTime) return contact; - if (metric === Metric.campingDefenseTime) return camping; - return contact + camping; - }); - tournamentValue = avg(perMatch); + const contact = avg(calculateTimeMetric(sr, "DEFENDING")); + const camping = avg(calculateTimeMetric(sr, "CAMPING")); + if (metric === Metric.contactDefenseTime) matchValue = contact; + else if (metric === Metric.campingDefenseTime) matchValue = camping; + else matchValue = contact + camping; break; } /* ---------- SCORED METRICS ---------- */ case Metric.driverAbility: - tournamentValue = avg(sr.map((r) => r.driverAbility)); + matchValue = avg(sr.map((r) => r.driverAbility)); break; case Metric.defenseEffectiveness: - tournamentValue = avg(sr.map((r) => r.defenseEffectiveness)); + matchValue = avg(sr.map((r) => r.defenseEffectiveness)); break; case Metric.accuracy: { - const defined = sr.filter((r) => r.accuracy !== null && r.accuracy !== undefined); - tournamentValue = defined.length - ? avg(defined.map((r) => accuracyToPercentage[r.accuracy as any])) + const defined = sr.filter( + (r) => r.accuracy !== null && r.accuracy !== undefined, + ); + matchValue = defined.length + ? avg( + defined.map((r) => accuracyToPercentage[r.accuracy as any]), + ) : 0; } break; case Metric.autoPoints: case Metric.teleopPoints: case Metric.totalPoints: { - const perMatch = sr.map((r) => { + const perReport = sr.map((r) => { const auto = r.events .filter((e) => e.time <= autoEnd && e.action === "STOP_SCORING") .reduce((a, b) => a + b.points, 0); const tele = r.events .filter((e) => e.time > autoEnd && e.action === "STOP_SCORING") .reduce((a, b) => a + b.points, 0); - const aClimb = avg( - sr.map((s) => (s.autoClimb !== AutoClimb.SUCCEEDED ? 0 : 15)), - ); - const endgame = avg( - sr.map((s) => endgameToPoints[s.endgameClimb]), - ); - if (metric === Metric.autoPoints) return auto; - if (metric === Metric.teleopPoints) return tele; - return aClimb + auto + tele + endgame; + const aClimb = r.autoClimb === AutoClimb.SUCCEEDED ? 15 : 0; + const endgame = endgameToPoints[r.endgameClimb]; + if (metric === Metric.autoPoints) + return auto * r.accuracy + aClimb; + if (metric === Metric.teleopPoints) return tele * r.accuracy; + return aClimb + auto * r.accuracy + tele * r.accuracy + endgame; }); - tournamentValue = avg(perMatch); + matchValue = avg(perReport); break; } case Metric.fuelPerSecond: { - const perMatch = sr.map((r) => { - const totalFuel = r.events - .filter((e) => e.action === "STOP_SCORING") - .reduce((acc, cur) => acc + (cur.quantity ?? 0), 0); - const duration = calculateTimeMetric(sr, "SCORING").reduce( - (a, b) => a + b, - 0, - ); - return duration > 0 ? totalFuel / duration : 0; - }); - tournamentValue = avg(perMatch); + // Total fuel across all reports / total duration across all reports + const totalFuel = sr + .flatMap((r) => r.events) + .filter((e) => e.action === "STOP_SCORING") + .reduce((acc, cur) => acc + (cur.quantity ?? 0), 0); + const totalDuration = calculateTimeMetric(sr, "SCORING").reduce( + (a, b) => a + b, + 0, + ); + matchValue = totalDuration > 0 ? totalFuel / totalDuration : 0; break; } case Metric.totalFuelOutputted: { - const perMatch = sr.map((r) => { + const perReport = sr.map((r) => { const shotQty = r.events .filter((e) => e.action === "STOP_SCORING") .reduce((acc, cur) => acc + (cur.quantity ?? 0), 0); @@ -244,35 +253,32 @@ const config: AnalysisFunctionConfig = { .reduce((acc, cur) => acc + (cur.quantity ?? 0), 0); return shotQty + feedQty; }); - tournamentValue = avg(perMatch); + matchValue = avg(perReport); break; } case Metric.totalBallsFed: { - const perMatch = sr.map((r) => { + const perReport = sr.map((r) => { return r.events .filter((e) => e.action === "STOP_FEEDING") .reduce((acc, cur) => acc + (cur.quantity ?? 0), 0); }); - tournamentValue = avg(perMatch); + matchValue = avg(perReport); break; } case Metric.totalBallThroughput: { - const perMatch = sr.map((r) => { + const perReport = sr.map((r) => { return r.events - .filter( - (e) => - e.action === "STOP_FEEDING" || e.action === "STOP_SCORING", - ) + .filter((e) => e.action === "STOP_SCORING") .reduce((acc, cur) => acc + (cur.quantity ?? 0), 0); }); - tournamentValue = avg(perMatch); + matchValue = avg(perReport); break; } case Metric.feedsPerMatch: { - const perMatch = sr.map( + const perReport = sr.map( (r) => r.events.filter((e) => e.action === "STOP_FEEDING").length, ); - tournamentValue = avg(perMatch); + matchValue = avg(perReport); break; } case Metric.feedingRate: { @@ -284,43 +290,50 @@ const config: AnalysisFunctionConfig = { (acc, f) => acc + (f.quantity ?? 0), 0, ); - tournamentValue = + matchValue = totalFeedQuantity > 0 ? totalFeedQuantity / avg(feedTime) : 0; break; } case Metric.timeFeeding: { - tournamentValue = avg(calculateTimeMetric(sr, "FEEDING")); + matchValue = avg(calculateTimeMetric(sr, "FEEDING")); break; } case Metric.outpostIntakes: { - const perMatch = sr.map( + const perReport = sr.map( (r) => r.events.filter( (e) => e.action === "INTAKE" && e.position === "OUTPOST", ).length, ); - tournamentValue = avg(perMatch); + matchValue = avg(perReport); break; } default: { - const perMatch = sr.map( + const perReport = sr.map( (r) => r.events.filter((e) => e.action === metricToEvent[metric]) .length, ); - tournamentValue = avg(perMatch); + matchValue = avg(perReport); } } - resultsByTeam[team].push(tournamentValue); + resultsByTeamAndTournament[team][tnmt].push(matchValue); } finalResults[String(metric)] = {}; for (const team of args.teams) { - const teamResults = resultsByTeam[team]; + // First average within each tournament, then apply weighted average across tournaments + const tournamentAverages: number[] = []; + for (const values of Object.values(resultsByTeamAndTournament[team])) { + const valid = values.filter((v) => v !== -1); + if (valid.length > 0) tournamentAverages.push(avg(valid)); + } finalResults[String(metric)][String(team)] = - teamResults.length > 0 ? weightedTourAvgLeft(teamResults) : -1; + tournamentAverages.length > 0 + ? weightedTourAvgLeft(tournamentAverages) + : -1; } } diff --git a/src/handler/analysis/csv/getTeamCSV.ts b/src/handler/analysis/csv/getTeamCSV.ts index b542206..8d67324 100644 --- a/src/handler/analysis/csv/getTeamCSV.ts +++ b/src/handler/analysis/csv/getTeamCSV.ts @@ -1,4 +1,5 @@ import { Response } from "express"; +import axios from "axios"; import prismaClient from "../../../prismaClient.js"; import { AuthenticatedRequest } from "../../../lib/middleware/requireAuth.js"; import { stringify } from "csv-stringify/sync"; @@ -181,6 +182,195 @@ export const getTeamCSV = async ( }); if (datapoints.length === 0) { + // No TeamMatchData found - try to fetch teams from TBA and get their reports from all tournaments + try { + const url = "https://www.thebluealliance.com/api/v3"; + const teamsResponse = await axios.get( + `${url}/event/${params.data.tournamentKey}/teams/simple`, + { + headers: { + "X-TBA-Auth-Key": process.env.TBA_KEY, + }, + }, + ); + + if (teamsResponse.data && teamsResponse.data.length > 0) { + const teams = (teamsResponse.data as { team_number: number }[]).map( + (t) => t.team_number + ); + + // Build the data source filters + const parsedTeamRule = dataSourceRuleSchema(z.number()).safeParse( + req.user?.teamSourceRule, + ); + const teamFilter = parsedTeamRule.success + ? dataSourceRuleToPrismaFilter(parsedTeamRule.data) + : undefined; + + const parsedTournamentRule = dataSourceRuleSchema(z.string()).safeParse( + req.user?.tournamentSourceRule, + ); + const tournamentFilter = parsedTournamentRule.success + ? dataSourceRuleToPrismaFilter(parsedTournamentRule.data) + : undefined; + + // Fetch all scout reports for these teams from filtered tournaments + const allReports = await prismaClient.scoutReport.findMany({ + where: { + teamMatchData: { + teamNumber: { in: teams }, + ...(tournamentFilter ? { tournamentKey: tournamentFilter } : {}), + }, + ...(teamFilter ? { scouter: { sourceTeamNumber: teamFilter } } : {}), + }, + select: { + uuid: true, + robotRoles: true, + accuracy: true, + endgameClimb: true, + autoClimb: true, + fieldTraversal: true, + driverAbility: true, + defenseEffectiveness: true, + beached: true, + climbSide: true, + climbPosition: true, + scoresWhileMoving: true, + disrupts: true, + feederTypes: true, + intakeType: true, + teamMatchData: { + select: { + teamNumber: true, + }, + }, + events: { + where: eventTimeFilter, + select: { + time: true, + action: true, + position: true, + points: true, + }, + }, + }, + }); + + if (allReports.length === 0) { + res.status(400).send("Not enough scouting data from provided sources"); + return; + } + + // Group reports by team number + const groupedByTeam: Record = {}; + + for (const report of allReports) { + const teamNum = report.teamMatchData.teamNumber; + if (!groupedByTeam[teamNum]) { + groupedByTeam[teamNum] = { reports: [], numMatches: 0 }; + } + groupedByTeam[teamNum].reports.push({ + ...report, + weight: 1, + }); + } + + // Count unique matches per team + const matchCounts = await prismaClient.teamMatchData.groupBy({ + by: ['teamNumber'], + where: { + teamNumber: { in: teams }, + ...(tournamentFilter ? { tournamentKey: tournamentFilter } : {}), + scoutReports: { + some: teamFilter + ? { scouter: { sourceTeamNumber: teamFilter } } + : {}, + }, + }, + _count: { + key: true, + }, + }); + + for (const count of matchCounts) { + if (groupedByTeam[count.teamNumber]) { + groupedByTeam[count.teamNumber].numMatches = count._count.key; + } + } + + // Aggregate data for teams with reports + const teamsWithReports = teams.filter((t: number) => groupedByTeam[t]?.reports?.length > 0); + + if (teamsWithReports.length === 0) { + res.status(400).send("Not enough scouting data from provided sources"); + return; + } + + // Compute metrics using averageManyFast + const metrics: Metric[] = [ + Metric.totalPoints, + Metric.autoPoints, + Metric.teleopPoints, + Metric.driverAbility, + Metric.accuracy, + Metric.defenseEffectiveness, + Metric.fuelPerSecond, + Metric.volleysPerMatch, + Metric.l1StartTime, + Metric.l2StartTime, + Metric.l3StartTime, + Metric.autoClimbStartTime, + Metric.contactDefenseTime, + Metric.campingDefenseTime, + Metric.totalDefenseTime, + Metric.timeFeeding, + Metric.feedingRate, + Metric.feedsPerMatch, + Metric.totalFuelOutputted, + Metric.totalBallsFed, + Metric.totalBallThroughput, + Metric.outpostIntakes, + ]; + + const fast = (await averageManyFast(req.user, { + teams: teamsWithReports, + metrics, + })) as Record>; + + // Aggregate data + const aggregatedData: AggregatedTeamData[] = await Promise.all( + teamsWithReports.map((teamNum: number) => { + const group = groupedByTeam[teamNum]; + return aggregateTeamReports( + teamNum, + group.numMatches, + group.reports, + includeAuto, + includeTeleop, + fast, + ); + }), + ); + + const csvString = stringify(aggregatedData, { + header: true, + columns: aggregatedData.length ? Object.keys(aggregatedData[0]) : [], + bom: true, + cast: { + boolean: (b) => (b ? "TRUE" : "FALSE"), + }, + quote: false, + }); + + res.attachment("teamDataDownload.csv"); + res.header("Content-Type", "text/csv"); + res.send(csvString); + return; + } + } catch (error) { + console.error("Error fetching teams from TBA:", error); + } + res.status(400).send("Not enough scouting data from provided sources"); return; } diff --git a/src/handler/manager/scoutershifts/generateSchedule.ts b/src/handler/manager/scoutershifts/generateSchedule.ts new file mode 100644 index 0000000..b919a5e --- /dev/null +++ b/src/handler/manager/scoutershifts/generateSchedule.ts @@ -0,0 +1,266 @@ +import axios from "axios"; +import z, { ZodError } from "zod"; +import prismaClient from "../../../prismaClient.js"; +// import { writeFileSync } from "fs"; +// import { join } from "path"; +// import { homedir } from "os"; +import { AuthenticatedRequest } from "../../../lib/middleware/requireAuth.js"; +import { Response } from "express"; + +const shiftScouterEx = [ + ["Christian", "Oren", "Ben"], + ["Gabi", "Jasmeh", "Colin"], +]; + +const generateSchedule = async ( + tournamentKey: string, + shiftScouters?: string[][], +) => { + shiftScouters = shiftScouters || shiftScouterEx; + if (tournamentKey === undefined) { + throw "tournament key is undefined"; + } + + if (!tournamentKey.startsWith("2026")) { + return; + } + + // console.log("generating schedule for " + tournamentKey); + + const url = "https://www.thebluealliance.com/api/v3"; + const tournamentRow = await prismaClient.tournament.findUnique({ + where: { + key: tournamentKey, + }, + }); + + if (tournamentRow === null) { + throw "tournament not found when trying to insert tournament matches"; + } + + const eventResponse = await fetch(`${url}/event/${tournamentKey}`, { + headers: { "X-TBA-Auth-Key": process.env.TBA_KEY }, + }); + + const json = await eventResponse.json(); + + const { remap_teams } = z + .object({ + remap_teams: z + .record(z.string(), z.string()) + .or(z.null()) + .transform((v) => v ?? {}), + }) + .parse(json); + + let matchesResponse = null; + let teamsResponse = null; + // console.log("fetching matches for " + tournamentKey); + try { + matchesResponse = await axios.get(`${url}/event/${tournamentKey}/matches`, { + headers: { + "X-TBA-Auth-Key": process.env.TBA_KEY, + }, + }); + teamsResponse = await axios.get(`${url}/event/${tournamentKey}/teams`, { + headers: { + "X-TBA-Auth-Key": process.env.TBA_KEY, + }, + }); + } catch (error) { + if (axios.isAxiosError(error) && error.response?.status === 304) { + return; + } else { + throw error; + } + } + + await prismaClient.tournament.update({ + where: { + key: tournamentKey, + }, + data: { + latestFetchETag: matchesResponse.headers.etag, + }, + }); + + matchesResponse.data = matchesResponse.data.filter( + (match: any) => match.comp_level === "qm", + ); + + matchesResponse.data.sort( + (a: any, b: any) => a.match_number - b.match_number, + ); + + const gaps = await getScheduleGaps(matchesResponse.data); + + const shifts = await buildShifts(gaps, shiftScouters); + + const lastEODMatch = gaps + .filter((gap) => gap.type === "EOD") + .slice(-1)[0].match_number; + + matchesResponse.data = matchesResponse.data.filter( + (match: any) => match.match_number <= lastEODMatch, + ); + + const teams: Record = Object.fromEntries( + teamsResponse.data + .map((team: any) => [team.team_number, 0]) + .sort((a: number[], b: number[]) => a[0] - b[0]), + ); + + const csvRows = [ + "Match Number,Scouter 1,Team,Scouter 2,Team,Scouter 3,Team,Expected Time", + ]; + + for (const match of matchesResponse.data) { + const gap = gaps.find((g) => g.match_number === match.match_number - 1); + const newShift = shifts.find((g) => g.end === match.match_number - 1); + if (gap) { + csvRows.push(`${gap.type},,,,,,,`); + } else if (newShift) { + csvRows.push(`SHIFT CHANGE,,,,,,,`); + } + + const shift = shifts.find( + (s) => match.match_number >= s.start && match.match_number <= s.end, + ); + const teamNumbers = [ + ...match.alliances.red.team_keys, + ...match.alliances.blue.team_keys, + ].map((teamKey: string) => { + if (remap_teams[teamKey]) { + return parseInt(remap_teams[teamKey].replace("frc", "")); + } + return parseInt(teamKey.replace("frc", "")); + }); + + for (const t of teamNumbers) { + if (teams[t] === undefined) teams[t] = 0; + } + + const top3 = [...teamNumbers] + .sort((a, b) => teams[a] - teams[b]) + .slice(0, 3); + + const s0 = top3[match.match_number % 3]; + const s1 = top3[(match.match_number + 1) % 3]; + const s2 = top3[(match.match_number + 2) % 3]; + + csvRows.push( + `${match.match_number},${shift.scouters[0]},${s0},${shift.scouters[1]},${s1},${shift.scouters[2]},${s2},${new Date( + match.time * 1000, + ).toLocaleTimeString("en-US", { + weekday: "short", + hour: "2-digit", + minute: "2-digit", + })}`, + ); + + for (const t of top3) { + teams[t]++; + } + } + + csvRows.push("EOD,,,,,,,"); + + // console.log(csvRows.join("\n")); + + // writeFileSync( + // join(homedir(), "Downloads", "temp", `${tournamentKey}_schedule.csv`), + // csvRows.join("\n"), + // ); + + return csvRows.join("\n"); +}; + +export const superScoutingSchedule = async ( + req: AuthenticatedRequest, + res: Response, +) => { + try { + const params = z.object({ tournamentKey: z.string() }).parse(req.query); + + const schedule = await generateSchedule(params.tournamentKey); + + res.status(200).send(schedule); + } catch (error) { + if (error instanceof ZodError) { + res.status(400).send("Bad input"); + } else { + res.status(500).send("Internal server error"); + } + } +}; + +interface Gap { + match_number: number; + gap: number; + type: "LUNCH" | "EOD"; +} + +interface Period { + start: number; + end: number; +} + +interface Shift { + start: number; + end: number; + scouters: string[]; +} + +const optimalMatches = 10; + +const getScheduleGaps = async (matches: any[]) => { + let previousTime = matches[0].time; + const gaps: Gap[] = []; + for (const match of matches) { + const gap = match.time - previousTime; + if (gap > 1000) { + gaps.push({ + match_number: match.match_number - 1, + gap: gap, + type: gap < 10000 ? "LUNCH" : "EOD", + }); + } + previousTime = match.time; + } + // console.log(gaps); + return gaps; +}; + +const buildShifts = (gaps: Gap[], shiftScouters: string[][] = []) => { + let periodStart = 1; + const periods: Period[] = []; + const shifts: Shift[] = []; + for (const gap of gaps) { + periods.push({ + start: periodStart, + end: gap.match_number, + }); + periodStart = gap.match_number + 1; + } + // console.log(periods.length); + + for (const period of periods) { + const periodLength = period.end - period.start + 1; + // console.log(periodLength); + const numShifts = Math.ceil(periodLength / optimalMatches); + // console.log(numShifts); + const shiftLength = Math.ceil(periodLength / numShifts); + // console.log(shiftLength); + for (let i = 0; i < numShifts; i++) { + shifts.push({ + start: period.start + i * shiftLength, + end: Math.min(period.start + (i + 1) * shiftLength - 1, period.end), + scouters: shiftScouters[i % shiftScouters.length], + }); + } + } + + // console.log(shifts); + + return shifts; +}; diff --git a/src/routes/manager/scoutershifts.routes.ts b/src/routes/manager/scoutershifts.routes.ts index adfb18b..d514720 100644 --- a/src/routes/manager/scoutershifts.routes.ts +++ b/src/routes/manager/scoutershifts.routes.ts @@ -5,6 +5,7 @@ import { deleteScouterShift } from "../../handler/manager/scoutershifts/deleteSc import { registry } from "../../lib/openapi.js"; import { z } from "zod"; import { requireVerifiedTeam } from "../../lib/middleware/requireVerifiedTeam.js"; +import { superScoutingSchedule } from "../../handler/manager/scoutershifts/generateSchedule.js"; const router = Router(); @@ -61,6 +62,8 @@ registry.registerPath({ router.use(requireAuth, requireVerifiedTeam); +router.get("/generate", superScoutingSchedule); + router.post("/:uuid", updateScouterShift); router.delete("/:uuid", deleteScouterShift);