diff --git a/app/Commands/SyncPaypalPayments.php b/app/Commands/SyncPaypalPayments.php index 05667ca..ef23972 100644 --- a/app/Commands/SyncPaypalPayments.php +++ b/app/Commands/SyncPaypalPayments.php @@ -36,13 +36,13 @@ class SyncPaypalPayments extends BaseCommand $this->invoiceModel = new InvoiceModel(); $this->studentModel = new StudentModel(); $this->enrollmentModel = new EnrollmentModel(); - - $this->semester = $this->configModel->getConfig('semester'); - $this->schoolYear = $this->configModel->getConfig('school_year'); } public function run(array $params) { + $this->semester = (string) ($this->configModel->getConfig('semester') ?? ''); + $this->schoolYear = (string) ($this->configModel->getConfig('school_year') ?? ''); + $dryRun = CLI::getOption('dry-run'); $reportOnly = CLI::getOption('report-only'); $mode = $reportOnly ? 'REPORT-ONLY' : ($dryRun ? 'DRY-RUN' : 'LIVE'); diff --git a/app/Config/Routes.php b/app/Config/Routes.php index 1ec333d..9553317 100644 --- a/app/Config/Routes.php +++ b/app/Config/Routes.php @@ -174,6 +174,10 @@ $routes->group('administrator/subject-curriculum', ['filter' => 'auth:update_cur $routes->post('delete/(:num)', 'View\SubjectCurriculumController::delete/$1'); }); +$routes->get('administrator/trophy', 'View\TrophyController::index', ['filter' => 'auth:admin']); +$routes->get('administrator/trophy/winners', 'View\TrophyController::winners', ['filter' => 'auth:admin']); +$routes->get('administrator/trophy/final', 'View\TrophyController::final', ['filter' => 'auth:admin']); + /* diff --git a/app/Config/Services.php b/app/Config/Services.php index c68d299..68aba46 100644 --- a/app/Config/Services.php +++ b/app/Config/Services.php @@ -36,7 +36,7 @@ class Services extends BaseService /** * Override CI Email service to enforce a global Reply-To. */ - public static function email(array $config = null, bool $getShared = true) + public static function email(?array $config = null, bool $getShared = true) { if ($getShared) { return static::getSharedInstance('email', $config); diff --git a/app/Controllers/View/TrophyController.php b/app/Controllers/View/TrophyController.php new file mode 100644 index 0000000..719cf27 --- /dev/null +++ b/app/Controllers/View/TrophyController.php @@ -0,0 +1,504 @@ +configModel = new ConfigurationModel(); + } + + public function index() + { + $db = \Config\Database::connect(); + + $currentYear = $this->configModel->getConfig('school_year') ?? ''; + $selectedYear = $this->request->getGet('school_year') ?? $currentYear; + + $percentile = (float) ($this->request->getGet('percentile') ?? 75); + $percentile = max(1.0, min(99.0, $percentile)); + + // Available school years from class assignments so the current year can + // still appear even if fall scores are not fully entered yet. + $years = $db->table('student_class') + ->select('school_year') + ->distinct() + ->orderBy('school_year', 'DESC') + ->get() + ->getResultArray(); + $years = array_column($years, 'school_year'); + + // Build a fall-score-based projection. We start from class assignments so + // students without a recorded fall score still appear as "not yet + // projected" rather than disappearing from the page. + $rows = $db->table('student_class sc') + ->select([ + 'sc.student_id', + 'sc.class_section_id', + 'MAX(ss.semester_score) AS fall_score', + 'cs.class_section_name', + 'CONCAT(s.firstname, " ", s.lastname) AS student_name', + 's.school_id', + 's.gender', + ]) + ->join('classSection cs', 'cs.class_section_id = sc.class_section_id', 'left') + ->join('students s', 's.id = sc.student_id', 'left') + ->join( + 'semester_scores ss', + 'ss.student_id = sc.student_id' + . ' AND ss.class_section_id = sc.class_section_id' + . ' AND ss.school_year = ' . $db->escape($selectedYear) + . ' AND LOWER(ss.semester) = "fall"', + 'left' + ) + ->where('sc.school_year', $selectedYear) + ->orderBy('cs.class_section_name', 'ASC') + ->orderBy('fall_score', 'DESC') + ->orderBy('student_name', 'ASC') + ->groupBy('sc.student_id, sc.class_section_id, cs.class_section_name, s.firstname, s.lastname, s.school_id') + ->get() + ->getResultArray(); + + // Group rows by class section + $sections = []; + foreach ($rows as $row) { + $sid = (int) ($row['class_section_id'] ?? 0); + $name = $row['class_section_name'] ?? 'Section ' . $sid; + if (!isset($sections[$sid])) { + $sections[$sid] = ['name' => $name, 'students' => []]; + } + $sections[$sid]['students'][] = [ + 'student_id' => (int) $row['student_id'], + 'name' => $row['student_name'], + 'school_id' => $row['school_id'], + 'gender' => $row['gender'] ?? '', + 'fall_score' => is_numeric($row['fall_score']) ? (float) $row['fall_score'] : null, + ]; + } + + // Calculate trophy thresholds from fall scores only, then project who is + // currently on track for a year-end trophy. + $classResults = []; + foreach ($sections as $sid => $section) { + usort($section['students'], static function (array $a, array $b): int { + $aScore = $a['fall_score']; + $bScore = $b['fall_score']; + + if ($aScore === null && $bScore === null) { + return strcmp($a['name'], $b['name']); + } + + if ($aScore === null) { + return 1; + } + + if ($bScore === null) { + return -1; + } + + return $bScore <=> $aScore ?: strcmp($a['name'], $b['name']); + }); + + $scores = array_column($section['students'], 'fall_score'); + $calc = $this->calculateThreshold($scores, $percentile); + $threshold = $calc['threshold']; + + $students = array_map(function (array $student) use ($threshold): array { + $student['projected_trophy'] = $threshold !== null + && $student['fall_score'] !== null + && $student['fall_score'] >= $threshold; + return $student; + }, $section['students']); + + $scoredCount = count(array_filter( + array_column($students, 'fall_score'), + static fn ($score) => $score !== null + )); + + $boys = array_filter($students, static fn ($s) => strtolower($s['gender']) === 'male'); + $girls = array_filter($students, static fn ($s) => strtolower($s['gender']) === 'female'); + $trophyBoys = array_filter($boys, static fn ($s) => $s['projected_trophy']); + $trophyGirls = array_filter($girls, static fn ($s) => $s['projected_trophy']); + $nBoys = count($boys); + $nGirls = count($girls); + $nTrophyBoys = count($trophyBoys); + $nTrophyGirls = count($trophyGirls); + $total = count($students); + + $classResults[] = [ + 'section_id' => $sid, + 'section_name' => $section['name'], + 'students' => $students, + 'threshold' => $threshold, + 'trophy_count' => $calc['winners'], + 'student_count' => $total, + 'scored_count' => $scoredCount, + 'method' => $calc['method'], + 'boys' => $nBoys, + 'girls' => $nGirls, + 'trophy_boys' => $nTrophyBoys, + 'trophy_girls' => $nTrophyGirls, + 'pct_boys' => $total > 0 ? round($nBoys / $total * 100) : 0, + 'pct_girls' => $total > 0 ? round($nGirls / $total * 100) : 0, + 'pct_trophy_boys' => $nBoys > 0 ? round($nTrophyBoys / $nBoys * 100) : 0, + 'pct_trophy_girls' => $nGirls > 0 ? round($nTrophyGirls / $nGirls * 100) : 0, + 'pct_trophy_total' => $total > 0 ? round($calc['winners'] / $total * 100): 0, + ]; + } + + return view('administrator/trophy', [ + 'classResults' => $classResults, + 'selectedYear' => $selectedYear, + 'selectedPercentile'=> $percentile, + 'years' => $years, + ]); + } + + public function winners() + { + $db = \Config\Database::connect(); + + $currentYear = $this->configModel->getConfig('school_year') ?? ''; + $selectedYear = $this->request->getGet('school_year') ?? $currentYear; + $percentile = (float) ($this->request->getGet('percentile') ?? 75); + $percentile = max(1.0, min(99.0, $percentile)); + + $years = $db->table('student_class') + ->select('school_year')->distinct() + ->orderBy('school_year', 'DESC') + ->get()->getResultArray(); + $years = array_column($years, 'school_year'); + + $ey = $db->escape($selectedYear); + + $rows = $db->table('student_class sc') + ->select([ + 'sc.student_id', + 'sc.class_section_id', + 'cs.class_section_name', + 'CONCAT(s.firstname, " ", s.lastname) AS student_name', + 's.school_id', + 's.gender', + 'MAX(sf.semester_score) AS fall_score', + 'MAX(sp.semester_score) AS spring_score', + ]) + ->join('classSection cs', 'cs.class_section_id = sc.class_section_id', 'left') + ->join('students s', 's.id = sc.student_id', 'left') + ->join('semester_scores sf', + 'sf.student_id = sc.student_id AND sf.class_section_id = sc.class_section_id' + . ' AND sf.school_year = ' . $ey . ' AND LOWER(sf.semester) = "fall"', 'left') + ->join('semester_scores sp', + 'sp.student_id = sc.student_id AND sp.class_section_id = sc.class_section_id' + . ' AND sp.school_year = ' . $ey . ' AND LOWER(sp.semester) = "spring"', 'left') + ->where('sc.school_year', $selectedYear) + ->orderBy('cs.class_section_name', 'ASC') + ->orderBy('student_name', 'ASC') + ->groupBy('sc.student_id, sc.class_section_id, cs.class_section_name, s.firstname, s.lastname, s.school_id, s.gender') + ->get()->getResultArray(); + + $sections = []; + foreach ($rows as $row) { + $sid = (int) ($row['class_section_id'] ?? 0); + $name = $row['class_section_name'] ?? 'Section ' . $sid; + if (!isset($sections[$sid])) $sections[$sid] = ['name' => $name, 'students' => []]; + $fall = is_numeric($row['fall_score']) ? (float) $row['fall_score'] : null; + $spring = is_numeric($row['spring_score']) ? (float) $row['spring_score'] : null; + $year = ($fall !== null && $spring !== null) + ? round(($fall + $spring) / 2, 1) + : ($fall ?? $spring); + $sections[$sid]['students'][] = [ + 'name' => $row['student_name'], + 'school_id' => $row['school_id'], + 'gender' => $row['gender'] ?? '', + 'fall_score' => $fall, + 'spring_score' => $spring, + 'year_score' => $year, + ]; + } + + $classResults = []; + foreach ($sections as $section) { + usort($section['students'], static function ($a, $b) { + if ($a['fall_score'] === null && $b['fall_score'] === null) return strcmp($a['name'], $b['name']); + if ($a['fall_score'] === null) return 1; + if ($b['fall_score'] === null) return -1; + return $b['fall_score'] <=> $a['fall_score'] ?: strcmp($a['name'], $b['name']); + }); + + $scores = array_column($section['students'], 'fall_score'); + $calc = $this->calculateThreshold($scores, $percentile); + $threshold = $calc['threshold']; + + $allStudents = $section['students']; + $winners = array_values(array_filter($allStudents, function ($s) use ($threshold) { + return $threshold !== null && $s['fall_score'] !== null && $s['fall_score'] >= $threshold; + })); + + if (empty($winners)) continue; + + $boys = array_filter($allStudents, static fn($s) => strtolower($s['gender']) === 'male'); + $girls = array_filter($allStudents, static fn($s) => strtolower($s['gender']) === 'female'); + $trophyBoys = array_filter($winners, static fn($s) => strtolower($s['gender']) === 'male'); + $trophyGirls = array_filter($winners, static fn($s) => strtolower($s['gender']) === 'female'); + $n = count($allStudents); + $nBoys = count($boys); + $nGirls = count($girls); + $nTB = count($trophyBoys); + $nTG = count($trophyGirls); + + $classResults[] = [ + 'section_name' => $section['name'], + 'threshold' => $threshold, + 'winners' => $winners, + 'student_count' => $n, + 'boys' => $nBoys, + 'girls' => $nGirls, + 'trophy_boys' => $nTB, + 'trophy_girls' => $nTG, + 'pct_boys' => $n > 0 ? round($nBoys / $n * 100) : 0, + 'pct_girls' => $n > 0 ? round($nGirls / $n * 100) : 0, + 'pct_trophy_boys' => $nBoys > 0 ? round($nTB / $nBoys * 100) : 0, + 'pct_trophy_girls' => $nGirls > 0 ? round($nTG / $nGirls * 100) : 0, + 'pct_trophy_total' => $n > 0 ? round(count($winners) / $n * 100) : 0, + ]; + } + + return view('administrator/trophy_winners', [ + 'classResults' => $classResults, + 'selectedYear' => $selectedYear, + 'selectedPercentile' => $percentile, + 'years' => $years, + ]); + } + + public function final() + { + $db = \Config\Database::connect(); + + $currentYear = $this->configModel->getConfig('school_year') ?? ''; + $selectedYear = $this->request->getGet('school_year') ?? $currentYear; + $percentile = (float) ($this->request->getGet('percentile') ?? 75); + $percentile = max(1.0, min(99.0, $percentile)); + + $years = $db->table('student_class') + ->select('school_year')->distinct() + ->orderBy('school_year', 'DESC') + ->get()->getResultArray(); + $years = array_column($years, 'school_year'); + + $ey = $db->escape($selectedYear); + + $rows = $db->table('student_class sc') + ->select([ + 'sc.student_id', + 'sc.class_section_id', + 'cs.class_section_name', + 'CONCAT(s.firstname, " ", s.lastname) AS student_name', + 's.school_id', + 's.gender', + 'MAX(sf.semester_score) AS fall_score', + 'MAX(sp.semester_score) AS spring_score', + ]) + ->join('classSection cs', 'cs.class_section_id = sc.class_section_id', 'left') + ->join('students s', 's.id = sc.student_id', 'left') + ->join('semester_scores sf', + 'sf.student_id = sc.student_id AND sf.class_section_id = sc.class_section_id' + . ' AND sf.school_year = ' . $ey . ' AND LOWER(sf.semester) = "fall"', 'left') + ->join('semester_scores sp', + 'sp.student_id = sc.student_id AND sp.class_section_id = sc.class_section_id' + . ' AND sp.school_year = ' . $ey . ' AND LOWER(sp.semester) = "spring"', 'left') + ->where('sc.school_year', $selectedYear) + ->orderBy('cs.class_section_name', 'ASC') + ->orderBy('student_name', 'ASC') + ->groupBy('sc.student_id, sc.class_section_id, cs.class_section_name, s.firstname, s.lastname, s.school_id, s.gender') + ->get()->getResultArray(); + + $sections = []; + foreach ($rows as $row) { + $sid = (int) ($row['class_section_id'] ?? 0); + $name = $row['class_section_name'] ?? 'Section ' . $sid; + if (!isset($sections[$sid])) $sections[$sid] = ['name' => $name, 'students' => []]; + + $fall = is_numeric($row['fall_score']) ? (float) $row['fall_score'] : null; + $spring = is_numeric($row['spring_score']) ? (float) $row['spring_score'] : null; + $year = ($fall !== null && $spring !== null) + ? round(($fall + $spring) / 2, 1) + : ($fall ?? $spring); + + $sections[$sid]['students'][] = [ + 'name' => $row['student_name'], + 'school_id' => $row['school_id'], + 'gender' => $row['gender'] ?? '', + 'fall_score' => $fall, + 'spring_score' => $spring, + 'year_score' => $year, + ]; + } + + $classResults = []; + foreach ($sections as $section) { + $students = $section['students']; + + $fallCalc = $this->calculateThreshold(array_column($students, 'fall_score'), $percentile); + $yearCalc = $this->calculateThreshold(array_column($students, 'year_score'), $percentile); + $fallThreshold = $fallCalc['threshold']; + $yearThreshold = $yearCalc['threshold']; + + $annotated = array_map(static function (array $s) use ($fallThreshold, $yearThreshold): array { + $predicted = $fallThreshold !== null && $s['fall_score'] !== null && $s['fall_score'] >= $fallThreshold; + $actual = $yearThreshold !== null && $s['year_score'] !== null && $s['year_score'] >= $yearThreshold; + $status = match (true) { + $predicted && $actual => 'confirmed', + !$predicted && $actual => 'surprise', + $predicted && !$actual => 'missed', + default => 'none', + }; + return $s + compact('predicted', 'actual', 'status'); + }, $students); + + usort($annotated, static function (array $a, array $b): int { + if ($a['year_score'] === null && $b['year_score'] === null) return strcmp($a['name'], $b['name']); + if ($a['year_score'] === null) return 1; + if ($b['year_score'] === null) return -1; + return $b['year_score'] <=> $a['year_score'] ?: strcmp($a['name'], $b['name']); + }); + + $nConfirmed = count(array_filter($annotated, static fn ($s) => $s['status'] === 'confirmed')); + $nSurprise = count(array_filter($annotated, static fn ($s) => $s['status'] === 'surprise')); + $nMissed = count(array_filter($annotated, static fn ($s) => $s['status'] === 'missed')); + $nPredicted = count(array_filter($annotated, static fn ($s) => $s['predicted'])); + $nActual = count(array_filter($annotated, static fn ($s) => $s['actual'])); + $n = count($annotated); + + $classResults[] = [ + 'section_name' => $section['name'], + 'students' => $annotated, + 'student_count' => $n, + 'fall_threshold' => $fallThreshold, + 'year_threshold' => $yearThreshold, + 'predicted_count' => $nPredicted, + 'actual_count' => $nActual, + 'confirmed' => $nConfirmed, + 'surprises' => $nSurprise, + 'missed' => $nMissed, + 'accuracy' => $nPredicted > 0 ? round($nConfirmed / $nPredicted * 100) : ($nActual === 0 ? 100 : 0), + ]; + } + + return view('administrator/trophy_final', [ + 'classResults' => $classResults, + 'selectedYear' => $selectedYear, + 'selectedPercentile' => $percentile, + 'years' => $years, + ]); + } + + private function calculateThreshold(array $scores, float $percentile = 75.0): array + { + $scores = array_values(array_filter( + $scores, + static fn ($v) => is_numeric($v) && $v !== null + )); + $scores = array_map('floatval', $scores); + sort($scores); // ascending + $n = count($scores); + + if ($n === 0) { + return ['threshold' => null, 'winners' => 0, 'method' => 'empty']; + } + + $minWinners = 3; // never fewer than 3, regardless of class size or score count + // Hard maximum: top (100 - percentile)% of class, but never below the minimum. + $maxWinners = max($minWinners, (int) floor($n * (1 - $percentile / 100))); + + $threshold = $this->empiricalPercentile($scores, $percentile); + $winners = $this->countAtOrAbove($scores, $threshold); + + if ($winners < $minWinners) { + // Too few qualify — REDUCE the threshold down to the score of the + // 3rd-ranked student so the minimum of 3 is always met. + // If fewer than 3 students have scores, award all of them. + $target = min($minWinners, $n); + $desc = array_reverse($scores); // descending + $threshold = $desc[$target - 1]; // score at rank $target + $winners = $this->countAtOrAbove($scores, $threshold); // ties included + return ['threshold' => $threshold, 'winners' => $winners, 'method' => 'min3_reduced']; + } + + if ($winners <= $maxWinners) { + return ['threshold' => $threshold, 'winners' => $winners, 'method' => 'empirical_percentile']; + } + + // Too many winners — raise threshold to respect the cap. + $result = $this->capByRank($scores, $maxWinners); + + // capByRank may overshoot and drop below the minimum (e.g. bimodal scores + // where only 2 students are in the top cluster). Enforce min=3 here too. + if ($result['winners'] < $minWinners) { + $target = min($minWinners, $n); + $desc = array_reverse($scores); + $threshold = $desc[$target - 1]; + $winners = $this->countAtOrAbove($scores, $threshold); + return ['threshold' => $threshold, 'winners' => $winners, 'method' => 'min3_after_cap']; + } + + return $result; + } + + /** + * Raise the threshold until at most $max students qualify. + * Returns the result without checking the minimum — caller must do that. + */ + private function capByRank(array $sortedScores, int $max): array + { + $desc = array_reverse($sortedScores); + $threshold = $desc[$max - 1]; + $winners = $this->countAtOrAbove($sortedScores, $threshold); + + if ($winners <= $max) { + return ['threshold' => $threshold, 'winners' => $winners, 'method' => 'capped_25pct']; + } + + // Ties push it over — walk up through distinct scores. + $unique = array_values(array_unique(array_filter( + $sortedScores, + static fn ($s) => $s > $threshold + ))); + sort($unique); + + foreach ($unique as $candidate) { + $w = $this->countAtOrAbove($sortedScores, $candidate); + if ($w <= $max) { + return ['threshold' => $candidate, 'winners' => $w, 'method' => 'capped_25pct']; + } + } + + // All scores are equal. + return ['threshold' => $sortedScores[0], 'winners' => count($sortedScores), 'method' => 'all_equal']; + } + + private function empiricalPercentile(array $sortedScores, float $p): float + { + $n = count($sortedScores); + if ($n === 0) return 0.0; + $index = ($p / 100.0) * ($n - 1); + $lower = (int) floor($index); + $upper = (int) ceil($index); + if ($lower === $upper) return $sortedScores[$lower]; + return $sortedScores[$lower] + ($index - $lower) * ($sortedScores[$upper] - $sortedScores[$lower]); + } + + private function countAtOrAbove(array $scores, float $threshold): int + { + return count(array_filter($scores, static fn ($s) => $s >= $threshold)); + } + +} diff --git a/app/Views/administrator/trophy.php b/app/Views/administrator/trophy.php new file mode 100644 index 0000000..32449c5 --- /dev/null +++ b/app/Views/administrator/trophy.php @@ -0,0 +1,389 @@ += $this->extend('layout/management_layout') ?> += $this->section('content') ?> + + 0 ? round($totalBoys / $totalStudents * 100) : 0; +$pctGirls = $totalStudents > 0 ? round($totalGirls / $totalStudents * 100) : 0; +$pctTrophyBoys = $totalBoys > 0 ? round($totalTrophyBoys / $totalBoys * 100) : 0; +$pctTrophyGirls = $totalGirls > 0 ? round($totalTrophyGirls / $totalGirls * 100) : 0; +$pctTrophyAll = $totalStudents > 0 ? round($totalTrophies / $totalStudents * 100) : 0; +?> + +
+ Fall scores are used to calculate a per-class threshold at the + = (int) $selectedPercentile ?>th percentile and project which students are on track + for a year-end trophy. Minimum 3 trophies per class. Names are hidden by default. +
+| Class | +Students | +Scored | ++ Trophies + | ++ Boys + | ++ Girls + | +Trophy Boys | +Trophy Girls | +Rate | +Threshold | +Custom Threshold | +
|---|---|---|---|---|---|---|---|---|---|---|
| = esc($cls['section_name']) ?> | += $cls['student_count'] ?> | ++ + + = $cls['scored_count'] ?> + + + = $cls['scored_count'] ?> + + | ++ + + = $cls['trophy_count'] ?> + + | ++ = $cls['boys'] ?> + (= $cls['pct_boys'] ?>%) + | ++ = $cls['girls'] ?> + (= $cls['pct_girls'] ?>%) + | += $cls['trophy_boys'] ?> 0): ?> (= $cls['pct_trophy_boys'] ?>%) | += $cls['trophy_girls'] ?> 0): ?> (= $cls['pct_trophy_girls'] ?>%) | +
+
+
+
+ = $cls['pct_trophy_total'] ?>%
+ |
+ + = $cls['threshold'] !== null ? number_format((float)$cls['threshold'], 1) : '—' ?> + | +
+
+ $s !== null))) ?>'
+ data-girls='= json_encode(array_values(array_filter(array_map(fn($s) => $s['gender'] === 'Female' ? $s['fall_score'] : null, $cls['students']), fn($s) => $s !== null))) ?>'
+ data-total="= $cls['student_count'] ?>"
+ data-nboys="= $cls['boys'] ?>"
+ data-ngirls="= $cls['girls'] ?>">
+
+
+
+ |
+
| Total | += $totalStudents ?> | += $totalScored ?> | ++ + = $totalTrophies ?> + + | ++ = $totalBoys ?> + (= $pctBoys ?>%) + | ++ = $totalGirls ?> + (= $pctGirls ?>%) + | ++ = $totalTrophyBoys ?> + (= $pctTrophyBoys ?>%) + | ++ = $totalTrophyGirls ?> + (= $pctTrophyGirls ?>%) + | +
+
+
+
+ = $pctTrophyAll ?>%
+ |
+ + | + |
+ Compares the Fall-score prediction (= (int)$selectedPercentile ?>th percentile) + with the year-end result based on the average of Fall & Spring scores. +
+| # | +Name | +Gender | +Fall | +Spring | +Year Avg | +Predicted | +Actual | +Status | +
|---|---|---|---|---|---|---|---|---|
| = $rank ?> | += esc($s['name']) ?> | ++ + = $isMale ? 'M' : 'F' ?> + + | += $s['fall_score'] !== null ? number_format($s['fall_score'], 1) : '—' ?> | += $s['spring_score'] !== null ? number_format($s['spring_score'], 1) : '—' ?> | += $s['year_score'] !== null ? number_format($s['year_score'], 1) : '—' ?> | ++ = $s['predicted'] + ? 'Yes' + : 'No' ?> + | ++ = $s['actual'] + ? 'Yes' + : 'No' ?> + | ++ print '✓ Confirmed', + 'surprise' => print '↑ Surprise', + 'missed' => print '↓ Missed', + default => print '—', + }; ?> + | +
| Class | +Students | +Predicted | +Actual | +✓ Confirmed | +↑ Surprises | +↓ Missed | +Accuracy | +Fall ≥ | +Year ≥ | +
|---|---|---|---|---|---|---|---|---|---|
| = esc($cls['section_name']) ?> | += $cls['student_count'] ?> | += $cls['predicted_count'] ?> | += $cls['actual_count'] ?> | += $cls['confirmed'] ?> | += $cls['surprises'] ?> | += $cls['missed'] ?> | ++ = $cls['accuracy'] ?>% + | += $cls['fall_threshold'] !== null ? number_format((float)$cls['fall_threshold'], 1) : '—' ?> | += $cls['year_threshold'] !== null ? number_format((float)$cls['year_threshold'], 1) : '—' ?> | +
| Total | += $totalStudents ?> | += $totalPredicted ?> | += $totalActual ?> | += $totalConfirmed ?> | += $totalSurprise ?> | += $totalMissed ?> | ++ = $overallAccuracy ?>% + | ++ | |
+ = (int)$selectedPercentile ?>th percentile — + Predicted: = $totalPredicted ?> — + Actual: = $totalActual ?> — + Confirmed: = $totalConfirmed ?> — + Accuracy: = $overallAccuracy ?>% +
+| # | +Class | +Name | +G | +Fall | +Spring | +Year Avg | +Predicted | +Actual | +Status | +
|---|---|---|---|---|---|---|---|---|---|
| = $rank ?> | += esc($cls['section_name']) ?> | += esc($s['name']) ?> | += $isMale ? 'M' : 'F' ?> | += $s['fall_score'] !== null ? number_format($s['fall_score'], 1) : '—' ?> | += $s['spring_score'] !== null ? number_format($s['spring_score'], 1) : '—' ?> | += $s['year_score'] !== null ? number_format($s['year_score'], 1) : '—' ?> | += $s['predicted'] ? 'Yes' : 'No' ?> | += $s['actual'] ? 'Yes' : 'No' ?> | += $statusLabel ?> | +
| Class | +Students | +Predicted | +Actual | +✓ Confirmed | +↑ Surprises | +↓ Missed | +Accuracy | +Fall ≥ | +Year ≥ | +
|---|---|---|---|---|---|---|---|---|---|
| = esc($cls['section_name']) ?> | += $cls['student_count'] ?> | += $cls['predicted_count'] ?> | += $cls['actual_count'] ?> | += $cls['confirmed'] ?> | += $cls['surprises'] ?> | += $cls['missed'] ?> | += $cls['accuracy'] ?>% | += $cls['fall_threshold'] !== null ? number_format((float)$cls['fall_threshold'], 1) : '—' ?> | += $cls['year_threshold'] !== null ? number_format((float)$cls['year_threshold'], 1) : '—' ?> | +
| Total | += $totalStudents ?> | += $totalPredicted ?> | += $totalActual ?> | += $totalConfirmed ?> | += $totalSurprise ?> | += $totalMissed ?> | += $overallAccuracy ?>% | ++ | |
Trophy Counts per Class
+Prediction Accuracy per Class
+Overall Outcome Breakdown
++ = esc($selectedYear) ?> — = (int)$selectedPercentile ?>th percentile — + = $totalWinners ?> winners across = count($classResults) ?> classes +
++ = (int)$selectedPercentile ?>th percentile — + = $totalWinners ?> winners / = $totalStudents ?> students — + = count($classResults) ?> classes +
+| # | +Class | +Name | +Gender | +Fall Score | +Spring Score | +Year Score | +
|---|---|---|---|---|---|---|
| = $globalRank ?> | += esc($cls['section_name']) ?> | += esc($student['name']) ?> | ++ + = $isMale ? 'M' : 'F' ?> + + | ++ = $student['fall_score'] !== null ? number_format($student['fall_score'], 1) : '—' ?> + | ++ = $student['spring_score'] !== null ? number_format($student['spring_score'], 1) : '—' ?> + | ++ = $student['year_score'] !== null ? number_format($student['year_score'], 1) : '—' ?> + | +
| Total | += $totalWinners ?> winners | += $pctWinners ?>% of students | +||||
| Class | +Students | +Boys | +Girls | +Winners | +Trophy Boys | +Trophy Girls | +Rate | +Fall Threshold | +
|---|---|---|---|---|---|---|---|---|
| = esc($cls['section_name']) ?> | += $cls['student_count'] ?> | += $cls['boys'] ?> (= $cls['pct_boys'] ?>%) | += $cls['girls'] ?> (= $cls['pct_girls'] ?>%) | += count($cls['winners']) ?> | ++ = $cls['trophy_boys'] ?> + = $cls['boys'] > 0 ? '(' . $cls['pct_trophy_boys'] . '%)' : '' ?> + | ++ = $cls['trophy_girls'] ?> + = $cls['girls'] > 0 ? '(' . $cls['pct_trophy_girls'] . '%)' : '' ?> + | += $cls['pct_trophy_total'] ?>% | += $cls['threshold'] !== null ? number_format((float)$cls['threshold'], 1) : '—' ?> | +
| Total | += $totalStudents ?> | += $totalBoys ?> (= $pctBoys ?>%) | += $totalGirls ?> (= $pctGirls ?>%) | += $totalWinners ?> | ++ = $totalTrophyBoys ?> (= $pctTrophyBoys ?>%) + | ++ = $totalTrophyGirls ?> (= $pctTrophyGirls ?>%) + | += $pctWinners ?>% | ++ |
| # | +Name | +Gender | +Fall Score | +Spring Score | +Year Score | +
|---|---|---|---|---|---|
| = $i + 1 ?> | += esc($student['name']) ?> | ++ + = $isMale ? 'M' : 'F' ?> + + | ++ = $student['fall_score'] !== null ? number_format($student['fall_score'], 1) : '—' ?> + | ++ = $student['spring_score'] !== null + ? '' . number_format($student['spring_score'], 1) . '' + : '—' ?> + | ++ = $student['year_score'] !== null ? number_format($student['year_score'], 1) : '—' ?> + | +