Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion apps/frontend/locale/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down Expand Up @@ -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"
}
}
}
Expand Down
9 changes: 9 additions & 0 deletions apps/frontend/locale/he.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": "עיצוב רובוט"
}
}
}
Expand Down
11 changes: 10 additions & 1 deletion apps/frontend/locale/pl.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down Expand Up @@ -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"
}
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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<string, string> = {
'core-values': '#d32f2f',
'innovation-project': '#1976d2',
'robot-design': '#388e3c'
};

const CATEGORY_BG_COLORS: Record<string, string> = {
'core-values': '#ffebee',
'innovation-project': '#e3f2fd',
'robot-design': '#e8f5e8'
};

interface AnomalyAlertProps {
anomalies: Anomaly[];
}

export const AnomalyAlert: React.FC<AnomalyAlertProps> = ({ anomalies }) => {
const t = useTranslations('pages.deliberations.final.anomalies');
const theme = useTheme();
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null);

if (!anomalies || anomalies.length === 0) {
return null;
}

const handleAlertClick = (event: React.MouseEvent<HTMLDivElement>) => {
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 (
<>
<Alert
severity="warning"
icon={<Warning />}
onClick={handleAlertClick}
sx={{
cursor: 'pointer',
mb: 2,
'&:hover': {
backgroundColor: alpha(theme.palette.warning.main, 0.15)
}
}}
>
<Box>
<Typography variant="subtitle1" sx={{ fontWeight: 600, mb: 0.5 }}>
{t('alert-title')}
</Typography>
<Typography variant="body2">{t('alert-description')}</Typography>
</Box>
</Alert>

<Popover
id={popoverId}
open={open}
anchorEl={anchorEl}
onClose={handleClose}
anchorOrigin={{
vertical: 'bottom',
horizontal: 'left'
}}
transformOrigin={{
vertical: 'top',
horizontal: 'left'
}}
>
<Box
sx={{
p: 2,
maxWidth: '500px',
maxHeight: '400px',
overflow: 'auto'
}}
>
<Typography variant="h6" sx={{ mb: 2, fontWeight: 600 }}>
{t('alert-title')}
</Typography>

<Stack spacing={1.5}>
{anomalies.map((anomaly, index) => {
const categoryColor = CATEGORY_COLORS[anomaly.category];
const categoryBgColor = CATEGORY_BG_COLORS[anomaly.category];

return (
<Box
key={`${anomaly.teamId}-${anomaly.category}-${index}`}
sx={{
display: 'flex',
alignItems: 'flex-start',
gap: 1.5,
p: 1.5,
backgroundColor: categoryBgColor,
borderLeft: `4px solid ${categoryColor}`,
borderRadius: 1
}}
>
<Box
sx={{
mt: 0.25,
width: 8,
height: 8,
borderRadius: '50%',
backgroundColor: categoryColor,
flexShrink: 0
}}
/>
<Typography variant="body2" sx={{ flex: 1, color: 'text.primary' }}>
{getAnomalyMessage(anomaly)}
</Typography>
</Box>
);
})}
</Stack>
</Box>
</Popover>
</>
);
};
Original file line number Diff line number Diff line change
Expand Up @@ -23,14 +23,15 @@ 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'];

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(() => {
Expand Down Expand Up @@ -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
}}
>
<Stepper
Expand All @@ -109,6 +113,7 @@ export const FinalDeliberationGrid: React.FC = () => {
</Step>
))}
</Stepper>
<AnomalyAlert anomalies={anomalies} />
</Box>

{/* Main Content - Flex grow to fill remaining space */}
Expand Down
Original file line number Diff line number Diff line change
@@ -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
*/
Expand Down Expand Up @@ -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.
*
Expand Down Expand Up @@ -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<JudgingCategory, string[]>
): 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;
}
Loading