0%
balqalam Logo
balqalam
FeaturesCommunityPricingDocumentation
Login
  • Introduction
  • Help center
  • Common questions
  • 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

Timetable

PreviousNext

How the timetable works today — where it lives, how a schedule is built and generated, how each role sees it, and what is built but not yet wired.

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 tree
page.tsx— Overview / Today — role-routed
layout.tsx— role-based tab nav (PageNav)
error.tsx— error boundary (top level only)
loading.tsx— skeleton
full/
page.tsx— Full-week tab (defaultTab="full")
analytics/
page.tsx
loading.tsx
conflicts/
page.tsx
loading.tsx
generate/
page.tsx
loading.tsx
settings/
page.tsx
loading.tsx
src/components/school-dashboard/timetable/— the block — all UI and logic live here
content.tsx— entry: SessionProvider → RoleRouter
actions.ts— server actions + queries ("use server", ~6k lines)
validation.ts— Zod schemas
types.ts— domain + UI types
config.ts— options, labels, defaults + constants
permissions-config.ts— client-safe PERMISSION_MATRIX + role checks
permissions.ts— server-side access guards
structures.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 slot
print.css— A4 print styles
views/— one view per role
role-router.tsx— dispatch by role + editable flag
admin-view.tsx— room / teacher grid, editable, cookie-persistent filter
teacher-view.tsx
student-view.tsx
guardian-view.tsx
simple-grid.tsx
preview.tsx
live-join-button.tsx
start-live-class-button.tsx
index.ts
generate/— auto-generation
content.tsx— Generate page
algorithm.ts— section-based scheduler
analytics/
content.tsx— utilization reporting
conflicts/
content.tsx— conflict page
settings/
content.tsx— config page
substitutions/— BUILT but not wired to any route or tab
content.tsx
absence-form.tsx
substitute-finder.tsx
substitution-list.tsx
index.ts
export/— PDF export
timetable-pdf.tsx
use-timetable-export.ts
index.ts
README.md— block docs (decisions, danger zones)
ISSUE.md
FEATURES.md
CLAUDE.md
src/tests/school-dashboard/timetable/— Vitest suites
actions.test.ts
structures.test.ts
validation.test.ts
production-readiness.test.ts
prisma/models/— data model
timetable.prisma— Timetable, TeacherConstraint, RoomConstraint, TimetableTemplate, ScheduleException, TeacherAbsence, SubstitutionRecord
school.prisma— Period (+ core school models)
schedule.prisma— SchoolWeekConfig
src/app/api/mobile/— REST — mobile only (web uses server actions directly)
timetable/[userId]/route.ts
guardian/children/[childId]/timetable/route.ts
src/components/onboarding/schedule/— onboarding step (order 5, optional)
content.tsx— structure picker
structure-preview.tsx— visual timeline
actions.ts— getSchoolScheduleData / saveScheduleChoice
config.ts— constants
validation.ts— Zod schema

The 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.

RoleViewModifyConflictsSettingsAnalyticsExport
DEVELOPERAllYYYYY
ADMINAllYYYYY
TEACHERAll + own———YY
ACCOUNTANTAll (read-only)———YY
STAFFAll (read-only)————Y
STUDENTOwn section————Y
GUARDIANChild————Y
USERNone—————

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.

RouteComponentIn nav?
/timetableTimetableContentYes — Overview/Today
/timetable/fullTimetableContentYes — Full
/timetable/analyticsTimetableAnalyticsContentYes — Analytics
/timetable/generateGenerateTimetableContentYes — Generate
/timetable/conflictsTimetableConflictsContentYes — Conflicts
/timetable/settingsTimetableSettingsContentYes — Settings
/timetable/teachersAssignmentsContentYes — 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.

RoleViewData sourceSees
DEVELOPERAdminViewgetTimetableByRoom / getTimetableByTeacherRoom / teacher grid, editable slots, slot editor
ADMINAdminViewSame as DEVELOPERSame as DEVELOPER
TEACHERTeacherViewgetTimetableByTeacher + getTodaySchedulePersonal schedule, workload, current / next class
STUDENTStudentViewgetTimetableByStudentGrade + getTodayScheduleRead-only week grid for own section + current/next card (no header card, no PDF export)
GUARDIANGuardianViewgetGuardianChildren + getChildTimetableChild selector + each child's timetable
ACCOUNTANTAdminViewSame as ADMINFull admin grid, read-only (editable={false})
STAFFAdminViewSame as ADMINFull 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:

SurfaceAcceptsGate
GET /api/mobile/timetable/:userIda user id in the pathstudent target → canAccessStudent (self, linked guardian, or staff); teacher target → self, or DEVELOPER / ADMIN / STAFF. 403 otherwise
GET /api/mobile/teacher/schedulenothingrole gate, then resolves the teacher from auth.userId
getTimetableByTeacher (server action)teacherIdrequirePermission("view_all") — granted to DEVELOPER / ADMIN / TEACHER / ACCOUNTANT / STAFF, withheld from STUDENT / GUARDIAN
getTimetableByRoom (server action)roomIdrequirePermission("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.

FilePurpose
src/app/[lang]/onboarding/[id]/schedule/page.tsxRoute
src/components/onboarding/schedule/content.tsxStep UI
src/components/onboarding/schedule/structure-preview.tsxVisual timeline
src/components/onboarding/schedule/actions.tsgetSchoolScheduleData(), saveScheduleChoice()
src/components/onboarding/schedule/{config,validation}.tsConstants + 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:

  1. generateTimetablePreview() runs generateSectionTimetable() (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 via Section.classroomId — a 1:1 nullable FK, never more than one) and assigns lab/hall/field rooms per-period via Timetable.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.
  2. applyGeneratedTimetable() commits the preview into Timetable rows.

See Classrooms › Integration.

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*Constraints helpers were exported from a "use server" file with a caller-supplied schoolId (a cross-tenant read surface); they are now internal-only. Constraint writes verify the teacher/parent-constraint belongs to the school first, and filterTimetableByRole throws rather than silently dropping the tenant filter.
  • Correctness — detectTimetableConflicts no longer crashes on section-based slots (it dereferenced a null class) and batches its detail fetch into two queries; moveTimetableSlot blocks a section double-book at the target cell; applyTemplateToTerm and setActiveTerm are 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

Onboarding

The wizard that runs the schedule-picker step.

Attendance

Section-based consumer — periods drive auto-detection.

Multi-tenancy

How schoolId scoping isolates every query.

Catalog

Subjects and grade allocations the generator reads.

Exam WizardClassrooms

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

Built by Databayt ·

Welcome to balqalam.

A great journey is about to begin.