diff --git a/apps/client/src/hooks/useEvent.ts b/apps/client/src/hooks/useEvent.ts index 267287e0..8878d9db 100644 --- a/apps/client/src/hooks/useEvent.ts +++ b/apps/client/src/hooks/useEvent.ts @@ -37,6 +37,11 @@ const GET_EVENT = gql` name length climb + classLegs { + legNumber + length + climb + } } user { id diff --git a/apps/client/src/pages/Event/EventResultsView.tsx b/apps/client/src/pages/Event/EventResultsView.tsx index 0128d4f0..4852f3fd 100644 --- a/apps/client/src/pages/Event/EventResultsView.tsx +++ b/apps/client/src/pages/Event/EventResultsView.tsx @@ -2,7 +2,6 @@ import { Sheet, SheetContent, SheetDescription, - SheetHeader, SheetTitle, SheetTrigger, } from '@/components/ui/sheet'; @@ -22,7 +21,7 @@ import { useNavigate } from '@tanstack/react-router'; import { TFunction } from 'i18next'; import { ChevronDown, Loader2, Radio, Trophy, Users } from 'lucide-react'; import { motion } from 'motion/react'; -import React, { useEffect, useRef, useState } from 'react'; +import React, { useEffect, useMemo, useRef, useState } from 'react'; import { Badge, Button, CountryFlag, Tooltip } from '../../components/atoms'; import { Alert } from '../../components/organisms'; import { CompetitorName, getMobileCompetitorName } from './CompetitorName'; @@ -54,6 +53,12 @@ const COMPETITORS_BY_CLASS_UPDATED = gql` status lateStart note + leg + teamId + team { + id + name + } } } `; @@ -105,36 +110,11 @@ const COMPETITORS_BY_ORGANISATION = gql` } `; -const RELAY_RESULTS_UPDATED = gql` - subscription RelayResultsUpdated($eventId: String!, $classId: Int!) { - relayResultsUpdated(eventId: $eventId, classId: $classId) { - id - rank - teamName - club - countryCode - totalTime - behind - legs { - legNumber - runnerName - time - rank - status - } - } - } -`; - // Type definitions interface CompetitorsByClassUpdatedResponse { competitorsByClassUpdated: Competitor[]; } -interface RelayResultsUpdatedResponse { - relayResultsUpdated: RelayResult[]; -} - interface EventResultsViewProps { t: TFunction; event: Event; @@ -162,6 +142,9 @@ interface Competitor { status: string; lateStart?: boolean; note?: string; + leg?: number; + teamId?: number; + team?: { id: number; name: string } | null; } interface ProcessedCompetitor extends Competitor { @@ -204,23 +187,6 @@ interface ProcessedClubResult { runners: ClubRunner[]; } -interface RelayResult { - id: string; - rank: number; - teamName: string; - club: string; - countryCode: string; - totalTime: string; - behind?: string; - legs: { - legNumber: number; - runnerName: string; - time: string; - rank: number; - status: 'finished' | 'running' | 'not_started'; - }[]; -} - export const EventResultsView = ({ t, event }: EventResultsViewProps) => { const isRelay = event.relay; const navigate = useNavigate(); @@ -294,10 +260,10 @@ export const EventResultsView = ({ t, event }: EventResultsViewProps) => { if (isRelay) { return ( cls.name) || []} /> ); } @@ -1490,191 +1456,751 @@ const ClubResultsView = ({ ); }; +// ─── Relay overview ────────────────────────────────────────────────────────── + +interface TeamLegResult { + legNumber: number; + runner: Competitor; + legTime?: number; + legRank?: number; + legLoss?: number; + cumulativeTime?: number; + cumulativeRank?: number; + cumulativeLoss?: number; + positionChange?: number; +} + +interface TeamResult { + teamId: number; + teamName: string; + club: string; + finalRank?: number; + totalTime?: number; + timeDiff?: number; + legs: TeamLegResult[]; +} + +const computeRelayOverall = ( + allCompetitors: Competitor[], + maxLeg: number, +): TeamResult[] => { + if (maxLeg === 0 || allCompetitors.length === 0) return []; + + const teamMap = new Map(); + for (const c of allCompetitors) { + if (c.teamId == null) continue; + if (!teamMap.has(c.teamId)) teamMap.set(c.teamId, []); + teamMap.get(c.teamId)!.push(c); + } + if (teamMap.size === 0) return []; + + const legBestTimes = new Map(); + const legRankById = new Map(); + for (let leg = 1; leg <= maxLeg; leg++) { + const legRunners = allCompetitors + .filter(c => c.leg === leg && c.status === 'OK' && c.time != null) + .sort((a, b) => (a.time ?? 0) - (b.time ?? 0)); + const firstTime = legRunners[0]?.time; + if (firstTime != null) legBestTimes.set(leg, firstTime); + let pos = 1; + for (let i = 0; i < legRunners.length; i++) { + const cur = legRunners[i]!; + const prev = i > 0 ? legRunners[i - 1] : undefined; + const prevRank = prev ? (legRankById.get(prev.id) ?? pos) : undefined; + const rank = + prev && cur.time === prev.time && prevRank != null ? prevRank : pos; + legRankById.set(cur.id, rank); + pos++; + } + } + + const teamCumulByLeg = new Map>(); + for (const [teamId, runners] of teamMap) { + const byLeg = new Map(); + for (const r of runners) { + if (r.leg != null) byLeg.set(r.leg, r); + } + const cumul = new Map(); + let total = 0; + let broken = false; + for (let leg = 1; leg <= maxLeg; leg++) { + if (broken) continue; + const runner = byLeg.get(leg); + if (!runner || runner.status !== 'OK' || runner.time == null) { + broken = true; + continue; + } + total += runner.time; + cumul.set(leg, total); + } + teamCumulByLeg.set(teamId, cumul); + } + + const cumulRankKey = (tid: number, leg: number) => `${tid}_${leg}`; + const cumulRankMap = new Map(); + const legLeaderCumulTime = new Map(); + for (let leg = 1; leg <= maxLeg; leg++) { + const entries: { teamId: number; time: number }[] = []; + for (const [teamId, cumul] of teamCumulByLeg) { + const t = cumul.get(leg); + if (t != null) entries.push({ teamId, time: t }); + } + entries.sort((a, b) => a.time - b.time); + const firstEntry = entries[0]; + if (firstEntry != null) legLeaderCumulTime.set(leg, firstEntry.time); + let pos = 1; + for (let i = 0; i < entries.length; i++) { + const cur = entries[i]!; + const prev = i > 0 ? entries[i - 1] : undefined; + const prevRank = prev + ? (cumulRankMap.get(cumulRankKey(prev.teamId, leg)) ?? pos) + : undefined; + const rank = + prev && cur.time === prev.time && prevRank != null ? prevRank : pos; + cumulRankMap.set(cumulRankKey(cur.teamId, leg), rank); + pos++; + } + } + + const results: TeamResult[] = []; + for (const [teamId, runners] of teamMap) { + const byLeg = new Map(); + for (const r of runners) { + if (r.leg != null) byLeg.set(r.leg, r); + } + const cumul = teamCumulByLeg.get(teamId) ?? new Map(); + const totalTime = cumul.get(maxLeg); + const finalRank = cumulRankMap.get(cumulRankKey(teamId, maxLeg)); + const leaderFinalTime = legLeaderCumulTime.get(maxLeg); + const timeDiff = + totalTime != null && leaderFinalTime != null && totalTime > leaderFinalTime + ? totalTime - leaderFinalTime + : undefined; + + const sortedRunners = [...byLeg.entries()] + .sort(([a], [b]) => a - b) + .map(([, r]) => r); + const firstRunner = sortedRunners[0] ?? runners[0]!; + const teamName = firstRunner.team?.name ?? String(teamId); + const club = firstRunner.organisation ?? ''; + + const legs: TeamLegResult[] = []; + for (let leg = 1; leg <= maxLeg; leg++) { + const runner = byLeg.get(leg); + if (!runner) continue; + const legTime = + runner.status === 'OK' ? (runner.time ?? undefined) : undefined; + const legBest = legBestTimes.get(leg); + const legRank = legRankById.get(runner.id); + const legLoss = + legTime != null && legBest != null && legTime > legBest + ? legTime - legBest + : undefined; + const cumulTime = cumul.get(leg); + const cumulRank = cumulRankMap.get(cumulRankKey(teamId, leg)); + const prevCumulRank = + leg > 1 + ? cumulRankMap.get(cumulRankKey(teamId, leg - 1)) + : undefined; + const positionChange = + cumulRank != null && prevCumulRank != null + ? cumulRank - prevCumulRank + : undefined; + const leaderCumul = legLeaderCumulTime.get(leg); + const cumulativeLoss = + cumulTime != null && leaderCumul != null && cumulTime > leaderCumul + ? cumulTime - leaderCumul + : undefined; + + const legResult: TeamLegResult = { legNumber: leg, runner }; + if (legTime !== undefined) legResult.legTime = legTime; + if (legRank !== undefined) legResult.legRank = legRank; + if (legLoss !== undefined) legResult.legLoss = legLoss; + if (cumulTime !== undefined) legResult.cumulativeTime = cumulTime; + if (cumulRank !== undefined) legResult.cumulativeRank = cumulRank; + if (positionChange !== undefined) legResult.positionChange = positionChange; + if (cumulativeLoss !== undefined) legResult.cumulativeLoss = cumulativeLoss; + legs.push(legResult); + } + + const teamResult: TeamResult = { teamId, teamName, club, legs }; + if (finalRank !== undefined) teamResult.finalRank = finalRank; + if (totalTime !== undefined) teamResult.totalTime = totalTime; + if (timeDiff !== undefined) teamResult.timeDiff = timeDiff; + results.push(teamResult); + } + + results.sort((a, b) => { + if (a.finalRank != null && b.finalRank != null) return a.finalRank - b.finalRank; + if (a.finalRank != null) return -1; + if (b.finalRank != null) return 1; + return ( + b.legs.filter(l => l.cumulativeTime != null).length - + a.legs.filter(l => l.cumulativeTime != null).length + ); + }); + + return results; +}; + +const relayStatusEmoji: Record = { + Active: '🏃', + DidNotFinish: '🏳️', + DidNotStart: '🚷', + Disqualified: '🟥', + Finished: '🏁', + Inactive: '🛏️', + MissingPunch: '🙈', + NotCompeting: '🦄', + OverTime: '⌛', +}; + +const relayStatusTooltip: Record = { + Active: 'Giving it their all right now', + DidNotFinish: 'Did Not Finish', + DidNotStart: 'Did Not Start', + Disqualified: 'Disqualified', + Finished: 'Waiting for readout', + Inactive: 'Waiting for start time', + MissingPunch: 'Missing Punch', + NotCompeting: 'Not competing', + OverTime: 'Over Time', +}; + +const PositionChange: React.FC<{ change: number }> = ({ change }) => { + if (change === 0) { + return ( + + — + + ); + } + if (change < 0) { + return ( + + ▲{Math.abs(change)} + + ); + } + return ( + + ▼{change} + + ); +}; + +const RelayOverallView: React.FC<{ teams: TeamResult[] }> = ({ teams }) => { + if (teams.length === 0) return null; + + return ( +
+
+ + + + # + Name + + Club + + Leg + + +Leg + + Time + +Time + + + + {teams.map((team, teamIndex) => ( + + + + {team.finalRank != null ? `${team.finalRank}.` : '–'} + + +
+ {team.teamName} + + {team.club} + +
+
+ + {team.club} + + + + + {team.totalTime != null + ? formatSecondsToTime(team.totalTime) + : '–'} + + + {team.timeDiff != null && team.timeDiff > 0 + ? `+${formatSecondsToTime(team.timeDiff)}` + : ''} + +
+ {team.legs.map(leg => ( + + + + + {leg.runner.registration} + + {leg.runner.firstname} {leg.runner.lastname} + + + + {leg.legTime != null ? ( + <> + {formatSecondsToTime(leg.legTime)} + {leg.legRank != null && ( + + ({leg.legRank}.) + + )} + + ) : ( + + {relayStatusEmoji[leg.runner.status] ?? '–'} + + )} + + + {leg.legLoss != null + ? `+${formatSecondsToTime(leg.legLoss)}` + : ''} + + + {leg.cumulativeTime != null ? ( + + {leg.positionChange != null && + leg.positionChange !== 0 && ( + + )} + {formatSecondsToTime(leg.cumulativeTime)} + {leg.cumulativeRank != null && ( + + ({leg.cumulativeRank}.) + + )} + + ) : ( + '–' + )} + + + {leg.cumulativeLoss != null + ? `+${formatSecondsToTime(leg.cumulativeLoss)}` + : ''} + + + ))} +
+ ))} +
+
+
+
+ ); +}; + // Relay Results Component +interface RelayLegCompetitor extends Competitor { + cumulativeTime?: number; + position?: number | string; + positionTooltip?: string; + loss?: number; +} + +const processRelayLegCompetitors = ( + legCompetitors: Competitor[], + selectedLeg: number, + teamTimeMap: Map>, +): RelayLegCompetitor[] => { + const getCumulativeTime = (c: Competitor): number | undefined => { + if (selectedLeg === 1) return c.time ?? undefined; + if (c.teamId == null || c.leg == null) return c.time ?? undefined; + const legMap = teamTimeMap.get(c.teamId); + if (!legMap) return undefined; + let total = 0; + for (let l = 1; l <= c.leg; l++) { + const t = legMap.get(l); + if (t == null) return undefined; + total += t; + } + return total; + }; + + const statusPriority: Record = { + OK: 0, + Active: 1, + Finished: 2, + Inactive: 3, + NotCompeting: 4, + OverTime: 5, + Disqualified: 6, + MissingPunch: 7, + DidNotFinish: 8, + DidNotStart: 9, + }; + + type WithCumulative = Competitor & { cumulativeTime?: number }; + + const withCumulative: WithCumulative[] = legCompetitors.map(c => { + const entry: WithCumulative = { ...c }; + const ct = getCumulativeTime(c); + if (ct !== undefined) entry.cumulativeTime = ct; + return entry; + }); + + withCumulative.sort((a, b) => { + if (a.status === 'OK' && b.status === 'OK') { + return (a.cumulativeTime ?? Infinity) - (b.cumulativeTime ?? Infinity); + } + if (a.status === 'OK') return -1; + if (b.status === 'OK') return 1; + const pa = statusPriority[a.status] ?? 10; + const pb = statusPriority[b.status] ?? 10; + if (pa !== pb) return pa - pb; + const sa = a.startTime ? new Date(a.startTime).getTime() : Infinity; + const sb = b.startTime ? new Date(b.startTime).getTime() : Infinity; + return sa - sb; + }); + + const okCompetitors = withCumulative.filter(c => c.status === 'OK'); + const leaderTime = okCompetitors[0]?.cumulativeTime ?? null; + + let position = 1; + const posMap = new Map(); + for (let i = 0; i < okCompetitors.length; i++) { + const cur = okCompetitors[i]!; + const prev = i > 0 ? okCompetitors[i - 1] : undefined; + const curT = cur.cumulativeTime ?? -1; + const prevT = prev?.cumulativeTime ?? -1; + const assignedPos = + prev && curT === prevT ? (posMap.get(prev.id) ?? position) : position; + posMap.set(cur.id, assignedPos); + position++; + } + + const statusEmojis: Record = { + Active: ['🏃', 'Giving it their all right now'], + DidNotFinish: ['🏳️', 'Did Not Finish'], + DidNotStart: ['🚷', 'Did not start'], + Disqualified: ['🟥', 'Disqualified'], + Finished: ['🏁', 'Waiting for readout'], + Inactive: ['🛏️', 'Waiting for start time'], + MissingPunch: ['🙈', 'Missing Punch'], + NotCompeting: ['🦄', 'Not competing'], + OverTime: ['⌛', 'Over Time'], + }; + + return withCumulative.map(c => { + const pos = posMap.get(c.id); + if (pos !== undefined) { + const loss = + leaderTime != null && c.cumulativeTime != null + ? c.cumulativeTime - leaderTime + : undefined; + const result: RelayLegCompetitor = { ...c, position: pos }; + if (loss !== undefined && loss > 0) result.loss = loss; + return result; + } + const [emoji, tooltip] = statusEmojis[c.status] ?? ['❓', 'Unknown status']; + return { ...c, position: emoji, positionTooltip: tooltip } as RelayLegCompetitor; + }); +}; + interface RelayResultsViewProps { + t: TFunction; event: Event; selectedClass: string; setSelectedClass: (cls: string) => void; - availableClasses: string[]; } const RelayResultsView = ({ + t, event, selectedClass, setSelectedClass, - availableClasses, }: RelayResultsViewProps) => { - const [expandedTeam, setExpandedTeam] = useState(null); - const [isSheetOpen, setIsSheetOpen] = useState(false); - const [relayResults, setRelayResults] = useState([]); + const [selectedTab, setSelectedTab] = useState<'overall' | number>('overall'); const navigate = useNavigate(); - const selectedClassId = event.classes?.find( - cls => cls.name === selectedClass - )?.id; + const currentClass = event.classes?.find(cls => cls.name === selectedClass); + const selectedClassId = currentClass?.id; - const { loading, data } = useSubscription( - RELAY_RESULTS_UPDATED, - { - variables: { - eventId: event.id, - classId: selectedClassId, - }, - skip: !selectedClassId, - } + const { loading, error, data } = + useSubscription( + COMPETITORS_BY_CLASS_UPDATED, + { variables: { classId: selectedClassId }, skip: !selectedClassId }, + ); + + useEffect(() => { + setSelectedTab('overall'); + }, [selectedClassId]); + + const allCompetitors = data?.competitorsByClassUpdated ?? []; + + const maxLeg = useMemo( + () => allCompetitors.reduce((max, c) => Math.max(max, c.leg ?? 1), 0), + [allCompetitors], ); - // Handler pro změnu třídy - const handleClassChange = (cls: string) => { - setSelectedClass(cls); - const newSearchParams = new URLSearchParams(window.location.search); - newSearchParams.set('class', cls); + const legs = useMemo( + () => (maxLeg > 0 ? Array.from({ length: maxLeg }, (_, i) => i + 1) : []), + [maxLeg], + ); + + const teamResults = useMemo( + () => computeRelayOverall(allCompetitors, maxLeg), + [allCompetitors, maxLeg], + ); + + const teamTimeMap = useMemo(() => { + const map = new Map>(); + for (const c of allCompetitors) { + if (c.teamId != null && c.leg != null && c.time != null) { + if (!map.has(c.teamId)) map.set(c.teamId, new Map()); + map.get(c.teamId)!.set(c.leg, c.time); + } + } + return map; + }, [allCompetitors]); + + const selectedLeg = typeof selectedTab === 'number' ? selectedTab : null; + + const processedLegCompetitors = useMemo(() => { + if (selectedLeg == null) return []; + const legComps = allCompetitors.filter(c => (c.leg ?? 1) === selectedLeg); + return processRelayLegCompetitors(legComps, selectedLeg, teamTimeMap); + }, [allCompetitors, selectedLeg, teamTimeMap]); + + const legPositionChangeMap = useMemo(() => { + if (selectedLeg == null || selectedLeg <= 1) return new Map(); + const map = new Map(); + for (const team of teamResults) { + const legResult = team.legs.find(l => l.legNumber === selectedLeg); + if (legResult?.positionChange != null) { + map.set(legResult.runner.id, legResult.positionChange); + } + } + return map; + }, [teamResults, selectedLeg]); + + const hasContent = + selectedTab === 'overall' + ? teamResults.length > 0 + : processedLegCompetitors.length > 0; + const handleClassChange = (classId: number) => { + const cls = event.classes?.find(c => c.id === classId); + if (!cls) return; + const newSearchParams = new URLSearchParams(window.location.search); + newSearchParams.set('class', cls.name); navigate({ to: window.location.pathname, search: Object.fromEntries(newSearchParams), replace: true, }); - setIsSheetOpen(false); + setSelectedClass(cls.name); }; - useEffect(() => { - if (data?.relayResultsUpdated) { - setRelayResults(data.relayResultsUpdated); - } - }, [data]); - return ( -
-
-

Relay Results

- - - - - - - - Select Class - - Choose a competition class from the available options - - -
- {availableClasses.map(cls => ( +
+
+
+
+ {(() => { + const totalLength = currentClass?.classLegs?.reduce( + (sum, l) => sum + (l.length ?? 0), 0, + ) ?? 0; + return ( - ))} -
- - + ); + })()} + {legs.map(leg => { + const legCourse = currentClass?.classLegs?.find(l => l.legNumber === leg); + return ( + + ); + })} +
+ {event.classes && currentClass && ( + + )} +
- {loading && relayResults.length === 0 && ( + {loading && !hasContent && (
- Loading relay results... + {t('Pages.Event.Results.Loading')}
)} -
- {relayResults.map(result => ( -
-
- setExpandedTeam(expandedTeam === result.id ? null : result.id) - } - > -
-
- - {result.rank === 1 && '🥇'} - {result.rank === 2 && '🥈'} - {result.rank === 3 && '🥉'} - {result.rank > 3 && result.rank} - -
-
{result.teamName}
-
- - {result.club} -
-
-
-
-
- {result.totalTime} -
- {result.behind && ( -
- {result.behind} -
- )} -
-
-
+ {error && ( + + {error.message} + + )} - {expandedTeam === result.id && ( -
- - - - Leg - Runner - Time - Rank - - - - {result.legs.map(leg => ( - - - {leg.legNumber} - - {leg.runnerName} - - {leg.time} - - - {leg.rank === 1 ? ( - - {leg.rank} - - ) : ( - - {leg.rank} - - )} - - - ))} - -
-
- )} + {!loading && !error && !hasContent && ( + + {t('Pages.Event.Alert.EventDataNotAvailableMessage', { + view: t('Pages.Event.Alert.ViewResults'), + })} + + )} + + {selectedTab === 'overall' && } + + {typeof selectedTab === 'number' && processedLegCompetitors.length > 0 && ( +
+
+ + + + # + Name + + Club + + + Leg + + {selectedLeg != null && selectedLeg > 1 && ( + + Cumul. + + )} + + Diff + + + + + {processedLegCompetitors.map((competitor, index) => ( + + + + + {competitor.position} + {typeof competitor.position === 'number' && '.'} + + {(() => { + const change = legPositionChangeMap.get(competitor.id); + if (change == null || change === 0) return null; + return ; + })()} + + + +
+ + + {competitor.organisation} + +
+
+ + {competitor.organisation} + + + {competitor.time != null + ? formatSecondsToTime(competitor.time) + : '-'} + + {selectedLeg != null && selectedLeg > 1 && ( + + {competitor.cumulativeTime != null + ? formatSecondsToTime(competitor.cumulativeTime) + : '-'} + + )} + + {competitor.loss && competitor.loss > 0 + ? `+${formatSecondsToTime(competitor.loss)}` + : '-'} + +
+ ))} +
+
- ))} -
+
+ )}
); }; diff --git a/apps/client/src/types/event.ts b/apps/client/src/types/event.ts index 5ea98c2d..cf6b0acd 100644 --- a/apps/client/src/types/event.ts +++ b/apps/client/src/types/event.ts @@ -15,6 +15,12 @@ export type EventFilter = | 'popular' | 'nearby'; +export interface EventClassLeg { + legNumber: number; + length?: number | null; + climb?: number | null; +} + export interface EventClass { id: number; name: string; @@ -22,6 +28,7 @@ export interface EventClass { length?: number; climb?: number; controls?: number; + classLegs?: EventClassLeg[]; } export type EventStatusPrimary = 'DRAFT' | 'UPCOMING' | 'LIVE' | 'DONE'; diff --git a/apps/server/prisma/migrations/20260504120000_add_class_leg_table/migration.sql b/apps/server/prisma/migrations/20260504120000_add_class_leg_table/migration.sql new file mode 100644 index 00000000..111b8920 --- /dev/null +++ b/apps/server/prisma/migrations/20260504120000_add_class_leg_table/migration.sql @@ -0,0 +1,15 @@ +-- CreateTable +CREATE TABLE `ClassLeg` ( + `id` INTEGER UNSIGNED NOT NULL AUTO_INCREMENT, + `classId` INTEGER UNSIGNED NOT NULL, + `legNumber` INTEGER UNSIGNED NOT NULL, + `length` INTEGER UNSIGNED NULL, + `climb` INTEGER UNSIGNED NULL, + + INDEX `ClassLeg_classId_idx`(`classId`), + UNIQUE INDEX `ClassLeg_classId_legNumber_key`(`classId`, `legNumber`), + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- AddForeignKey +ALTER TABLE `ClassLeg` ADD CONSTRAINT `ClassLeg_classId_fkey` FOREIGN KEY (`classId`) REFERENCES `Class`(`id`) ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/apps/server/prisma/schema.prisma b/apps/server/prisma/schema.prisma index 683ee887..7b494b77 100644 --- a/apps/server/prisma/schema.prisma +++ b/apps/server/prisma/schema.prisma @@ -183,6 +183,20 @@ model Class { status ClassStatus @default(NORMAL) competitors Competitor[] teams Team[] + classLegs ClassLeg[] +} + +model ClassLeg { + id Int @id @default(autoincrement()) @db.UnsignedInt + classId Int @db.UnsignedInt + legNumber Int @db.UnsignedInt + length Int? @db.UnsignedInt + climb Int? @db.UnsignedInt + + class Class @relation(fields: [classId], references: [id], onDelete: Cascade) + + @@unique([classId, legNumber]) + @@index([classId]) } model Competitor { diff --git a/apps/server/src/graphql/class/index.ts b/apps/server/src/graphql/class/index.ts index 4750a81d..f006b911 100644 --- a/apps/server/src/graphql/class/index.ts +++ b/apps/server/src/graphql/class/index.ts @@ -23,5 +23,11 @@ const resolvers = { where: { classId: parent.id }, }); }, + classLegs(parent) { + return prisma.classLeg.findMany({ + where: { classId: parent.id }, + orderBy: { legNumber: 'asc' }, + }); + }, }, }; diff --git a/apps/server/src/graphql/class/schema.ts b/apps/server/src/graphql/class/schema.ts index a7717061..f3eae5a1 100644 --- a/apps/server/src/graphql/class/schema.ts +++ b/apps/server/src/graphql/class/schema.ts @@ -4,6 +4,12 @@ export const typeDef = /* GraphQL */ ` eventClasses(eventId: String!): [Class!] eventClassesByIds(eventId: String!, ids: [Int!]): [Class!] } + type ClassLeg { + id: Int! + legNumber: Int! + length: Int + climb: Int + } type Class { id: Int! eventId: String! @@ -21,5 +27,6 @@ export const typeDef = /* GraphQL */ ` status: String competitors: [Competitor!] teams: [Team!] + classLegs: [ClassLeg!] } `; diff --git a/apps/server/src/graphql/competitor/shared.ts b/apps/server/src/graphql/competitor/shared.ts index 21dfa1f8..a72cf320 100644 --- a/apps/server/src/graphql/competitor/shared.ts +++ b/apps/server/src/graphql/competitor/shared.ts @@ -11,6 +11,7 @@ async function getCompetitorsByClassBase(classId, includeSplits = false) { where: { classId }, include: { organisation: { select: organisationSelect }, + team: { select: { id: true, name: true } }, ...(includeSplits ? { splits: { diff --git a/apps/server/src/modules/upload/upload.handlers.ts b/apps/server/src/modules/upload/upload.handlers.ts index eec5ca9d..1d97319e 100644 --- a/apps/server/src/modules/upload/upload.handlers.ts +++ b/apps/server/src/modules/upload/upload.handlers.ts @@ -535,6 +535,23 @@ async function upsertTeam( return existingTeam.id; } +async function upsertClassLegCourse( + classId: number, + legSource: unknown, + courseEl: Record, +): Promise { + const legNumber = getIofIntegerValue(legSource); + if (legNumber == null) return; + const length = getIofIntegerValue(courseEl.Length); + const climb = getIofIntegerValue(courseEl.Climb); + if (length == null && climb == null) return; + await prisma.classLeg.upsert({ + where: { classId_legNumber: { classId, legNumber } }, + update: { length, climb }, + create: { classId, legNumber, length, climb }, + }); +} + /** * Processes class starts for an event. * @@ -641,6 +658,11 @@ async function processClassStarts( const start = [...teamMemberStart.Start].shift(); const leg = [...start.Leg].shift(); + const startCourse = Array.isArray(start.Course) ? start.Course[0] : null; + if (startCourse) { + await upsertClassLegCourse(classId, start.Leg, startCourse); + } + const { updated } = await upsertCompetitor( eventId, classId, @@ -775,6 +797,11 @@ async function processClassResults( return; } + const resultCourse = Array.isArray(result.Course) ? result.Course[0] : null; + if (resultCourse) { + await upsertClassLegCourse(classId, result.Leg, resultCourse); + } + const { id: competitorId, updated } = await upsertCompetitor( eventId, classId, @@ -1122,6 +1149,32 @@ async function handleIofXmlUpload( }); await Promise.all( courseData.map(async (course) => { + const assignment = Array.isArray(course.ClassAssignment) + ? course.ClassAssignment[0] + : null; + const legNumber = assignment?.Leg + ? getIofIntegerValue(assignment.Leg) + : null; + const length = getIofIntegerValue(course.Length); + const climb = getIofIntegerValue(course.Climb); + + // Per-leg course: ClassAssignment carries a Leg number and class name + if (legNumber != null && assignment) { + const className: string = + (Array.isArray(assignment.ClassName) ? assignment.ClassName[0] : null) ?? + (Array.isArray(assignment.ClassShortName) ? assignment.ClassShortName[0] : null) ?? + course.Name[0]; + const classDetails = { Name: [className], Id: [], ATTR: {} }; + const classId = await upsertClass(eventId, classDetails, dbClassLists, {}); + await prisma.classLeg.upsert({ + where: { classId_legNumber: { classId, legNumber } }, + update: { length, climb }, + create: { classId, legNumber, length, climb }, + }); + return; + } + + // Standard (non-relay) course: update the class-level length/climb const classDetails = { Name: [course.Name[0]], Id: [], @@ -1129,8 +1182,8 @@ async function handleIofXmlUpload( }; const additionalData = { ...normalizeCourseMetrics({ - length: getIofIntegerValue(course.Length), - climb: getIofIntegerValue(course.Climb), + length, + climb, controlsCount: Array.isArray(course.CourseControl) ? course.CourseControl.length - 2 : null,