calendarModel = new CalendarModel(); $this->semesterRangeService = new SemesterRangeService($this->configModel); $this->ackModel = new ReportCardAcknowledgementModel(); } public function report_card() { return view('printables_reports/report_card', [ 'schoolYear' => (string)($this->schoolYear ?? ''), 'semester' => (string)($this->semester ?? ''), ]); } /** * API: report card meta for UI hydration * GET params: school_year * Returns: { ok, schoolYears, selectedYear, students:[{id,firstname,lastname}], classSections:[{id,name}] } */ public function reportCardMeta() { $year = trim((string)($this->request->getGet('school_year') ?? $this->schoolYear ?? '')); $sem = trim((string)($this->request->getGet('semester') ?? $this->semester ?? '')); if ($year === '') { // fallback to latest present in classSection or semester_score $yr = $this->db->table('classSection')->select('school_year')->orderBy('school_year', 'DESC')->get(1)->getRowArray(); $year = (string)($yr['school_year'] ?? ''); } // Build schoolYears from classSection and semester_scores $schoolYears = []; try { $q1 = $this->db->table('classSection')->select('DISTINCT school_year', false)->where('school_year IS NOT NULL', null, false)->orderBy('school_year', 'DESC')->get()->getResultArray(); $schoolYears = array_values(array_filter(array_map(static fn($r) => (string)($r['school_year'] ?? ''), $q1))); } catch (\Throwable $e) { } try { $q2 = $this->db->table('semester_scores')->select('DISTINCT school_year', false)->where('school_year IS NOT NULL', null, false)->orderBy('school_year', 'DESC')->get()->getResultArray(); foreach ($q2 as $r) { $val = (string)($r['school_year'] ?? ''); if ($val !== '' && !in_array($val, $schoolYears, true)) $schoolYears[] = $val; } } catch (\Throwable $e) { } rsort($schoolYears); // Semesters available for the selected year (from scores + rosters), with defaults $semesters = []; try { $rs = $this->db->table('semester_scores') ->select('DISTINCT semester', false) ->where('school_year', $year) ->where('semester IS NOT NULL', null, false) ->orderBy('semester', 'ASC') ->get()->getResultArray(); $semesters = array_values(array_filter(array_map(static fn($r) => (string)($r['semester'] ?? ''), $rs))); } catch (\Throwable $e) { } // Ensure standard options are present even if data is missing (so Spring can be selected) $defaults = array_values(array_filter([(string)($this->semester ?? ''), 'Fall', 'Spring'], static fn($v) => $v !== '')); foreach ($defaults as $d) { if (!in_array($d, $semesters, true)) $semesters[] = $d; } $semesters = array_values(array_unique($semesters)); // Preserve existing selected semester if provided, otherwise pick the first if ($sem === '' && !empty($semesters)) { $sem = $semesters[0]; } elseif ($sem !== '' && !in_array($sem, $semesters, true)) { array_unshift($semesters, $sem); $semesters = array_values(array_unique($semesters)); } // Students: prefer those with scores in selected year (+semester if provided); fallback to all $students = []; try { $builder = $this->db->table('semester_scores ss') ->select('s.id, s.firstname, s.lastname, GROUP_CONCAT(DISTINCT cs.class_section_name ORDER BY cs.class_section_name SEPARATOR ", ") AS class_section_name', false) ->join('students s', 's.id = ss.student_id', 'inner') ->join('student_class sc', 'sc.student_id = s.id AND sc.school_year = ss.school_year', 'left') ->join('classSection cs', 'cs.class_section_id = sc.class_section_id', 'left') ->where('s.is_active', 1) ->where('ss.school_year', $year); if ($sem !== '') { $this->applySemesterFilter($builder, $sem, 'ss.semester'); } $stuRows = $builder ->groupBy('s.id, s.firstname, s.lastname') ->orderBy('class_section_name', 'ASC') ->orderBy('s.firstname', 'ASC') ->orderBy('s.lastname', 'ASC') ->get() ->getResultArray(); if (!empty($stuRows)) $students = $stuRows; } catch (\Throwable $e) { } if (empty($students)) { $fallback = $this->studentModel ->select('students.id, students.firstname, students.lastname, GROUP_CONCAT(DISTINCT cs.class_section_name ORDER BY cs.class_section_name SEPARATOR ", ") AS class_section_name', false) ->join('student_class sc', 'sc.student_id = students.id', 'left') ->join('classSection cs', 'cs.class_section_id = sc.class_section_id', 'left'); if ($year !== '') { $fallback->where('sc.school_year', $year); } $students = $fallback ->where('students.is_active', 1) ->groupBy('students.id, students.firstname, students.lastname') ->orderBy('class_section_name', 'ASC') ->orderBy('students.firstname', 'ASC') ->orderBy('students.lastname', 'ASC') ->findAll(); } // Class sections with at least one student for the selected year/semester $classSections = []; try { $builder = $this->db->table('classSection cs') ->select('cs.id, cs.class_section_id, cs.class_section_name, cs.school_year') ->join('student_class sc', 'sc.class_section_id = cs.class_section_id', 'inner') ->join('students s', 's.id = sc.student_id', 'inner') ->where('s.is_active', 1) ->where('sc.school_year', $year); $classSections = $builder ->groupBy('cs.id, cs.class_section_id, cs.class_section_name, cs.school_year') ->orderBy('cs.class_section_name', 'ASC') ->get() ->getResultArray(); // If no matches for the semester filter, retry for the year only if (empty($classSections) && $sem !== '') { $classSections = $this->db->table('classSection cs') ->select('cs.id, cs.class_section_id, cs.class_section_name, cs.school_year') ->join('student_class sc', 'sc.class_section_id = cs.class_section_id', 'inner') ->join('students s', 's.id = sc.student_id', 'inner') ->where('s.is_active', 1) ->where('sc.school_year', $year) ->groupBy('cs.id, cs.class_section_id, cs.class_section_name, cs.school_year') ->orderBy('cs.class_section_name', 'ASC') ->get() ->getResultArray(); } } catch (\Throwable $e) { } return $this->response->setJSON([ 'ok' => true, 'schoolYears' => $schoolYears, 'selectedYear' => $year, 'semesters' => $semesters, 'selectedSemester' => $sem, 'students' => $students, 'classSections' => array_map(static fn($r) => [ 'id' => (int)($r['id'] ?? 0), 'class_section_id' => (int)($r['class_section_id'] ?? ($r['id'] ?? 0)), 'class_section_name' => (string)($r['class_section_name'] ?? 'Section'), 'school_year' => (string)($r['school_year'] ?? ''), ], $classSections), 'csrf_token' => csrf_token(), 'csrf_hash' => csrf_hash(), ]); } /** * API: report card completeness check for a class section. * GET params: class_section_id, school_year, semester * Returns: { ok, summary, students:[{id,name,missing[],warnings[],complete}] } */ public function reportCardCompleteness() { $year = trim((string)($this->request->getGet('school_year') ?? $this->schoolYear ?? '')); $sem = trim((string)($this->request->getGet('semester') ?? $this->semester ?? '')); $sectionInput = (int)($this->request->getGet('class_section_id') ?? 0); if ($year === '' || $sectionInput <= 0) { return $this->response->setJSON([ 'ok' => false, 'message' => 'Missing school year or class section.', ]); } $sectionCode = $sectionInput; $sectionId = null; $sectionName = ''; try { $row = $this->db->table('classSection') ->select('id, class_section_id, class_section_name') ->where('class_section_id', $sectionInput) ->limit(1) ->get() ->getRowArray(); if ($row) { $sectionId = (int)$row['id']; $sectionCode = (int)$row['class_section_id']; $sectionName = (string)($row['class_section_name'] ?? ''); } else { $row = $this->db->table('classSection') ->select('id, class_section_id, class_section_name') ->where('id', $sectionInput) ->limit(1) ->get() ->getRowArray(); if ($row) { $sectionId = (int)$row['id']; $sectionCode = (int)$row['class_section_id']; $sectionName = (string)($row['class_section_name'] ?? ''); } } } catch (\Throwable $e) { } if ($sectionName === '') { $sectionName = (string)($this->resolveClassName($this->db, $sectionCode) ?? ''); } $students = $this->fetchStudentsByClass($sectionCode, $year); if (empty($students) && $sectionId && $sectionId !== $sectionCode) { $students = $this->fetchStudentsByClass($sectionId, $year); } $rosterSemester = $sem; $usedFallback = false; if (empty($students) && $sem !== '') { $students = $this->fetchStudentsByClass($sectionCode, $year); if (empty($students) && $sectionId && $sectionId !== $sectionCode) { $students = $this->fetchStudentsByClass($sectionId, $year); } $rosterSemester = ''; $usedFallback = !empty($students); } if (empty($students)) { return $this->response->setJSON([ 'ok' => true, 'message' => 'No students found for the selected class and year.', 'summary' => [ 'total' => 0, 'complete' => 0, 'incomplete' => 0, 'warnings' => 0, ], 'students' => [], 'roster_semester' => $rosterSemester, 'used_fallback' => $usedFallback, ]); } $studentIds = array_values(array_filter(array_map( static fn($s) => (int)($s['id'] ?? 0), $students ), static fn($v) => $v > 0)); if (empty($studentIds)) { return $this->response->setJSON([ 'ok' => false, 'message' => 'No valid students found.', ]); } $scoreRows = $this->semesterScoreModel ->whereIn('student_id', $studentIds) ->where('school_year', $year); if ($sem !== '') { $this->applySemesterFilter($scoreRows, $sem, 'semester'); } $scoreRows = $scoreRows ->orderBy('updated_at', 'DESC') ->orderBy('id', 'DESC') ->findAll(); $scoreByStudent = []; foreach ($scoreRows as $row) { $sid = (int)($row['student_id'] ?? 0); if ($sid > 0 && !isset($scoreByStudent[$sid])) { $scoreByStudent[$sid] = $row; } } $semLower = strtolower(trim($sem)); $isFirst = in_array($semLower, ['fall', 'first', 'semester 1', '1'], true); $isSecond = in_array($semLower, ['spring', 'second', 'semester 2', '2'], true); $firstScoreByStudent = []; if ($isSecond) { $otherRows = $this->semesterScoreModel ->whereIn('student_id', $studentIds) ->where('school_year', $year); if ($sem !== '') { $this->applySemesterExclusion($otherRows, $sem, 'semester'); } $otherRows = $otherRows ->orderBy('updated_at', 'DESC') ->orderBy('id', 'DESC') ->findAll(); foreach ($otherRows as $row) { $sid = (int)($row['student_id'] ?? 0); if ($sid > 0 && !isset($firstScoreByStudent[$sid])) { $firstScoreByStudent[$sid] = $row; } } } $examCommentTypes = $isSecond ? ['final'] : ($isFirst ? ['midterm'] : ['midterm', 'final']); $commentTypes = array_merge($examCommentTypes, ['ptap', 'attendance', 'attendance_comment']); $hasCommentReview = $this->db->fieldExists('comment_review', 'score_comments'); $commentSelect = $hasCommentReview ? 'student_id, score_type, comment, comment_review' : 'student_id, score_type, comment'; $commentBuilder = $this->scoreCommentModel ->select($commentSelect) ->whereIn('student_id', $studentIds) ->where('school_year', $year) ->whereIn('score_type', $commentTypes); if ($sem !== '') { $this->applySemesterFilter($commentBuilder, $sem, 'semester'); } $commentRows = []; try { $commentRows = $commentBuilder->findAll(); } catch (\Throwable $e) { } $commentsByStudent = []; foreach ($commentRows as $row) { $sid = (int)($row['student_id'] ?? 0); if ($sid <= 0) { continue; } $typeRaw = strtolower(trim((string)($row['score_type'] ?? ''))); if ($typeRaw === '') { continue; } if ($typeRaw === 'attendance_comment') { $typeRaw = 'attendance'; } $reviewVal = $hasCommentReview ? trim((string)($row['comment_review'] ?? '')) : ''; $commentVal = $hasCommentReview ? $reviewVal : trim((string)($row['comment'] ?? '')); if ($commentVal === '') { continue; } $commentsByStudent[$sid][$typeRaw] = $commentVal; } $isNumeric = static fn($v) => $v !== null && $v !== '' && is_numeric($v); $ackMap = []; try { $ackRows = $this->ackModel ->whereIn('student_id', $studentIds) ->where('school_year', $year) ->where('semester', $sem) ->orderBy('updated_at', 'DESC') ->findAll(); foreach ($ackRows as $row) { $sid = (int) ($row['student_id'] ?? 0); if ($sid > 0 && ! isset($ackMap[$sid])) { $ackMap[$sid] = $row; } } } catch (\Throwable $e) { $ackMap = []; } $results = []; $completeCount = 0; $warningCount = 0; foreach ($students as $student) { $sid = (int)($student['id'] ?? 0); $name = trim(((string)($student['firstname'] ?? '')) . ' ' . ((string)($student['lastname'] ?? ''))); $missing = []; $warnings = []; if ($sectionName === '') { $missing[] = 'Grade/Section'; } $score = $sid > 0 ? ($scoreByStudent[$sid] ?? null) : null; $examScore = null; $attendanceScore = null; $ptapScore = null; $semesterScore = null; $hasAllComponents = false; if (!$score) { $missing[] = 'Score record'; } else { $attendanceScore = $score['attendance_score'] ?? null; $ptapScore = $score['ptap_score'] ?? null; $semesterScore = $score['semester_score'] ?? null; $components = [ $score['homework_avg'] ?? null, $score['quiz_avg'] ?? null, $score['project_avg'] ?? null, $score['test_avg'] ?? null, ]; $hasAllComponents = !in_array(false, array_map($isNumeric, $components), true); if ($isSecond) { $examScore = $score['final_exam_score'] ?? null; if (!$isNumeric($examScore) && $isNumeric($score['midterm_exam_score'] ?? null)) { $examScore = $score['midterm_exam_score']; $warnings[] = 'Final exam stored as midterm'; } } elseif ($isFirst) { $examScore = $score['midterm_exam_score'] ?? null; } else { $examScore = $score['final_exam_score'] ?? ($score['midterm_exam_score'] ?? null); } if (!$isNumeric($examScore)) { $missing[] = $isSecond ? 'Final exam score' : ($isFirst ? 'Midterm exam score' : 'Exam score'); } if (!$isNumeric($attendanceScore)) { $missing[] = 'Attendance score'; } if (!$isNumeric($ptapScore)) { if ($hasAllComponents) { $warnings[] = 'PTAP computed from components'; } else { $missing[] = 'PTAP score'; } } $canComputeSemester = $isNumeric($examScore) && $isNumeric($attendanceScore) && ($isNumeric($ptapScore) || $hasAllComponents); if (!$isNumeric($semesterScore)) { if ($canComputeSemester) { $warnings[] = 'Semester score computed'; } else { $missing[] = 'Semester score'; } } if ($isSecond) { $firstScore = $firstScoreByStudent[$sid]['semester_score'] ?? null; if (!$isNumeric($firstScore)) { $missing[] = 'First semester score'; } } } $commentKey = $isSecond ? 'final' : ($isFirst ? 'midterm' : ''); $commentLabel = $isSecond ? 'Final' : ($isFirst ? 'Midterm' : 'Exam'); $commentSet = $commentsByStudent[$sid] ?? []; $examComment = $commentKey !== '' ? ($commentSet[$commentKey] ?? '') : ($commentSet['final'] ?? ($commentSet['midterm'] ?? '')); if (trim((string)$examComment) === '') { $missing[] = $commentLabel . ' comment'; } if (trim((string)($commentSet['ptap'] ?? '')) === '') { $missing[] = 'PTAP comment'; } if (trim((string)($commentSet['attendance'] ?? '')) === '') { $missing[] = 'Attendance comment'; } $isComplete = empty($missing); if ($isComplete) { $completeCount++; } if (!empty($warnings)) { $warningCount++; } $ack = $sid > 0 ? ($ackMap[$sid] ?? null) : null; $results[] = [ 'id' => $sid, 'name' => $name !== '' ? $name : 'N/A', 'missing' => $missing, 'warnings' => $warnings, 'complete' => $isComplete, 'viewed_at' => $ack['viewed_at'] ?? null, 'signed_at' => $ack['signed_at'] ?? null, 'signed_name' => $ack['signed_name'] ?? null, ]; } $total = count($results); $incomplete = $total - $completeCount; return $this->response->setJSON([ 'ok' => true, 'summary' => [ 'total' => $total, 'complete' => $completeCount, 'incomplete' => $incomplete, 'warnings' => $warningCount, ], 'students' => $results, 'class_section_name' => $sectionName, 'class_section_id' => $sectionCode, 'school_year' => $year, 'semester' => $sem, 'roster_semester' => $rosterSemester, 'used_fallback' => $usedFallback, ]); } /** * API: report card parent acknowledgement status * GET params: student_id, school_year, semester */ public function reportCardAcknowledgement() { $studentId = (int) ($this->request->getGet('student_id') ?? 0); if ($studentId <= 0) { return $this->response->setJSON(['ok' => false, 'error' => 'Missing student_id'])->setStatusCode(400); } $schoolYear = trim((string) ($this->request->getGet('school_year') ?? $this->schoolYear ?? '')); $semester = trim((string) ($this->request->getGet('semester') ?? $this->semester ?? '')); $row = $this->ackModel ->where('student_id', $studentId) ->where('school_year', $schoolYear) ->where('semester', $semester) ->orderBy('updated_at', 'DESC') ->first(); return $this->response->setJSON([ 'ok' => true, 'student_id' => $studentId, 'school_year' => $schoolYear, 'semester' => $semester, 'viewed_at' => $row['viewed_at'] ?? null, 'signed_at' => $row['signed_at'] ?? null, 'signed_name' => $row['signed_name'] ?? null, ]); } protected function fetchStudentsByClass(int $sectionId, ?string $schoolYear) { $b = $this->studentClassModel ->select('s.id, s.firstname, s.lastname, s.registration_grade, s.gender') ->join('students s', 's.id = student_class.student_id', 'inner') ->where('student_class.class_section_id', $sectionId) ->where('s.is_active', 1) ->orderBy('s.firstname', 'ASC') ->orderBy('s.lastname', 'ASC'); if (!empty($schoolYear)) { $b->where('student_class.school_year', $schoolYear); } return $b->findAll(); } protected function formatReportPDF($pdf, $data) { $w = $pdf->GetPageWidth(); // Layout helpers (mirroring the shared StudentReportPDF style but using DB data) $pdf->SetMargins(10, 10, 10); $bottomMarginVal = 3; $pdf->SetAutoPageBreak(true, $bottomMarginVal); $pdf->AddPage('P', 'Letter'); $leftMargin = 10; $leftLogo = $this->firstExisting([ FCPATH . 'images/Isgl_logo.png', FCPATH . 'images/isgl_logo.png', FCPATH . 'assets/images/isgl_logo.png', FCPATH . 'assets/images/isgl.png', ]); $rightLogo = $this->firstExisting([ FCPATH . 'images/logo.png', FCPATH . 'assets/images/logo.png', FCPATH . 'assets/images/school_logo.png', ]); if ($leftLogo) { $pdf->Image($leftLogo, 5, 5, 32); } if ($rightLogo) { $pdf->Image($rightLogo, $w - 33, 5, 32); } $pdf->SetFont('Times', 'B', 24); $pdf->Cell(0, 12, 'Al Rahma Sunday School', 0, 1, 'C'); $pdf->Cell(0, 12, 'Student Report Card', 0, 1, 'C'); $pdf->Ln(4); $numFmt = static function ($val) { if ($val === null || $val === '') return 'N/A'; if (is_numeric($val)) return number_format((float)$val, 1); return (string)$val; }; $splitText = static function ($text, int $limit = 55): array { $text = trim((string)$text); if ($text === '') { return [' ', '']; // keep an extra blank line for spacing } $lines = explode("\n", wordwrap($text, $limit)); $lines[] = ''; // extra empty line after the comment return $lines; }; $drawLabeledCell = static function (\FPDF $pdf, string $label, string $value, float $width = 95) { $pdf->SetFont('Helvetica', 'B', 11); $pdf->Cell($width, 10, $label, 1, 0); $pdf->SetFont('Helvetica', '', 12); $pdf->Cell($width, 10, $value, 1, 1); }; $firstNonNull = static function (...$items) { foreach ($items as $item) { if ($item !== null) { return $item; } } return null; }; $wrapLinesToWidth = static function (\FPDF $pdf, string $text, float $width) { $text = trim((string)$text); if ($text === '') { return [' ']; } if ($width <= 0) { return [$text]; } $segments = preg_split("/\\r?\\n+/", $text); $lines = []; foreach ($segments as $seg) { $seg = trim($seg); if ($seg === '') { $lines[] = ' '; continue; } $words = preg_split('/\\s+/u', $seg); $current = ''; foreach ($words as $word) { $trial = trim($current === '' ? $word : ($current . ' ' . $word)); if ($pdf->GetStringWidth($trial) <= $width) { $current = $trial; } else { if ($current !== '') $lines[] = $current; $current = $word; } } if ($current !== '') $lines[] = $current; } return $lines; }; $draw3ColumnRow = static function (\FPDF $pdf, string $label, $score, $feedback) use ($wrapLinesToWidth) { $w1 = 55; $w2 = 30; $w3 = 110; // comments column width $commentWrapW = $w3 - 4; // allow more horizontal space for comments $lineHeight = 6; // Wrap using the same font settings as the comment cell to avoid odd line breaks $pdf->SetFont('Helvetica', '', 11); $cellMargin = 1.0; // FPDF default internal cell margin (protected property) $wrapWidth = max(1, $commentWrapW - (2 * $cellMargin)); $lines = $wrapLinesToWidth($pdf, (string)$feedback, $wrapWidth); if (trim((string)$feedback) !== '') { $lines[] = ' '; } $linesCount = max(1, count($lines)); $rowHeight = max($lineHeight, $linesCount * $lineHeight); $textOffsetY = ($rowHeight - ($linesCount * $lineHeight)) / 2; $startX = $pdf->GetX(); $startY = $pdf->GetY(); $labelPad = 2; // Label cell $pdf->SetFont('Helvetica', 'B', 11); $pdf->SetXY($startX + $labelPad, $startY + $textOffsetY); $pdf->Cell($w1 - $labelPad, $lineHeight, $label, 0, 0); // Score cell $pdf->SetFont('Helvetica', '', 12); $scoreStr = is_numeric($score) ? number_format((float)$score, 1) : ((string)$score !== '' ? (string)$score : 'N/A'); $pdf->SetXY($startX + $w1, $startY + $textOffsetY); $pdf->Cell($w2, $lineHeight, $scoreStr, 0, 0, 'C'); // Comments cell $commentX = $pdf->GetX(); $pdf->SetFont('Helvetica', '', 11); $pdf->SetXY($commentX + 2, $startY + $textOffsetY); $pdf->MultiCell($commentWrapW, $lineHeight, implode("\n", $lines), 0, 'L'); // Advance to next row $pdf->SetXY($startX, $startY + $rowHeight); }; $drawInfoRows = static function (\FPDF $pdf, array $rows, float $height = 12, ?float $startY = null) use ($leftMargin) { $leftW = 130; // Teacher/Name column widened by 10 $rightW = 65; // School Year column reduced by 10 $startX = $leftMargin; // ensure all rows start at left margin $currentY = $startY !== null ? $startY : $pdf->GetY(); $lineH = 6; $wrapLines = static function (string $text, float $width) use ($pdf) { $text = trim($text); if ($text === '') { return [' ']; } $segments = preg_split('/\\r?\\n+/', $text); $out = []; foreach ($segments as $seg) { $seg = trim($seg); if ($seg === '') { // Preserve intentional blank line $out[] = ' '; continue; } $words = preg_split('/\\s+/u', $seg); $current = ''; foreach ($words as $word) { $trial = $current === '' ? $word : ($current . ' ' . $word); if ($pdf->GetStringWidth($trial) <= ($width - 2)) { $current = $trial; } else { if ($current !== '') { $out[] = $current; } $current = $word; } } if ($current !== '') { $out[] = $current; } } return !empty($out) ? $out : [' ']; }; if ($startY !== null) { $pdf->SetY($startY); } foreach ($rows as $row) { if (!is_array($row) || count($row) < 4) { continue; } [$labelLeft, $valueLeft, $labelRight, $valueRight] = $row; $y = $currentY; $labelY = $y+2; // align titles and contents vertically $isTeacherRow = stripos($labelLeft, 'teacher') !== false; if ($isTeacherRow) { $labelY += 1; } // For teacher rows, respect the explicit line breaks (two names per line) without extra wrapping $leftLines = $isTeacherRow ? array_values(array_filter(array_map('rtrim', explode("\n", (string)$valueLeft)), static fn($v) => $v !== '')) : $wrapLines((string)$valueLeft, $leftW - 6); $rightLines = $wrapLines((string)$valueRight, $rightW - 6); $rowHeight = max($height, max(count($leftLines), count($rightLines)) * $lineH + 4); // If teacher names span multiple lines, add a small breathing room (half line) after the second line if (stripos($labelLeft, 'teacher') !== false && count($leftLines) > 1) { $rowHeight += ($lineH / 4); } $pdf->SetXY($startX, $y); $pdf->Rect($startX, $y, $leftW, $rowHeight); $pdf->Rect($startX + $leftW, $y, $rightW, $rowHeight); $pdf->SetFont('Helvetica', 'B', 11); $pdf->SetXY($startX + 3, $labelY); $pdf->Write($lineH - 1, $labelLeft); $pdf->SetFont('Arial', '', 11); $labelLeftWidth = $pdf->GetStringWidth($labelLeft); $pad = 2; // small gap after the label $valLeftWidth = $leftW - 6 - $labelLeftWidth - $pad; if ($valLeftWidth < 10) { $valLeftWidth = $leftW - 6 - $pad; } $pdf->SetXY($startX + 3 + $labelLeftWidth + $pad, $labelY); if (count($leftLines) === 1) { $pdf->Write($lineH - 1, $leftLines[0]); } else { $pdf->MultiCell($valLeftWidth, $lineH, implode("\n", $leftLines), 0, 'L'); } $pdf->SetFont('Helvetica', 'B', 11); $pdf->SetXY($startX + $leftW + 3, $labelY); $pdf->Write($lineH - 1, $labelRight); $pdf->SetFont('Arial', '', 11); $labelRightWidth = $pdf->GetStringWidth($labelRight); $valRightWidth = $rightW - 6 - $labelRightWidth - $pad; if ($valRightWidth < 10) { $valRightWidth = $rightW - 6 - $pad; } $pdf->SetXY($startX + $leftW + 3 + $labelRightWidth + $pad, $labelY); if (count($rightLines) === 1) { $pdf->Write($lineH - 1, $rightLines[0]); } else { $pdf->MultiCell($valRightWidth, $lineH, implode("\n", $rightLines), 0, 'L'); } $currentY += $rowHeight; $pdf->SetY($currentY); } return $currentY; }; $semRaw = (string)($data['score']['semester'] ?? ($data['selected_semester'] ?? '')); $semNum = null; $semFriendly = 'Second Semester'; $s = strtolower(trim($semRaw)); if ($s === 'fall' || $s === 'first' || $s === 'semester 1' || $s === '1') { $semNum = 1; $semFriendly = 'First Semester'; } elseif ($s === 'spring' || $s === 'second' || $s === 'semester 2' || $s === '2') { $semNum = 2; $semFriendly = 'Second Semester'; } // Teacher names (support multiple main teachers + wrap to a second line if needed) $teacherNamesList = $data['teacher_names'] ?? []; if (empty($teacherNamesList) && !empty($data['teacher_name'])) { $teacherNamesList = [trim((string)$data['teacher_name'])]; } $teacherNamesList = array_values(array_filter( array_map('trim', $teacherNamesList), static fn($v) => $v !== '' && strcasecmp($v, 'N/A') !== 0 )); $tas = array_values(array_filter( array_map(static fn($v) => trim((string)$v), $data['ta_names'] ?? []), static fn($v) => $v !== '' && strcasecmp($v, 'N/A') !== 0 )); $allTeacherNames = array_values(array_unique(array_filter( array_merge($teacherNamesList, $tas), static fn($v) => $v !== '' ))); $teacherNameLines = []; $teacherCount = count($allTeacherNames); for ($i = 0; $i < $teacherCount; $i += 2) { $pair = array_slice($allTeacherNames, $i, 2); $isLastPair = ($i + 2) >= $teacherCount; if (count($pair) === 2) { $teacherNameLines[] = implode($isLastPair ? ' & ' : ', ', $pair); } else { $teacherNameLines[] = $pair[0]; } } if (empty($teacherNameLines)) { $teacherNameLines[] = 'N/A'; } $teacherNames = implode("\n", $teacherNameLines); $schoolYear = (string)($data['score']['school_year'] ?? ($data['score']['year'] ?? 'N/A')); if (preg_match('/^(\\d{4})-(\\d{4})$/', $schoolYear, $m)) { $schoolYear = $m[1] . '/' . $m[2]; } $studentName = trim(($data['student']['firstname'] ?? '') . ' ' . ($data['student']['lastname'] ?? '')); $gradeLabel = (string)($data['grade'] ?? ($data['class_section_name'] ?? 'N/A')); $today = (string)($data['report_date_display'] ?? date('m-d-Y')); $termRank = trim((string)($data['term_rank_display'] ?? '')); $firstSemScore = $data['first_semester_score'] ?? null; $secondSemScore = $data['second_semester_score'] ?? ($data['total_score'] ?? null); $finalAverage = $data['final_average'] ?? null; $comments = $data['comments'] ?? []; $infoRow = function ($labelLeft, $valueLeft, $labelRight, $valueRight, $height = 12) use ($pdf) { $x = $pdf->GetX(); $y = $pdf->GetY(); $leftW = 115; $rightW = 68; $pdf->Rect($x, $y, $leftW, $height); $pdf->Rect($x + $leftW, $y, $rightW, $height); $pdf->SetFont('Helvetica', 'B', 11); $pdf->SetXY($x + 3, $y + 3); $pdf->Write(5, $labelLeft); $pdf->SetFont('Arial', '', 11); $pdf->Write(5, $valueLeft); $pdf->SetFont('Helvetica', 'B', 11); $pdf->SetXY($x + $leftW + 3, $y + 3); $pdf->Write(5, $labelRight); $pdf->SetFont('Arial', '', 11); $pdf->Write(5, $valueRight); $pdf->Ln($height); }; // Student + teacher + grade (three stacked rows, edges touching) $afterInfoY = $drawInfoRows($pdf, [ ["Teachers' Names: ", ' ' . ($teacherNames !== '' ? $teacherNames : 'N/A'), 'School Year: ', ' ' . $schoolYear], ['Student Name: ', ' ' . ($studentName !== '' ? $studentName : 'N/A'), 'Grade: ', ' ' . ($gradeLabel !== '' ? $gradeLabel : 'N/A')], ['Term: ', ' ' . $semFriendly, 'Date: ', ' ' . $today], ]); $pdf->SetY($afterInfoY); // No extra spacing to keep rectangles touching the next block $pdf->Ln(2); // Scores and comments rows $finalExamScore = $data['final_exam_score'] ?? null; if ($finalExamScore === null) { if ($semNum === 1) { $finalExamScore = $data['score']['midterm_exam_score'] ?? null; } elseif ($semNum === 2) { $finalExamScore = $data['score']['final_exam_score'] ?? ($data['score']['midterm_exam_score'] ?? null); } else { $finalExamScore = $data['score']['final_exam_score'] ?? ($data['score']['midterm_exam_score'] ?? null); } } $ptapScore = $data['ptap'] ?? $data['score']['ptap_score'] ?? null; $attendance = $data['score']['attendance_score'] ?? null; $examLabel = ($semNum === 1) ? 'Midterm Exam*' : 'Final Exam*'; // Header for subject table $pdf->SetFont('Helvetica', 'B', 11); $headerX = $pdf->GetX(); $headerY = $pdf->GetY(); $pdf->Cell(55, 15, 'Subject', 1, 0, 'C'); $scoreHeader = $semNum === 1 ? "1st Semester\nScore (/100)" : "2nd Semester\nScore (/100)"; $pdf->SetXY($headerX + 55, $headerY); $pdf->MultiCell(30, 7.5, $scoreHeader, 1, 'C'); $pdf->SetXY($headerX + 85, $headerY); $pdf->Cell(110, 15, "Teachers' Comments", 1, 1, 'C'); // Match comment to the correct row $examComment = $semNum === 1 ? $firstNonNull($comments['midterm'] ?? null, $data['score']['midterm_comment'] ?? null, $data['score']['comment'] ?? null) : $firstNonNull($comments['final'] ?? null, $data['score']['final_comment'] ?? null, $data['score']['comment'] ?? null); $ptapComment = $firstNonNull($comments['ptap'] ?? null, $data['score']['ptap_comment'] ?? null); $attendanceComment = $firstNonNull( $comments['attendance'] ?? null, $comments['attendance_comment'] ?? null, $data['score']['attendance_comment'] ?? null ); // Add a blank line before the exam row to separate header from data $pdf->Ln(2); $draw3ColumnRow($pdf, $examLabel, $numFmt($finalExamScore), $examComment ?? ''); $draw3ColumnRow($pdf, 'PTAP**', $numFmt($ptapScore), $ptapComment ?? ''); $attendanceComment = $firstNonNull( $comments['attendance'] ?? null, $comments['attendance_comment'] ?? null, $data['score']['attendance_comment'] ?? null ); $draw3ColumnRow($pdf, 'Attendance***', $numFmt($attendance), $attendanceComment ?? ''); // Draw outer box and vertical dividers around the score/comment block (no horizontal separators) $rowsStartX = $headerX; $rowsStartY = $headerY + 15; // header height $rowsEndY = $pdf->GetY(); $blockHeight = $rowsEndY - $rowsStartY; $blockWidth = 195; // 55 + 30 + 110 $pdf->Rect($rowsStartX, $rowsStartY, $blockWidth, $blockHeight); $pdf->Line($rowsStartX + 55, $rowsStartY, $rowsStartX + 55, $rowsStartY + $blockHeight); $pdf->Line($rowsStartX + 85, $rowsStartY, $rowsStartX + 85, $rowsStartY + $blockHeight); // Attendance summary (absences / tardies) $totalDays = $data['total_attendance_days'] ?? $data['score']['total_semester_days'] ?? $data['score']['total_days'] ?? '-'; $absences = $data['score']['total_absences'] ?? $data['score']['absences'] ?? '0'; $tardies = $data['score']['total_tardies'] ?? $data['score']['tardies'] ?? '0'; $pdf->Ln(2); // Attendance summary on a single row (three columns) $statW = 65; $statH = 12; $startX = $pdf->GetX(); $startY = $pdf->GetY(); $stats = [ ['Total Semester Days: ', (string)$totalDays], ['Total Absences: ', (string)$absences], ['Tardies (Unjustified): ', (string)$tardies], ]; foreach ($stats as $i => [$lbl, $val]) { $x = $startX + ($i * $statW); $pdf->Rect($x, $startY, $statW, $statH); $pdf->SetXY($x + 2, $startY + 3); $pdf->SetFont('Helvetica', 'B', 11); $pdf->Write(5, $lbl . ' '); $pdf->SetFont('Helvetica', '', 11); $pdf->Write(5, $val); } $pdf->SetY($startY + $statH); // Semester scores + final $effectiveSecond = $secondSemScore ?? $data['score']['semester_score'] ?? $data['total_score'] ?? null; $finalScoreVal = $finalAverage; if (!is_numeric($finalScoreVal)) { $finalScoreVal = (is_numeric($firstSemScore) && is_numeric($effectiveSecond)) ? number_format(((float)$firstSemScore + (float)$effectiveSecond) / 2, 1) : $numFmt($effectiveSecond); } // Keep grade label and value within bordered cells $gradeCellWidth = 65; // match Total Semester Days column width $gradeCellHeight = 12; $gradeX = $pdf->GetX(); $gradeY = $pdf->GetY(); $gradePad = max(0, 2 - (4 * 0.3528)); // reduce indentation by 4pt $drawGradeCell = static function ( \FPDF $pdf, float $x, float $y, float $w, float $h, string $label, string $value, float $pad ) { $pdf->Rect($x, $y, $w, $h); $pdf->SetXY($x + $pad, $y + 3); $pdf->SetFont('Helvetica', 'B', 11); $pdf->Write(5, $label . ' '); $labelWidth = $pdf->GetStringWidth($label . ' '); $pdf->SetFont('Helvetica', '', 12); $pdf->SetXY($x + 2 + $labelWidth, $y + 3); $pdf->Write(5, $value); }; $drawRankCell = static function ( \FPDF $pdf, float $x, float $y, float $w, float $h, string $rankValue, float $pad ) { $rankValue = trim($rankValue) !== '' ? trim($rankValue) : 'N/A'; $pdf->Rect($x, $y, $w, $h); $pdf->SetXY($x + $pad, $y + 3); $pdf->SetFont('Helvetica', 'B', 11); $pdf->Write(5, ' Class Rank: '); $labelWidth = $pdf->GetStringWidth(' Class Rank: '); $pdf->SetFont('Helvetica', '', 12); $pdf->SetXY($x + 2 + $labelWidth, $y + 3); $pdf->Write(5, $rankValue); }; if ($semNum === 2) { $firstLabel = ' 1st Semester Grade:'; $firstValue = $numFmt($firstSemScore) . '/100'; $secondLabel = ' 2nd Semester Grade:'; $secondValue = $numFmt($effectiveSecond) . '/100'; $drawGradeCell($pdf, $gradeX, $gradeY, $gradeCellWidth, $gradeCellHeight, $firstLabel, $firstValue, $gradePad); $drawGradeCell($pdf, $gradeX + $gradeCellWidth, $gradeY, $gradeCellWidth, $gradeCellHeight, $secondLabel, $secondValue, $gradePad); } else { $gradeLabel = ' 1st Semester Grade:'; $gradeValue = $numFmt($effectiveSecond) . '/100'; $drawGradeCell($pdf, $gradeX, $gradeY, $gradeCellWidth, $gradeCellHeight, $gradeLabel, $gradeValue, $gradePad); // Fall: Ranking goes to the right of 1st Semester Grade. $drawRankCell($pdf, $gradeX + $gradeCellWidth, $gradeY, $gradeCellWidth, $gradeCellHeight, $termRank, $gradePad); } $pdf->SetY($gradeY + $gradeCellHeight); $finalScoreRowHeight = 12; $finalScoreEndY = null; // Show Final Score only for second semester / Spring. if ($semNum === 2) { $finalLabel = 'Final Score****:'; $finalValue = (is_string($finalScoreVal) ? $finalScoreVal : $numFmt($finalScoreVal)) . '/100'; $finalCellWidth = 65; $finalCellHeight = $finalScoreRowHeight; $finalX = $pdf->GetX(); $finalY = $pdf->GetY(); $finalPad = max(0, 2 - (4 * 0.3528)); $drawGradeCell($pdf, $finalX, $finalY, $finalCellWidth, $finalCellHeight, ' ' . $finalLabel, $finalValue, $finalPad); // Spring: Ranking goes to the right of Final Score. $drawRankCell($pdf, $finalX + $finalCellWidth, $finalY, $finalCellWidth, $finalCellHeight, $termRank, $finalPad); $pdf->SetY($finalY + $finalCellHeight); $finalScoreEndY = $finalY + $finalCellHeight; } $scoresEndY = $pdf->GetY(); // Legend anchored near bottom with a 3mm buffer (dynamic placement) $examLegendLabel = ($semNum === 1) ? 'Midterm Exam' : 'Final Exam'; $legendLines = [ "(*) {$examLegendLabel} (Weight: 60% of semester score)", "(**) PTAP - Participation, Tests, Assignments, Projects (Weight: 20%)", "(***) Attendance = ({$totalDays} - Total Absences + One Sick Day) / {$totalDays} ) x 100 (Weight: 20%)", ]; if ($semNum === 2) { $legendLines[] = "(****) Final Score = (1st Semester Score + 2nd Semester Score) / 2"; } $legendLines[] = ['text' => "If you have any questions, please contact the school via email at alrahma.isgl@gmail.com", 'bold' => true]; $lineHeight = 4; $legendHeight = count($legendLines) * $lineHeight; $bottomPad = 0.1; // distance from bottom of the page // Respect the configured bottom margin so legend stays on-page $targetY = $pdf->GetPageHeight() - $bottomMarginVal - $bottomPad - $legendHeight; $targetY = max($pdf->GetY(), $targetY); // avoid overlapping prior content $legendY = $targetY; // Two-column layout: legend on left, signature block on right $marginL = 10; $sigWidth = 40; $sigHeight = $finalScoreRowHeight; $sigX = $w - $sigWidth - 15; $legendWidth = $sigX - $marginL - 5; // leave gutter before signature // Legend (left column) $pdf->SetXY($marginL, $legendY); foreach ($legendLines as $line) { $isBold = is_array($line) ? ($line['bold'] ?? false) : false; $text = is_array($line) ? ($line['text'] ?? '') : $line; $pdf->SetFont('Arial', $isBold ? 'B' : '', 8); $pdf->MultiCell($legendWidth, $lineHeight, $text, 0, 'L'); } $legendEndY = $pdf->GetY(); // Signature block (right column), bottom-aligned to the score block $pdf->SetFont('Times', 'BU', 16); $labelHeight = $finalScoreRowHeight; $sigPath = $this->firstExisting([ FCPATH . 'images/signature.png', FCPATH . 'assets/images/signature.png', WRITEPATH . 'report_assets/signature.png', ]); $signatureBlockHeight = $labelHeight + ($sigPath ? $sigHeight : 0); $anchorEndY = ($semNum === 2 && $finalScoreEndY !== null) ? $finalScoreEndY : $scoresEndY; $sigGap = 2; $sigY = $anchorEndY + $sigGap; $pdf->SetXY($sigX, $sigY); $pdf->Cell($sigWidth + 5, $labelHeight, "School Official", 0, 0, "R"); $signatureEndY = $sigY + $labelHeight; if ($sigPath) { $imgY = $sigY + $labelHeight; $pdf->Image($sigPath, $sigX+3, $imgY-2, $sigWidth+2, $sigHeight+5); $signatureEndY = $imgY + $sigHeight; } // Advance cursor to the lower of legend or signature blocks plus small padding $pdf->SetY(max($legendEndY, $signatureEndY) + 4); } public function generateSingleReport($studentId) { // Prevent Debug Toolbar from corrupting PDF if (ENVIRONMENT !== 'production') { // Turn off toolbar manually by unsetting the collector unset(service('toolbar')->collectors['CodeIgniter\Debug\Toolbar\Collectors\Timers']); } $download = $this->request->getGet('download') === '1'; $schoolYear = $this->request->getGet('school_year') ?? $this->schoolYear; $semester = $this->request->getGet('semester') ?? $this->semester; $reportDate = $this->sanitizeReportDate($this->request->getGet('report_date')); $data = $this->prepareStudentReportData((int)$studentId, (string)$schoolYear, (string)$semester); if (!$data) { return "Student not found or missing scores."; } $data['report_date'] = $reportDate['iso']; $data['report_date_display'] = $reportDate['display']; $pdf = new \FPDF('P', 'mm', 'Letter'); $this->formatReportPDF($pdf, $data); $filename = 'ReportCard_' . $data['student']['lastname'] . '.pdf'; // Important: Clean output buffer to prevent PDF corruption if (ob_get_length()) { ob_end_clean(); } $pdfContent = $pdf->Output('S'); $disposition = $download ? 'attachment' : 'inline'; return $this->response ->setHeader('Content-Type', 'application/pdf') ->setHeader('Content-Disposition', $disposition . '; filename="' . $filename . '"') ->setHeader('Cache-Control', 'private, max-age=0, must-revalidate') ->setHeader('Pragma', 'public') ->setHeader('Content-Length', (string) strlen($pdfContent)) ->setBody($pdfContent); } public function generateClassReport($classSectionId) { // Disable toolbar output to prevent PDF corruption if (ENVIRONMENT !== 'production') { unset(service('toolbar')->collectors['CodeIgniter\Debug\Toolbar\Collectors\Timers']); } $download = $this->request->getGet('download') === '1'; $schoolYear = $this->request->getGet('school_year') ?? $this->schoolYear; $semester = $this->request->getGet('semester') ?? $this->semester; $reportDate = $this->sanitizeReportDate($this->request->getGet('report_date')); $q = $this->semesterScoreModel ->select('semester_scores.*') ->join('students s', 's.id = semester_scores.student_id', 'inner') ->where('s.is_active', 1) ->where('semester_scores.class_section_id', (int)$classSectionId); if (!empty($schoolYear)) $q->where('semester_scores.school_year', (string)$schoolYear); if (!empty($semester)) { $this->applySemesterFilter($q, (string)$semester, 'semester_scores.semester'); } $scores = $q ->orderBy('s.firstname', 'ASC') ->orderBy('s.lastname', 'ASC') ->findAll(); if (!$scores) { return "No students found for this class section."; } $pdf = new \FPDF('P', 'mm', 'Letter'); foreach ($scores as $score) { $studentId = $score['student_id']; $data = $this->prepareStudentReportData((int)$studentId, (string)$schoolYear, (string)$semester); if ($data) { $data['report_date'] = $reportDate['iso']; $data['report_date_display'] = $reportDate['display']; $this->formatReportPDF($pdf, $data); } } $filename = 'ClassReport_Section_' . $classSectionId . '.pdf'; // Clean any existing output buffer if (ob_get_length()) { ob_end_clean(); } $pdfContent = $pdf->Output('S'); $disposition = $download ? 'attachment' : 'inline'; return $this->response ->setHeader('Content-Type', 'application/pdf') ->setHeader('Content-Disposition', $disposition . '; filename="' . $filename . '"') ->setHeader('Cache-Control', 'private, max-age=0, must-revalidate') ->setHeader('Pragma', 'public') ->setHeader('Content-Length', (string) strlen($pdfContent)) ->setBody($pdfContent); } protected function normalizeSemester(?string $input): string { $s = strtolower(trim((string)$input)); if ($s === 'fall' || $s === 'first' || str_contains($s, 'fall') || str_contains($s, '1')) return 'fall'; if ($s === 'spring' || $s === 'second' || str_contains($s, 'spring') || str_contains($s, '2')) return 'spring'; return ''; } protected function semesterVariants(?string $semester): array { $raw = trim((string)$semester); if ($raw === '') { return []; } $norm = $this->normalizeSemester($raw); if ($norm === 'fall') { $vals = ['Fall', 'fall', 'First', 'first', 'Semester 1', 'semester 1', 'Semester1', 'semester1', 'Sem 1', 'sem 1', '1', '01', 'S1', 's1']; } elseif ($norm === 'spring') { $vals = ['Spring', 'spring', 'Second', 'second', 'Semester 2', 'semester 2', 'Semester2', 'semester2', 'Sem 2', 'sem 2', '2', '02', 'S2', 's2']; } else { $vals = [$raw]; } $vals[] = $raw; return array_values(array_unique($vals)); } protected function applySemesterFilter($builder, ?string $semester, string $field = 'semester'): void { $vals = $this->semesterVariants($semester); if (!empty($vals)) { $builder->whereIn($field, $vals); } } protected function applySemesterExclusion($builder, ?string $semester, string $field = 'semester'): void { $vals = $this->semesterVariants($semester); if (!empty($vals)) { $builder->whereNotIn($field, $vals); } } protected function listSundays(string $startDate, string $endDate): array { try { $start = new \DateTimeImmutable($startDate); } catch (\Throwable) { return []; } try { $end = new \DateTimeImmutable($endDate); } catch (\Throwable) { return []; } $sundays = []; $cursor = $start; $w = (int)$cursor->format('w'); if ($w !== 0) { $cursor = $cursor->modify('next sunday'); } while ($cursor <= $end) { $sundays[] = $cursor->format('Y-m-d'); $cursor = $cursor->modify('+7 days'); } return $sundays; } protected function resolveAnchorSunday(): string { $tzName = (string) (config('School')->attendance['timezone'] ?? user_timezone()); try { $tzObj = new \DateTimeZone($tzName ?: 'UTC'); } catch (\Throwable) { try { $tzObj = new \DateTimeZone(user_timezone() ?: 'UTC'); } catch (\Throwable) { $tzObj = new \DateTimeZone('UTC'); } } try { $now = new \DateTime('now', $tzObj); } catch (\Throwable) { $now = new \DateTime('now'); } $weekday = (int)$now->format('w'); if ($weekday === 0) { return $now->format('Y-m-d'); } return $now->modify('next sunday')->format('Y-m-d'); } protected function prepareStudentReportData(int $studentId, ?string $schoolYear = null, ?string $semester = null) { // 0) Student $student = $this->studentModel->find($studentId); if (!$student || (int)($student['is_active'] ?? 1) !== 1) return null; helper('attendance_comment'); // 1) Resolve year/semester $refYear = $schoolYear ?: ($this->schoolYear ?? ''); $refSemester = $semester ?: ($this->semester ?? ''); // 2) Grade(s) for the year (best effort) $grade = $this->studentClassModel->getClassSectionsByStudentId($studentId, $refYear); // 3) Pull the semester score (filtered if provided) $qb = $this->semesterScoreModel->where('student_id', $studentId); if ($refYear !== '') $qb->where('school_year', $refYear); if ($refSemester !== '') { $this->applySemesterFilter($qb, $refSemester, 'semester'); } $score = $qb->orderBy('updated_at', 'DESC')->orderBy('id', 'DESC')->first(); if (!$score) return null; // ---------------------------- // Resolve section PK and code // ---------------------------- $sectionCode = (int)($score['class_section_id'] ?? 0); // may be code or PK (legacy) $sectionId = null; if ($sectionCode > 0) { // Try as CODE first $row = $this->db->table('classSection') ->select('id, class_section_id, class_section_name') ->where('class_section_id', $sectionCode)->limit(1)->get()->getRowArray(); if ($row) { $sectionId = (int)$row['id']; $sectionCode = (int)$row['class_section_id']; } else { // Maybe score stored PK directly $row2 = $this->db->table('classSection') ->select('id, class_section_id, class_section_name') ->where('id', $sectionCode)->limit(1)->get()->getRowArray(); if ($row2) { $sectionId = (int)$row2['id']; $sectionCode = (int)$row2['class_section_id']; } } } if (!$sectionId || $sectionCode <= 0) { // Fallback to latest student_class for same year try { $scRow = $this->db->table('student_class') ->select('class_section_id') ->where('student_id', $studentId) ->where('school_year', (string)($score['school_year'] ?? $refYear)) ->orderBy('COALESCE(updated_at, created_at)', 'DESC', false) ->limit(1)->get()->getRowArray(); if (!empty($scRow['class_section_id'])) { $sectionCode = (int)$scRow['class_section_id']; $row = $this->db->table('classSection') ->select('id')->where('class_section_id', $sectionCode) ->limit(1)->get()->getRowArray(); if ($row) $sectionId = (int)$row['id']; } } catch (\Throwable $e) { /* ignore */ } } // ---------------------------- // Teacher & TA lookup (single source of truth) // ---------------------------- $teacherName = 'N/A'; $teacherNamesList = []; $taNames = []; // Prefer the section CODE (what teacher_class stores) and fall back to PK if needed if ($sectionCode > 0) { $names = $this->resolveTeacherAndTAs((int)$sectionCode, $refYear, $refSemester); $teacherName = $names['teacher_name']; $taNames = $names['ta_names']; $teacherNamesList = $names['teacher_names'] ?? []; } // If code lookup failed or is empty, try the PK (legacy data may store either) if (($teacherName === 'N/A' || empty($taNames)) && $sectionId) { $names = $this->resolveTeacherAndTAs((int)$sectionId, $refYear, $refSemester); if ($teacherName === 'N/A') { $teacherName = $names['teacher_name']; } if (empty($taNames)) { $taNames = $names['ta_names']; } if (empty($teacherNamesList) && !empty($names['teacher_names'])) { $teacherNamesList = $names['teacher_names']; } } $taNames = array_values(array_unique(array_filter($taNames))); $taLine = implode(', ', $taNames); // Fallback: if no teacher assignment found, try the user who updated the score if (($teacherName === 'N/A' || trim((string)$teacherName) === '') && !empty($score['updated_by'])) { try { $updId = (int)$score['updated_by']; if ($updId > 0) { $u = $this->userModel->find($updId); if ($u) { $fallbackName = trim(((string)($u['firstname'] ?? '')) . ' ' . ((string)($u['lastname'] ?? ''))); if ($fallbackName !== '') { log_message( 'warning', 'ReportCard: No teacher assignment found; using updated_by as teacher. section_id={sectionId} section_code={sectionCode} year={year} sem={sem} updated_by={uid} name={name}', [ 'sectionId' => $sectionId, 'sectionCode' => $sectionCode, 'year' => (string)($score['school_year'] ?? $refYear), 'sem' => (string)($score['semester'] ?? $refSemester), 'uid' => $updId, 'name' => $fallbackName, ] ); $teacherName = $fallbackName; } } } } catch (\Throwable $e) { // ignore fallback errors } } // ---------------------------- // Class section name (display) // ---------------------------- $classSectionName = 'N/A'; if ($sectionId) { $cs = $this->classSectionModel->find($sectionId); if (!empty($cs['class_section_name'])) $classSectionName = $cs['class_section_name']; } elseif ($sectionCode > 0) { $cs = $this->db->table('classSection')->select('class_section_name') ->where('class_section_id', $sectionCode)->limit(1)->get()->getRowArray(); if (!empty($cs['class_section_name'])) $classSectionName = $cs['class_section_name']; } // ---------------------------- // Names & scores // ---------------------------- $studentName = trim(($student['firstname'] ?? '') . ' ' . ($student['lastname'] ?? '')); $studentFirstName = trim((string)($student['firstname'] ?? '')); $num = fn($v) => is_numeric($v) ? (float)$v : null; $hw = $num($score['homework_avg'] ?? null) ?? 0.0; $qz = $num($score['quiz_avg'] ?? null) ?? 0.0; $prj = $num($score['project_avg'] ?? null) ?? 0.0; $tst = $num($score['test_avg'] ?? null) ?? 0.0; $semesterForAttendance = $refSemester !== '' ? $refSemester : (string)($score['semester'] ?? ''); $normSemester = $this->normalizeSemester($semesterForAttendance); if ($normSemester === 'spring') { $finalExam = $num($score['final_exam_score'] ?? null); if ($finalExam === null) { $finalExam = $num($score['midterm_exam_score'] ?? null); } } elseif ($normSemester === 'fall') { $finalExam = $num($score['midterm_exam_score'] ?? null); } else { $finalExam = $num($score['final_exam_score'] ?? $score['midterm_exam_score'] ?? null); } $att = $num($score['attendance_score'] ?? null); $ptap = $num($score['ptap_score'] ?? null); if ($ptap === null) { $ptap = round((($hw + $qz + $prj + $tst) / 4), 1); } $ptap = max(0, min(100, $ptap)); $secondSemesterScore = $num($score['semester_score'] ?? null); if ($secondSemesterScore === null && $finalExam !== null && $att !== null) { $secondSemesterScore = round(($finalExam * 0.6) + ($ptap * 0.2) + ($att * 0.2), 1); } if ($secondSemesterScore !== null) { $secondSemesterScore = max(0, min(100, $secondSemesterScore)); } // Pick up comments from score_comments if available $commentMap = []; try { $commentBuilder = $this->scoreCommentModel ->where('student_id', $studentId) ->where('school_year', $refYear) ->whereIn('score_type', ['final', 'midterm', 'ptap', 'attendance', 'attendance_comment']); if ($refSemester !== '') { $this->applySemesterFilter($commentBuilder, $refSemester, 'semester'); } $commentRows = $commentBuilder->findAll(); foreach ($commentRows as $row) { $typeRaw = strtolower(trim((string)($row['score_type'] ?? ''))); if ($typeRaw === '') { continue; } // Normalize common variants to a single key if ($typeRaw === 'attendance_comment') { $typeRaw = 'attendance'; } $reviewVal = trim((string)($row['comment_review'] ?? '')); $commentVal = $this->db->fieldExists('comment_review', 'score_comments') ? $reviewVal : trim((string)($row['comment'] ?? '')); if ($commentVal === '') { continue; } $commentMap[$typeRaw] = $commentVal; } } catch (\Throwable $e) { // ignore comment failures } // Expose attendance comment alongside scores for easier PDF fallback if (isset($commentMap['attendance']) && !isset($score['attendance_comment'])) { $score['attendance_comment'] = $commentMap['attendance']; } // If attendance comment is still missing, auto-generate from attendance score for the PDF $attendanceCommentVal = $commentMap['attendance'] ?? $score['attendance_comment'] ?? null; if ((!is_string($attendanceCommentVal) || trim($attendanceCommentVal) === '') && $att !== null) { $autoAttendance = attendance_comment_from_score($att, $studentFirstName ?? ''); if ($autoAttendance !== null) { $commentMap['attendance'] = $autoAttendance; if (!isset($score['attendance_comment'])) { $score['attendance_comment'] = $autoAttendance; } } } // First semester score (for second-semester reports) by looking for any other term in the same year $firstSemesterScore = null; try { $other = $this->semesterScoreModel ->where('student_id', $studentId) ->where('school_year', $refYear); if ($refSemester !== '') { $this->applySemesterExclusion($other, $refSemester, 'semester'); } $other = $other ->orderBy('updated_at', 'DESC') ->orderBy('id', 'DESC') ->first(); if ($other && is_numeric($other['semester_score'] ?? null)) { $firstSemesterScore = (float)$other['semester_score']; } } catch (\Throwable $e) { // ignore } if ($firstSemesterScore === null && in_array(strtolower(trim((string)$refSemester)), ['fall', 'first', '1', 'semester 1'], true)) { $firstSemesterScore = $secondSemesterScore; } $rankingScore = $secondSemesterScore; if ($normSemester === 'spring' && is_numeric($firstSemesterScore) && is_numeric($secondSemesterScore)) { $rankingScore = round(((float)$firstSemesterScore + (float)$secondSemesterScore) / 2, 1); } $termRanking = $this->calculateTermRanking( $studentId, $sectionCode, $sectionId, $refYear, $refSemester !== '' ? $refSemester : (string)($score['semester'] ?? ''), $rankingScore ); // Total semester days from attendance records (max total_attendance within same term) $totalSemesterDays = null; if ($refYear !== '' && $normSemester !== '') { $semRange = $this->semesterRangeService->getSemesterRange($refYear, ucfirst($normSemester)); if ($semRange) { try { // Fetch all events for the year (no semester filter) so that // no-school events stored with a different semester label or // NULL semester are still picked up. Date-range filtering below. $events = $this->calendarModel->getEventsBySchoolYearAndSemester($refYear, null); $noSchoolDays = []; foreach ($events as $event) { if (empty($event['no_school'])) { continue; } $dateStr = substr((string)($event['date'] ?? ''), 0, 10); if ($dateStr === '' || $dateStr < $semRange[0] || $dateStr > $semRange[1]) { continue; } $noSchoolDays[$dateStr] = true; } $sundays = $this->listSundays($semRange[0], $semRange[1]); $limit = $semRange[1]; // Only cap at "today" while the semester is still in progress. // Once the semester end date has passed, count all its Sundays. if ($semRange[1] > date('Y-m-d')) { $anchor = $this->resolveAnchorSunday(); if ($anchor !== '' && $anchor >= $semRange[0]) { $limit = min($anchor, $semRange[1]); } } $totalSemesterDays = 0; foreach ($sundays as $date) { if ($date > $limit) { continue; } if (!empty($noSchoolDays[$date])) { continue; } $totalSemesterDays++; } } catch (\Throwable $e) { log_message('error', 'Failed to count sundays for report card: ' . $e->getMessage()); $totalSemesterDays = null; } } } if ($totalSemesterDays === null) { try { $attRows = $this->attendanceRecordModel ->select('total_attendance') ->where('student_id', $studentId) ->where('school_year', $refYear); if ($semesterForAttendance !== '') { $this->applySemesterFilter($attRows, $semesterForAttendance, 'semester'); } $vals = array_values(array_filter(array_map( static fn($r) => isset($r['total_attendance']) ? (int)$r['total_attendance'] : null, $attRows->findAll() ), static fn($v) => $v !== null)); if (!empty($vals)) { $totalSemesterDays = max($vals); } } catch (\Throwable $e) { // ignore attendance lookup errors } } // Load attendance sums (absences, lates) $attendanceTotals = [ 'total_absences' => 0, 'total_tardies' => 0, ]; try { $attendanceDataModel = new AttendanceDataModel(); $absences = $attendanceDataModel ->where('student_id', $studentId) ->where('school_year', $refYear) ->where('status', 'absent'); if ($semesterForAttendance !== '') { $this->applySemesterFilter($absences, $semesterForAttendance, 'semester'); } $attendanceTotals['total_absences'] = $absences->countAllResults(); $lates = $attendanceDataModel ->where('student_id', $studentId) ->where('school_year', $refYear) ->where('status', 'late'); if ($semesterForAttendance !== '') { $this->applySemesterFilter($lates, $semesterForAttendance, 'semester'); } $attendanceTotals['total_tardies'] = $lates->countAllResults(); } catch (\Throwable $e) { // ignore attendance lookup errors } if ($score) { $score['total_absences'] = $attendanceTotals['total_absences']; $score['total_tardies'] = $attendanceTotals['total_tardies']; } $finalAverage = null; if (is_numeric($firstSemesterScore) && is_numeric($secondSemesterScore)) { $finalAverage = number_format(((float)$firstSemesterScore + (float)$secondSemesterScore) / 2, 1); } return [ 'student' => $student, 'student_name' => ($studentName !== '' ? $studentName : 'N/A'), 'teacher_name' => ($teacherName !== '' ? $teacherName : 'N/A'), 'teacher_names' => array_values(array_unique(array_filter($teacherNamesList))), 'ta_names' => $taNames, 'ta_names_line' => ($taLine !== '' ? $taLine : 'N/A'), 'class_section_name' => $classSectionName, 'class_section_code' => $sectionCode ?: null, 'class_section_id' => $sectionId ?: null, 'selected_semester' => $refSemester, 'grade' => $grade, 'score' => $score, 'ptap' => $ptap, 'final_exam_score' => $finalExam, 'second_semester_score' => $secondSemesterScore, 'first_semester_score' => $firstSemesterScore, 'final_average' => $finalAverage, 'term_rank' => $termRanking, 'term_rank_display' => $termRanking['display'] ?? null, 'comments' => $commentMap, 'total_score' => $secondSemesterScore, 'total_attendance_days' => $totalSemesterDays, ]; } private function calculateTermRanking( int $studentId, int $sectionCode, ?int $sectionId, string $schoolYear, ?string $semester, ?float $studentScore ): ?array { if ($studentId <= 0 || $schoolYear === '' || $studentScore === null) { return null; } $sectionIds = array_values(array_unique(array_filter([ $sectionCode > 0 ? $sectionCode : null, $sectionId && $sectionId > 0 ? $sectionId : null, ]))); if (empty($sectionIds)) { return null; } $semesterForRank = trim((string)$semester); $rankByFinalScore = $this->normalizeSemester($semesterForRank) === 'spring'; $builder = $this->db->table('semester_scores ss') ->select('ss.student_id, ss.semester, ss.semester_score, ss.updated_at, ss.id, s.firstname, s.lastname') ->join('students s', 's.id = ss.student_id', 'inner') ->where('s.is_active', 1) ->where('ss.school_year', $schoolYear) ->whereIn('ss.class_section_id', $sectionIds) ->orderBy('ss.updated_at', 'DESC') ->orderBy('ss.id', 'DESC'); if ($semesterForRank !== '') { $this->applySemesterFilter($builder, $semesterForRank, 'ss.semester'); } $rows = $builder->get()->getResultArray(); if (empty($rows)) { return null; } $scoresByStudent = []; foreach ($rows as $row) { $sid = (int)($row['student_id'] ?? 0); if ($sid <= 0 || isset($scoresByStudent[$sid])) { continue; } $scoreVal = $row['semester_score'] ?? null; if (!is_numeric($scoreVal)) { continue; } $rawScore = (float)$scoreVal; $scoresByStudent[$sid] = [ 'student_id' => $sid, 'score' => $rawScore, 'rank_score' => round($rawScore, 1), 'firstname' => trim((string)($row['firstname'] ?? '')), 'lastname' => trim((string)($row['lastname'] ?? '')), ]; } if (empty($scoresByStudent) || !isset($scoresByStudent[$studentId])) { return null; } // For Spring, rank by final year average: // (first semester score + second semester score) / 2. if ($rankByFinalScore) { $studentIds = array_keys($scoresByStudent); $firstRowsBuilder = $this->db->table('semester_scores ss') ->select('ss.student_id, ss.semester, ss.semester_score, ss.updated_at, ss.id') ->where('ss.school_year', $schoolYear) ->whereIn('ss.student_id', $studentIds) ->whereIn('ss.class_section_id', $sectionIds) ->orderBy('ss.updated_at', 'DESC') ->orderBy('ss.id', 'DESC'); if ($semesterForRank !== '') { $this->applySemesterExclusion($firstRowsBuilder, $semesterForRank, 'ss.semester'); } $firstRows = $firstRowsBuilder->get()->getResultArray(); $firstScoresByStudent = []; foreach ($firstRows as $row) { $sid = (int)($row['student_id'] ?? 0); if ($sid <= 0 || isset($firstScoresByStudent[$sid])) { continue; } $scoreVal = $row['semester_score'] ?? null; if (!is_numeric($scoreVal)) { continue; } $firstScoresByStudent[$sid] = (float)$scoreVal; } foreach ($scoresByStudent as $sid => &$rankRow) { if (!isset($firstScoresByStudent[$sid])) { unset($scoresByStudent[$sid]); continue; } $avg = ((float)$firstScoresByStudent[$sid] + (float)$rankRow['score']) / 2; $rankRow['score'] = $avg; $rankRow['rank_score'] = round($avg, 1); } unset($rankRow); if (empty($scoresByStudent) || !isset($scoresByStudent[$studentId])) { return null; } // Force the selected student to use the exact score already computed for the report. // Then rank using the displayed precision: one decimal. $scoresByStudent[$studentId]['score'] = (float)$studentScore; $scoresByStudent[$studentId]['rank_score'] = round((float)$studentScore, 1); } else { // Fall: also force selected student to match the report-card computed score. $scoresByStudent[$studentId]['score'] = (float)$studentScore; $scoresByStudent[$studentId]['rank_score'] = round((float)$studentScore, 1); } $rankable = array_values($scoresByStudent); usort($rankable, static function (array $a, array $b): int { // Highest displayed/rank score first. $scoreCmp = $b['rank_score'] <=> $a['rank_score']; if ($scoreCmp !== 0) { return $scoreCmp; } // Tie-breakers only control display order. // They do NOT change rank. $lastCmp = strcasecmp($a['lastname'], $b['lastname']); if ($lastCmp !== 0) { return $lastCmp; } $firstCmp = strcasecmp($a['firstname'], $b['firstname']); if ($firstCmp !== 0) { return $firstCmp; } return $a['student_id'] <=> $b['student_id']; }); $position = null; $previousScore = null; foreach ($rankable as $index => $row) { $currentScore = (float)$row['rank_score']; // Competition ranking: // 1, 1, 3, 4... // Same score = same rank. if ($previousScore === null || abs($currentScore - $previousScore) > 0.0001) { $position = $index + 1; $previousScore = $currentScore; } if ((int)$row['student_id'] === $studentId) { $total = count($rankable); return [ 'position' => $position, 'total' => $total, 'score' => $currentScore, 'display' => $this->formatOrdinal($position) . ' out of ' . $total, ]; } } return null; } private function formatOrdinal(?int $value): string { $n = (int)$value; if ($n <= 0) { return ''; } $mod100 = $n % 100; if ($mod100 >= 11 && $mod100 <= 13) { return $n . 'th'; } return match ($n % 10) { 1 => $n . 'st', 2 => $n . 'nd', 3 => $n . 'rd', default => $n . 'th', }; } /** * Resolve teacher & TA names for a class section (by PK or code), * relaxing semester/year if needed. Returns ['teacher_name' => string, 'ta_names' => string[]]. */ private function resolveTeacherAndTAs(int $sectionIdOrCode, ?string $schoolYear = null, ?string $semester = null): array { $teacherName = 'N/A'; $teacherNames = []; $taNames = []; $withPrefix = static function (array $row): string { $first = trim((string)($row['firstname'] ?? '')); $last = trim((string)($row['lastname'] ?? '')); $gender = strtolower(trim((string)($row['gender'] ?? ''))); $name = trim($first . ' ' . $last); if ($name === '') return ''; $prefix = ($gender === 'female') ? 'Sr ' : (($gender === 'male') ? 'Br ' : ''); return trim($prefix . $name); }; $normalizePos = static function (?string $pos): string { $p = strtolower(trim((string)$pos)); if ($p === 'teacher' || str_contains($p, 'main') || str_contains($p, 'co-teach')) return 'main'; if ($p === 'ta' || str_contains($p, 'assist')) return 'ta'; return 'other'; }; $consume = static function (array $rows) use (&$teacherName, &$teacherNames, &$taNames, $normalizePos, $withPrefix) { $any = false; $first = null; foreach ($rows as $r) { $full = $withPrefix($r); if ($full === '') continue; if ($first === null) $first = $full; $any = true; $cls = $normalizePos($r['position'] ?? null); if ($cls === 'main') { if ($teacherName === 'N/A') { $teacherName = $full; } $teacherNames[] = $full; } elseif ($cls === 'ta') { $taNames[] = $full; } } if ($teacherName === 'N/A' && $any && $first) { $teacherName = $first; $teacherNames[] = $first; } }; // 1) Try your model if available if (method_exists($this->teacherClassModel, 'getTeacherTABySection')) { $rows = $this->teacherClassModel->getTeacherTABySection($sectionIdOrCode, (string)$semester, (string)$schoolYear) ?? []; if (!empty($rows)) { if (isset($rows['teacher_names']) || isset($rows['teacher_name']) || isset($rows['tas'])) { $aggTeacherNames = $rows['teacher_names'] ?? []; if (empty($aggTeacherNames) && !empty($rows['teacher_name'])) { $aggTeacherNames = [$rows['teacher_name']]; } foreach ($aggTeacherNames as $nm) { $nm = trim((string)$nm); if ($nm !== '') { if ($teacherName === 'N/A') $teacherName = $nm; $teacherNames[] = $nm; } } foreach (($rows['tas'] ?? []) as $taRow) { if (is_array($taRow) && isset($taRow['name'])) { $taNm = trim((string)$taRow['name']); if ($taNm !== '') $taNames[] = $taNm; } elseif (is_string($taRow)) { $taNm = trim($taRow); if ($taNm !== '') $taNames[] = $taNm; } } } else { $consume($rows); } } } // If still unknown, do robust DB queries that cover PK/code variants and relaxed filters if ($teacherName === 'N/A') { $ids = [$sectionIdOrCode]; $runner = function (array $ids, string $yr, string $sm) { $qb = $this->db->table('teacher_class tc') ->select('tc.teacher_id, tc.position, u.firstname, u.lastname, u.gender') ->join('users u', 'u.id = tc.teacher_id', 'inner') ->whereIn('tc.class_section_id', array_map('intval', $ids)) ->orderBy("CASE WHEN LOWER(tc.position) IN ('main','teacher','main teacher') THEN 0 ELSE 1 END", 'ASC', false) ->orderBy('COALESCE(tc.updated_at, tc.id)', 'DESC', false); if ($yr !== '') $qb->where('tc.school_year', $yr); return $qb->get()->getResultArray(); }; $yr = (string)($schoolYear ?? ''); $sm = (string)($semester ?? ''); $rows = $runner($ids, $yr, $sm); if (empty($rows)) $rows = $runner($ids, $yr, ''); if (empty($rows)) $rows = $runner($ids, '', ''); if (!empty($rows)) $consume($rows); } // 3) Final bridge via name-join if PK/code are mixed in tc if ($teacherName === 'N/A') { // Resolve section name from either PK or code $csName = null; $byId = $this->db->table('classSection')->select('class_section_name')->where('id', $sectionIdOrCode)->get()->getRowArray(); if (!empty($byId['class_section_name'])) { $csName = $byId['class_section_name']; } else { $byCode = $this->db->table('classSection')->select('class_section_name')->where('class_section_id', $sectionIdOrCode)->get()->getRowArray(); if (!empty($byCode['class_section_name'])) $csName = $byCode['class_section_name']; } if ($csName) { $base = $this->db->table('teacher_class tc') ->select('tc.teacher_id, tc.position, u.firstname, u.lastname, u.gender') ->join('users u', 'u.id = tc.teacher_id', 'inner') // unescaped join to allow OR; CI4: escape=false is the 4th arg ->join('classSection cs', 'cs.id = tc.class_section_id OR cs.class_section_id = tc.class_section_id', 'inner', false) ->where('cs.class_section_name', $csName) ->orderBy("CASE WHEN LOWER(tc.position) IN ('main','teacher','main teacher') THEN 0 ELSE 1 END", 'ASC', false) ->orderBy('COALESCE(tc.updated_at, tc.id)', 'DESC', false); $yr = (string)($schoolYear ?? ''); $sm = (string)($semester ?? ''); $qb1 = clone $base; if ($yr !== '') $qb1->where('tc.school_year', $yr); $rows = $qb1->get()->getResultArray(); if (empty($rows) && $yr !== '') { $qb2 = clone $base; $qb2->where('tc.school_year', $yr); $rows = $qb2->get()->getResultArray(); } if (empty($rows)) $rows = $base->get()->getResultArray(); if (!empty($rows)) $consume($rows); } } // Clean names $taNames = array_values(array_unique(array_filter(array_map('trim', $taNames)))); $teacherNames = array_values(array_unique(array_filter(array_map('trim', $teacherNames)))); if (empty($teacherNames) && $teacherName !== 'N/A') { $teacherNames[] = $teacherName; } return [ 'teacher_name' => $teacherName, 'teacher_names' => $teacherNames, 'ta_names' => $taNames, ]; } /** * Normalize a report date (YYYY-MM-DD) from input; falls back to today. * Returns ['iso' => 'Y-m-d', 'display' => 'F j, Y']. */ private function sanitizeReportDate($input): array { $raw = trim((string)$input); $ts = null; if ($raw !== '') { $ts = strtotime($raw); } if ($ts === false || $ts === null) { $ts = time(); } $iso = date('Y-m-d', $ts); $display = date('F j, Y', $ts); return ['iso' => $iso, 'display' => $display]; } }