242 lines
9.3 KiB
JavaScript
242 lines
9.3 KiB
JavaScript
function parseDate(dateStr) {
|
||
const multiDayMatch = dateStr.match(/^(\d{1,2})(-([A-Za-z]{3}))?(\s+to\s+)(\d{1,2})-([A-Za-z]{3})-(\d{4})$/);
|
||
if (multiDayMatch) {
|
||
const day = parseInt(multiDayMatch[1]);
|
||
const month = multiDayMatch[3] || multiDayMatch[6];
|
||
const year = parseInt(multiDayMatch[7]);
|
||
|
||
const monthMap = {
|
||
'Jan': 0, 'Feb': 1, 'Mar': 2, 'Apr': 3, 'May': 4, 'Jun': 5,
|
||
'Jul': 6, 'Aug': 7, 'Sep': 8, 'Oct': 9, 'Nov': 10, 'Dec': 11
|
||
};
|
||
|
||
return new Date(year, monthMap[month], day);
|
||
}
|
||
|
||
const formats = [
|
||
/^(\d{1,2})-([A-Za-z]{3})-(\d{4})$/,
|
||
/^(\d{1,2})\/(\d{1,2})\/(\d{4})$/
|
||
];
|
||
|
||
for (const format of formats) {
|
||
const match = dateStr.match(format);
|
||
if (match) {
|
||
if (format === formats[0]) {
|
||
const monthMap = {
|
||
'Jan': 0, 'Feb': 1, 'Mar': 2, 'Apr': 3, 'May': 4, 'Jun': 5,
|
||
'Jul': 6, 'Aug': 7, 'Sep': 8, 'Oct': 9, 'Nov': 10, 'Dec': 11
|
||
};
|
||
const day = parseInt(match[1]);
|
||
const month = monthMap[match[2]];
|
||
const year = parseInt(match[3]);
|
||
return new Date(year, month, day);
|
||
}
|
||
}
|
||
}
|
||
|
||
return new Date(dateStr);
|
||
}
|
||
|
||
function getNextPDGAUpdateDate() {
|
||
const today = new Date();
|
||
const currentMonth = today.getMonth();
|
||
const currentYear = today.getFullYear();
|
||
|
||
const firstDayOfMonth = new Date(currentYear, currentMonth, 1);
|
||
const firstTuesday = new Date(firstDayOfMonth);
|
||
|
||
const daysUntilTuesday = (2 - firstDayOfMonth.getDay() + 7) % 7;
|
||
firstTuesday.setDate(1 + daysUntilTuesday);
|
||
|
||
const secondTuesday = new Date(firstTuesday);
|
||
secondTuesday.setDate(firstTuesday.getDate() + 7);
|
||
|
||
if (today <= secondTuesday) {
|
||
return secondTuesday;
|
||
} else {
|
||
const nextMonth = currentMonth === 11 ? 0 : currentMonth + 1;
|
||
const nextYear = currentMonth === 11 ? currentYear + 1 : currentYear;
|
||
|
||
const firstDayNextMonth = new Date(nextYear, nextMonth, 1);
|
||
const firstTuesdayNext = new Date(firstDayNextMonth);
|
||
|
||
const daysUntilTuesdayNext = (2 - firstDayNextMonth.getDay() + 7) % 7;
|
||
firstTuesdayNext.setDate(1 + daysUntilTuesdayNext);
|
||
|
||
const secondTuesdayNext = new Date(firstTuesdayNext);
|
||
secondTuesdayNext.setDate(firstTuesdayNext.getDate() + 7);
|
||
|
||
return secondTuesdayNext;
|
||
}
|
||
}
|
||
|
||
function calculateStandardDeviation(ratings) {
|
||
if (!ratings || ratings.length === 0) return 0;
|
||
|
||
const mean = ratings.reduce((sum, r) => sum + r, 0) / ratings.length;
|
||
const variance = ratings.reduce((sum, r) => sum + Math.pow(r - mean, 2), 0) / ratings.length;
|
||
|
||
return Math.sqrt(variance);
|
||
}
|
||
|
||
function calculatePredictedRating(roundRatings) {
|
||
const debugLog = [];
|
||
debugLog.push('=== PDGA RATING CALCULATION (Following Official Rules) ===');
|
||
|
||
if (!roundRatings || roundRatings.length === 0) {
|
||
debugLog.push('❌ No rounds provided for prediction');
|
||
return { rating: 0, debugLog, excludedRoundsCount: null, cutoffRating: null };
|
||
}
|
||
|
||
debugLog.push(`📊 Starting with ${roundRatings.length} total rounds`);
|
||
|
||
const nextUpdateDate = getNextPDGAUpdateDate();
|
||
debugLog.push(`🎯 PDGA Update Simulation: Next update date is ${nextUpdateDate.toDateString()}`);
|
||
debugLog.push(` Only including rounds played before ${nextUpdateDate.toDateString()}`);
|
||
|
||
const allSortedRounds = roundRatings
|
||
.filter(r => r.rating > 0 && r.date < nextUpdateDate)
|
||
.sort((a, b) => b.date - a.date);
|
||
|
||
if (allSortedRounds.length === 0) {
|
||
debugLog.push('❌ No valid rounds after filtering for update date');
|
||
return { rating: 0, debugLog, excludedRoundsCount: null, cutoffRating: null };
|
||
}
|
||
|
||
debugLog.push(`📊 After update date filter: ${allSortedRounds.length} rounds`);
|
||
|
||
const twelveMonthsBeforeUpdate = new Date(nextUpdateDate);
|
||
twelveMonthsBeforeUpdate.setFullYear(twelveMonthsBeforeUpdate.getFullYear() - 1);
|
||
|
||
const mostRecentDate = allSortedRounds[0].date;
|
||
debugLog.push(`📅 Most recent round: ${mostRecentDate.toDateString()}`);
|
||
debugLog.push(`📅 12-month cutoff: ${twelveMonthsBeforeUpdate.toDateString()} (1 year before update)`);
|
||
|
||
let eligibleRounds = allSortedRounds.filter(r => r.date >= twelveMonthsBeforeUpdate);
|
||
|
||
debugLog.push('🗓️ 12-MONTH FILTERING:');
|
||
debugLog.push(`✅ Rounds in last 12 months: ${eligibleRounds.length}`);
|
||
|
||
if (eligibleRounds.length < 8) {
|
||
const twentyFourMonthsBeforeUpdate = new Date(nextUpdateDate);
|
||
twentyFourMonthsBeforeUpdate.setFullYear(twentyFourMonthsBeforeUpdate.getFullYear() - 2);
|
||
|
||
eligibleRounds = allSortedRounds.filter(r => r.date >= twentyFourMonthsBeforeUpdate);
|
||
debugLog.push(`⚠️ Extended to 24 months before update (${twentyFourMonthsBeforeUpdate.toDateString()}) - now ${eligibleRounds.length} rounds`);
|
||
}
|
||
|
||
if (eligibleRounds.length === 0) {
|
||
debugLog.push('❌ No eligible rounds found');
|
||
return { rating: 0, debugLog, excludedRoundsCount: null, cutoffRating: null };
|
||
}
|
||
|
||
debugLog.push(`📈 ELIGIBLE ROUNDS: ${eligibleRounds.length}`);
|
||
eligibleRounds.forEach((round, index) => {
|
||
debugLog.push(` ${index + 1}. ${round.date.toDateString()}: ${round.rating} (${round.competition})`);
|
||
});
|
||
|
||
let workingRounds = [...eligibleRounds];
|
||
let workingRatings = workingRounds.map(r => r.rating);
|
||
let excludedRoundsCount = 0;
|
||
let cutoffRating = null;
|
||
|
||
if (workingRatings.length >= 7) {
|
||
debugLog.push('🔍 OUTLIER EXCLUSION (≥7 rounds available):');
|
||
|
||
const mean = workingRatings.reduce((sum, r) => sum + r, 0) / workingRatings.length;
|
||
const stdDev = calculateStandardDeviation(workingRatings);
|
||
|
||
debugLog.push(` Mean: ${mean.toFixed(1)}`);
|
||
debugLog.push(` Std Dev: ${stdDev.toFixed(1)}`);
|
||
|
||
const stdDevCutoff = mean - 2.5 * stdDev;
|
||
const hundredPointCutoff = mean - 100;
|
||
|
||
debugLog.push(` 2.5σ cutoff: ${stdDevCutoff.toFixed(1)}`);
|
||
debugLog.push(` 100-point cutoff: ${hundredPointCutoff.toFixed(1)}`);
|
||
|
||
const filteredRatings = workingRatings.filter(rating =>
|
||
rating >= stdDevCutoff && rating >= hundredPointCutoff
|
||
);
|
||
|
||
const stdDevOutliers = workingRatings.filter(rating => rating < stdDevCutoff);
|
||
const hundredPointOutliers = workingRatings.filter(rating => rating < hundredPointCutoff && rating >= stdDevCutoff);
|
||
|
||
excludedRoundsCount = stdDevOutliers.length + hundredPointOutliers.length;
|
||
cutoffRating = Math.round(Math.max(stdDevCutoff, hundredPointCutoff));
|
||
|
||
if (stdDevOutliers.length > 0) {
|
||
debugLog.push(` ❌ 2.5σ outliers removed: ${stdDevOutliers.length} rounds`);
|
||
stdDevOutliers.forEach(rating => {
|
||
const round = workingRounds.find(r => r.rating === rating);
|
||
debugLog.push(` - ${rating} (${round.date.toDateString()}: ${round.competition})`);
|
||
});
|
||
}
|
||
|
||
if (hundredPointOutliers.length > 0) {
|
||
debugLog.push(` ❌ 100-point outliers removed: ${hundredPointOutliers.length} rounds`);
|
||
hundredPointOutliers.forEach(rating => {
|
||
const round = workingRounds.find(r => r.rating === rating);
|
||
debugLog.push(` - ${rating} (${round.date.toDateString()}: ${round.competition})`);
|
||
});
|
||
}
|
||
|
||
if (stdDevOutliers.length === 0 && hundredPointOutliers.length === 0) {
|
||
debugLog.push(` ✅ No outliers detected`);
|
||
}
|
||
|
||
if (filteredRatings.length >= 4) {
|
||
workingRounds = workingRounds.filter(round =>
|
||
round.rating >= stdDevCutoff && round.rating >= hundredPointCutoff
|
||
);
|
||
workingRatings = filteredRatings;
|
||
debugLog.push(` ✅ Using ${filteredRatings.length} rounds after outlier removal`);
|
||
} else {
|
||
debugLog.push(` ⚠️ Too few rounds after outlier removal (${filteredRatings.length}), keeping all rounds`);
|
||
excludedRoundsCount = 0;
|
||
cutoffRating = null;
|
||
}
|
||
} else {
|
||
debugLog.push(`⏭️ OUTLIER EXCLUSION SKIPPED (only ${workingRatings.length} rounds, need ≥7)`);
|
||
}
|
||
|
||
debugLog.push('⚖️ WEIGHTING (Most recent 25% count double if ≥9 rounds):');
|
||
const weightedRatings = [];
|
||
|
||
if (workingRatings.length >= 9) {
|
||
const recentCount = Math.round(workingRatings.length * 0.25);
|
||
debugLog.push(` ✅ Double-weighting most recent ${recentCount} rounds`);
|
||
|
||
weightedRatings.push(...workingRatings);
|
||
|
||
for (let i = 0; i < recentCount; i++) {
|
||
weightedRatings.push(workingRatings[i]);
|
||
const round = workingRounds[i];
|
||
debugLog.push(` 2x weight: ${workingRatings[i]} (${round.date.toDateString()}: ${round.competition})`);
|
||
}
|
||
|
||
debugLog.push(` 📊 Total values: ${workingRatings.length} + ${recentCount} double-weighted = ${weightedRatings.length}`);
|
||
} else {
|
||
debugLog.push(` ➡️ No double weighting (${workingRatings.length} rounds, need ≥9)`);
|
||
weightedRatings.push(...workingRatings);
|
||
}
|
||
|
||
const sum = weightedRatings.reduce((sum, r) => sum + r, 0);
|
||
const average = sum / weightedRatings.length;
|
||
const finalRating = Math.round(average);
|
||
|
||
const stdDev = calculateStandardDeviation(weightedRatings);
|
||
|
||
debugLog.push('🎯 FINAL CALCULATION:');
|
||
debugLog.push(` Sum: ${sum}`);
|
||
debugLog.push(` Count: ${weightedRatings.length}`);
|
||
debugLog.push(` Average: ${average.toFixed(1)}`);
|
||
debugLog.push(` Standard Deviation: ${stdDev.toFixed(1)}`);
|
||
debugLog.push(` Final Rating: ${finalRating}`);
|
||
debugLog.push('=== END PDGA CALCULATION ===');
|
||
|
||
return { rating: finalRating, stdDev: Math.round(stdDev), debugLog, excludedRoundsCount, cutoffRating };
|
||
}
|
||
|
||
module.exports = { parseDate, getNextPDGAUpdateDate, calculatePredictedRating, calculateStandardDeviation };
|