Skip to content

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:

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

InputProducerRequired state
Term (name + start/end dates)School adminRow in terms matching the requested term name and academic year
Grading scaleSchool adminschools.grading_scale set (falls back to YeboLearn defaults if not)
School logoSchool adminschools.logo_url reachable over HTTP (PDF still renders without it)
MarksTeacherRows in grades matching the requested term and academic_year strings
AttendanceTeacher / systemRows in attendance with date falling inside the term window
Teacher / principal remarksTeacher or school adminRow 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

  1. Open Settings and choose the Academic tab.
  2. Set the Academic Year (e.g. 2026).
  3. Set the Current Term — its name (e.g. Term 1), kind (term, semester, quarter or trimester), and start and end dates.
  4. 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.

  1. Open Settings and stay on the General tab.
  2. 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

  1. Open Gradebook in the teacher dashboard.
  2. 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:

  1. Exam name (e.g. Mid-term Exam).
  2. Assessment typetest, exam, quiz, assignment, project, practical, or oral.
  3. Date.
  4. Max score (e.g. 100).
  5. 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

  1. Open Gradebook and select the class.
  2. In the Report cards panel, set the academic year and pick the term.
  3. 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.

The remarks icon on a student row in the Gradebook

From the school admin dashboard

  1. Open Report Cards.
  2. Choose the class, term, and academic year.
  3. Expand the class to show the student list, and click Remarks on any student.

The Report Cards page with a class expanded

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.

The remarks dialog with all three fields

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.

The teacher and principal remark boxes on a printed report card

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:

  1. School banner with logo, motto, address, contact details, and brand color stripe.
  2. Student block — name, admission number, class, date of birth, gender.
  3. Subjects table — for each subject: number of assessments, average percentage, letter grade, total score, total max.
  4. Overall average — the simple mean of subject percentages. No weighting.
  5. Attendance summary — present, absent, late, total. Term-bounded if the school configured the term dates, otherwise all-time (with an italic note).
  6. Teacher comment, principal comment, conduct grade.
  7. Grading scale legend.
  8. 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

MethodPathWho 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-cardsAuthenticated student (own only)
GET/me/report-card?term=&academicYear=Authenticated student (own only)
GET/parent/children/:childId/report-cardsAuthenticated parent (guardianship enforced)
GET/parent/children/:childId/report-card?term=&academicYear=Authenticated parent (guardianship enforced)
PUT/report-card-commentsteacher, 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 with term 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 via client_updated_at.
  • termskind ∈ {term, semester, quarter, trimester}, optional parent_term_id for 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, teacher and tenancy on req.user.school_id.
  • Self-serve endpoints (/me/*, /parent/children/*) use the authenticated user's UUID directly — those paths were never affected by the historical parseInt(uuid) bug.
  • report-card-readiness is gated on the custom_reports subscription 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:

  1. Open the browser dev tools → Application → IndexedDB → look for queued grade rows.
  2. 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.

Built with VitePress