diff --git a/apps/frontend/locale/en.json b/apps/frontend/locale/en.json index 88aa9933e..c61f26b81 100644 --- a/apps/frontend/locale/en.json +++ b/apps/frontend/locale/en.json @@ -223,7 +223,7 @@ "pit-map": "Pit Map" } }, - "judging-head-queuer": { + "judging-head-queuer": { "page-title": "Head Judging Queuer", "no-data": "No data loaded yet. Check in a minute.", "current-sessions": { @@ -1493,6 +1493,15 @@ "success-message": "Awards locked successfully", "error-message": "Failed to lock awards" } + }, + "anomalies": { + "alert-title": "Ranking Anomalies Detected", + "alert-description": "Some teams have significant differences between their picklist position and calculated rank.", + "higher": "Team {teamNumber} was ranked {difference, plural, =1 {one place} other {# places}} higher in the {category} category", + "lower": "Team {teamNumber} was ranked {difference, plural, =1 {one place} other {# places}} lower in the {category} category", + "core-values": "Core Values", + "innovation-project": "Innovation Project", + "robot-design": "Robot Design" } } } diff --git a/apps/frontend/locale/he.json b/apps/frontend/locale/he.json index aebace233..8ae1d0b7c 100644 --- a/apps/frontend/locale/he.json +++ b/apps/frontend/locale/he.json @@ -1490,6 +1490,15 @@ "success-message": "הפרסים ננעלו בהצלחה", "error-message": "נעילה נכשלה." } + }, + "anomalies": { + "alert-title": "אנומליות בדירוג זוהו", + "alert-description": "לכמה קבוצות יש הבדלים משמעותיים בין מיקום הרשימה שלהן לדירוג המחושב.", + "higher": "קבוצה {teamNumber} דורגה {difference, plural, =1 {מקום אחד} other {# מקומות}} גבוה יותר בתחום {category}", + "lower": "קבוצה {teamNumber} דורגה {difference, plural, =1 {מקום אחד} other {# מקומות}} נמוך יותר בתחום {category}", + "core-values": "ערכים ליבה", + "innovation-project": "פרויקט חדשנות", + "robot-design": "עיצוב רובוט" } } } diff --git a/apps/frontend/locale/pl.json b/apps/frontend/locale/pl.json index 5def63ffb..74c7bae9a 100644 --- a/apps/frontend/locale/pl.json +++ b/apps/frontend/locale/pl.json @@ -223,7 +223,7 @@ "pit-map": "Mapa pitów" } }, - "judging-head-queuer": { + "judging-head-queuer": { "page-title": "Główny queuer sędziowania", "no-data": "Brak załadowanych danych. Sprawdź za minutę.", "current-sessions": { @@ -1492,6 +1492,15 @@ "success-message": "Nagrody zostały zablokowane", "error-message": "Nie udało się zablokować nagród" } + }, + "anomalies": { + "alert-title": "Wykryto anomalie w rankingu", + "alert-description": "Niektóre zespoły mają znaczące różnice między pozycją na liście a obliczonym rankingiem.", + "higher": "Zespół {teamNumber} został umieszczony o {difference, plural, =1 {jedno miejsce} other {# miejsca}} wyżej w kategorii {category}", + "lower": "Zespół {teamNumber} został umieszczony o {difference, plural, =1 {jedno miejsce} other {# miejsca}} niżej w kategorii {category}", + "core-values": "Wartości Podstawowe", + "innovation-project": "Projekt Innowacyjny", + "robot-design": "Projekt Robota" } } } diff --git a/apps/frontend/src/app/[locale]/lems/(volunteer)/deliberation/final/components/anomaly-alert.tsx b/apps/frontend/src/app/[locale]/lems/(volunteer)/deliberation/final/components/anomaly-alert.tsx new file mode 100644 index 000000000..f655c6389 --- /dev/null +++ b/apps/frontend/src/app/[locale]/lems/(volunteer)/deliberation/final/components/anomaly-alert.tsx @@ -0,0 +1,144 @@ +'use client'; + +import { useState } from 'react'; +import { Alert, Box, Popover, Stack, Typography, useTheme, alpha } from '@mui/material'; +import { Warning } from '@mui/icons-material'; +import { useTranslations } from 'next-intl'; +import { Anomaly } from '../final-deliberation-computation'; + +const CATEGORY_COLORS: Record = { + 'core-values': '#d32f2f', + 'innovation-project': '#1976d2', + 'robot-design': '#388e3c' +}; + +const CATEGORY_BG_COLORS: Record = { + 'core-values': '#ffebee', + 'innovation-project': '#e3f2fd', + 'robot-design': '#e8f5e8' +}; + +interface AnomalyAlertProps { + anomalies: Anomaly[]; +} + +export const AnomalyAlert: React.FC = ({ anomalies }) => { + const t = useTranslations('pages.deliberations.final.anomalies'); + const theme = useTheme(); + const [anchorEl, setAnchorEl] = useState(null); + + if (!anomalies || anomalies.length === 0) { + return null; + } + + const handleAlertClick = (event: React.MouseEvent) => { + setAnchorEl(event.currentTarget); + }; + + const handleClose = () => { + setAnchorEl(null); + }; + + const open = Boolean(anchorEl); + const popoverId = open ? 'anomaly-popover' : undefined; + + const getAnomalyMessage = (anomaly: Anomaly): string => { + const categoryKey = anomaly.category as 'core-values' | 'innovation-project' | 'robot-design'; + const categoryLabel = t(categoryKey); + const messageKey = anomaly.isHigher ? 'higher' : 'lower'; + + return t(messageKey, { + teamNumber: anomaly.teamNumber, + difference: anomaly.difference, + category: categoryLabel + }); + }; + + return ( + <> + } + onClick={handleAlertClick} + sx={{ + cursor: 'pointer', + mb: 2, + '&:hover': { + backgroundColor: alpha(theme.palette.warning.main, 0.15) + } + }} + > + + + {t('alert-title')} + + {t('alert-description')} + + + + + + + {t('alert-title')} + + + + {anomalies.map((anomaly, index) => { + const categoryColor = CATEGORY_COLORS[anomaly.category]; + const categoryBgColor = CATEGORY_BG_COLORS[anomaly.category]; + + return ( + + + + {getAnomalyMessage(anomaly)} + + + ); + })} + + + + + ); +}; diff --git a/apps/frontend/src/app/[locale]/lems/(volunteer)/deliberation/final/components/final-deliberation-grid.tsx b/apps/frontend/src/app/[locale]/lems/(volunteer)/deliberation/final/components/final-deliberation-grid.tsx index 6e3ec1d1f..5ec543598 100644 --- a/apps/frontend/src/app/[locale]/lems/(volunteer)/deliberation/final/components/final-deliberation-grid.tsx +++ b/apps/frontend/src/app/[locale]/lems/(volunteer)/deliberation/final/components/final-deliberation-grid.tsx @@ -23,6 +23,7 @@ import { ChampionsStage } from './champions/champions-stage'; import { CoreAwardsStage } from './core-awards/core-awards-stage'; import { OptionalAwardsStage } from './optional-awards/optional-awards-stage'; import { ReviewStage } from './review/review-stage'; +import { AnomalyAlert } from './anomaly-alert'; const STAGES: FinalDeliberationStage[] = ['champions', 'core-awards', 'optional-awards', 'review']; @@ -30,7 +31,7 @@ export const FinalDeliberationGrid: React.FC = () => { const t = useTranslations('pages.deliberations.final'); const theme = useTheme(); const router = useRouter(); - const { awardCounts, deliberation } = useFinalDeliberation(); + const { awardCounts, deliberation, anomalies } = useFinalDeliberation(); // Determine visible stages based on whether optional awards exist const visibleStages = useMemo(() => { @@ -87,7 +88,10 @@ export const FinalDeliberationGrid: React.FC = () => { backgroundColor: alpha(theme.palette.primary.light, 0.1), borderBottom: `1px solid ${theme.palette.divider}`, py: 2, - px: 4 + px: 4, + display: 'flex', + flexDirection: 'column', + gap: 2 }} > { ))} + {/* Main Content - Flex grow to fill remaining space */} diff --git a/apps/frontend/src/app/[locale]/lems/(volunteer)/deliberation/final/final-deliberation-computation.ts b/apps/frontend/src/app/[locale]/lems/(volunteer)/deliberation/final/final-deliberation-computation.ts index 98872d44a..c30a4df1f 100644 --- a/apps/frontend/src/app/[locale]/lems/(volunteer)/deliberation/final/final-deliberation-computation.ts +++ b/apps/frontend/src/app/[locale]/lems/(volunteer)/deliberation/final/final-deliberation-computation.ts @@ -1,8 +1,23 @@ import { FinalDeliberationAwards, JudgingCategory } from '@lems/database'; import { compareScoreArrays } from '@lems/shared/utils/arrays'; +import { JUDGING_CATEGORIES } from '@lems/types/judging'; import { CategorizedRubrics, MetricPerCategory, Team } from '../types'; import { EnrichedTeam, OptionalAwardNominations, RanksPerCategory } from './types'; +/** + * Represents an anomaly where a team's picklist position differs significantly + * from their calculated rank based on rubric scores. + */ +export interface Anomaly { + teamId: string; + teamNumber: string; + category: JudgingCategory; + calculatedRank: number; + picklistPosition: number; + difference: number; + isHigher: boolean; // true if team ranked higher (more favorable) in picklist than calculated +} + /** * Extracts optional award nominations from a rubric */ @@ -31,6 +46,42 @@ export function extractOptionalAwards(rubrics: CategorizedRubrics): OptionalAwar return nominations; } +/** + * Computes raw ranking for teams based purely on rubric scores (no picklist consideration). + * + * This is used for anomaly detection to compare against picklist positions. + * + * @param team - The team to compute raw rank for + * @param allTeams - All teams (used for relative ranking) + * @returns RanksPerCategory object with score-based ranks only + */ +export function computeRawRank( + team: Team & { scores: MetricPerCategory; robotGameScores: number[] }, + allTeams: (Team & { scores: MetricPerCategory; robotGameScores: number[] })[] +): RanksPerCategory { + const rawRanks: RanksPerCategory = { + 'innovation-project': 0, + 'robot-design': 0, + 'core-values': 0, + 'robot-game': 0, + total: 0 + }; + + const computeRankByScore = (category: JudgingCategory, teamScore: number): number => { + const higherScoreCount = allTeams.filter(t => { + const score = t.scores[category]; + return score > teamScore; + }).length; + return higherScoreCount + 1; + }; + + JUDGING_CATEGORIES.forEach(category => { + rawRanks[category] = computeRankByScore(category, team.scores[category]); + }); + + return rawRanks; +} + /** * Computes ranking for teams based on scores and picklists. * @@ -152,3 +203,53 @@ export const computeOptionalAwardsEligibility = ( return Object.keys(team.awardNominations).length > 0 || manualNominations.includes(team.id); }; + +/** + * Computes anomalies for the final deliberation. + * + * An anomaly occurs when a team's picklist position differs by 3 or more places + * from their raw rubric score rank (score-based ranking without picklist consideration). + * + * @param enrichedTeams - Teams with computed scores and ranks + * @param categoryPicklists - Picklist rankings for each category + * @returns Array of detected anomalies + */ +export function computeAnomalies( + enrichedTeams: EnrichedTeam[], + categoryPicklists: Record +): Anomaly[] { + const anomalies: Anomaly[] = []; + + const categories: JudgingCategory[] = ['core-values', 'innovation-project', 'robot-design']; + + categories.forEach(category => { + const picklist = categoryPicklists[category]; + + enrichedTeams.forEach(team => { + const picklistPosition = picklist.indexOf(team.id) + 1; + + // Only check teams that are in the picklist + if (picklistPosition > 0) { + const calculatedRank = team.rawRanks[category]; + const difference = Math.abs(picklistPosition - calculatedRank); + + // Anomaly threshold is 3 or more places difference + if (difference >= 3) { + const isHigher = picklistPosition < calculatedRank; + + anomalies.push({ + teamId: team.id, + teamNumber: team.number, + category, + calculatedRank, + picklistPosition, + difference, + isHigher + }); + } + } + }); + }); + + return anomalies; +} diff --git a/apps/frontend/src/app/[locale]/lems/(volunteer)/deliberation/final/final-deliberation-context.tsx b/apps/frontend/src/app/[locale]/lems/(volunteer)/deliberation/final/final-deliberation-context.tsx index 5816ea3b7..16d020cbd 100644 --- a/apps/frontend/src/app/[locale]/lems/(volunteer)/deliberation/final/final-deliberation-context.tsx +++ b/apps/frontend/src/app/[locale]/lems/(volunteer)/deliberation/final/final-deliberation-context.tsx @@ -30,7 +30,9 @@ import { computeCoreAwardsEligibility, computeOptionalAwardsEligibility, computeRank, - extractOptionalAwards + computeRawRank, + extractOptionalAwards, + computeAnomalies } from './final-deliberation-computation'; const FinalDeliberationContext = createContext(null); @@ -161,8 +163,10 @@ export const FinalDeliberationProvider = ({ team.judgingSession?.room.id ); const awardNominations = extractOptionalAwards(team.rubrics); - // Compute ranks + // Compute ranks (with picklist consideration) const ranks = computeRank(teamsWithScores[index], teamsWithScores, categoryPicklists); + // Compute raw ranks (score-based only, for anomaly detection) + const rawRanks = computeRawRank(teamsWithScores[index], teamsWithScores); const eligibilites: Partial = { 'core-awards': computeCoreAwardsEligibility( @@ -198,6 +202,7 @@ export const FinalDeliberationProvider = ({ scores, normalizedScores, ranks, + rawRanks, eligibility: eligibilites, robotGameScores, rubricsFields: { @@ -237,6 +242,9 @@ export const FinalDeliberationProvider = ({ .filter(t => (eligibleTeams[deliberation.stage as StagesWithNomination] ?? []).includes(t.id)) .map(t => t.id); + // Compute anomalies based on picklist positions vs calculated ranks + const anomalies = computeAnomalies(enrichedTeams, categoryPicklists); + return { division, deliberation, @@ -247,6 +255,7 @@ export const FinalDeliberationProvider = ({ awards, awardCounts, roomMetrics, + anomalies, startDeliberation: handleStartFinalDeliberation, updateAward: handleUpdateFinalDeliberationAwards, advanceStage: handleAdvanceStage, diff --git a/apps/frontend/src/app/[locale]/lems/(volunteer)/deliberation/final/types.ts b/apps/frontend/src/app/[locale]/lems/(volunteer)/deliberation/final/types.ts index 4072da7f5..a06050838 100644 --- a/apps/frontend/src/app/[locale]/lems/(volunteer)/deliberation/final/types.ts +++ b/apps/frontend/src/app/[locale]/lems/(volunteer)/deliberation/final/types.ts @@ -2,6 +2,7 @@ import { Award, OptionalAwards } from '@lems/shared'; import { JudgingCategory } from '@lems/database'; import { Room, MetricPerCategory, RoomMetricsMap } from '../types'; import { Division, FinalJudgingDeliberation } from './graphql'; +import { Anomaly } from './final-deliberation-computation'; export type FinalDeliberationStage = 'champions' | 'core-awards' | 'optional-awards' | 'review'; export type StagesWithNomination = 'champions' | 'core-awards' | 'optional-awards'; @@ -27,6 +28,7 @@ export type EnrichedTeam = { scores: MetricPerCategory; normalizedScores: Omit; ranks: RanksPerCategory; + rawRanks: RanksPerCategory; eligibility: EligiblityPerStage; @@ -60,6 +62,8 @@ export interface FinalDeliberationContextValue { roomMetrics: RoomMetricsMap; + anomalies: Anomaly[]; + startDeliberation(): Promise; updateAward(awardName: Award, updatedAward: string[] | Record): Promise; advanceStage(): Promise;