diff --git a/src/main/java/de/tum/cit/aet/analysis/service/AnalysisQueryService.java b/src/main/java/de/tum/cit/aet/analysis/service/AnalysisQueryService.java index 1aff04f4..e2764050 100644 --- a/src/main/java/de/tum/cit/aet/analysis/service/AnalysisQueryService.java +++ b/src/main/java/de/tum/cit/aet/analysis/service/AnalysisQueryService.java @@ -65,7 +65,16 @@ public List getTeamsByExerciseId(Long exerciseId) { return List.of(); } return participations.stream() - .map(this::mapParticipationToClientResponse) + .map(p -> { + try { + return mapParticipationToClientResponse(p); + } catch (Exception e) { + log.warn("Failed to map participation {} (team {}): {}", + p.getParticipation(), p.getName(), e.getMessage()); + return null; + } + }) + .filter(java.util.Objects::nonNull) .toList(); } @@ -229,7 +238,8 @@ public LlmTokenTotalsDTO readTeamTokenTotals(TeamParticipation tp) { */ public CQIResultDTO reconstructCqiDetails(TeamParticipation participation, AnalysisMode mode) { if (participation.getCqiEffortBalance() == null && participation.getCqiLocBalance() == null - && participation.getCqiTemporalSpread() == null && participation.getCqiOwnershipSpread() == null) { + && participation.getCqiTemporalSpread() == null && participation.getCqiOwnershipSpread() == null + && participation.getCqiPairProgrammingStatus() == null) { return null; } diff --git a/src/main/java/de/tum/cit/aet/analysis/service/AnalysisResultPersistenceService.java b/src/main/java/de/tum/cit/aet/analysis/service/AnalysisResultPersistenceService.java index ac158045..131b47ce 100644 --- a/src/main/java/de/tum/cit/aet/analysis/service/AnalysisResultPersistenceService.java +++ b/src/main/java/de/tum/cit/aet/analysis/service/AnalysisResultPersistenceService.java @@ -190,6 +190,9 @@ public ClientResponseDTO saveGitAnalysisResult(TeamRepositoryDTO repo, teamRepo.setVcsLogs(vcsLogs); teamRepositoryRepository.save(teamRepo); + // Compute git-only CQI (including PP status) for ALL teams before the failed check + CQIResultDTO gitCqiDetails = calculateGitOnlyCqi(repo, teamParticipation, team, students); + boolean hasFailed = checkAndMarkFailed(teamParticipation, students); if (hasFailed) { return new ClientResponseDTO( @@ -197,11 +200,9 @@ public ClientResponseDTO saveGitAnalysisResult(TeamRepositoryDTO repo, team.id(), participation.id(), team.name(), team.shortName(), participation.submissionCount(), studentDtos, 0.0, false, TeamAnalysisStatus.DONE, - null, null, null, null, 0, true, null); + gitCqiDetails, null, null, null, 0, true, null); } - CQIResultDTO gitCqiDetails = calculateGitOnlyCqi(repo, teamParticipation, team, students); - CQIResultDTO finalDetails = gitCqiDetails; if (mode == AnalysisMode.SIMPLE && gitCqiDetails != null) { finalDetails = cqiCalculatorService.renormalizeWithoutEffort(gitCqiDetails, exerciseId); @@ -730,8 +731,7 @@ private boolean checkAndMarkFailed(TeamParticipation tp, List students) tp.setCqiLocBalance(null); tp.setCqiTemporalSpread(null); tp.setCqiOwnershipSpread(null); - tp.setCqiPairProgramming(null); - tp.setCqiPairProgrammingStatus(null); + // Preserve PP fields — failed teams should still show their PP status tp.setCqiBaseScore(null); teamParticipationRepository.save(tp); } diff --git a/src/main/java/de/tum/cit/aet/dataProcessing/service/StreamingAnalysisPipelineService.java b/src/main/java/de/tum/cit/aet/dataProcessing/service/StreamingAnalysisPipelineService.java index 94a9669c..0e9b2c8c 100644 --- a/src/main/java/de/tum/cit/aet/dataProcessing/service/StreamingAnalysisPipelineService.java +++ b/src/main/java/de/tum/cit/aet/dataProcessing/service/StreamingAnalysisPipelineService.java @@ -11,6 +11,8 @@ import de.tum.cit.aet.artemis.ArtemisClientService; import de.tum.cit.aet.core.dto.ArtemisCredentials; import de.tum.cit.aet.dataProcessing.domain.AnalysisMode; +import de.tum.cit.aet.pairProgramming.service.PairProgrammingRecomputeService; +import de.tum.cit.aet.pairProgramming.service.PairProgrammingService; import de.tum.cit.aet.repositoryProcessing.domain.TeamAnalysisStatus; import de.tum.cit.aet.repositoryProcessing.domain.TeamParticipation; import de.tum.cit.aet.repositoryProcessing.dto.*; @@ -51,6 +53,8 @@ public class StreamingAnalysisPipelineService { private final AnalysisTaskManager analysisTaskManager; private final ExerciseTeamLifecycleService exerciseDataCleanupService; private final AnalysisResultPersistenceService persistenceService; + private final PairProgrammingService pairProgrammingService; + private final PairProgrammingRecomputeService pairProgrammingRecomputeService; public StreamingAnalysisPipelineService( ArtemisClientService artemisClientService, @@ -62,7 +66,9 @@ public StreamingAnalysisPipelineService( ExerciseTemplateAuthorRepository templateAuthorRepository, AnalysisTaskManager analysisTaskManager, ExerciseTeamLifecycleService exerciseDataCleanupService, - AnalysisResultPersistenceService persistenceService) { + AnalysisResultPersistenceService persistenceService, + PairProgrammingService pairProgrammingService, + PairProgrammingRecomputeService pairProgrammingRecomputeService) { this.artemisClientService = artemisClientService; this.gitOperationsService = gitOperationsService; this.analysisStateService = analysisStateService; @@ -73,6 +79,8 @@ public StreamingAnalysisPipelineService( this.analysisTaskManager = analysisTaskManager; this.exerciseDataCleanupService = exerciseDataCleanupService; this.persistenceService = persistenceService; + this.pairProgrammingService = pairProgrammingService; + this.pairProgrammingRecomputeService = pairProgrammingRecomputeService; } // ===================================================================== @@ -264,6 +272,17 @@ public void fetchAnalyzeAndSaveRepositoriesStream(ArtemisCredentials credentials log.info("Phase 2 complete: {} of {} repositories git-analyzed", gitAnalyzedCount.get(), clonedCount); eventEmitter.accept(Map.of("type", "GIT_DONE", "processed", gitAnalyzedCount.get())); + // Synchronously recompute PP statuses after git analysis so they are in the DB + // before AI analysis starts (async would race with AI analysis saves) + if (pairProgrammingService.hasAttendanceData()) { + try { + int ppUpdated = pairProgrammingRecomputeService.recomputePairProgrammingForExercise(exerciseId); + log.info("PP recompute after git analysis: {} teams updated for exerciseId={}", ppUpdated, exerciseId); + } catch (Exception e) { + log.error("PP recompute after git analysis failed for exerciseId={}: {}", exerciseId, e.getMessage()); + } + } + if (!analysisStateService.isRunning(exerciseId)) { exerciseDataCleanupService.markPendingTeamsAsCancelled(exerciseId); eventEmitter.accept(Map.of("type", "CANCELLED", diff --git a/src/main/java/de/tum/cit/aet/pairProgramming/service/PairProgrammingService.java b/src/main/java/de/tum/cit/aet/pairProgramming/service/PairProgrammingService.java index 39412b26..f2e00b11 100644 --- a/src/main/java/de/tum/cit/aet/pairProgramming/service/PairProgrammingService.java +++ b/src/main/java/de/tum/cit/aet/pairProgramming/service/PairProgrammingService.java @@ -415,6 +415,9 @@ public void clear() { * @return the pair programming status */ public PairProgrammingStatus getPairProgrammingStatus(String teamName, String shortName) { + if (!hasAttendanceData()) { + return null; // No Excel uploaded — PP not applicable + } boolean hasAttendance = hasTeamAttendance(teamName, shortName); boolean hasCancelledWarning = hasCancelledSessionWarning(teamName, shortName); boolean pairedMandatory = isPairedMandatorySessions(teamName, shortName); @@ -435,6 +438,9 @@ public Double calculateScore(String teamName, String shortName, if (teamName == null || teamSize != 2) { return null; } + if (!hasAttendanceData()) { + return null; // No Excel uploaded — PP not applicable + } try { boolean hasAttendance = hasTeamAttendance(teamName, shortName); boolean hasCancelledWarning = hasCancelledSessionWarning(teamName, shortName); diff --git a/src/main/webapp/src/components/CqiWeightsPanel.tsx b/src/main/webapp/src/components/CqiWeightsPanel.tsx index debde87a..14249d6a 100644 --- a/src/main/webapp/src/components/CqiWeightsPanel.tsx +++ b/src/main/webapp/src/components/CqiWeightsPanel.tsx @@ -91,6 +91,7 @@ export default function CqiWeightsPanel({ exerciseId, disabled }: CqiWeightsPane }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['cqiWeights', exerciseId] }); + queryClient.invalidateQueries({ queryKey: ['teams', exerciseId] }); toast({ title: 'CQI weights saved' }); }, onError: (error: unknown) => { @@ -110,6 +111,7 @@ export default function CqiWeightsPanel({ exerciseId, disabled }: CqiWeightsPane }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['cqiWeights', exerciseId] }); + queryClient.invalidateQueries({ queryKey: ['teams', exerciseId] }); toast({ title: 'CQI weights reset to defaults' }); }, onError: (error: unknown) => { diff --git a/src/main/webapp/src/components/TeamDetail.tsx b/src/main/webapp/src/components/TeamDetail.tsx index 62c2c5b9..781c0ae8 100644 --- a/src/main/webapp/src/components/TeamDetail.tsx +++ b/src/main/webapp/src/components/TeamDetail.tsx @@ -25,7 +25,6 @@ interface TeamDetailProps { onBack: () => void; course?: string; exercise?: string; - pairProgrammingBadgeStatus?: PairProgrammingBadgeStatus | null; courseAverages?: CourseAverages | null; onTeamUpdate?: (team: TeamDTO) => void; onToggleReviewed?: () => void; @@ -40,7 +39,6 @@ interface TeamDetailProps { * @param onBack - navigate back to the teams list * @param course - course * @param exercise - exercise - * @param pairProgrammingBadgeStatus - status of the PP badge * @param courseAverages - course average * @param onTeamUpdate * @param onToggleReviewed @@ -51,7 +49,6 @@ const TeamDetail = ({ onBack, course, exercise, - pairProgrammingBadgeStatus = null, courseAverages = null, onTeamUpdate, onToggleReviewed, @@ -236,7 +233,11 @@ const TeamDetail = ({ ); }; - // Show Pair Programming card from server subMetrics when present; otherwise from attendance badge when available (e.g. when analysis failed) + // Derive PP badge status from server data + const pairProgrammingBadgeStatus: PairProgrammingBadgeStatus | null = team.pairProgrammingStatus + ? (team.pairProgrammingStatus.toLowerCase() as PairProgrammingBadgeStatus) + : null; + const isSimpleMode = analysisMode === 'SIMPLE'; const metricsToShow = useMemo((): SubMetric[] => { const effortBalancePlaceholder: SubMetric = isAiComputing @@ -310,50 +311,8 @@ const TeamDetail = ({ return m; }); } - if (pairProgrammingBadgeStatus != null) { - const description = 'Did both students commit during pair programming sessions?'; - const synthetic: SubMetric = - pairProgrammingBadgeStatus === 'pass' - ? { - name: 'Pair Programming', - value: 100, - weight: 0, - description, - details: - 'Verifies that both team members actually collaborated by checking if they both made commits on the dates when they attended pair programming tutorials together.', - } - : pairProgrammingBadgeStatus === 'fail' - ? { - name: 'Pair Programming', - value: 0, - weight: 0, - description, - details: 'Team was found in Excel but attended fewer than the mandatory number of pair-programming sessions.', - } - : pairProgrammingBadgeStatus === 'warning' - ? { - name: 'Pair Programming', - value: -3, - weight: 0, - description, - details: - 'Some pair-programming tutorials were cancelled, so mandatory attendance could not be evaluated reliably. Some sessions were attended.', - status: 'WARNING', - } - : { - name: 'Pair Programming', - value: -2, - weight: 0, - description, - details: 'Team not found in attendance Excel file. Please check that the team name in the Excel matches exactly.', - status: 'NOT_FOUND', - }; - // Client-side attendance data is the most current source of truth — override any server PP metric - const withoutServerPP = fromServer.filter(m => m.name !== 'Pair Programming'); - return withoutServerPP.concat(synthetic); - } return fromServer; - }, [team.subMetrics, pairProgrammingBadgeStatus, isSimpleMode, hasAiResult, isAiComputing]); + }, [team.subMetrics, isSimpleMode, hasAiResult, isAiComputing]); return (
@@ -509,7 +468,13 @@ const TeamDetail = ({ Analyzing... )} - + {pairProgrammingBadgeStatus ? ( + + ) : team.analysisStatus !== 'ERROR' && team.analysisStatus !== 'CANCELLED' ? ( + + PP: Pending + + ) : null}
diff --git a/src/main/webapp/src/components/TeamsList.tsx b/src/main/webapp/src/components/TeamsList.tsx index b275aedf..98318bed 100644 --- a/src/main/webapp/src/components/TeamsList.tsx +++ b/src/main/webapp/src/components/TeamsList.tsx @@ -51,17 +51,12 @@ import FileUpload from '@/components/FileUpload'; import { getFailedReason } from '@/lib/utils'; import PairProgrammingBadge from '@/components/PairProgrammingBadge'; import { PairProgrammingFilterButton, type PairProgrammingFilterValue } from '@/components/PairProgrammingFilterButton'; -import { - getPairProgrammingBadgeStatus, - hasValidPairProgrammingAttendanceData, - type PairProgrammingAttendanceMap, - type PairProgrammingBadgeStatus, -} from '@/lib/pairProgramming'; +import type { PairProgrammingBadgeStatus } from '@/lib/pairProgramming'; interface TeamsListProps { teams: TeamDTO[]; courseAverages: CourseAverages | null; - onTeamSelect: (team: TeamDTO, pairProgrammingBadgeStatus: PairProgrammingBadgeStatus | null) => void; + onTeamSelect: (team: TeamDTO) => void; onToggleReviewed: (teamId: string) => void; onBackToHome: () => void; onStart: (mode: AnalysisMode) => void; @@ -74,7 +69,6 @@ interface TeamsListProps { pairProgrammingEnabled: boolean; attendanceFile: File | null; uploadedAttendanceFileName: string | null; - pairProgrammingAttendanceByTeamName: PairProgrammingAttendanceMap; onAttendanceFileSelect: (file: File | null) => void; onAttendanceUpload: () => void; onRemoveUploadedAttendanceFile: () => void; @@ -112,7 +106,6 @@ const TeamsList = ({ pairProgrammingEnabled, attendanceFile, uploadedAttendanceFileName, - pairProgrammingAttendanceByTeamName, onAttendanceFileSelect, onAttendanceUpload, onRemoveUploadedAttendanceFile, @@ -274,14 +267,13 @@ const TeamsList = ({ }; // Get priority for analysis status (lower = shown first) - // Failed teams (isFailed) get a separate priority so they sort below teams with real CQI scores + // Active analysis (AI_ANALYZING) shown near top so progress is visible const getStatusPriority = (team: TeamDTO): number => { - if (team.isFailed) return 1; // Failed teams after successful DONE teams switch (team.analysisStatus) { - case 'DONE': - return 0; // Fully completed - show first case 'AI_ANALYZING': - return 2; // AI analysis in progress + return 0; // AI analysis in progress — show at top + case 'DONE': + return team.isFailed ? 2 : 1; // Successful after AI_ANALYZING, failed below case 'GIT_DONE': return 3; // Git analysis done, waiting for AI case 'GIT_ANALYZING': @@ -298,11 +290,7 @@ const TeamsList = ({ } }; - const hasValidPairProgrammingData = hasValidPairProgrammingAttendanceData( - pairProgrammingEnabled, - uploadedAttendanceFileName, - pairProgrammingAttendanceByTeamName, - ); + const hasValidPairProgrammingData = pairProgrammingEnabled && teams.some(t => t.pairProgrammingStatus != null); const sortedAndFilteredTeams = useMemo(() => { let filtered = teams.slice(); @@ -353,12 +341,9 @@ const TeamsList = ({ // Apply pair programming filter (multi-select) if (pairProgrammingFilter.length > 0 && hasValidPairProgrammingData) { filtered = filtered.filter(team => { - const badge = getPairProgrammingBadgeStatus( - team.teamName ?? '', - hasValidPairProgrammingData, - pairProgrammingAttendanceByTeamName, - team.shortName, - ); + const badge: PairProgrammingBadgeStatus | null = team.pairProgrammingStatus + ? (team.pairProgrammingStatus.toLowerCase() as PairProgrammingBadgeStatus) + : null; return badge != null && pairProgrammingFilter.includes(badge); }); } @@ -416,16 +401,7 @@ const TeamsList = ({ } return filtered; - }, [ - teams, - searchQuery, - sortColumn, - sortDirection, - statusFilter, - pairProgrammingFilter, - hasValidPairProgrammingData, - pairProgrammingAttendanceByTeamName, - ]); + }, [teams, searchQuery, sortColumn, sortDirection, statusFilter, pairProgrammingFilter, hasValidPairProgrammingData]); const renderStartDropdown = (label: string, isPending: boolean, onAction: (mode: AnalysisMode) => void) => ( @@ -964,17 +940,14 @@ const TeamsList = ({ {sortedAndFilteredTeams.map(team => { - const pairProgrammingBadgeStatus = getPairProgrammingBadgeStatus( - team.teamName ?? '', - hasValidPairProgrammingData, - pairProgrammingAttendanceByTeamName, - team.shortName, - ); + const pairProgrammingBadgeStatus: PairProgrammingBadgeStatus | null = team.pairProgrammingStatus + ? (team.pairProgrammingStatus.toLowerCase() as PairProgrammingBadgeStatus) + : null; return ( onTeamSelect(team, pairProgrammingBadgeStatus)} + onClick={() => onTeamSelect(team)} className="border-b last:border-b-0 hover:bg-muted/30 cursor-pointer transition-colors" > @@ -1088,7 +1061,13 @@ const TeamsList = ({ {pairProgrammingEnabled && (
- + {pairProgrammingBadgeStatus ? ( + + ) : hasUploadedAttendanceDocument && team.analysisStatus !== 'ERROR' && team.analysisStatus !== 'CANCELLED' ? ( + + Pending + + ) : null}
)} diff --git a/src/main/webapp/src/data/dataLoaders.ts b/src/main/webapp/src/data/dataLoaders.ts index 79ca509f..9a84556a 100644 --- a/src/main/webapp/src/data/dataLoaders.ts +++ b/src/main/webapp/src/data/dataLoaders.ts @@ -12,7 +12,11 @@ export interface SubMetric { } /** A ClientResponseDTO extended with client-computed sub-metrics and review status. */ -export type TeamDTO = ClientResponseDTO & { subMetrics?: SubMetric[]; isReviewed?: boolean }; +export type TeamDTO = ClientResponseDTO & { + subMetrics?: SubMetric[]; + isReviewed?: boolean; + pairProgrammingStatus?: 'PASS' | 'FAIL' | 'NOT_FOUND' | 'WARNING' | null; +}; // ============================================================ // DATA TRANSFORMATION - Convert DTO to Client Types @@ -109,6 +113,7 @@ export function transformToComplexTeamData(dto: ClientResponseDTO): TeamDTO { cqi, isSuspicious, subMetrics, + pairProgrammingStatus: (pairProgrammingStatus as TeamDTO['pairProgrammingStatus']) ?? null, }); } diff --git a/src/main/webapp/src/lib/pairProgramming.ts b/src/main/webapp/src/lib/pairProgramming.ts index ebc72fd5..3b49086e 100644 --- a/src/main/webapp/src/lib/pairProgramming.ts +++ b/src/main/webapp/src/lib/pairProgramming.ts @@ -1,78 +1,5 @@ -import { normalizeTeamName } from '@/lib/utils'; - -export type PairProgrammingAttendanceStatus = 'pass' | 'fail' | 'warning'; - -export type PairProgrammingAttendanceMap = Record; - export type PairProgrammingBadgeStatus = 'not_found' | 'warning' | 'pass' | 'fail'; export const getPairProgrammingAttendanceFileStorageKey = (exerciseId: string): string => { return `pair-programming-attendance-file:${exerciseId}`; }; - -export const getPairProgrammingAttendanceMapStorageKey = (exerciseId: string): string => { - return `pair-programming-attendance-status:${exerciseId}`; -}; - -export const readStoredPairProgrammingAttendanceMap = (storageKey: string): PairProgrammingAttendanceMap => { - const rawValue = window.sessionStorage.getItem(storageKey); - if (!rawValue) { - return {}; - } - - try { - const parsed: unknown = JSON.parse(rawValue); - if (typeof parsed !== 'object' || parsed === null) { - return {}; - } - - return Object.entries(parsed).reduce((acc, [teamName, storedStatus]) => { - if (storedStatus === 'pass' || storedStatus === 'fail' || storedStatus === 'warning') { - acc[normalizeTeamName(teamName)] = storedStatus; - } else if (typeof storedStatus === 'boolean') { - // Backward compatibility for previously stored values. - acc[normalizeTeamName(teamName)] = storedStatus ? 'pass' : 'fail'; - } - return acc; - }, {}); - } catch { - return {}; - } -}; - -export const hasValidPairProgrammingAttendanceData = ( - pairProgrammingEnabled: boolean, - uploadedAttendanceFileName: string | null, - pairProgrammingAttendanceByTeamName: PairProgrammingAttendanceMap, -): boolean => { - return pairProgrammingEnabled && !!uploadedAttendanceFileName && Object.keys(pairProgrammingAttendanceByTeamName).length > 0; -}; - -/** - * Resolves badge status from the attendance map, trying team name first then short name. - * So when the Excel is keyed by short name and the UI shows the full name, the badge still shows PASS/FAIL. - */ -export const getPairProgrammingBadgeStatus = ( - teamName: string, - hasValidPairProgrammingData: boolean, - pairProgrammingAttendanceByTeamName: PairProgrammingAttendanceMap, - shortName?: string | null, -): PairProgrammingBadgeStatus | null => { - if (!hasValidPairProgrammingData) { - return null; - } - - const normalizedTeamName = normalizeTeamName(teamName); - let status: PairProgrammingAttendanceStatus | undefined = pairProgrammingAttendanceByTeamName[normalizedTeamName]; - - if (status === undefined && shortName != null) { - const normalizedShortName = normalizeTeamName(shortName); - status = pairProgrammingAttendanceByTeamName[normalizedShortName]; - } - - if (status === undefined) { - return 'not_found'; - } - - return status; -}; diff --git a/src/main/webapp/src/pages/TeamDetailPage.tsx b/src/main/webapp/src/pages/TeamDetailPage.tsx index 3a25dde5..4fb73742 100644 --- a/src/main/webapp/src/pages/TeamDetailPage.tsx +++ b/src/main/webapp/src/pages/TeamDetailPage.tsx @@ -2,7 +2,6 @@ import { useNavigate, useLocation, useParams } from 'react-router-dom'; import { useState } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import TeamDetail from '@/components/TeamDetail'; -import type { PairProgrammingBadgeStatus } from '@/lib/pairProgramming'; import { transformToComplexTeamData, type TeamDTO } from '@/data/dataLoaders'; import type { CourseAverages } from '@/lib/courseAverages'; import { requestApi } from '@/lib/apiClient'; @@ -19,7 +18,6 @@ export default function TeamDetailPage() { course, exercise, pairProgrammingEnabled, - pairProgrammingBadgeStatus, courseAverages, analysisMode, teamsSearchParams, @@ -28,7 +26,6 @@ export default function TeamDetailPage() { course?: string; exercise?: string; pairProgrammingEnabled?: boolean; - pairProgrammingBadgeStatus?: PairProgrammingBadgeStatus | null; courseAverages?: CourseAverages | null; analysisMode?: 'SIMPLE' | 'FULL'; teamsSearchParams?: string; @@ -121,7 +118,6 @@ export default function TeamDetailPage() { onBack={() => navigateBackToTeams()} course={course} exercise={exercise} - pairProgrammingBadgeStatus={pairProgrammingBadgeStatus} courseAverages={courseAverages} onTeamUpdate={setTeam} onToggleReviewed={() => toggleReviewedMutation.mutate()} diff --git a/src/main/webapp/src/pages/Teams.tsx b/src/main/webapp/src/pages/Teams.tsx index e49ccd2a..53e16cac 100644 --- a/src/main/webapp/src/pages/Teams.tsx +++ b/src/main/webapp/src/pages/Teams.tsx @@ -1,53 +1,15 @@ import { useLocation, useNavigate, useSearchParams } from 'react-router-dom'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; -import { useState, useMemo } from 'react'; +import { useState, useMemo, useEffect, useRef } from 'react'; import TeamsList from '@/components/TeamsList'; -import type { TeamAttendanceDTO, TeamsScheduleDTO } from '@/app/generated'; import { computeCourseAverages } from '@/lib/courseAverages'; import { toast } from '@/hooks/use-toast'; import { useAnalysisStatus, cancelAnalysis, clearData } from '@/hooks/useAnalysisStatus'; import { loadBasicTeamDataStream, transformSummaryToTeamDTO, type TemplateAuthorInfo, type TeamDTO } from '@/data/dataLoaders'; import type { AnalysisMode } from '@/hooks/useAnalysisStatus'; -import { normalizeTeamName } from '@/lib/utils'; -import { - getPairProgrammingAttendanceFileStorageKey, - getPairProgrammingAttendanceMapStorageKey, - readStoredPairProgrammingAttendanceMap, - type PairProgrammingAttendanceMap, - type PairProgrammingBadgeStatus, -} from '@/lib/pairProgramming'; +import { getPairProgrammingAttendanceFileStorageKey } from '@/lib/pairProgramming'; import { pairProgrammingApi, emailMappingApi, requestApi } from '@/lib/apiClient'; -type NullableAttendanceMap = Record; - -const hasCancelledSessionAttendance = (attendance?: TeamAttendanceDTO): boolean => { - const student1Attendance = (attendance?.student1Attendance ?? {}) as NullableAttendanceMap; - const student2Attendance = (attendance?.student2Attendance ?? {}) as NullableAttendanceMap; - return Object.values(student1Attendance) - .concat(Object.values(student2Attendance)) - .some(value => value === null); -}; - -const buildPairProgrammingAttendanceMap = (schedule?: TeamsScheduleDTO): PairProgrammingAttendanceMap => { - const teams = schedule?.teams; - if (!teams) { - return {}; - } - - return Object.entries(teams).reduce((acc, [teamName, attendance]) => { - if (!teamName) { - return acc; - } - const hasCancelledSessionWarning = hasCancelledSessionAttendance(attendance); - acc[normalizeTeamName(teamName)] = hasCancelledSessionWarning - ? 'warning' - : attendance?.pairedMandatorySessions === true - ? 'pass' - : 'fail'; - return acc; - }, {}); -}; - /** * Teams page — the main analysis dashboard. * @@ -70,14 +32,10 @@ export default function Teams() { pairProgrammingEnabled?: boolean; }; const attendanceStorageKey = getPairProgrammingAttendanceFileStorageKey(exercise); - const pairProgrammingAttendanceMapStorageKey = getPairProgrammingAttendanceMapStorageKey(exercise); const [attendanceFile, setAttendanceFile] = useState(null); const [uploadedAttendanceFileName, setUploadedAttendanceFileName] = useState(() => window.sessionStorage.getItem(attendanceStorageKey), ); - const [pairProgrammingAttendanceByTeamName, setPairProgrammingAttendanceByTeamName] = useState(() => - readStoredPairProgrammingAttendanceMap(pairProgrammingAttendanceMapStorageKey), - ); // Template author candidates — no server endpoint, managed purely via query cache const templateAuthorCandidates = queryClient.getQueryData(['templateAuthorCandidates', exercise]) ?? null; @@ -109,12 +67,22 @@ export default function Teams() { const { data: pairProgrammingRecomputing } = useQuery({ queryKey: ['pairProgrammingRecomputing', exercise], queryFn: () => pairProgrammingApi.isRecomputing(parseInt(exercise)).then(res => res.data), - enabled: !!exercise && pairProgrammingEnabled && !!uploadedAttendanceFileName, + enabled: !!exercise && pairProgrammingEnabled, refetchInterval: query => (query.state.data?.recomputing ? 2000 : false), staleTime: 0, }); - const isPairProgrammingScoresUpdating = !!uploadedAttendanceFileName && (pairProgrammingRecomputing?.recomputing ?? false); + const isPairProgrammingScoresUpdating = pairProgrammingRecomputing?.recomputing ?? false; + + // When PP recompute finishes, refetch teams to get updated server PP status + const prevRecomputing = useRef(false); + useEffect(() => { + const isRecomputing = pairProgrammingRecomputing?.recomputing ?? false; + if (prevRecomputing.current && !isRecomputing) { + queryClient.invalidateQueries({ queryKey: ['teams', exercise] }); + } + prevRecomputing.current = isRecomputing; + }, [pairProgrammingRecomputing?.recomputing, queryClient, exercise]); // Fetch team summaries from database on load (one-time, no polling). // During analysis, SSE is the single source of truth for updates. @@ -122,17 +90,15 @@ export default function Teams() { const { data: teams = [] } = useQuery({ queryKey: ['teams', exercise], queryFn: async () => { - try { - const response = await requestApi.getTeamSummaries(parseInt(exercise)); - return response.data.map(transformSummaryToTeamDTO); - } catch { - return []; - } + const response = await requestApi.getTeamSummaries(parseInt(exercise)); + return response.data.map(transformSummaryToTeamDTO); }, staleTime: 30 * 1000, gcTime: 10 * 60 * 1000, enabled: !!exercise, refetchOnWindowFocus: !isAnalysisRunning, + // On error, keep showing cached/SSE-accumulated data instead of clearing + retry: 1, }); const toggleReviewedMutation = useMutation({ @@ -168,12 +134,9 @@ export default function Teams() { } return pairProgrammingApi.uploadAttendance(courseId, exerciseId, file); }, - onSuccess: (response, file) => { - const pairProgrammingAttendanceMap = buildPairProgrammingAttendanceMap(response.data); + onSuccess: (_response, file) => { setUploadedAttendanceFileName(file.name); window.sessionStorage.setItem(attendanceStorageKey, file.name); - setPairProgrammingAttendanceByTeamName(pairProgrammingAttendanceMap); - window.sessionStorage.setItem(pairProgrammingAttendanceMapStorageKey, JSON.stringify(pairProgrammingAttendanceMap)); queryClient.invalidateQueries({ queryKey: ['teams', exercise] }); queryClient.invalidateQueries({ queryKey: ['pairProgrammingRecomputing', exercise] }); toast({ @@ -202,9 +165,8 @@ export default function Teams() { onSuccess: () => { setAttendanceFile(null); setUploadedAttendanceFileName(null); - setPairProgrammingAttendanceByTeamName({}); window.sessionStorage.removeItem(attendanceStorageKey); - window.sessionStorage.removeItem(pairProgrammingAttendanceMapStorageKey); + queryClient.invalidateQueries({ queryKey: ['teams', exercise] }); toast({ title: 'Attendance file removed', description: 'Pair programming data was cleared.', @@ -269,7 +231,29 @@ export default function Teams() { reject(error); }, undefined, // onPhaseChange - undefined, // onGitDone + async () => { + // PP recompute runs synchronously on the server before GIT_DONE is emitted, + // so PP statuses are already in the DB. Fetch from DB and merge only PP fields + // into the SSE-driven cache (preserving analysisStatus, cqi, etc.) + try { + const response = await requestApi.getTeamSummaries(parseInt(exercise)); + const dbTeams = response.data.map(transformSummaryToTeamDTO); + queryClient.setQueryData(['teams', exercise], (old: TeamDTO[] = []) => + old.map(existing => { + const db = dbTeams.find(d => d.teamId === existing.teamId); + if (!db) return existing; + return Object.assign({}, existing, { + pairProgrammingStatus: db.pairProgrammingStatus, + subMetrics: existing.subMetrics?.map(m => + m.name === 'Pair Programming' && db.subMetrics ? (db.subMetrics.find(dm => dm.name === 'Pair Programming') ?? m) : m, + ), + }); + }), + ); + } catch { + // Non-critical — PP badges will appear on next page refresh + } + }, // onGitDone info => queryClient.setQueryData(['templateAuthors', exercise], (old: TemplateAuthorInfo[] = []) => { if (old.some(a => a.email === info.email)) return old; @@ -278,6 +262,9 @@ export default function Teams() { candidates => queryClient.setQueryData(['templateAuthorCandidates', exercise], candidates), mode, statusUpdate => { + // Don't override CANCELLED status with RUNNING from lingering SSE events + const currentStatus = queryClient.getQueryData(['analysisStatus', exercise]); + if (currentStatus?.state === 'CANCELLED') return; queryClient.setQueryData(['analysisStatus', exercise], (old: typeof status) => Object.assign({}, old, { state: 'RUNNING' as const, @@ -293,6 +280,14 @@ export default function Teams() { }); }, onSuccess: () => { + // Check if analysis was cancelled — the SSE CANCELLED event also resolves via onComplete + const currentStatus = queryClient.getQueryData(['analysisStatus', exercise]); + if (currentStatus?.state === 'CANCELLED') { + // Already handled by cancelMutation.onSuccess — just refetch to be safe + queryClient.invalidateQueries({ queryKey: ['teams', exercise] }); + refetchStatus(); + return; + } toast({ title: 'Analysis completed!' }); queryClient.invalidateQueries({ queryKey: ['teams', exercise] }); queryClient.invalidateQueries({ queryKey: ['templateAuthors', exercise] }); @@ -305,6 +300,13 @@ export default function Teams() { refetchStatus(); return; } + // Don't show error toast if analysis was cancelled + const currentStatus = queryClient.getQueryData(['analysisStatus', exercise]); + if (currentStatus?.state === 'CANCELLED') { + queryClient.invalidateQueries({ queryKey: ['teams', exercise] }); + refetchStatus(); + return; + } toast({ variant: 'destructive', title: 'Failed to start analysis', @@ -325,8 +327,10 @@ export default function Teams() { }, onSuccess: () => { toast({ title: 'Analysis cancelled' }); - // Invalidate to get fresh data including cancelled teams - queryClient.invalidateQueries({ queryKey: ['teams', exercise] }); + // Don't invalidate teams here — the server may still be processing + // (DB was cleared at analysis start, teams may not be re-initialized yet). + // The SSE stream will close shortly and startMutation/recomputeMutation + // handlers will refetch teams once the server is in a consistent state. refetchStatus(); }, onError: () => { @@ -382,7 +386,26 @@ export default function Teams() { reject(error); }, undefined, // onPhaseChange - undefined, // onGitDone + async () => { + try { + const response = await requestApi.getTeamSummaries(parseInt(exercise)); + const dbTeams = response.data.map(transformSummaryToTeamDTO); + queryClient.setQueryData(['teams', exercise], (old: TeamDTO[] = []) => + old.map(existing => { + const db = dbTeams.find(d => d.teamId === existing.teamId); + if (!db) return existing; + return Object.assign({}, existing, { + pairProgrammingStatus: db.pairProgrammingStatus, + subMetrics: existing.subMetrics?.map(m => + m.name === 'Pair Programming' && db.subMetrics ? (db.subMetrics.find(dm => dm.name === 'Pair Programming') ?? m) : m, + ), + }); + }), + ); + } catch { + // Non-critical + } + }, // onGitDone info => queryClient.setQueryData(['templateAuthors', exercise], (old: TemplateAuthorInfo[] = []) => { if (old.some(a => a.email === info.email)) return old; @@ -391,6 +414,8 @@ export default function Teams() { candidates => queryClient.setQueryData(['templateAuthorCandidates', exercise], candidates), mode, statusUpdate => { + const currentStatus = queryClient.getQueryData(['analysisStatus', exercise]); + if (currentStatus?.state === 'CANCELLED') return; queryClient.setQueryData(['analysisStatus', exercise], (old: typeof status) => Object.assign({}, old, { state: 'RUNNING' as const, @@ -406,6 +431,12 @@ export default function Teams() { }); }, onSuccess: () => { + const currentStatus = queryClient.getQueryData(['analysisStatus', exercise]); + if (currentStatus?.state === 'CANCELLED') { + queryClient.invalidateQueries({ queryKey: ['teams', exercise] }); + refetchStatus(); + return; + } toast({ title: 'Reanalysis completed!' }); queryClient.invalidateQueries({ queryKey: ['teams', exercise] }); queryClient.invalidateQueries({ queryKey: ['templateAuthors', exercise] }); @@ -417,6 +448,12 @@ export default function Teams() { refetchStatus(); return; } + const currentStatus = queryClient.getQueryData(['analysisStatus', exercise]); + if (currentStatus?.state === 'CANCELLED') { + queryClient.invalidateQueries({ queryKey: ['teams', exercise] }); + refetchStatus(); + return; + } toast({ variant: 'destructive', title: 'Failed to reanalyze', @@ -432,9 +469,7 @@ export default function Teams() { onSuccess: () => { setAttendanceFile(null); setUploadedAttendanceFileName(null); - setPairProgrammingAttendanceByTeamName({}); window.sessionStorage.removeItem(attendanceStorageKey); - window.sessionStorage.removeItem(pairProgrammingAttendanceMapStorageKey); toast({ title: 'Data cleared successfully' }); queryClient.invalidateQueries({ queryKey: ['teams', exercise] }); refetchStatus(); @@ -493,14 +528,13 @@ export default function Teams() { // --- Handlers --- - const handleTeamSelect = (team: TeamDTO, pairProgrammingBadgeStatus: PairProgrammingBadgeStatus | null) => { + const handleTeamSelect = (team: TeamDTO) => { navigate(`/teams/${String(team.teamId)}`, { state: { teamId: team.teamId, course, exercise, pairProgrammingEnabled, - pairProgrammingBadgeStatus, courseAverages, analysisMode: status.analysisMode, teamsSearchParams: searchParams.toString(), @@ -551,7 +585,6 @@ export default function Teams() { pairProgrammingEnabled={pairProgrammingEnabled} attendanceFile={attendanceFile} uploadedAttendanceFileName={uploadedAttendanceFileName} - pairProgrammingAttendanceByTeamName={pairProgrammingAttendanceByTeamName} onAttendanceFileSelect={setAttendanceFile} onAttendanceUpload={handleAttendanceUpload} onRemoveUploadedAttendanceFile={handleRemoveUploadedAttendanceFile}