- Introduction
- Pitch
- Hogwarts
- Live Demo
- MVP
- Roadmap
- Launch Sprint
- PRD
- Get Started
- Localhost
- Architecture
- Structure
- Pattern
- Page
- Layout
- Content
- Types
- Config
- Actions
- Queries
- Authorization
- Validation
- Form
- Table
- Detail
- Card
- Util
- Hooks
- List Params
- Views
- README.md
- ISSUE.md
- Technology Stack
- Database
- File
- CDN Assets
- Entry Points
- Dashboard
- Authentication
- Credentials
- OAuth
- Flow Diagrams
- Multi-Tenancy
- Offline
- Onboarding
- Onboarding Videos
- Add Values
- Admission
- Application
- Attendance
- Compliance
- Profile
- Exams
- Exam Wizard
- Timetable
- Classrooms
- Notifications
- Conference
- LMS (Lumos)
- Finance
- Fee Management
- Invoice
- Wallet
- Salary
- Payroll
- Timesheet
- Expenses
- Budget
- Receipt
- Accounts
- Banking
- Reports
- Dashboard
- Permissions
- Messages
- Integration Flow
- Provision
- AI Document Processing
- Document Intelligence
- Internationalization
- Translation
- Translation Guide
- Icons
- Docs Factory
- Inspiration
- Listings
- Teachers
- Students
- Catalog
- Library
- Contributing
- Code of conduct
- GitHub Workflow
- Database Seeds
- Database Safety
- Test Accounts
- Playwright
- Prettier
- Block Rebound
The timetable module builds and serves weekly school schedules. An admin picks a structure during onboarding, configures working days and periods, assigns or auto-generates weekly slots, resolves conflicts, and exports to PDF; every other role gets a read view tailored to who they are. Schedules are section-based end-to-end — each section (Grade 1-A, Grade 7-B) gets a full week, with Timetable rows carrying sectionId, subjectId, classroomId, and a nullable teacherId. The slot editor writes section + subject (no new classId data is created); editing a legacy classId row backfills its section fields, migrating it in place. Slot deletion is id-based, so section-based and legacy slots delete alike. Student and guardian reads resolve Student.sectionId alongside legacy StudentClass enrollments, so section-generated schedules are visible the moment a student is placed. Default terms are calendar-aware — ACADEMIC_CALENDARS in calendars.ts derives term count and boundaries from the school's country and structure (see Provision). All operations are scoped by schoolId.
Where it lives
src/app/[lang]/s/[subdomain]/(school-dashboard)/timetable/— routes — mirror the component treepage.tsx— Overview / Today — role-routedlayout.tsx— role-based tab nav (PageNav)error.tsx— error boundary (top level only)loading.tsx— skeletonfull/page.tsx— Full-week tab (defaultTab="full")analytics/page.tsxloading.tsxconflicts/page.tsxloading.tsxgenerate/page.tsxloading.tsxsettings/page.tsxloading.tsxsrc/components/school-dashboard/timetable/— the block — all UI and logic live herecontent.tsx— entry: SessionProvider → RoleRouteractions.ts— server actions + queries ("use server", ~6k lines)validation.ts— Zod schemastypes.ts— domain + UI typesconfig.ts— options, labels, defaults + constantspermissions-config.ts— client-safe PERMISSION_MATRIX + role checkspermissions.ts— server-side access guardsstructures.ts— predefined school structures (sd-gov-default, …)util.ts— pure helpers (findAvailableSlots, …)live-class-join.ts— live-class join/start resolver (conference link)slot-editor-dialog.tsx— assign subject / teacher / room to a slotprint.css— A4 print stylesviews/— one view per rolerole-router.tsx— dispatch by role + editable flagadmin-view.tsx— room / teacher grid, editable, cookie-persistent filterteacher-view.tsxstudent-view.tsxguardian-view.tsxsimple-grid.tsxpreview.tsxlive-join-button.tsxstart-live-class-button.tsxindex.tsgenerate/— auto-generationcontent.tsx— Generate pagealgorithm.ts— section-based scheduleranalytics/content.tsx— utilization reportingconflicts/content.tsx— conflict pagesettings/content.tsx— config pagesubstitutions/— BUILT but not wired to any route or tabcontent.tsxabsence-form.tsxsubstitute-finder.tsxsubstitution-list.tsxindex.tsexport/— PDF exporttimetable-pdf.tsxuse-timetable-export.tsindex.tsREADME.md— block docs (decisions, danger zones)ISSUE.mdFEATURES.mdCLAUDE.mdsrc/tests/school-dashboard/timetable/— Vitest suitesactions.test.tsstructures.test.tsvalidation.test.tsproduction-readiness.test.tsprisma/models/— data modeltimetable.prisma— Timetable, TeacherConstraint, RoomConstraint, TimetableTemplate, ScheduleException, TeacherAbsence, SubstitutionRecordschool.prisma— Period (+ core school models)schedule.prisma— SchoolWeekConfigsrc/app/api/mobile/— REST — mobile only (web uses server actions directly)timetable/[userId]/route.tsguardian/children/[childId]/timetable/route.tssrc/components/onboarding/schedule/— onboarding step (order 5, optional)content.tsx— structure pickerstructure-preview.tsx— visual timelineactions.ts— getSchoolScheduleData / saveScheduleChoiceconfig.ts— constantsvalidation.ts— Zod schemaThe block is unusually centralized: actions.ts is a single ~6,000-line file holding all ~70 server actions and every read query — there is no separate queries.ts. The route tree under app/.../timetable/ mirrors the component tree, and every route is reachable from the UI — the admin's room/teacher axes are a filter inside one view, not separate routes (see Routes).
Permissions
Roles map to actions through PERMISSION_MATRIX in permissions-config.ts (client-safe, imported by both the layout and the views). "Modify" covers create / edit / delete / import / bulk-edit.
| Role | View | Modify | Conflicts | Settings | Analytics | Export |
|---|---|---|---|---|---|---|
| DEVELOPER | All | Y | Y | Y | Y | Y |
| ADMIN | All | Y | Y | Y | Y | Y |
| TEACHER | All + own | — | — | — | Y | Y |
| ACCOUNTANT | All (read-only) | — | — | — | Y | Y |
| STAFF | All (read-only) | — | — | — | — | Y |
| STUDENT | Own grade | — | — | — | — | Y |
| GUARDIAN | Child | — | — | — | — | Y |
| USER | None | — | — | — | — | — |
Every role except USER can export. manage_substitutions is granted to ADMIN / DEVELOPER in the matrix, but the substitution UI has no route yet (see Substitutions).
Routes
layout.tsx renders role-based tabs via PageNav, each tab individually permission-gated. Admins see Overview, Analytics, Generate, Conflicts, Settings. Non-admins see Today and Full; the Analytics tab additionally appears for any role with view_analytics (teacher, accountant).
There is no separate /my-timetable route. Students and guardians use the same /timetable and /timetable/full routes — both render TimetableContent, differing only by defaultTab ("today" vs "full"), and RoleRouter dispatches to the correct view by role.
| Route | Component | In nav? |
|---|---|---|
/timetable | TimetableContent | Yes — Overview/Today |
/timetable/full | TimetableContent | Yes — Full |
/timetable/analytics | TimetableAnalyticsContent | Yes — Analytics |
/timetable/generate | GenerateTimetableContent | Yes — Generate |
/timetable/conflicts | TimetableConflictsContent | Yes — Conflicts |
/timetable/settings | TimetableSettingsContent | Yes — Settings |
Earlier builds carried standalone by-class, by-teacher, by-room, and templates routes that nothing linked to. They were removed: the admin already sees a single role-routed grid (AdminView) whose classroom / teacher selection is a cookie-persistent filter — one route serves every admin axis, and the choice survives refresh and navigation. (The template actions remain in actions.ts; only the unreached UI was deleted.)
Boundaries are uneven: there is a single top-level error.tsx, the remaining sub-routes ship a loading.tsx, and full/ has neither. Sidebar entry: key: "timetable", href: "/timetable", icon clock, roles ADMIN / TEACHER / STUDENT.
Role-based views
content.tsx wraps RoleRouter in SessionProvider. On mount RoleRouter calls getActiveTerm() then getPersonalizedTimetable() to resolve viewType + editable, and renders the matching view, which fetches its own data.
| Role | View | Data source | Sees |
|---|---|---|---|
| DEVELOPER | AdminView | getTimetableByRoom / getTimetableByTeacher | Room / teacher grid, editable slots, slot editor |
| ADMIN | AdminView | Same as DEVELOPER | Same as DEVELOPER |
| TEACHER | TeacherView | getTimetableByTeacher + getTodaySchedule | Personal schedule, workload, current / next class |
| STUDENT | StudentView | getTimetableByStudentGrade + getTodaySchedule | Enrolled subjects (grade-based), PDF export |
| GUARDIAN | GuardianView | getGuardianChildren + getChildTimetable | Child selector + each child's timetable |
| ACCOUNTANT | AdminView | Same as ADMIN | Full admin grid, read-only (editable={false}) |
| STAFF | AdminView | Same as ADMIN | Full admin grid, read-only |
getPersonalizedTimetable sets editable: false for ACCOUNTANT / STAFF; AdminView forwards editable to SimpleGrid and disables onSlotClick when read-only. AdminView's classroom / teacher selection is saved to a cookie (tt_filter), so the chosen axis and entity persist across refresh and navigation. The classId prop on StudentView is legacy and unused — student data is grade-resolved.
Student grade resolution
getTimetableByStudentGrade resolves a student's schedule for the active term along both axes:
User (session)
→ Student (db.student.findFirst where userId, schoolId)
→ Student.sectionId → section-based Timetable rows (primary)
→ StudentClass (per termId) → classIds[] → legacy Timetable rows
→ OR of both in one query
A student placed in a section but not yet enrolled in course classes still gets a full schedule. If no StudentClass rows match the term, the legacy axis falls back to grade-name pattern matching — db.class.findMany where name ends with " - {gradeName}" — for backward compatibility with older data. The grade label comes from Student → StudentYearLevel → YearLevel.levelName (with lang for the localized name).
Onboarding schedule step
The schedule picker is an optional onboarding step (order 5), between Capacity and Branding.
1. Read school country, schoolType, schoolLevel
2. getSchoolScheduleData() — recommended structures + alternatives
3. User picks (saveScheduleChoice → school.system) or skips
4. "Next" is always enabled — the step is optional
Each structure card shows name, description, period count, time range, working days, and a color-coded timeline (blue = class, amber = break, green = lunch), with a "Recommended" badge on the top matches.
| File | Purpose |
|---|---|
src/app/[lang]/onboarding/[id]/schedule/page.tsx | Route |
src/components/onboarding/schedule/content.tsx | Step UI |
src/components/onboarding/schedule/structure-preview.tsx | Visual timeline |
src/components/onboarding/schedule/actions.ts | getSchoolScheduleData(), saveScheduleChoice() |
src/components/onboarding/schedule/{config,validation}.ts | Constants + Zod schema |
Applying a structure
applyTimetableStructure() turns a named structure into Period rows and a SchoolWeekConfig.
await applyTimetableStructure({
yearId: "clx...",
structureSlug: "sd-gov-default",
replaceExisting: true,
})
// Legacy template names map automatically via LEGACY_TEMPLATE_MAP
await applyTimetableStructure({
yearId: "clx...",
structureSlug: "standard_8", // → sd-gov-default
})It validates admin + tenant, looks up the structure (resolving legacy names), optionally deletes existing periods (replaceExisting), creates a Period row per entry (class / break / lunch), upserts SchoolWeekConfig with the working days + lunch position, and logs an audit entry.
In Settings, the schedule configurator mirrors the onboarding schedule step: a preset <Select> (defaulted to the country-recommended structure) drives a live StructurePreview, with quick-config knobs — weekend, periods/day, duration, start — for fine-tuning. Applying writes the periods; the per-period editor below handles row-level manual tweaks. The StructurePreview is shared between onboarding and Settings so both surfaces look identical.
The seed reads the school's declared structure rather than hand-rolling its own period list, so a school that says sd-private actually gets sd-private. Periods a previous structure left behind are pruned — but only when nothing references them; a period still holding timetable slots is kept and reported, since deleting it would take the slots with it.
Breaks are typed, never inferred
Period.isBreak is the source of truth for whether a period is teaching time. Never re-derive it from Period.name: the name is user-editable free text, and testing it for the English substrings "break"/"lunch" means an Arabic «فسحة» matches nothing — the generator then treats the break as teaching time and schedules classes into it. Writers set it from StructurePeriod.type !== "class"; the grid renders break rows straight from the period data, so every break shows in its real time slot.
The Sudanese school day
A Sudanese school day has exactly one break: the mid-morning فسحة (40 min), when فطور is eaten. There is no school lunch — الغداء is eaten at home after dismissal, so a midday "Lunch" period is a Western import and is not modelled. sd-gov-default runs 07:30–14:40 (8×45min) and sd-private 07:15–14:10 (7×50min), both Sun–Thu with a single فسحة after period 3. sd-british is the one SD structure that keeps a real lunch — it follows the British curriculum, Mon–Fri.
Sections and homerooms follow the school's language
Section letters, section names, and homeroom codes are generated from School.preferredLanguage via sectionLetters() / defaultSectionName() in catalog/room-naming.ts — shared by autoProvisionSections and the classrooms Configure tab so the two provisioning paths cannot drift. An Arabic school gets أ/ب/ج (أبجد order), sections named الصف الأول - أ composed from the grade's own name, and homerooms أ01…أ12; digits stay Latin to match how the UI renders numbers everywhere else (الحصة 1, 07:15). English schools are unchanged (Grade 1-A, A01). Both paths persist lang on the rows they create — a row whose stored lang disagrees with the language it is actually written in cannot be fixed at read time, because the translation layer trusts the tag.
Generating a timetable
The Generate tab is a two-step, preview-then-commit flow:
generateTimetablePreview()runsgenerateSectionTimetable()(generate/algorithm.ts) over every section. For each section it fills the week from the grade's subject allocations, assigns a teacher from that teacher's subject expertise (left Unassigned when none is free), routes regular subjects to the section's homeroom (the room coupled to the section viaSection.classroomId— a 1:1 nullable FK, never more than one) and assigns lab/hall/field rooms per-period viaTimetable.classroomId(these shared rooms are NOT owned by the section), and prevents a section from being double-booked in the same period. Three unique constraints enforce double-booking prevention: teacher, room, and section cannot appear more than once in the same period.applyGeneratedTimetable()commits the preview intoTimetablerows.
Zero-click auto-provision
You don't have to press Generate to get a timetable. Every school auto-provisions one as part of the provisioning doctor (repairProvisioning → autoGenerateTimetableForSchool), which runs at onboarding completion, on publish, and via the operator "Repair Provisioning" action.
The demo seed uses this same generator (2026-07-16), so seeded and real schools get identical, section-based timetables from one code path. The hand-rolled seedTimetable it replaced scheduled classes into whatever room happened to be free — offices, labs, even the football field — and wrote legacy classId-only rows the section-based reads can't see.
Anything the seed writes must be scoped to the active term, not
terms[0]. Which term is active is derived from today's date, so seeding onto
the first term while a later one is active leaves the grid reading an empty
term on a school with a full timetable. Term pickers likewise default to the
active term (getTermsForSelection orders isActive first), never simply the
newest.
Curriculum (the grade → subject allocations) plus the schedule (periods +
working days) is enough to fill the grid with teacher-less slots; teacher names
appear once teachers are assigned, and students see their schedule the moment
they're placed in a section. The active term is detected from today's date, and
generation targets the same term the grid reads (resolveActiveTerm) so the two
never disagree. A school that never picked a schedule structure still provisions
— the doctor derives a country-recommended default and saves it.
detectTimetableConflicts() backs the Conflicts tab — teacher, room, and section double-booking — and suggestFreeSlots() powers the slot editor's free-period suggestions. getTimetableAnalytics() backs the Analytics tab.
Substitutions
The substitution subsystem is fully built in the data and action layers — the TeacherAbsence and SubstitutionRecord models, and the createTeacherAbsence, findAvailableSubstitutes, assignSubstitute, respondToSubstitution, getSubstitutionRecords, and cancelSubstitution server actions — plus a complete UI under substitutions/ (absence form, substitute finder, record list).
None of it is wired. There is no route page and no tab, and nothing imports substitutions/content. The manage_substitutions permission exists for ADMIN / DEVELOPER but has no surface. Adding a route + tab is the remaining step to ship it.
What is wired vs. not
Working end-to-end: builder grid, the five role views (admin room / teacher grid with a cookie-persistent filter), conflict detection, generate (preview + apply), analytics, settings, PDF export, the onboarding schedule step, and the mobile endpoints. The web app calls server actions directly — the old /api/timetable/* REST routes (thin wrappers consumed only by a now-deleted client store) were removed; only the mobile feeds under /api/mobile/ remain.
Built but not surfaced: the substitution subsystem (above). The template actions (createTemplateFromTerm, applyTemplateToTerm, …) also remain in actions.ts with no UI, after the unreached standalone templates route (and the by-class / by-teacher / by-room routes) were removed and folded into the role-routed view + filter.
Open (from the block's ISSUE.md): drag-and-drop slot editing (currently click-based), mobile-view polish, the full ARIA grid pattern + keyboard navigation (the conflict indicator and heading-nesting fixes have landed; the grid interaction model has not), splitting the 6.5k-line actions.ts into queries.ts/actions.ts, the content-file i18n long-tail, and print tuning for varied day counts.
Production-readiness hardening (2026-06-13)
A 9-dimension adversarial audit drove a security/correctness pass:
- Tenant isolation — the
validate*Constraintshelpers were exported from a"use server"file with a caller-suppliedschoolId(a cross-tenant read surface); they are now internal-only. Constraint writes verify the teacher/parent-constraint belongs to the school first, andfilterTimetableByRolethrows rather than silently dropping the tenant filter. - Correctness —
detectTimetableConflictsno longer crashes on section-based slots (it dereferenced a nullclass) and batches its detail fetch into two queries;moveTimetableSlotblocks a section double-book at the target cell;applyTemplateToTermandsetActiveTermare now atomic ($transaction). - Validation — ~12 previously-unvalidated mutations now Zod-parse their input with bounds.
- i18n — server-side substitution/move/delete notifications are localized by
School.preferredLanguage(they were hardcoded Arabic). - a11y — invalid heading nesting removed, decorative elements hidden from assistive tech, and conflicts signalled with an icon + screen-reader text rather than colour alone.
All mutations are admin-gated and schoolId-scoped; 180 timetable tests pass and the block is tsc-clean.
See also
On This Page
Where it livesPermissionsRoutesRole-based viewsStudent grade resolutionOnboarding schedule stepApplying a structureBreaks are typed, never inferredThe Sudanese school daySections and homerooms follow the school's languageGenerating a timetableZero-click auto-provisionSubstitutionsWhat is wired vs. notProduction-readiness hardening (2026-06-13)See also