- 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
- Bulk import
- Playwright
- Prettier
- Block Rebound
- Transportation
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; slot deletion is id-based. Student and guardian reads resolve Student.sectionId, so section-generated schedules are visible the moment a student is placed. The Class model and every classId column were removed on 2026-10-04. Slots are unique per (school, term, day, period, section, week offset). 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 section | — | — | — | — | 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, Teachers, Analytics, Generate, Conflicts, Settings. Teachers and guardians see Today and Full; the Analytics tab additionally appears for any role with view_analytics (teacher, accountant). Students see no tabs at all — a student has one schedule, so Today and Full were the same data twice; /timetable renders the week grid directly. The strip is skipped rather than rendered empty, because PageNav still paints its bottom border when every item is hidden.
There is no separate /my-timetable route — the name survives in the roleRoutes matrix with no page behind it. Guardians and teachers use /timetable and /timetable/full, which both render TimetableContent and differ only by defaultTab ("today" vs "full"); RoleRouter dispatches to the correct view by role. StudentView ignores defaultTab and always renders the grid, so /timetable/full is simply a duplicate for a student.
Access is decided at the edge, in src/routes.ts — these pages carry no guard of their own. /timetable and /timetable/full are exact entries listing ADMIN / TEACHER / STUDENT / GUARDIAN / DEVELOPER, while the /timetable/* wildcard stays ADMIN / TEACHER / DEVELOPER so generate, settings, conflicts and analytics stay closed. isRouteAllowedForRole matches exact before wildcard, which is what lets the two coexist; widening the wildcard would open all four admin pages at once. Until 2026-09-01 the matrix omitted STUDENT entirely, so every student who followed the sidebar link landed on /unauthorized.
| 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 |
/timetable/teachers | AssignmentsContent | Yes — Teachers |
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 sub-routes ship a loading.tsx, and full/ has neither — the top-level route's own loading.tsx was replaced by a role-aware <Suspense> in layout.tsx (see The loading frame). 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 | Read-only week grid for own section + current/next card (no header card, no 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. StudentView renders the same SimpleGrid the admin builds in, read-only and filtered to the student's own section; the Current/Next card above it is kept deliberately, because the grid shows live indicators but carries no Join button. It still calls getTodaySchedule, which is what that card derives from. The header card (name + grade, term badge, subject and period counts, Download) was removed, so a student's page is the heading and the grid — note this took PDF export and Print with it, since both lived only in that card's dropdown; teacher and guardian keep theirs, and browser printing still works via print.css. getTimetableByStudentGrade still returns studentInfo, schoolName and subjectCount; this view no longer reads them. The grid renders bare — no Card wrapper — beneath an AdminView-shaped toolbar (space-y-12 above the grid), so the student and admin surfaces are visually identical. The week/day control is the pricing page's billing toggle reused verbatim (saas-marketing/pricing/billing-toggle.tsx) — a two-column ToggleGroup with a bg-muted thumb slid by translate-x-full rtl:-translate-x-full. Its halves are rendered from an order array that the viewport chooses — ["day","week"] under 768px, ["week","day"] above — and the leading half is also the default, so a phone leads with and opens on the day while a laptop leads with and opens on the week. Keeping order and default as one value means they cannot drift apart, and the thumb slides on "is the active half the trailing one". The week label is its own short studentView.week key; studentView.weekView keeps its longer wording for the teacher/guardian tab. The halves are sized min-w-[64px] px-3 rather than pricing's min-w-[148px] px-6 — 130px of chrome instead of 298px, which on a phone was as wide as the one-column grid beneath it. It switches week / day: day mode narrows the same SimpleGrid to a single column rather than rendering a different component (SimpleGrid already supports a 1-length workingDays; and below the width where a full week would cramp, the grid scrolls rather than compresses — its inner wrapper carries a per-column floor of totalCols x 128px via min-w-[var(--tt-grid-min-w)], which never binds where there is room and is reset by print:min-w-0 so A4 output is unaffected. When it does scroll, the period column stays pinned (sticky start-0 z-10 on the header, break and period cells) so a row never loses its time label — start-0 rather than left-0 because in RTL that column sits on the right), falling forward to the next working day when today is not one. On viewports under 768px day mode is the default — a five-day grid does not fit a phone. It is derived from matchMedia rather than seeded into state (the server has no viewport, so seeding would hydrate mismatched), which means the default keeps following rotation until the student picks a side; their pick then wins at every width, and nothing is persisted. Day mode also turns highlightToday off — simple-grid.tsx appends bg-primary/5 in the same cn() as the subject colour, and tailwind-merge keeps only the last bg-*, so the highlight replaces the colour instead of tinting it (which is why today's column is grey in every week grid, admin's included). (The count, when it was shown, was fixed to tally distinct subjects across the student's slots — counting enrolled Class rows reported 36 subjects above a nine-subject grid, since a student holds one row per subject per section.)
The loading frame
The role a reader gets decides the SHAPE of the grid, and on a phone that is a
difference of five columns to one — so the placeholder cannot be role-blind.
views/grid-skeleton.tsx mirrors SimpleGrid's DOM (same container, same
grid-cols-N header, same period rows, a break row in the right place) and
takes the same workingDays / periods arrays the grid is about to receive.
Its collapseToDayOnMobile flag narrows it to period + one day below md. That
narrowing is done in CSS on purpose: day mode is decided by matchMedia, which
cannot be read until after mount, so narrowing the day array in JavaScript is
correct exactly one frame too late. StudentView passes the flag while the
student has not picked a half — an explicit pick is width-independent and
narrows the days instead.
RoleRouter learns the role from getPersonalizedTimetable, which is the whole
wait the placeholder covers, so page.tsx resolves it from the session first and
passes studentShell (rendersStudentTimetable, mirroring that action's own
switch — ACCOUNTANT and STAFF take the admin grid, USER falls through to the
student one). That picks both the collapsed grid and a 130px toolbar chip in
place of two 180px comboboxes.
The route-level fallback needs the same answer and gets it the same way. A
loading.tsx cannot: it has to render instantly, so it cannot await auth(),
and this route's drew the five-day week for every role — plus a page title and a
five-tab strip that its own layout.tsx had already painted directly above it.
It is gone. layout.tsx already awaits auth() for the tab strip, so it now
wraps {children} in the <Suspense> Next would have built anyway and branches
the fallback on the role. Sub-routes that ship their own loading.tsx keep it.
Both that fallback and RoleRouter's loading branch render the shared
TimetableSurfaceSkeleton, because they appear back to back inside a single
load and any difference between them is a step the reader watches happen.
Two gating rules came out of this and generalise past the timetable. A loading
gate on useTransition's pending flag opens on whatever branch follows it,
because that flag only rises once the effect that starts the transition runs —
which is after the first paint. RoleRouter gated on it and served
"no timetable data" as the opening frame of a page that was merely loading, in
the server HTML as well as the browser. Gate on a settled flag set in the
load's finally. The mirror image is a view whose own isLoading starts
false and is raised from an effect: it paints one frame of empty content
before its skeleton, which is what StudentView did.
Band heights are measured off the live grid rather than derived from padding,
because a real cell's height comes from its text. Header 41/65 and break row
68/84 (mobile/sm+) are measurements. The mobile teaching band is not: the day
cell's min-h-18 floors that row at 72px, so the band simply is the cell's
min-height and the two must move together. Note that min-h-14 there had never
bound at all — the period column beside it is 64px tall from its own padding and
a grid row takes its tallest cell, so any floor at or below 64px was a silent
no-op.
Student grade resolution
getTimetableByStudentGrade resolves a student's schedule for the active term from the student's section:
User (session)
→ Student (db.student.findFirst where userId, schoolId)
→ Student.sectionId → section-based Timetable rows
A student placed in a section gets the full schedule of that section. The grade label comes from Student → StudentYearLevel → YearLevel.levelName (with lang for the localized name). The old course axis (StudentClass and grade-name pattern matching on Class names) was removed on 2026-10-04 — everything is section and subject.
Joining a live class from the grid
Two resolvers feed the timetable from Conference. getLiveClassIndicators returns timetableId → "live" | "scheduled" — a dot, and nothing you can act on. attachLiveClasses returns a real target (sessionId, provider, meetingUrl, status), resolved most-specific-first: this slot's session today → today's session for the same (section, subject) → the subject's recurring ConferenceLink → the section's all-day open room.
Until 2026-09-02 only the Today cards received the second one, so the weekly grid was awareness-only by design. That held while every role had a day list; it stopped holding when the student view became a bare grid, leaving a student able to see that their third period was online with no route into it. resolveTodayJoinTargets now hands every weekly read a timetableId → LiveClassJoinInfo map, and SimpleGrid takes an optional renderSlotAction(slot, period) render prop that each role view supplies — students, guardians and admins render Join; teachers render Join, or StartLiveClassButton when the window is open and no session exists yet.
Whether a class is online at all is the school's configuration, not the timetable's. The standing ConferenceLink tier is gated on resolveOnlinePolicies — physical never resolves one, online always does, and hybrid consults the school default, the per-section and per-grade overrides and the go-online window. Subject granularity needs no extra setting: it is already expressed by which (section, subject) pairs have links or sessions. Materialized sessions are deliberately not gated — online is additive, and a room someone is already sitting in should not vanish because the school flipped mid-day.
In the cell, the indicator and the join link share one slot rather than sitting side by side: a green a corner mark, never a background: cells always keep their subject colour, and an online class carries LiveMark (an inline ring-and-dot drawn with currentColor). Only the one cell worth acting on is coloured — --live green while it runs, --upcoming amber before it, --missed red after — and the rest sit muted in the cell's foreground. An earlier version coloured the whole background and blinked; it read as decoration, and it could not beat getSubjectTailwind's hover:bg-* on hover anyway, since tailwind-merge does not collapse a hover variant against a base bg-*.
Clicking a cell opens a dialog. On the admin grid that is the slot editor; on the read-only grids it is views/slot-detail-dialog.tsx, which shares the editor's chrome deliberately — the same gesture in a different verb. It is also where the per-person answer lives: getSlotDetail resolves the viewer from the session and returns their own attendance for that period (a guardian gets their child's, a teacher the roster split and whether it has been taken), and it carries the Join button.
Three further things are deliberate. The map holds today's slots only — tiers 2 and 3 of the resolver are not day-aware, so passing a whole week would let a standing link stamp every weekday row of that subject — which also means no caller needs a day check, and a day-mode grid showing the next working day is correctly join-free. The day is the school's, via schoolDayOfWeek, never the server's getDay(). And a cell can show Join with no dot: the dot requires a materialized sessionId, while a tier-3 standing link joins with sessionId: null — the same asymmetry the Today rows have always had.
Whose timetable, and who may ask
Identity is resolved server-side from the session, never from a value the caller sends. getTimetableByStudentGrade looks up Student by session.user.id and reads the student's sectionId; getPersonalizedTimetable resolves Teacher.id (not User.id) the same way and hands it to TeacherView. Nothing on the web surface accepts an identity from the client.
Where an id is accepted, it names whose timetable is wanted and the caller still has to be entitled to it:
| Surface | Accepts | Gate |
|---|---|---|
GET /api/mobile/timetable/:userId | a user id in the path | student target → canAccessStudent (self, linked guardian, or staff); teacher target → self, or DEVELOPER / ADMIN / STAFF. 403 otherwise |
GET /api/mobile/teacher/schedule | nothing | role gate, then resolves the teacher from auth.userId |
getTimetableByTeacher (server action) | teacherId | requirePermission("view_all") — granted to DEVELOPER / ADMIN / TEACHER / ACCOUNTANT / STAFF, withheld from STUDENT / GUARDIAN |
getTimetableByRoom (server action) | roomId | requirePermission("view_all") — same gate; tightened when the read began returning Join targets as well as slots |
Two things to hold onto. Every export in a "use server" file is a public POST endpoint, so an action taking an id needs a permission that means "may look at someone else's data" — requireReadAccess() only means "may see a timetable at all", which is why it was not enough for getTimetableByTeacher. And the mobile route must OR both slot axes exactly as the web read does: gating on sectionId alone returned an empty week for every student whose data predates the section-first migration.
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.
Parallel sections and periods waiting for a teacher
A new school's timetable exists before anyone is hired, so most periods start
with no teacher. Generation still keeps parallel sections apart: every
(grade, subject) gets a placeholder teacher shared by the grade's sections
(generate/teacher-plan.ts), so 7-A and 7-B never have Math at the same period
and one future teacher can take both. Placeholders are stripped to an empty
teacher before anything is saved. Real qualified teachers always get first pick,
and a subject is tried on every day for a teacher before a teacher-less period
is accepted.
In the admin room view, a period with no teacher reads «بانتظار معلم» / Needs teacher, with a count of how many periods in the room are still waiting. Students and guardians don't see the label.
Assigning teachers to subjects
Who teaches what is one fact per section and subject: SubjectTeacher (section × subject → teacher, per term). There are no classes to create — a subject a grade teaches is already in each of its sections' timetables, waiting for a teacher until someone is assigned. An admin assigns a teacher in three places, all backed by the same engine:
- the Subjects & sections step of the Add Teacher wizard;
- Assign subjects in the row menu on
/teachers(next to View and Generate credentials); - the Teachers tab,
/timetable/teachers— every grade as a table of sections × subjects, each cell a teacher picker, with each teacher's weekly load against their cap and a Suggest button that proposes qualified teachers for the waiting subjects.
When a teacher takes a subject, the timetable follows: every period of that
subject in those sections gets the teacher, and where the teacher is already
busy, periods swap inside the same section (or move into an empty period).
Another section is never touched, a live or upcoming online class and a pending
substitution never move, and the teacher's daily and weekly limits hold —
assigning past the weekly limit asks first. Anything that still can't be placed
is listed with the reason. In the slot editor, changing a period's teacher can
apply to all periods of that subject in the section. Its teacher picker lists
the subject's specialists first, then other teachers, each with their weekly
load against their cap (12/25); a teacher already teaching at that day and
period shows Busy and can't be picked.
A teacher's specialties are what the generator, Suggest and the slot editor's picker treat as "qualified". In the Assign subjects dialog the ★ on a subject card marks a specialty for that subject in every grade (subjects of the same name), and the dialog opens filtered to the teacher's specialties, with All subjects and a grade filter to widen it. Teaching a subject always makes it a specialty.
A new term starts with last term's teachers and timetable. A teacher who is deactivated or deleted gives their subjects back — those periods wait for a teacher again. Regenerating the timetable keeps every assigned teacher on their subjects.
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 viewsThe loading frameStudent grade resolutionJoining a live class from the gridWhose timetable, and who may askOnboarding schedule stepApplying a structureBreaks are typed, never inferredThe Sudanese school daySections and homerooms follow the school's languageGenerating a timetableZero-click auto-provisionParallel sections and periods waiting for a teacherAssigning teachers to subjectsSubstitutionsWhat is wired vs. notProduction-readiness hardening (2026-06-13)See also