Bahn: aisupport, Analyse-O2C-C2S, awesome-bahn-mcp-servers, beam-mcp,
Confluence_Bot, db-planet-mcp-server, O2C-Harness, project-audit,
Projekt-KIQ-HP, teamlandkarte-mcp
Dhive: Jury-Voting
Privat: CV, NoteGraph (NOTE: NoteGraph needs complete redo after consolidation)
Shared: AI-Orchestrator, OrgMyLife, power_skills_and_more
Shared/references: symphony (read-only)
Bahn repos remain available as independent remotes - this monorepo
pulls them in via subtree, the originals are untouched.
150 lines
4.6 KiB
TypeScript
150 lines
4.6 KiB
TypeScript
import { Score, Team, TeamResult, Criterion, CriterionStats } from '../shared/types.js';
|
|
|
|
/**
|
|
* Calculate the aggregated result for a single team.
|
|
*
|
|
* - total = sum of all score.value where score.teamId matches
|
|
* - jurorCount = count of distinct jurorIds that have submitted ALL criteria (criteriaCount scores)
|
|
* - average = total / jurorCount (or 0 if jurorCount is 0)
|
|
* - stddev = standard deviation of per-juror totals
|
|
* - complete = jurorCount equals total number of jurors who have at least one score for this team
|
|
* AND all have all criteria
|
|
*/
|
|
export function calculateTeamResult(
|
|
teamId: string,
|
|
teamName: string,
|
|
scores: Score[],
|
|
criteriaCount: number,
|
|
criteria?: Criterion[]
|
|
): TeamResult {
|
|
const teamScores = scores.filter((s) => s.teamId === teamId);
|
|
const total = teamScores.reduce((sum, s) => sum + s.value, 0);
|
|
|
|
// Group scores by jurorId
|
|
const scoresByJuror = new Map<string, Score[]>();
|
|
for (const score of teamScores) {
|
|
const existing = scoresByJuror.get(score.jurorId) || [];
|
|
existing.push(score);
|
|
scoresByJuror.set(score.jurorId, existing);
|
|
}
|
|
|
|
// Count jurors who have submitted ALL criteria
|
|
const jurorCount = Array.from(scoresByJuror.values()).filter(
|
|
(jurorScores) => jurorScores.length >= criteriaCount
|
|
).length;
|
|
|
|
const average = jurorCount > 0 ? total / jurorCount : 0;
|
|
|
|
// Calculate stddev of per-juror totals
|
|
const jurorTotals = Array.from(scoresByJuror.values())
|
|
.filter((jurorScores) => jurorScores.length >= criteriaCount)
|
|
.map((jurorScores) => jurorScores.reduce((sum, s) => sum + s.value, 0));
|
|
|
|
let stddev = 0;
|
|
if (jurorTotals.length > 1) {
|
|
const mean = jurorTotals.reduce((a, b) => a + b, 0) / jurorTotals.length;
|
|
const variance = jurorTotals.reduce((sum, v) => sum + (v - mean) ** 2, 0) / jurorTotals.length;
|
|
stddev = Math.sqrt(variance);
|
|
}
|
|
|
|
// Complete = all jurors who have at least one score have submitted all criteria
|
|
const totalJurorsWithScores = scoresByJuror.size;
|
|
const complete = totalJurorsWithScores > 0 && jurorCount === totalJurorsWithScores;
|
|
|
|
// Per-criterion stats
|
|
let criteriaStats: CriterionStats[] | undefined;
|
|
if (criteria) {
|
|
criteriaStats = criteria.map((criterion) => {
|
|
const criterionScores = teamScores
|
|
.filter((s) => s.criterionId === criterion.id)
|
|
.map((s) => s.value);
|
|
const cMean = criterionScores.length > 0
|
|
? criterionScores.reduce((a, b) => a + b, 0) / criterionScores.length
|
|
: 0;
|
|
let cStddev = 0;
|
|
if (criterionScores.length > 1) {
|
|
const cVariance = criterionScores.reduce((sum, v) => sum + (v - cMean) ** 2, 0) / criterionScores.length;
|
|
cStddev = Math.sqrt(cVariance);
|
|
}
|
|
return {
|
|
criterionId: criterion.id,
|
|
criterionName: criterion.nameDE,
|
|
mean: cMean,
|
|
stddev: cStddev,
|
|
scores: criterionScores,
|
|
};
|
|
});
|
|
}
|
|
|
|
return {
|
|
teamId,
|
|
teamName,
|
|
total,
|
|
average,
|
|
stddev,
|
|
rank: 0, // Will be assigned by calculateRankings
|
|
jurorCount,
|
|
complete,
|
|
criteriaStats,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Sort team results by total descending and assign dense ranking.
|
|
* Ties get the same rank.
|
|
* Example: totals [250, 240, 240, 230] → ranks [1, 2, 2, 4]
|
|
*/
|
|
export function calculateRankings(teamResults: TeamResult[]): TeamResult[] {
|
|
// Sort by total descending
|
|
const sorted = [...teamResults].sort((a, b) => b.total - a.total);
|
|
|
|
// Assign dense ranking with ties
|
|
let currentRank = 1;
|
|
for (let i = 0; i < sorted.length; i++) {
|
|
if (i > 0 && sorted[i].total < sorted[i - 1].total) {
|
|
currentRank = i + 1;
|
|
}
|
|
sorted[i] = { ...sorted[i], rank: currentRank };
|
|
}
|
|
|
|
return sorted;
|
|
}
|
|
|
|
/**
|
|
* Get the completion status for a specific juror-team pair.
|
|
*
|
|
* - 0 scores → 'pending'
|
|
* - 1 to criteriaCount-1 → 'partial'
|
|
* - criteriaCount → 'complete'
|
|
*/
|
|
export function getCompletionStatus(
|
|
jurorId: string,
|
|
teamId: string,
|
|
scores: Score[],
|
|
criteriaCount: number
|
|
): 'complete' | 'partial' | 'pending' {
|
|
const count = scores.filter(
|
|
(s) => s.jurorId === jurorId && s.teamId === teamId
|
|
).length;
|
|
|
|
if (count === 0) return 'pending';
|
|
if (count >= criteriaCount) return 'complete';
|
|
return 'partial';
|
|
}
|
|
|
|
/**
|
|
* Calculate results for all teams and apply rankings.
|
|
* Convenience function combining calculateTeamResult + calculateRankings.
|
|
*/
|
|
export function calculateAllResults(
|
|
scores: Score[],
|
|
teams: Team[],
|
|
criteriaCount: number,
|
|
criteria?: Criterion[]
|
|
): TeamResult[] {
|
|
const teamResults = teams.map((team) =>
|
|
calculateTeamResult(team.id, team.name, scores, criteriaCount, criteria)
|
|
);
|
|
return calculateRankings(teamResults);
|
|
}
|