query("SELECT * FROM classes ORDER BY name")->fetchAll(PDO::FETCH_ASSOC); $data['subjects'] = $pdo->query("SELECT id, name FROM subjects")->fetchAll(PDO::FETCH_KEY_PAIR); $data['teachers'] = $pdo->query("SELECT id, name FROM teachers")->fetchAll(PDO::FETCH_KEY_PAIR); $data['timeslots'] = $pdo->query("SELECT * FROM timeslots ORDER BY start_time")->fetchAll(PDO::FETCH_ASSOC); $workloads_stmt = $pdo->query(" SELECT w.class_id, w.subject_id, w.teacher_id, w.lessons_per_week, s.name as subject_name, s.has_double_lesson, s.elective_group_id, c.name as class_name, t.name as teacher_name, eg.name as elective_group_name FROM workloads w JOIN subjects s ON w.subject_id = s.id JOIN classes c ON w.class_id = c.id JOIN teachers t ON w.teacher_id = t.id LEFT JOIN elective_groups eg ON s.elective_group_id = eg.id "); $data['workloads'] = $workloads_stmt->fetchAll(PDO::FETCH_ASSOC); return $data; } // --- Main Scheduling Engine --- function generate_timetable($data, $days_of_week) { error_log("generate_timetable: Starting generation..."); $periods = array_values(array_filter($data['timeslots'], function($ts) { return !$ts['is_break']; })); $periods_per_day = count($periods); error_log("generate_timetable: Periods per day: $periods_per_day"); // 1. Initialize Timetables $class_timetables = []; $teacher_timetables = []; foreach ($data['classes'] as $class) { $class_timetables[$class['id']] = array_fill(0, count($days_of_week), array_fill(0, $periods_per_day, null)); } foreach ($data['teachers'] as $teacher_id => $teacher_name) { $teacher_timetables[$teacher_id] = array_fill(0, count($days_of_week), array_fill(0, $periods_per_day, null)); } error_log("generate_timetable: Initialized " . count($class_timetables) . " class timetables and " . count($teacher_timetables) . " teacher timetables."); // 2. Prepare Lessons $lessons_to_schedule = []; $electives_by_group_grade = []; foreach ($data['workloads'] as $workload) { if (empty($workload['elective_group_id'])) { // Regular lesson for ($i = 0; $i < $workload['lessons_per_week']; $i++) { $is_double = ($i == 0 && $workload['has_double_lesson'] && $workload['lessons_per_week'] >= 2); $lessons_to_schedule[] = [ 'type' => 'single', 'class_id' => $workload['class_id'], 'subject_id' => $workload['subject_id'], 'teacher_ids' => [$workload['teacher_id']], 'display_name' => $workload['subject_name'], 'teacher_name' => $workload['teacher_name'], 'is_double' => $is_double, 'is_elective' => false ]; if ($is_double) $i++; } } else { // Elective lesson part $grade = preg_replace('/[^0-9]/', '', $workload['class_name']); $key = $workload['elective_group_id'] . '_grade_' . $grade; if (!isset($electives_by_group_grade[$key])) { $electives_by_group_grade[$key] = [ 'type' => 'elective_group', 'display_name' => $workload['elective_group_name'] . " (Form " . $grade . ")", 'lessons_per_week' => $workload['lessons_per_week'], 'has_double_lesson' => $workload['has_double_lesson'], 'is_elective' => true, 'component_lessons' => [] ]; } $electives_by_group_grade[$key]['component_lessons'][] = $workload; } } foreach ($electives_by_group_grade as $group) { for ($i = 0; $i < $group['lessons_per_week']; $i++) { $is_double = ($i == 0 && $group['has_double_lesson'] && $group['lessons_per_week'] >= 2); $class_ids = array_unique(array_column($group['component_lessons'], 'class_id')); $teacher_ids = array_unique(array_column($group['component_lessons'], 'teacher_id')); $lessons_to_schedule[] = [ 'type' => 'elective_group', 'class_id' => $class_ids, // Now an array of classes 'subject_id' => null, // Grouped subject 'teacher_ids' => $teacher_ids, 'display_name' => $group['display_name'], 'is_double' => $is_double, 'is_elective' => true, 'component_lessons' => $group['component_lessons'] ]; if ($is_double) $i++; } } error_log("generate_timetable: Prepared " . count($lessons_to_schedule) . " lessons to schedule."); // 3. Sort lessons (place doubles and electives first) usort($lessons_to_schedule, function($a, $b) { if ($b['is_double'] != $a['is_double']) return $b['is_double'] <=> $a['is_double']; $a_count = is_array($a['class_id']) ? count($a['class_id']) : 1; $b_count = is_array($b['class_id']) ? count($b['class_id']) : 1; return $b_count <=> $a_count; }); // 4. Placement $lessons_placed = 0; $lessons_failed = 0; foreach ($lessons_to_schedule as $index => $lesson) { $lesson_label = $lesson['display_name'] . (is_array($lesson['class_id']) ? ' for ' . count($lesson['class_id']) . ' classes' : ' for class ' . $lesson['class_id']); error_log("generate_timetable: Attempting to place lesson #" . ($index + 1) . ": " . $lesson_label); $best_slot = find_best_slot_for_lesson($lesson, $class_timetables, $teacher_timetables, $days_of_week, $periods_per_day, $data['workloads'], $data['timeslots']); if ($best_slot) { $lessons_placed++; $day = $best_slot['day']; $period = $best_slot['period']; error_log("generate_timetable: Found best slot for lesson #" . ($index + 1) . " at Day $day, Period $period."); if ($lesson['type'] === 'single') { $class_id = $lesson['class_id']; $teacher_id = $lesson['teacher_ids'][0]; $lesson_data = [ 'subject_name' => $lesson['display_name'], 'teacher_name' => $lesson['teacher_name'], 'subject_id' => $lesson['subject_id'], 'teacher_ids' => $lesson['teacher_ids'], 'is_double' => $lesson['is_double'], 'is_elective' => false, ]; $class_timetables[$class_id][$day][$period] = $lesson_data; $teacher_timetables[$teacher_id][$day][$period] = true; if ($lesson['is_double']) { $class_timetables[$class_id][$day][$period + 1] = $lesson_data; $teacher_timetables[$teacher_id][$day][$period + 1] = true; } } else { // Elective Group // Mark all teachers in the group as busy for this slot foreach ($lesson['teacher_ids'] as $teacher_id) { $teacher_timetables[$teacher_id][$day][$period] = true; if ($lesson['is_double']) { $teacher_timetables[$teacher_id][$day][$period + 1] = true; } } // Populate the class-specific data for display foreach($lesson['component_lessons'] as $comp_lesson) { $class_id = $comp_lesson['class_id']; $teacher_id = $comp_lesson['teacher_id']; $lesson_data = [ 'subject_name' => $comp_lesson['subject_name'], 'teacher_name' => $comp_lesson['teacher_name'], 'subject_id' => $comp_lesson['subject_id'], 'teacher_ids' => [$teacher_id], // Specific teacher for this part 'is_double' => $lesson['is_double'], 'is_elective' => true, 'group_name' => $lesson['display_name'] ]; $class_timetables[$class_id][$day][$period] = $lesson_data; if ($lesson['is_double']) { $class_timetables[$class_id][$day][$period + 1] = $lesson_data; } } } } else { $lessons_failed++; error_log("generate_timetable: FAILED to find slot for lesson #" . ($index + 1) . ": " . $lesson_label); } } error_log("generate_timetable: Placement complete. Placed: $lessons_placed, Failed: $lessons_failed."); return $class_timetables; } function find_best_slot_for_lesson($lesson, &$class_timetables, &$teacher_timetables, $days_of_week, $periods_per_day, $workloads, $all_timeslots) { $best_slot = null; $best_score = -1; $is_double = $lesson['is_double']; $class_ids = is_array($lesson['class_id']) ? $lesson['class_id'] : [$lesson['class_id']]; $teacher_ids = $lesson['teacher_ids']; $subject_id = $lesson['subject_id']; // Rule 1: Get total lessons per week for this subject to check if we can repeat on the same day $lessons_per_week_for_subject = 0; if ($lesson['type'] === 'single') { foreach ($workloads as $w) { if ($w['class_id'] == $class_ids[0] && $w['subject_id'] == $subject_id) { $lessons_per_week_for_subject = $w['lessons_per_week']; break; } } } else { // Elective group $lessons_per_week_for_subject = $lesson['component_lessons'][0]['lessons_per_week']; } for ($day = 0; $day < count($days_of_week); $day++) { for ($period = 0; $period < $periods_per_day; $period++) { // Basic availability check if ($is_double && $period + 1 >= $periods_per_day) continue; // Prevent placing double lessons across breaks if ($is_double) { $non_break_periods = array_values(array_filter($all_timeslots, function($ts) { return !$ts['is_break']; })); $first_period_timeslot = $non_break_periods[$period]; $second_period_timeslot = $non_break_periods[$period + 1]; $first_original_index = -1; $second_original_index = -1; $all_timeslots_values = array_values($all_timeslots); foreach ($all_timeslots_values as $index => $ts) { if ($ts['id'] == $first_period_timeslot['id']) $first_original_index = $index; if ($ts['id'] == $second_period_timeslot['id']) $second_original_index = $index; } if ($first_original_index === -1 || $second_original_index === -1 || $second_original_index !== $first_original_index + 1) { continue; // This slot spans a break, so it's invalid for a double lesson. } } $slot_available = true; foreach ($class_ids as $cid) { if (!isset($class_timetables[$cid]) || $class_timetables[$cid][$day][$period] !== null || ($is_double && $class_timetables[$cid][$day][$period + 1] !== null)) { $slot_available = false; break; } } if (!$slot_available) continue; foreach ($teacher_ids as $tid) { if (!isset($teacher_timetables[$tid]) || $teacher_timetables[$tid][$day][$period] !== null || ($is_double && isset($teacher_timetables[$tid][$day][$period + 1]) && $teacher_timetables[$tid][$day][$period + 1] !== null)) { $slot_available = false; break; } } if ($slot_available) { $current_score = 100; // Start with a base score for an available slot // Rule 1: Penalize placing the same subject on the same day for a class $subject_on_day_count = 0; foreach ($class_ids as $cid) { for ($p = 0; $p < $periods_per_day; $p++) { $existing_lesson = $class_timetables[$cid][$day][$p]; if ($existing_lesson !== null) { if ($lesson['type'] === 'single' && isset($existing_lesson['subject_id']) && $existing_lesson['subject_id'] == $subject_id) { $subject_on_day_count++; } // Check for electives by group name if ($lesson['type'] === 'elective_group' && isset($existing_lesson['group_name']) && $existing_lesson['group_name'] == $lesson['display_name']) { $subject_on_day_count++; } } } } // Only apply penalty if the number of lessons is less than or equal to the number of days if ($lessons_per_week_for_subject <= count($days_of_week)) { if ($subject_on_day_count > 0) { $current_score -= 50; // Heavy penalty } } else { // If we must repeat, penalize stacking more than 2 in one day if ($subject_on_day_count > 1) { $current_score -= 25; } } // Rule 2: Penalize days that are already "full" for the class $lessons_on_day = 0; foreach ($class_ids as $cid) { $lessons_on_day += count(array_filter($class_timetables[$cid][$day])); } // The penalty increases quadratically to strongly avoid busy days $current_score -= $lessons_on_day * $lessons_on_day; // Rule 3: Penalize consecutive lessons for a teacher (teacher fatigue) foreach ($teacher_ids as $tid) { // Check period before if ($period > 0 && $teacher_timetables[$tid][$day][$period - 1] !== null) { $current_score -= 15; } // Check period after (don't check for double lessons as that's intended) if (!$is_double && $period < $periods_per_day - 1 && $teacher_timetables[$tid][$day][$period + 1] !== null) { $current_score -= 15; } } if ($current_score > $best_score) { $best_score = $current_score; $best_slot = ['day' => $day, 'period' => $period]; } } } } return $best_slot; } // --- Timetable Persistence --- function save_timetable($pdo, $class_timetables, $timeslots) { if (empty($class_timetables)) { error_log("save_timetable: Attempted to save an empty timetable. Aborting."); return; } error_log("save_timetable: Starting timetable save process."); try { $pdo->beginTransaction(); error_log("save_timetable: Transaction started."); // It's better to delete from the child table first to avoid foreign key issues. $pdo->exec('DELETE FROM schedule_teachers'); error_log("save_timetable: Deleted data from schedule_teachers."); $pdo->exec('DELETE FROM schedules'); error_log("save_timetable: Deleted data from schedules."); $stmt = $pdo->prepare( 'INSERT INTO schedules (class_id, day_of_week, timeslot_id, subject_id, lesson_display_name, teacher_display_name, is_double, is_elective) ' . 'VALUES (:class_id, :day_of_week, :timeslot_id, :subject_id, :lesson_display_name, :teacher_display_name, :is_double, :is_elective)' ); $teacher_stmt = $pdo->prepare( 'INSERT INTO schedule_teachers (schedule_id, teacher_id) VALUES (:schedule_id, :teacher_id)' ); $lesson_periods = array_values(array_filter($timeslots, function($ts) { return !$ts['is_break']; })); foreach ($class_timetables as $class_id => $day_schedule) { foreach ($day_schedule as $day_idx => $period_schedule) { $processed_periods = []; foreach ($period_schedule as $period_idx => $lesson) { if ($lesson && !in_array($period_idx, $processed_periods)) { if (!isset($lesson_periods[$period_idx]['id'])) { error_log("save_timetable: Missing timeslot for period index {$period_idx}. Skipping lesson."); continue; } $timeslot_id = $lesson_periods[$period_idx]['id']; $display_name = !empty($lesson['is_elective']) ? ($lesson['group_name'] . ' / ' . $lesson['subject_name']) : $lesson['subject_name']; $stmt->execute([ ':class_id' => $class_id, ':day_of_week' => $day_idx, ':timeslot_id' => $timeslot_id, ':subject_id' => $lesson['subject_id'], ':lesson_display_name' => $display_name, ':teacher_display_name' => $lesson['teacher_name'], ':is_double' => (int)$lesson['is_double'], ':is_elective' => (int)$lesson['is_elective'] ]); $schedule_id = $pdo->lastInsertId(); if (!empty($lesson['teacher_ids'])) { foreach ($lesson['teacher_ids'] as $teacher_id) { $teacher_stmt->execute([':schedule_id' => $schedule_id, ':teacher_id' => $teacher_id]); } } $processed_periods[] = $period_idx; if ($lesson['is_double']) { $processed_periods[] = $period_idx + 1; } } } } } if ($pdo->inTransaction()) { $pdo->commit(); error_log("save_timetable: Timetable save completed successfully. Transaction committed."); } else { error_log("save_timetable: Warning: No active transaction to commit."); } } catch (Exception $e) { error_log("save_timetable: An exception occurred. " . $e->getMessage()); if ($pdo->inTransaction()) { $pdo->rollBack(); error_log("save_timetable: Transaction rolled back."); } else { error_log("save_timetable: No active transaction to roll back."); } // Re-throw the exception to see the error on the screen if display_errors is on throw $e; } } function get_timetable_from_db($pdo, $classes, $timeslots, $days_of_week) { $stmt = $pdo->query('SELECT * FROM schedules ORDER BY id'); $saved_lessons = $stmt->fetchAll(PDO::FETCH_ASSOC); if (empty($saved_lessons)) { return []; } $periods = array_values(array_filter($timeslots, function($ts) { return !$ts['is_break']; })); $periods_per_day = count($periods); $class_timetables = []; foreach ($classes as $class) { $class_timetables[$class['id']] = array_fill(0, count($days_of_week), array_fill(0, $periods_per_day, null)); } $timeslot_id_to_period_idx = []; foreach($periods as $idx => $period) { $timeslot_id_to_period_idx[$period['id']] = $idx; } foreach ($saved_lessons as $lesson) { $class_id = $lesson['class_id']; $day_idx = $lesson['day_of_week']; $timeslot_id = $lesson['timeslot_id']; if ($day_idx >= count($days_of_week)) { error_log("Invalid day_idx {$day_idx} found for lesson ID {$lesson['id']}. Skipping."); continue; } if (!isset($timeslot_id_to_period_idx[$timeslot_id])) { continue; } if (!isset($class_timetables[$class_id])) { continue; } $period_idx = $timeslot_id_to_period_idx[$timeslot_id]; $lesson_data = [ 'id' => $lesson['id'], 'subject_name' => $lesson['lesson_display_name'], 'teacher_name' => $lesson['teacher_display_name'], 'is_double' => (bool)$lesson['is_double'], 'is_elective' => (bool)$lesson['is_elective'], ]; $class_timetables[$class_id][$day_idx][$period_idx] = $lesson_data; if ($lesson_data['is_double'] && isset($class_timetables[$class_id][$day_idx][$period_idx + 1])) { $class_timetables[$class_id][$day_idx][$period_idx + 1] = $lesson_data; } } return $class_timetables; } // --- Main Logic --- $pdoconn = db(); $all_data = get_all_data($pdoconn); $classes = $all_data['classes']; $timeslots = $all_data['timeslots']; $workloads = $all_data['workloads']; // Fetch working days from school settings $school_id = $_SESSION['school_id'] ?? null; $days_of_week = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday']; // Default if ($school_id) { $stmt = $pdoconn->prepare("SELECT working_days FROM schools WHERE id = ?"); $stmt->execute([$school_id]); $school_settings = $stmt->fetch(PDO::FETCH_ASSOC); if ($school_settings && !empty($school_settings['working_days'])) { $days_of_week = explode(',', $school_settings['working_days']); } } $class_timetables = []; if (isset($_POST['generate'])) { if (!empty($workloads)) { $class_timetables = generate_timetable($all_data, $days_of_week); save_timetable($pdoconn, $class_timetables, $timeslots); // Redirect to the same page to prevent form resubmission and show the new timetable header("Location: timetable.php"); exit; } } // Always fetch the latest timetable from the database for display $class_timetables = get_timetable_from_db($pdoconn, $classes, $timeslots, $days_of_week); ?> Timetable - Haki Schedule

Class Timetable

0) ? ($class_timetables[$class['id']][$day_idx][$period_idx - 1] ?? null) : null; if ($lesson_above && !empty($lesson_above['is_double']) && ($lesson_above['id'] ?? 'a') === ($lesson['id'] ?? 'b')) { $skip_cell = true; } if (!$skip_cell) : $rowspan = 1; if ($lesson && !empty($lesson['is_double'])) { // Check if the next timeslot is not a break to prevent rowspan over a break row $is_next_slot_a_break = false; $current_timeslot_index = -1; // Find the index of the current timeslot $timeslots_values = array_values($timeslots); foreach ($timeslots_values as $index => $ts) { if ($ts['id'] === $timeslot['id']) { $current_timeslot_index = $index; break; } } // Check the next timeslot if the current one was found if ($current_timeslot_index !== -1 && isset($timeslots_values[$current_timeslot_index + 1])) { $next_timeslot = $timeslots_values[$current_timeslot_index + 1]; if ($next_timeslot['is_break']) { $is_next_slot_a_break = true; } } if (!$is_next_slot_a_break) { $rowspan = 2; } } ?>
Time

-
Break

No workloads found. Please add classes, subjects, teachers, and workloads in the "Manage" section first. The "Generate Timetable" button is disabled. Click the "Generate Timetable" button to create a schedule.