Report Cards
This guide explains how YeboLearn report cards work end-to-end: how a school admin sets the term up, how a teacher's daily mark entry rolls into the printed PDF, how parents and students download the final card, and what to do when something goes wrong.
Pick your section:
- For school admins — get the school ready to print
- For teachers — daily mark entry
- For parents and students — find and download the report
- Architecture reference — for engineers
- Troubleshooting — fix common issues
How report cards work
A report card is generated on demand when someone clicks Download. There is no nightly job, no queue, and no stored PDF — every download regenerates fresh from the database. The pipeline pulls together five inputs:
School setup (admin) Daily entry (teacher)
┌────────────────────────┐ ┌───────────────────────┐
│ Term name + dates │ │ Marks (per student, │
│ Grading scale (JSON) │ │ per subject, per │
│ School logo │ │ assessment) │
└────────┬───────────────┘ │ Subject comments │
│ │ Attendance rows │
│ └──────────┬────────────┘
│ │
└─────────┬─────────────────────────┘
│
▼
┌─────────────────────────┐
│ reportService │
│ .generateReportCard() │
│ — joins all five inputs │
│ — aggregates per subject│
│ — renders via PDFKit │
└──────────┬──────────────┘
│
▼
┌─────────────────────────┐
│ PDF buffer streamed to │
│ the browser (Download) │
└─────────────────────────┘Data dependencies at a glance
| Input | Producer | Required state |
|---|---|---|
| Term (name + start/end dates) | School admin | Row in terms matching the requested term name and academic year |
| Grading scale | School admin | schools.grading_scale set (falls back to YeboLearn defaults if not) |
| School logo | School admin | schools.logo_url reachable over HTTP (PDF still renders without it) |
| Marks | Teacher | Rows in grades matching the requested term and academic_year strings |
| Attendance | Teacher / system | Rows in attendance with date falling inside the term window |
| Teacher / principal remarks | Teacher or school admin | Row in report_card_comments keyed by (school_id, student_id, term, academic_year). Optional — the boxes print empty if absent |
TIP
Term and academic year are stored on each grade row as plain text ("Term 1", "2026"). They must match exactly between entry and printing — "Term 1" and "term 1" are different terms as far as the database is concerned.
For school admins (set the school up)
You set the foundation. Without the right setup, teachers can enter marks but the printed PDF will look off — empty attendance, fallback grade scale, no logo.
Create the academic year and terms
- Open Settings and choose the Academic tab.
- Set the Academic Year (e.g.
2026). - Set the Current Term — its name (e.g.
Term 1), kind (term, semester, quarter or trimester), and start and end dates. - Click save.
The dates matter even if you only ever print marks: attendance on the report card is bounded by the term window. Without a matching term, the PDF falls back to all-time attendance and prints an italic warning.
Add each term as the year progresses
The Academic tab edits the current term. To have Term 1, Term 2 and Term 3 all available in the term dropdowns, come back and save each one as it begins.
Set the pass mark
On the same Academic tab, set Pass Mark (%).
Be aware of how the letter grades work today: only the pass mark is yours to choose. It becomes the minimum for a D, while A, B and C are fixed at 80%, 70% and 60%. If your school uses different bands, the letters printed on the card will not match your policy — the percentages and totals will still be correct.
Upload the school logo
- Open Settings and stay on the General tab.
- Upload the logo (a transparent PNG works best).
The logo is fetched over the web at the moment the PDF is built. If that fetch fails — broken link, expired URL — the card still prints, just without the logo. It is the only part of the layout treated as non-essential.
Check readiness before you print
Open Report Cards. Above the class list you'll see readiness per class: the share of subject-by-student combinations that have at least one mark recorded for the chosen term. A class at 100% is safe to print; one at 60% means marks are still missing, and those subjects will simply be absent from the printed cards.
The same readiness summary appears as a widget on your Dashboard.
For teachers (enter marks)
Mark entry flows into report cards continuously. There is no "submit for review" step — every save is immediately part of the next print.
Open the Gradebook
- Open Gradebook in the teacher dashboard.
- Pick the class and the subject from your class-subject assignments.
You only see the (class, subject) pairs your school admin has assigned to you in Classes → Subjects & Teachers. If a subject is missing, ask your admin to assign you.
Enter exam metadata, then per-student scores
For each assessment you grade:
- Exam name (e.g.
Mid-term Exam). - Assessment type —
test,exam,quiz,assignment,project,practical, ororal. - Date.
- Max score (e.g.
100). - Fill the score for each student in the roster.
Press Save. The Gradebook posts a batch to POST /grades/bulk and the server upserts every row by ID.
Important — "absent" vs "zero"
There is no separate "absent" status on a grade. The data model is binary: a grade row exists, or it doesn't.
- No row = "not graded yet" — does not pull the student's average down.
- Score = 0 = "graded zero" — does pull the average down.
If a student missed an assessment and you want it to count as a zero, enter 0 explicitly. If you want the missed assessment to disappear from the average, leave the cell blank.
Add the teacher's and principal's remarks
Every report card has two written remarks — one from the class teacher, one from the principal — plus a conduct grade. These are the boxes at the bottom of the printed card.
Pick the term before you open the dialog
Remarks are saved against a specific term and academic year. A remark saved under Term 1 will not appear on a Term 2 report card — the box simply prints empty, with no warning. Always set the term selector first; in both dashboards the remarks control stays disabled until you do.
You can add remarks from either dashboard. Both edit the same record, so a teacher and an admin working on the same student see each other's text.
From the teacher dashboard
- Open Gradebook and select the class.
- In the Report cards panel, set the academic year and pick the term.
- In the student table, click the purple speech-bubble icon on the student's row.
The icon sits next to the printer icon at the end of each row. It stays greyed out until you have picked a term — hover over it and it will tell you so.

From the school admin dashboard
- Open Report Cards.
- Choose the class, term, and academic year.
- Expand the class to show the student list, and click Remarks on any student.

Filling in the dialog
The dialog has three fields — the class teacher's remark, the principal's remark, and a conduct grade. Fill in what you need and click Save; you don't have to complete all three at once.

A few things worth knowing:
- Leaving a field alone keeps what's already there. Saving only the teacher's remark will not wipe a principal's remark that someone else has already written.
- To remove a remark, clear the text and save — delete the words rather than leaving the field untouched, or the previous text stays.
- Anyone who can edit grades can write either remark. There is no separate principal-only permission, so the principal's box can be filled in on their behalf. Agree internally on who writes what.
- Two people editing at once is safe. The most recent save wins rather than the two conflicting.
How remarks appear on the card
Both boxes are always printed, whether or not anything was written. An empty one reads "No comment recorded." — so a blank box means nothing was saved for that term, not that the feature is missing.

TIP
Marks save offline. If you lose network mid-class, the Gradebook queues your entries in IndexedDB and syncs when you reconnect. You'll see a small "queued offline" indicator until sync completes — if you don't see it, ask the user to refresh and try again rather than assuming success.
For parents and students (download and read the PDF)
Where to find your report card
- Students — open Reports → My Report Cards in the student dashboard. Every term that has at least one grade for you shows up as a card.
- Parents — open Children → (your child) → Report Cards. You see one list per child.
Download
Click Download on the term you want. The browser fetches the PDF and saves it with the school's preferred filename (e.g. report-card-student-470ec6b4-2026-Term-1.pdf). The PDF is the canonical printed artifact — there's no separate in-browser preview, by design, so you can't accidentally read a partial card mid-grading.
What's on the card
A YeboLearn report card has, top to bottom:
- School banner with logo, motto, address, contact details, and brand color stripe.
- Student block — name, admission number, class, date of birth, gender.
- Subjects table — for each subject: number of assessments, average percentage, letter grade, total score, total max.
- Overall average — the simple mean of subject percentages. No weighting.
- Attendance summary — present, absent, late, total. Term-bounded if the school configured the term dates, otherwise all-time (with an italic note).
- Teacher comment, principal comment, conduct grade.
- Grading scale legend.
- Signature blocks for class teacher, principal, and parent.
Architecture reference (for engineers)
Stack
YeboLearn report cards are server-side PDFs generated inline in yebolearn-api using PDFKit (v0.17.2). There is no worker, no queue, and no R2 storage of finished PDFs — reportService.generateReportCard() returns a Buffer that streams straight to the response.
Why inline? PDFs are small (typically <200 KB), generation takes well under a second, and the data is never cacheable across users — every download joins live data. A worker would add latency without saving anything.
Endpoints
| Method | Path | Who can call it |
|---|---|---|
GET | /reports/report-card/:studentId?term=&academicYear= | school_admin, principal, teacher |
GET | /reports/report-cards?classId=&term=&academicYear= | school_admin, principal, teacher (batch by class) |
GET | /reports/report-card-readiness?term=&academicYear= | Feature-gated on custom_reports plan |
GET | /me/report-cards | Authenticated student (own only) |
GET | /me/report-card?term=&academicYear= | Authenticated student (own only) |
GET | /parent/children/:childId/report-cards | Authenticated parent (guardianship enforced) |
GET | /parent/children/:childId/report-card?term=&academicYear= | Authenticated parent (guardianship enforced) |
PUT | /report-card-comments | teacher, principal, school_admin (LWW upsert) |
All path-param and required-query UUIDs go through assertUUID() (see src/utils/uuid.ts) — a malformed UUID returns a clean 400 instead of a Postgres-flavored 500.
Tables touched per report card
students— student row + class FK.classes— class name + grade level.schools— school name, logo URL, motto, principal, address, contact, brand color, grading scale JSON.subjects— subject names + codes.grades— UUID rows withterm TEXT,academic_year TEXT,score,max_score,assessment_type.attendance— date-range filtered by the term window.report_card_comments— keyed by(school_id, student_id, term, academic_year), last-write-wins viaclient_updated_at.terms—kind ∈ {term, semester, quarter, trimester}, optionalparent_term_idfor nested terms.
Aggregation
Per subject: percentage = ROUND( AVG( score / max_score ) * 100, 1 )
Overall card: average = mean(subject percentages)There is no per-assessment weighting. If you want one exam to count more than a quiz, the only current option is to weight via max_score (a 100-mark exam contributes more to an average of ratios than a 10-mark quiz does, but not in a configurable way).
RBAC and feature gates
- Admin-side endpoints check role membership in
school_admin,principal,teacherand tenancy onreq.user.school_id. - Self-serve endpoints (
/me/*,/parent/children/*) use the authenticated user's UUID directly — those paths were never affected by the historicalparseInt(uuid)bug. report-card-readinessis gated on thecustom_reportssubscription plan; free-tier schools get a 403.
Troubleshooting
No silent fallbacks
Per YeboLearn engineering policy, user-facing failures must surface as toasts, error states, or HTTP 4xx/5xx — never as hardcoded "default" data. The one approved exception on a report card is the logo fetch: if the logo URL is unreachable, the PDF still renders without it. Everything else fails loudly.
"PDF download returns 500"
Past root cause (fixed in commit c8a10a9): parseInt(req.params.studentId, 10) on a UUID produced either NaN or a leading-digit integer; Postgres rejected the corrupted value with invalid input syntax for type uuid. The fix removes every parseInt call on UUID params in reportController and gradeController, and adds an assertUUID() guard that surfaces malformed input as a 400 instead.
If you see a 500 after this commit, check the prod logs for Generate report card error: and report the exact message. The most likely remaining cause is a database connection issue or a corrupt grading_scale JSON value on the school row.
"Marks show 0 though I entered them"
The most common cause is a term or academic-year mismatch. The aggregation query filters by term = $3 AND academic_year = $4 against plain-text columns. So:
"Term 1"vs"term 1"— different terms."2026"vs"2026/2027"— different years."Term1"(typo) vs"Term 1"— different terms.
Open the Gradebook for the affected student and confirm the exact term and academic_year strings on their grade rows match what your Reports filter is sending.
A rarer second cause: the student was reassigned to a different class between mark entry and report-card generation. Grades belong to (student_id, subject_id), not class, so this is usually fine — but if the class-to-subject assignment changed too, some subjects may not show up.
"Attendance shows 0 / 'no term calendar is configured'"
There is no row in the terms table matching the (school_id, name, academic_year) you printed. Without a term window, the PDF prints all-time attendance with an italic warning. Open Settings → Academic Year → Terms and add the missing row, including start and end dates.
"Term dropdown is empty / 'No terms — ask admin'"
Same root cause — your school has no terms configured. The teacher and admin dashboards fetch GET /terms; when it returns an empty list, the dropdown disables itself. Add at least one term in Settings.
"The teacher's or principal's remark box is blank on the PDF"
Almost always a term mismatch: the remark was saved against a different term or academic year than the card being printed. Remarks are stored per student per term, so a Term 1 remark never appears on a Term 2 card.
To confirm, reopen the Remarks dialog with the exact term and year you are printing. If the fields come up empty, the text was saved elsewhere — re-enter it under the correct term.
Note that an empty box is not a broken feature: both boxes always print, and an unfilled one reads "No comment recorded."
A second, rarer cause: the remark was typed but the dialog was closed without clicking Save.
"Logo missing on the printed PDF"
Either schools.logo_url is unset, or the URL is unreachable (404, expired signed URL, blocked by CDN). The PDF still renders without the logo — this is the only intentional silent fallback in the pipeline (a missing logo is non-critical chrome). Re-upload in Settings → School Profile.
"Teacher saved grades, but they never reach the server"
The Gradebook queues writes to IndexedDB and syncs when the device returns to network. If the queue grows but never drains, the UI must show a persistent "queued offline" indicator — if it doesn't, that's a bug. Two checks:
- Open the browser dev tools → Application → IndexedDB → look for queued grade rows.
- Confirm the device's auth token hasn't expired (an expired token will cause queued POSTs to fail with 401 after re-connecting; a healthy app surfaces this as a "Sign in again" toast).
Per the no-silent-fallbacks policy, a queued grade that fails to sync must surface a visible error — never just disappear.
Related pages
- Teacher → Gradebook — daily mark-entry mechanics
- Teacher → Assessments — how assessment grades originate
- Teacher → Attendance — where the attendance summary comes from
- School Admin → Settings — terms, grading scale, school profile
- School Admin → Classes — class and subject configuration
- School Admin → Attendance — attendance source of truth
- Student → Reports — student-side viewing
- Student → Grades — student-side grade detail
- Parent → Reports — parent-side viewing