feat(gradebook): term-aware course links + transcript & GPA (B6: #139) - #468

Merged
AndresL230 merged 3 commits into
mainfrom
b6-semesters
Jul 30, 2026
Merged

feat(gradebook): term-aware course links + transcript & GPA (B6: #139)#468
AndresL230 merged 3 commits into
mainfrom
b6-semesters

Conversation

@AndresL230

@AndresL230AndresL230 commented Jul 30, 2026

Copy link
Copy Markdown
Collaborator

Bundle B6 — Semesters completion (#139)

Closes#139.

The issue body predates the terms/offerings redesign; built against shipped reality (epic #142's model is stale). The Landing term switcher already existed (#140); what this PR adds:

The bug fix (term switcher half)

  • Course cards now carry ?semester=<label> and getGradebookCourse passes it through — fixing a live 404: with multiple offerings of one course (seeded CS101 is in fall-2025 AND spring-2026) and no term hint, _resolve_enrollment resolves the current term and 404s ("Course not in your gradebook") from either chip. Course.tsx reads the param exactly like Landing's deep-link handling.

Transcript / GPA half

  • GradebookSummary stops discarding the gpa/semester fields the backend already returns; Landing shows "Term GPA" (gradebook-term-gpa).
  • New TranscriptModal (gradebook-transcript-open) consuming the existing-but-unconsumed GET /api/gradebook/gpa: cumulative GPA + per-semester sections with course rows (letter or "in progress", credits). Per-semester GPA computed in a pure lib/transcript.ts that mirrors gradebook_service.weighted_gpa exactly (lock-step comment; null grade_points listed-but-excluded, null credits → 1).
  • Error path follows the fix(frontend): surface the five swallowed failures — closes the #113 audit tail (#166 #185 #184 #183 #186) #463 pattern: toast + inline retry, dialog stays open.

Surface registration + journey

  • docs/frontend-testids.md gains the gradebook surface; the four owning files joined the eslint testid enforcement block; eslint-suppressions.json regenerated for the pre-existing untagged elements (delta: Course.tsx 9, Landing.tsx 1).
  • Promoted journey e2e/gradebook.spec.ts (first gradebook journey): (a) the multi-term regression with a DB-truth precondition via queryRaw (asserts the two CS101 enrollments' term ids), Fall→"Exams" / Spring→"Projects" discriminators from the rich seed; (b) transcript open → GPA + both term sections.

Gates

Note: sibling issue #141 got a corrected-state STOP-note instead of code (design conflict with the shipped #360 contract + file collision with #465/B5) — see the issue comment.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • View GPA for the selected academic term directly from the Gradebook.
    • Open a transcript showing cumulative GPA, semester GPAs, course grades, credits, and in-progress courses.
    • Gradebook course links and actions now remain scoped to the selected term.
  • Bug Fixes

    • Fixed term selection being lost when opening courses.
    • Fixed new assignments and grading updates being saved to the wrong enrollment.
    • Added retry handling when transcript data fails to load.

@supabase

supabaseBot commented Jul 30, 2026

Copy link
Copy Markdown

This pull request has been ignored for the connected project ybgqdonkoqftwrmweuyv because there are no changes detected in supabase directory. You can change this behaviour in Project Integrations Settings ↗︎.


Preview Branches by Supabase.
Learn more about Supabase Branching ↗︎.

@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented Jul 30, 2026

Copy link
Copy Markdown

Deploying with Cloudflare Workers Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

StatusNameLatest CommitPreview URLUpdated (UTC)
✅ Deployment successful!
View logs
frontend-staging1831442Commit Preview URL

Branch Preview URL
Jul 30 2026, 07:51 AM

@coderabbitai

coderabbitaiBot commented Jul 30, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Gradebook now supports semester-scoped course loading and mutations, displays term and cumulative GPA transcript data, preserves selected terms in course links, adds stable E2E test IDs, and includes unit, component, and browser-level coverage.

Changes

Gradebook semester and transcript flow

Layer / File(s)Summary
GPA contracts and transcript calculations
frontend/src/lib/types.ts, frontend/src/lib/semesters.ts, frontend/src/lib/transcript.ts, frontend/src/lib/*test.ts
Adds GPA response types, term ordering, transcript grouping, effective-credit normalization, weighted GPA calculation, and corresponding tests.
Semester-scoped API and course operations
frontend/src/lib/api.ts, frontend/src/components/screens/Gradebook/Course.tsx, frontend/src/components/screens/Gradebook/Course.semester.test.tsx
Passes the URL-selected semester through gradebook reads and mutations, with tests covering present and absent semester parameters.
Landing and transcript UI
frontend/src/components/Gradebook/CourseCard.tsx, frontend/src/components/Gradebook/TranscriptModal.tsx, frontend/src/components/screens/Gradebook/Landing.tsx, frontend/src/components/screens/Gradebook/*test.tsx
Displays term GPA, preserves semester course links, opens a cumulative transcript modal, and covers loading, failure, retry, and interaction behavior.
E2E hooks and Gradebook validation
frontend/src/components/Gradebook/Assignment*.tsx, frontend/eslint*, docs/frontend-testids.md, frontend/e2e/gradebook.spec.ts
Adds Gradebook test IDs, configures enforcement and suppressions, documents the inventory, and validates term-specific navigation, assignment persistence, and transcript sections.

Estimated code review effort: 4 (Complex) | ~60 minutes

Possibly related issues

  • SaplingLearn/Sapling issue 142 — Covers the same Gradebook term switcher, per-semester GPA, and cumulative transcript objectives.

Possibly related PRs

Suggested reviewers:jose-gael-cruz-lopez, darkest-teddy

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check nameStatusExplanationResolution
Docstring Coverage⚠️ WarningDocstring coverage is 47.37% which is insufficient. The required threshold is 80.00%.Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check nameStatusExplanation
Title check✅ PassedThe title clearly summarizes the gradebook term-link and transcript/GPA additions.
Description check✅ PassedThe description covers the main changes, related issue, testing gates, and reviewer notes, though it doesn't use the exact template headings.
Linked Issues check✅ PassedThe PR implements term-scoped gradebook fetches, transcript/cumulative GPA UI, and error/loading behavior required by #139.
Out of Scope Changes check✅ PassedThe changes stay focused on gradebook semester support, test coverage, and related enforcement/docs updates.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch b6-semesters

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@AndresL230

Copy link
Copy Markdown
CollaboratorAuthor

Code review

Found 1 issue:

  1. Read/write term split: the PR makes the course-page read term-aware (getGradebookCourse(userId, courseId, semester) from ?semester=), which for the first time surfaces an archived-term enrollment — but every mutation from that screen (bulkUpdateCategories, createGradedAssignment, setLetterScale, setCurveSettings) still calls the API without semester, and _resolve_enrollment(course, None) resolves the CURRENT term. Viewing Fall 2025 CS101 (seeded in both fall-2025 and spring-2026; e2e clock 2026-03-11 → spring is current) and saving weights/an assignment/scale/curve silently writes into the Spring enrollment with a 200. The backend bodies (BulkUpdateCategoriesBody, CreateAssignmentBody, SetLetterScaleBody, SetCurveBody) already accept semester and pass it into _resolve_enrollment — the frontend just never sends it (bug due to Course.tsx mutation call sites omitting the term the screen is scoped to)

}
awaitbulkUpdateCategories(userId,courseId,drafts);
awaitrefresh();
}}
/>
<AssignmentModal
open={assignModal.open}
initial={assignModal.initial}
categories={data.categories}
onClose={()=>setAssignModal({open: false,initial: null})}
onSave={async(draft: AssignmentDraft)=>{
if(assignModal.initial){
awaitupdateGradedAssignment(userId,assignModal.initial.id,draft);
}else{
awaitcreateGradedAssignment(userId,courseId,draft);
}
awaitrefresh();
}}
onDelete={
assignModal.initial
? async()=>{
awaitdeleteGradedAssignment(userId,assignModal.initial!.id);
awaitrefresh();
}
: null
}
/>
<LetterScaleEditor
open={editScale}
initial={data.letter_scale}
onClose={()=>setEditScale(false)}
onSave={async(scale)=>{
awaitsetLetterScale(userId,courseId,scale);
awaitrefresh();
}}

Sub-threshold notes (verified, fixed in a follow-up commit, scores <80): transcript rows render {credits ?? 1} cr while the GPA math treats 0/negative as 1 (60); requestedSemester is mount-frozen per the #368 no-Suspense precedent — made fetch-time instead (35); the promoted journey still needs its pre-merge stack run — queued in this session's flock'd cycle (25).

🤖 Generated with Claude Code

- If this code review was useful, please react with 👍. Otherwise, react with 👎.

AndresL230and others added 3 commits July 30, 2026 00:47
The gradebook's term switcher was half-wired: the landing had semester
chips, but the course-card link and getGradebookCourse dropped the
selected term, so _resolve_enrollment(user, course, None) resolved the
CURRENT term — a course also taken in an archived term (rich seed:
CS101 in both fall-2025 and spring-2026) 404'd from that term's chip.
And the backend's GPA endpoints had no frontend consumer at all.
- CourseCard carries the selected term (?semester=<label>) on the card
href; the course screen reads it off location (same pattern as
Landing's deep-link param) and passes it through getGradebookCourse.
- GradebookSummary stops discarding the summary's gpa/semester; the
landing surfaces "Term GPA x.xx" next to the chips
(gradebook-term-gpa, hidden while null).
- New TranscriptModal (gradebook-transcript-open) over the previously
unconsumed GET /api/gradebook/gpa: cumulative GPA
(gradebook-transcript-gpa) + per-semester sections, in-progress rows
listed but excluded from the math. Load failure toasts + inline
retry (#463 pattern).
- New pure lib/transcript.ts (buildTranscript/weightedGpa) mirroring
backend gradebook_service.weighted_gpa exactly (null grade_points
skipped, null/zero credits count as 1, empty -> null); term ordering
reuses lib/semesters (new compareTermLabels export).
- Testid surface `gradebook` registered in docs/frontend-testids.md +
the four owning files joined the eslint no-restricted-syntax block;
suppressions baseline regenerated for the pre-existing untagged
elements (Course.tsx 9, Landing.tsx 1).
- e2e/gradebook.spec.ts (authored, not run here): the promoted #139
regression journey (DB-truth precondition via queryRaw, then both
chips' CS101 cards resolve their own term's categories) + the
transcript journey.
- vitest: lib/transcript.test.ts, TranscriptModal.test.tsx (dialog
contract + loading/error/retry), Course.semester.test.tsx (param
plumbing), Landing.test.tsx extended (term GPA, real CourseCard href
under test via a next/link stub).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…lay (#468 review)
Closes the three findings from the #468 5-agent review of the #139 work.
1 (major) — read/write term split: getGradebookCourse was semester-aware
but every course-keyed mutation from the Course screen still resolved
term-blind, so editing a Fall 2025 CS101 page wrote into the Spring 2026
enrollment silently (backend _resolve_enrollment(course, None) = current
term). The backend body models already accept `semester`; now the
frontend sends it: optional `semester` on createCategory,
bulkUpdateCategories, createGradedAssignment, setLetterScale and
setCurveSettings in lib/api.ts (body field, JSON.stringify drops it when
unset), passed at every Course.tsx call site (curve toggle + curve
settings + weights + create-assignment + letter scale). The id-keyed
calls (deleteCategory, update/deleteGradedAssignment) resolve by row
ownership — verified against routes/gradebook.py — and stay as-is.
- Course.semester.test.tsx: 4 new tests drive the REAL
EditWeightsModal/AssignmentModal down to Save and assert
bulkUpdateCategories/createGradedAssignment get the URL's semester
when ?semester= is set, and undefined when absent.
- e2e/gradebook.spec.ts: the Fall leg now also creates an assignment
through the UI and queryRaw-polls that the new assignments row hangs
off rich-enr-active-cs101-f25, not the spring enrollment. New testids
gradebook-add-assignment / gradebook-assignment-title /
gradebook-assignment-save; AssignmentList.tsx + AssignmentModal.tsx
joined the eslint enforcement array (pre-existing untagged elements
baselined: 7 + 12) and the docs inventory.
2 — transcript credits display: the modal showed `credits ?? 1` while
the GPA math treated 0/negative as 1. New effectiveCredits() export in
lib/transcript.ts is now the ONE place the rule lives, used by both
weightedGpa and the "N cr" display; tests cover 0/negative/null and the
rendered "1 cr" for a zero-credit row.
3 — ?semester= is read at FETCH time: the mount-frozen useState
initializer became currentSemesterParam(), read inside refresh AND
inside every mutation callback (keeps the no-Suspense plain-location
pattern).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ing contract (ToastProvider stub, getGpa mock, summary gpa/semester fields)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
frontend/src/components/screens/Gradebook/Landing.tsx (1)

159-184: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Guard against stale getGradebookSummary responses on rapid term switches.

Switching selected quickly fires overlapping requests with no sequencing; a slower response for an earlier term can resolve after a newer one and overwrite courses/termGpa with stale data for the wrong term. This affects the newly-added termGpa display as well.

♻️ Proposed fix: ignore stale responses
 React.useEffect(() => {
if (!termsReady) return;
+ let cancelled = false;
if (!selected) {
setCourses([]);
setTermGpa(null);
setLoading(false);
return;
}
if (!userId) {
setCourses(SAMPLE_COURSES[selected] ?? []);
setTermGpa(null);
setLoading(false);
return;
}
setLoading(true);
getGradebookSummary(userId, selected)
.then((res) => {
+ if (cancelled) return;
setCourses(res.courses.length ? res.courses : []);
setTermGpa(res.gpa ?? null);
})
.catch(() => {
+ if (cancelled) return;
setCourses([]);
setTermGpa(null);
})
- .finally(() => setLoading(false));+ .finally(() => { if (!cancelled) setLoading(false); });+ return () => { cancelled = true; };
}, [userId, selected, termsReady]);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@frontend/src/components/screens/Gradebook/Landing.tsx` around lines 159 -
184, Update the useEffect that calls getGradebookSummary to ignore responses
from requests started for an earlier selected term. Track the active request or
selected-term identity and only apply courses, termGpa, error, and loading
updates when the response belongs to the current request; preserve the existing
reset, sample-course, and cleanup behavior.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@frontend/src/components/screens/Gradebook/Course.tsx`:
- Around line 98-109: Make the semester selection reactive in
GradebookCourseScreen instead of relying only on currentSemesterParam() at call
time: derive it from a reactive URL/search-parameter source, use that same value
for reads and course-keyed mutations, and include it in the relevant fetch/mount
dependencies. When the semester changes for the same courseId, clear or reload
the existing course state so stale term data is not displayed.
---
Nitpick comments:
In `@frontend/src/components/screens/Gradebook/Landing.tsx`:
- Around line 159-184: Update the useEffect that calls getGradebookSummary to
ignore responses from requests started for an earlier selected term. Track the
active request or selected-term identity and only apply courses, termGpa, error,
and loading updates when the response belongs to the current request; preserve
the existing reset, sample-course, and cleanup behavior.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: d910b0c7-0c93-4b8c-b3cc-b86db7f13777

📥 Commits

Reviewing files that changed from the base of the PR and between d0cd993 and 1831442.

📒 Files selected for processing (19)
  • docs/frontend-testids.md
  • frontend/e2e/gradebook.spec.ts
  • frontend/eslint-suppressions.json
  • frontend/eslint.config.mjs
  • frontend/src/components/Gradebook/AssignmentList.tsx
  • frontend/src/components/Gradebook/AssignmentModal.tsx
  • frontend/src/components/Gradebook/CourseCard.tsx
  • frontend/src/components/Gradebook/TranscriptModal.test.tsx
  • frontend/src/components/Gradebook/TranscriptModal.tsx
  • frontend/src/components/screens/Gradebook/Course.semester.test.tsx
  • frontend/src/components/screens/Gradebook/Course.tsx
  • frontend/src/components/screens/Gradebook/Landing.test.tsx
  • frontend/src/components/screens/Gradebook/Landing.testmode.test.tsx
  • frontend/src/components/screens/Gradebook/Landing.tsx
  • frontend/src/lib/api.ts
  • frontend/src/lib/semesters.ts
  • frontend/src/lib/transcript.test.ts
  • frontend/src/lib/transcript.ts
  • frontend/src/lib/types.ts

Comment on lines +98 to +109
// /gradebook/<id>?semester=<label> — the landing's cards carry the selected
// term so a course enrolled in several terms resolves to that term's
// enrollment instead of 404ing off the current one (#139). Read straight off
// location (useSearchParams() would need a Suspense boundary in the route
// shell, which this screen doesn't own), and read it at CALL time — every
// fetch AND every course-keyed mutation below must see the URL the user is
// actually on, not a mount-frozen copy. Writes that drop the term would
// silently land on the CURRENT term's enrollment (#468 review).
function currentSemesterParam(): string | undefined {
if (typeof window === "undefined") return undefined;
return new URLSearchParams(window.location.search).get("semester") || undefined;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bashset -euo pipefail
echo"Locate file"
git ls-files | rg 'frontend/src/components/screens/Gradebook/Course\.tsx$|Course\.tsx$'||trueechoecho"Outline"
ast-grep outline frontend/src/components/screens/Gradebook/Course.tsx --view compact ||trueechoecho"Relevant sections"
sed -n '1,320p' frontend/src/components/screens/Gradebook/Course.tsx
echo"---"
sed -n '430,720p' frontend/src/components/screens/Gradebook/Course.tsx
echoecho"Search for currentSemesterParam and refresh dependencies"
rg -n "currentSemesterParam|useEffect|currentSemester|semester|CourseEnrollment|student|record|mutation|useMutation|refresh" frontend/src/components/screens/Gradebook/Course.tsx

Repository: SaplingLearn/Sapling

Length of output: 25705


🏁 Script executed:

#!/bin/bashset -euo pipefail
echo"Inspect API helpers and mutation signatures"
fd -a 'api\.(ts|tsx|js)$|types\.(ts|tsx|js)$' frontend/src | sed 's#^./##'echo
rg -n "export async function getGradebookCourse|const getGradebookCourse|function getGradebookCourse|getGradebookCourse" frontend/src/lib frontend/src -g '*.ts' -g '*.tsx'echo
sed -n '1,220p' frontend/src/lib/api.ts
echo"---"
rg -n "createGradedAssignment|updateGradedAssignment|deleteGradedAssignment|bulkUpdateCategories|setLetterScale|setCurveSettings" frontend/src/lib/api.ts

Repository: SaplingLearn/Sapling

Length of output: 9818


🏁 Script executed:

#!/bin/bashset -euo pipefail
echo"API route/path parameters"
sed -n '1160,1300p' frontend/src/lib/api.ts
echoecho"GradebookCourseScreen effects/mutations around line 220-245"
sed -n '220,245p' frontend/src/components/screens/Gradebook/Course.tsx
echoecho"Semester-specific tests"
sed -n '1,180p' frontend/src/components/screens/Gradebook/Course.semester.test.tsx

Repository: SaplingLearn/Sapling

Length of output: 13171


🏁 Script executed:

#!/bin/bashset -euo pipefail
echo"Find gradebook route files"
git ls-files | rg 'frontend/src/(app|routes|pages).*(gradebook|gradebook/.*course|gradebook|Course.page|page\.(tsx|ts|js))'| head -80
echoecho"Search route declarations for /gradebook/[id]"
rg -n 'gradebook/.*/course|gradebook/\[|gradebook/\\\[|courseId|GradebookCourseScreen|semester=' frontend/src -g '*.tsx' -g '*.ts'echo
git ls-files 'frontend/src/app/**/page.tsx''frontend/src/pages/**'| xargs rg -n "courseId|GradebookCourseScreen|semester="||true

Repository: SaplingLearn/Sapling

Length of output: 24931


Make the semester param a reactive dependency before relying on it.

For the same course ID, navigating /gradebook/c1?semester=Fall+2025 to /gradebook/c1?semester=Spring+2026 reuses GradebookCourseScreen because query changes don’t trigger the existing courseId-only fetch/mount effect. That leaves Fall grade state on screen while course-keyed mutations send Spring to the backend. Derive the selected term reactively (read and write with the same value) and reload/clear course data when it changes.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@frontend/src/components/screens/Gradebook/Course.tsx` around lines 98 - 109,
Make the semester selection reactive in GradebookCourseScreen instead of relying
only on currentSemesterParam() at call time: derive it from a reactive
URL/search-parameter source, use that same value for reads and course-keyed
mutations, and include it in the relevant fetch/mount dependencies. When the
semester changes for the same courseId, clear or reload the existing course
state so stale term data is not displayed.

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[P2] Semesters: gradebook UI — term switcher + transcript / cumulative GPA

1 participant

@AndresL230
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

feat(gradebook): term-aware course links + transcript & GPA (B6: #139) - #468

Merged
AndresL230 merged 3 commits into
mainfrom
b6-semesters
Jul 30, 2026
Merged

feat(gradebook): term-aware course links + transcript & GPA (B6: #139)#468
AndresL230 merged 3 commits into
mainfrom
b6-semesters

Conversation

@AndresL230

@AndresL230AndresL230 commented Jul 30, 2026

Copy link
Copy Markdown
Collaborator

Bundle B6 — Semesters completion (#139)

Closes#139.

The issue body predates the terms/offerings redesign; built against shipped reality (epic #142's model is stale). The Landing term switcher already existed (#140); what this PR adds:

The bug fix (term switcher half)

  • Course cards now carry ?semester=<label> and getGradebookCourse passes it through — fixing a live 404: with multiple offerings of one course (seeded CS101 is in fall-2025 AND spring-2026) and no term hint, _resolve_enrollment resolves the current term and 404s ("Course not in your gradebook") from either chip. Course.tsx reads the param exactly like Landing's deep-link handling.

Transcript / GPA half

  • GradebookSummary stops discarding the gpa/semester fields the backend already returns; Landing shows "Term GPA" (gradebook-term-gpa).
  • New TranscriptModal (gradebook-transcript-open) consuming the existing-but-unconsumed GET /api/gradebook/gpa: cumulative GPA + per-semester sections with course rows (letter or "in progress", credits). Per-semester GPA computed in a pure lib/transcript.ts that mirrors gradebook_service.weighted_gpa exactly (lock-step comment; null grade_points listed-but-excluded, null credits → 1).
  • Error path follows the fix(frontend): surface the five swallowed failures — closes the #113 audit tail (#166 #185 #184 #183 #186) #463 pattern: toast + inline retry, dialog stays open.

Surface registration + journey

  • docs/frontend-testids.md gains the gradebook surface; the four owning files joined the eslint testid enforcement block; eslint-suppressions.json regenerated for the pre-existing untagged elements (delta: Course.tsx 9, Landing.tsx 1).
  • Promoted journey e2e/gradebook.spec.ts (first gradebook journey): (a) the multi-term regression with a DB-truth precondition via queryRaw (asserts the two CS101 enrollments' term ids), Fall→"Exams" / Spring→"Projects" discriminators from the rich seed; (b) transcript open → GPA + both term sections.

Gates

Note: sibling issue #141 got a corrected-state STOP-note instead of code (design conflict with the shipped #360 contract + file collision with #465/B5) — see the issue comment.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • View GPA for the selected academic term directly from the Gradebook.
    • Open a transcript showing cumulative GPA, semester GPAs, course grades, credits, and in-progress courses.
    • Gradebook course links and actions now remain scoped to the selected term.
  • Bug Fixes

    • Fixed term selection being lost when opening courses.
    • Fixed new assignments and grading updates being saved to the wrong enrollment.
    • Added retry handling when transcript data fails to load.

@supabase

supabaseBot commented Jul 30, 2026

Copy link
Copy Markdown

This pull request has been ignored for the connected project ybgqdonkoqftwrmweuyv because there are no changes detected in supabase directory. You can change this behaviour in Project Integrations Settings ↗︎.


Preview Branches by Supabase.
Learn more about Supabase Branching ↗︎.

@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented Jul 30, 2026

Copy link
Copy Markdown

Deploying with Cloudflare Workers Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

StatusNameLatest CommitPreview URLUpdated (UTC)
✅ Deployment successful!
View logs
frontend-staging1831442Commit Preview URL

Branch Preview URL
Jul 30 2026, 07:51 AM

@coderabbitai

coderabbitaiBot commented Jul 30, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Gradebook now supports semester-scoped course loading and mutations, displays term and cumulative GPA transcript data, preserves selected terms in course links, adds stable E2E test IDs, and includes unit, component, and browser-level coverage.

Changes

Gradebook semester and transcript flow

Layer / File(s)Summary
GPA contracts and transcript calculations
frontend/src/lib/types.ts, frontend/src/lib/semesters.ts, frontend/src/lib/transcript.ts, frontend/src/lib/*test.ts
Adds GPA response types, term ordering, transcript grouping, effective-credit normalization, weighted GPA calculation, and corresponding tests.
Semester-scoped API and course operations
frontend/src/lib/api.ts, frontend/src/components/screens/Gradebook/Course.tsx, frontend/src/components/screens/Gradebook/Course.semester.test.tsx
Passes the URL-selected semester through gradebook reads and mutations, with tests covering present and absent semester parameters.
Landing and transcript UI
frontend/src/components/Gradebook/CourseCard.tsx, frontend/src/components/Gradebook/TranscriptModal.tsx, frontend/src/components/screens/Gradebook/Landing.tsx, frontend/src/components/screens/Gradebook/*test.tsx
Displays term GPA, preserves semester course links, opens a cumulative transcript modal, and covers loading, failure, retry, and interaction behavior.
E2E hooks and Gradebook validation
frontend/src/components/Gradebook/Assignment*.tsx, frontend/eslint*, docs/frontend-testids.md, frontend/e2e/gradebook.spec.ts
Adds Gradebook test IDs, configures enforcement and suppressions, documents the inventory, and validates term-specific navigation, assignment persistence, and transcript sections.

Estimated code review effort: 4 (Complex) | ~60 minutes

Possibly related issues

  • SaplingLearn/Sapling issue 142 — Covers the same Gradebook term switcher, per-semester GPA, and cumulative transcript objectives.

Possibly related PRs

Suggested reviewers:jose-gael-cruz-lopez, darkest-teddy

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check nameStatusExplanationResolution
Docstring Coverage⚠️ WarningDocstring coverage is 47.37% which is insufficient. The required threshold is 80.00%.Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check nameStatusExplanation
Title check✅ PassedThe title clearly summarizes the gradebook term-link and transcript/GPA additions.
Description check✅ PassedThe description covers the main changes, related issue, testing gates, and reviewer notes, though it doesn't use the exact template headings.
Linked Issues check✅ PassedThe PR implements term-scoped gradebook fetches, transcript/cumulative GPA UI, and error/loading behavior required by #139.
Out of Scope Changes check✅ PassedThe changes stay focused on gradebook semester support, test coverage, and related enforcement/docs updates.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch b6-semesters

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@AndresL230

Copy link
Copy Markdown
CollaboratorAuthor

Code review

Found 1 issue:

  1. Read/write term split: the PR makes the course-page read term-aware (getGradebookCourse(userId, courseId, semester) from ?semester=), which for the first time surfaces an archived-term enrollment — but every mutation from that screen (bulkUpdateCategories, createGradedAssignment, setLetterScale, setCurveSettings) still calls the API without semester, and _resolve_enrollment(course, None) resolves the CURRENT term. Viewing Fall 2025 CS101 (seeded in both fall-2025 and spring-2026; e2e clock 2026-03-11 → spring is current) and saving weights/an assignment/scale/curve silently writes into the Spring enrollment with a 200. The backend bodies (BulkUpdateCategoriesBody, CreateAssignmentBody, SetLetterScaleBody, SetCurveBody) already accept semester and pass it into _resolve_enrollment — the frontend just never sends it (bug due to Course.tsx mutation call sites omitting the term the screen is scoped to)

}
awaitbulkUpdateCategories(userId,courseId,drafts);
awaitrefresh();
}}
/>
<AssignmentModal
open={assignModal.open}
initial={assignModal.initial}
categories={data.categories}
onClose={()=>setAssignModal({open: false,initial: null})}
onSave={async(draft: AssignmentDraft)=>{
if(assignModal.initial){
awaitupdateGradedAssignment(userId,assignModal.initial.id,draft);
}else{
awaitcreateGradedAssignment(userId,courseId,draft);
}
awaitrefresh();
}}
onDelete={
assignModal.initial
? async()=>{
awaitdeleteGradedAssignment(userId,assignModal.initial!.id);
awaitrefresh();
}
: null
}
/>
<LetterScaleEditor
open={editScale}
initial={data.letter_scale}
onClose={()=>setEditScale(false)}
onSave={async(scale)=>{
awaitsetLetterScale(userId,courseId,scale);
awaitrefresh();
}}

Sub-threshold notes (verified, fixed in a follow-up commit, scores <80): transcript rows render {credits ?? 1} cr while the GPA math treats 0/negative as 1 (60); requestedSemester is mount-frozen per the #368 no-Suspense precedent — made fetch-time instead (35); the promoted journey still needs its pre-merge stack run — queued in this session's flock'd cycle (25).

🤖 Generated with Claude Code

- If this code review was useful, please react with 👍. Otherwise, react with 👎.

AndresL230and others added 3 commits July 30, 2026 00:47
The gradebook's term switcher was half-wired: the landing had semester
chips, but the course-card link and getGradebookCourse dropped the
selected term, so _resolve_enrollment(user, course, None) resolved the
CURRENT term — a course also taken in an archived term (rich seed:
CS101 in both fall-2025 and spring-2026) 404'd from that term's chip.
And the backend's GPA endpoints had no frontend consumer at all.
- CourseCard carries the selected term (?semester=<label>) on the card
href; the course screen reads it off location (same pattern as
Landing's deep-link param) and passes it through getGradebookCourse.
- GradebookSummary stops discarding the summary's gpa/semester; the
landing surfaces "Term GPA x.xx" next to the chips
(gradebook-term-gpa, hidden while null).
- New TranscriptModal (gradebook-transcript-open) over the previously
unconsumed GET /api/gradebook/gpa: cumulative GPA
(gradebook-transcript-gpa) + per-semester sections, in-progress rows
listed but excluded from the math. Load failure toasts + inline
retry (#463 pattern).
- New pure lib/transcript.ts (buildTranscript/weightedGpa) mirroring
backend gradebook_service.weighted_gpa exactly (null grade_points
skipped, null/zero credits count as 1, empty -> null); term ordering
reuses lib/semesters (new compareTermLabels export).
- Testid surface `gradebook` registered in docs/frontend-testids.md +
the four owning files joined the eslint no-restricted-syntax block;
suppressions baseline regenerated for the pre-existing untagged
elements (Course.tsx 9, Landing.tsx 1).
- e2e/gradebook.spec.ts (authored, not run here): the promoted #139
regression journey (DB-truth precondition via queryRaw, then both
chips' CS101 cards resolve their own term's categories) + the
transcript journey.
- vitest: lib/transcript.test.ts, TranscriptModal.test.tsx (dialog
contract + loading/error/retry), Course.semester.test.tsx (param
plumbing), Landing.test.tsx extended (term GPA, real CourseCard href
under test via a next/link stub).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…lay (#468 review)
Closes the three findings from the #468 5-agent review of the #139 work.
1 (major) — read/write term split: getGradebookCourse was semester-aware
but every course-keyed mutation from the Course screen still resolved
term-blind, so editing a Fall 2025 CS101 page wrote into the Spring 2026
enrollment silently (backend _resolve_enrollment(course, None) = current
term). The backend body models already accept `semester`; now the
frontend sends it: optional `semester` on createCategory,
bulkUpdateCategories, createGradedAssignment, setLetterScale and
setCurveSettings in lib/api.ts (body field, JSON.stringify drops it when
unset), passed at every Course.tsx call site (curve toggle + curve
settings + weights + create-assignment + letter scale). The id-keyed
calls (deleteCategory, update/deleteGradedAssignment) resolve by row
ownership — verified against routes/gradebook.py — and stay as-is.
- Course.semester.test.tsx: 4 new tests drive the REAL
EditWeightsModal/AssignmentModal down to Save and assert
bulkUpdateCategories/createGradedAssignment get the URL's semester
when ?semester= is set, and undefined when absent.
- e2e/gradebook.spec.ts: the Fall leg now also creates an assignment
through the UI and queryRaw-polls that the new assignments row hangs
off rich-enr-active-cs101-f25, not the spring enrollment. New testids
gradebook-add-assignment / gradebook-assignment-title /
gradebook-assignment-save; AssignmentList.tsx + AssignmentModal.tsx
joined the eslint enforcement array (pre-existing untagged elements
baselined: 7 + 12) and the docs inventory.
2 — transcript credits display: the modal showed `credits ?? 1` while
the GPA math treated 0/negative as 1. New effectiveCredits() export in
lib/transcript.ts is now the ONE place the rule lives, used by both
weightedGpa and the "N cr" display; tests cover 0/negative/null and the
rendered "1 cr" for a zero-credit row.
3 — ?semester= is read at FETCH time: the mount-frozen useState
initializer became currentSemesterParam(), read inside refresh AND
inside every mutation callback (keeps the no-Suspense plain-location
pattern).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ing contract (ToastProvider stub, getGpa mock, summary gpa/semester fields)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
frontend/src/components/screens/Gradebook/Landing.tsx (1)

159-184: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Guard against stale getGradebookSummary responses on rapid term switches.

Switching selected quickly fires overlapping requests with no sequencing; a slower response for an earlier term can resolve after a newer one and overwrite courses/termGpa with stale data for the wrong term. This affects the newly-added termGpa display as well.

♻️ Proposed fix: ignore stale responses
 React.useEffect(() => {
if (!termsReady) return;
+ let cancelled = false;
if (!selected) {
setCourses([]);
setTermGpa(null);
setLoading(false);
return;
}
if (!userId) {
setCourses(SAMPLE_COURSES[selected] ?? []);
setTermGpa(null);
setLoading(false);
return;
}
setLoading(true);
getGradebookSummary(userId, selected)
.then((res) => {
+ if (cancelled) return;
setCourses(res.courses.length ? res.courses : []);
setTermGpa(res.gpa ?? null);
})
.catch(() => {
+ if (cancelled) return;
setCourses([]);
setTermGpa(null);
})
- .finally(() => setLoading(false));+ .finally(() => { if (!cancelled) setLoading(false); });+ return () => { cancelled = true; };
}, [userId, selected, termsReady]);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@frontend/src/components/screens/Gradebook/Landing.tsx` around lines 159 -
184, Update the useEffect that calls getGradebookSummary to ignore responses
from requests started for an earlier selected term. Track the active request or
selected-term identity and only apply courses, termGpa, error, and loading
updates when the response belongs to the current request; preserve the existing
reset, sample-course, and cleanup behavior.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@frontend/src/components/screens/Gradebook/Course.tsx`:
- Around line 98-109: Make the semester selection reactive in
GradebookCourseScreen instead of relying only on currentSemesterParam() at call
time: derive it from a reactive URL/search-parameter source, use that same value
for reads and course-keyed mutations, and include it in the relevant fetch/mount
dependencies. When the semester changes for the same courseId, clear or reload
the existing course state so stale term data is not displayed.
---
Nitpick comments:
In `@frontend/src/components/screens/Gradebook/Landing.tsx`:
- Around line 159-184: Update the useEffect that calls getGradebookSummary to
ignore responses from requests started for an earlier selected term. Track the
active request or selected-term identity and only apply courses, termGpa, error,
and loading updates when the response belongs to the current request; preserve
the existing reset, sample-course, and cleanup behavior.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: d910b0c7-0c93-4b8c-b3cc-b86db7f13777

📥 Commits

Reviewing files that changed from the base of the PR and between d0cd993 and 1831442.

📒 Files selected for processing (19)
  • docs/frontend-testids.md
  • frontend/e2e/gradebook.spec.ts
  • frontend/eslint-suppressions.json
  • frontend/eslint.config.mjs
  • frontend/src/components/Gradebook/AssignmentList.tsx
  • frontend/src/components/Gradebook/AssignmentModal.tsx
  • frontend/src/components/Gradebook/CourseCard.tsx
  • frontend/src/components/Gradebook/TranscriptModal.test.tsx
  • frontend/src/components/Gradebook/TranscriptModal.tsx
  • frontend/src/components/screens/Gradebook/Course.semester.test.tsx
  • frontend/src/components/screens/Gradebook/Course.tsx
  • frontend/src/components/screens/Gradebook/Landing.test.tsx
  • frontend/src/components/screens/Gradebook/Landing.testmode.test.tsx
  • frontend/src/components/screens/Gradebook/Landing.tsx
  • frontend/src/lib/api.ts
  • frontend/src/lib/semesters.ts
  • frontend/src/lib/transcript.test.ts
  • frontend/src/lib/transcript.ts
  • frontend/src/lib/types.ts

Comment on lines +98 to +109
// /gradebook/<id>?semester=<label> — the landing's cards carry the selected
// term so a course enrolled in several terms resolves to that term's
// enrollment instead of 404ing off the current one (#139). Read straight off
// location (useSearchParams() would need a Suspense boundary in the route
// shell, which this screen doesn't own), and read it at CALL time — every
// fetch AND every course-keyed mutation below must see the URL the user is
// actually on, not a mount-frozen copy. Writes that drop the term would
// silently land on the CURRENT term's enrollment (#468 review).
function currentSemesterParam(): string | undefined {
if (typeof window === "undefined") return undefined;
return new URLSearchParams(window.location.search).get("semester") || undefined;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bashset -euo pipefail
echo"Locate file"
git ls-files | rg 'frontend/src/components/screens/Gradebook/Course\.tsx$|Course\.tsx$'||trueechoecho"Outline"
ast-grep outline frontend/src/components/screens/Gradebook/Course.tsx --view compact ||trueechoecho"Relevant sections"
sed -n '1,320p' frontend/src/components/screens/Gradebook/Course.tsx
echo"---"
sed -n '430,720p' frontend/src/components/screens/Gradebook/Course.tsx
echoecho"Search for currentSemesterParam and refresh dependencies"
rg -n "currentSemesterParam|useEffect|currentSemester|semester|CourseEnrollment|student|record|mutation|useMutation|refresh" frontend/src/components/screens/Gradebook/Course.tsx

Repository: SaplingLearn/Sapling

Length of output: 25705


🏁 Script executed:

#!/bin/bashset -euo pipefail
echo"Inspect API helpers and mutation signatures"
fd -a 'api\.(ts|tsx|js)$|types\.(ts|tsx|js)$' frontend/src | sed 's#^./##'echo
rg -n "export async function getGradebookCourse|const getGradebookCourse|function getGradebookCourse|getGradebookCourse" frontend/src/lib frontend/src -g '*.ts' -g '*.tsx'echo
sed -n '1,220p' frontend/src/lib/api.ts
echo"---"
rg -n "createGradedAssignment|updateGradedAssignment|deleteGradedAssignment|bulkUpdateCategories|setLetterScale|setCurveSettings" frontend/src/lib/api.ts

Repository: SaplingLearn/Sapling

Length of output: 9818


🏁 Script executed:

#!/bin/bashset -euo pipefail
echo"API route/path parameters"
sed -n '1160,1300p' frontend/src/lib/api.ts
echoecho"GradebookCourseScreen effects/mutations around line 220-245"
sed -n '220,245p' frontend/src/components/screens/Gradebook/Course.tsx
echoecho"Semester-specific tests"
sed -n '1,180p' frontend/src/components/screens/Gradebook/Course.semester.test.tsx

Repository: SaplingLearn/Sapling

Length of output: 13171


🏁 Script executed:

#!/bin/bashset -euo pipefail
echo"Find gradebook route files"
git ls-files | rg 'frontend/src/(app|routes|pages).*(gradebook|gradebook/.*course|gradebook|Course.page|page\.(tsx|ts|js))'| head -80
echoecho"Search route declarations for /gradebook/[id]"
rg -n 'gradebook/.*/course|gradebook/\[|gradebook/\\\[|courseId|GradebookCourseScreen|semester=' frontend/src -g '*.tsx' -g '*.ts'echo
git ls-files 'frontend/src/app/**/page.tsx''frontend/src/pages/**'| xargs rg -n "courseId|GradebookCourseScreen|semester="||true

Repository: SaplingLearn/Sapling

Length of output: 24931


Make the semester param a reactive dependency before relying on it.

For the same course ID, navigating /gradebook/c1?semester=Fall+2025 to /gradebook/c1?semester=Spring+2026 reuses GradebookCourseScreen because query changes don’t trigger the existing courseId-only fetch/mount effect. That leaves Fall grade state on screen while course-keyed mutations send Spring to the backend. Derive the selected term reactively (read and write with the same value) and reload/clear course data when it changes.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@frontend/src/components/screens/Gradebook/Course.tsx` around lines 98 - 109,
Make the semester selection reactive in GradebookCourseScreen instead of relying
only on currentSemesterParam() at call time: derive it from a reactive
URL/search-parameter source, use that same value for reads and course-keyed
mutations, and include it in the relevant fetch/mount dependencies. When the
semester changes for the same courseId, clear or reload the existing course
state so stale term data is not displayed.

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[P2] Semesters: gradebook UI — term switcher + transcript / cumulative GPA

1 participant

@AndresL230
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

feat(gradebook): term-aware course links + transcript & GPA (B6: #139) - #468

Merged
AndresL230 merged 3 commits into
mainfrom
b6-semesters
Jul 30, 2026
Merged

feat(gradebook): term-aware course links + transcript & GPA (B6: #139)#468
AndresL230 merged 3 commits into
mainfrom
b6-semesters

Conversation

@AndresL230

@AndresL230AndresL230 commented Jul 30, 2026

Copy link
Copy Markdown
Collaborator

Bundle B6 — Semesters completion (#139)

Closes#139.

The issue body predates the terms/offerings redesign; built against shipped reality (epic #142's model is stale). The Landing term switcher already existed (#140); what this PR adds:

The bug fix (term switcher half)

  • Course cards now carry ?semester=<label> and getGradebookCourse passes it through — fixing a live 404: with multiple offerings of one course (seeded CS101 is in fall-2025 AND spring-2026) and no term hint, _resolve_enrollment resolves the current term and 404s ("Course not in your gradebook") from either chip. Course.tsx reads the param exactly like Landing's deep-link handling.

Transcript / GPA half

  • GradebookSummary stops discarding the gpa/semester fields the backend already returns; Landing shows "Term GPA" (gradebook-term-gpa).
  • New TranscriptModal (gradebook-transcript-open) consuming the existing-but-unconsumed GET /api/gradebook/gpa: cumulative GPA + per-semester sections with course rows (letter or "in progress", credits). Per-semester GPA computed in a pure lib/transcript.ts that mirrors gradebook_service.weighted_gpa exactly (lock-step comment; null grade_points listed-but-excluded, null credits → 1).
  • Error path follows the fix(frontend): surface the five swallowed failures — closes the #113 audit tail (#166 #185 #184 #183 #186) #463 pattern: toast + inline retry, dialog stays open.

Surface registration + journey

  • docs/frontend-testids.md gains the gradebook surface; the four owning files joined the eslint testid enforcement block; eslint-suppressions.json regenerated for the pre-existing untagged elements (delta: Course.tsx 9, Landing.tsx 1).
  • Promoted journey e2e/gradebook.spec.ts (first gradebook journey): (a) the multi-term regression with a DB-truth precondition via queryRaw (asserts the two CS101 enrollments' term ids), Fall→"Exams" / Spring→"Projects" discriminators from the rich seed; (b) transcript open → GPA + both term sections.

Gates

Note: sibling issue #141 got a corrected-state STOP-note instead of code (design conflict with the shipped #360 contract + file collision with #465/B5) — see the issue comment.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • View GPA for the selected academic term directly from the Gradebook.
    • Open a transcript showing cumulative GPA, semester GPAs, course grades, credits, and in-progress courses.
    • Gradebook course links and actions now remain scoped to the selected term.
  • Bug Fixes

    • Fixed term selection being lost when opening courses.
    • Fixed new assignments and grading updates being saved to the wrong enrollment.
    • Added retry handling when transcript data fails to load.

@supabase

supabaseBot commented Jul 30, 2026

Copy link
Copy Markdown

This pull request has been ignored for the connected project ybgqdonkoqftwrmweuyv because there are no changes detected in supabase directory. You can change this behaviour in Project Integrations Settings ↗︎.


Preview Branches by Supabase.
Learn more about Supabase Branching ↗︎.

@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented Jul 30, 2026

Copy link
Copy Markdown

Deploying with Cloudflare Workers Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

StatusNameLatest CommitPreview URLUpdated (UTC)
✅ Deployment successful!
View logs
frontend-staging1831442Commit Preview URL

Branch Preview URL
Jul 30 2026, 07:51 AM

@coderabbitai

coderabbitaiBot commented Jul 30, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Gradebook now supports semester-scoped course loading and mutations, displays term and cumulative GPA transcript data, preserves selected terms in course links, adds stable E2E test IDs, and includes unit, component, and browser-level coverage.

Changes

Gradebook semester and transcript flow

Layer / File(s)Summary
GPA contracts and transcript calculations
frontend/src/lib/types.ts, frontend/src/lib/semesters.ts, frontend/src/lib/transcript.ts, frontend/src/lib/*test.ts
Adds GPA response types, term ordering, transcript grouping, effective-credit normalization, weighted GPA calculation, and corresponding tests.
Semester-scoped API and course operations
frontend/src/lib/api.ts, frontend/src/components/screens/Gradebook/Course.tsx, frontend/src/components/screens/Gradebook/Course.semester.test.tsx
Passes the URL-selected semester through gradebook reads and mutations, with tests covering present and absent semester parameters.
Landing and transcript UI
frontend/src/components/Gradebook/CourseCard.tsx, frontend/src/components/Gradebook/TranscriptModal.tsx, frontend/src/components/screens/Gradebook/Landing.tsx, frontend/src/components/screens/Gradebook/*test.tsx
Displays term GPA, preserves semester course links, opens a cumulative transcript modal, and covers loading, failure, retry, and interaction behavior.
E2E hooks and Gradebook validation
frontend/src/components/Gradebook/Assignment*.tsx, frontend/eslint*, docs/frontend-testids.md, frontend/e2e/gradebook.spec.ts
Adds Gradebook test IDs, configures enforcement and suppressions, documents the inventory, and validates term-specific navigation, assignment persistence, and transcript sections.

Estimated code review effort: 4 (Complex) | ~60 minutes

Possibly related issues

  • SaplingLearn/Sapling issue 142 — Covers the same Gradebook term switcher, per-semester GPA, and cumulative transcript objectives.

Possibly related PRs

Suggested reviewers:jose-gael-cruz-lopez, darkest-teddy

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check nameStatusExplanationResolution
Docstring Coverage⚠️ WarningDocstring coverage is 47.37% which is insufficient. The required threshold is 80.00%.Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check nameStatusExplanation
Title check✅ PassedThe title clearly summarizes the gradebook term-link and transcript/GPA additions.
Description check✅ PassedThe description covers the main changes, related issue, testing gates, and reviewer notes, though it doesn't use the exact template headings.
Linked Issues check✅ PassedThe PR implements term-scoped gradebook fetches, transcript/cumulative GPA UI, and error/loading behavior required by #139.
Out of Scope Changes check✅ PassedThe changes stay focused on gradebook semester support, test coverage, and related enforcement/docs updates.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch b6-semesters

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@AndresL230

Copy link
Copy Markdown
CollaboratorAuthor

Code review

Found 1 issue:

  1. Read/write term split: the PR makes the course-page read term-aware (getGradebookCourse(userId, courseId, semester) from ?semester=), which for the first time surfaces an archived-term enrollment — but every mutation from that screen (bulkUpdateCategories, createGradedAssignment, setLetterScale, setCurveSettings) still calls the API without semester, and _resolve_enrollment(course, None) resolves the CURRENT term. Viewing Fall 2025 CS101 (seeded in both fall-2025 and spring-2026; e2e clock 2026-03-11 → spring is current) and saving weights/an assignment/scale/curve silently writes into the Spring enrollment with a 200. The backend bodies (BulkUpdateCategoriesBody, CreateAssignmentBody, SetLetterScaleBody, SetCurveBody) already accept semester and pass it into _resolve_enrollment — the frontend just never sends it (bug due to Course.tsx mutation call sites omitting the term the screen is scoped to)

}
awaitbulkUpdateCategories(userId,courseId,drafts);
awaitrefresh();
}}
/>
<AssignmentModal
open={assignModal.open}
initial={assignModal.initial}
categories={data.categories}
onClose={()=>setAssignModal({open: false,initial: null})}
onSave={async(draft: AssignmentDraft)=>{
if(assignModal.initial){
awaitupdateGradedAssignment(userId,assignModal.initial.id,draft);
}else{
awaitcreateGradedAssignment(userId,courseId,draft);
}
awaitrefresh();
}}
onDelete={
assignModal.initial
? async()=>{
awaitdeleteGradedAssignment(userId,assignModal.initial!.id);
awaitrefresh();
}
: null
}
/>
<LetterScaleEditor
open={editScale}
initial={data.letter_scale}
onClose={()=>setEditScale(false)}
onSave={async(scale)=>{
awaitsetLetterScale(userId,courseId,scale);
awaitrefresh();
}}

Sub-threshold notes (verified, fixed in a follow-up commit, scores <80): transcript rows render {credits ?? 1} cr while the GPA math treats 0/negative as 1 (60); requestedSemester is mount-frozen per the #368 no-Suspense precedent — made fetch-time instead (35); the promoted journey still needs its pre-merge stack run — queued in this session's flock'd cycle (25).

🤖 Generated with Claude Code

- If this code review was useful, please react with 👍. Otherwise, react with 👎.

AndresL230and others added 3 commits July 30, 2026 00:47
The gradebook's term switcher was half-wired: the landing had semester
chips, but the course-card link and getGradebookCourse dropped the
selected term, so _resolve_enrollment(user, course, None) resolved the
CURRENT term — a course also taken in an archived term (rich seed:
CS101 in both fall-2025 and spring-2026) 404'd from that term's chip.
And the backend's GPA endpoints had no frontend consumer at all.
- CourseCard carries the selected term (?semester=<label>) on the card
href; the course screen reads it off location (same pattern as
Landing's deep-link param) and passes it through getGradebookCourse.
- GradebookSummary stops discarding the summary's gpa/semester; the
landing surfaces "Term GPA x.xx" next to the chips
(gradebook-term-gpa, hidden while null).
- New TranscriptModal (gradebook-transcript-open) over the previously
unconsumed GET /api/gradebook/gpa: cumulative GPA
(gradebook-transcript-gpa) + per-semester sections, in-progress rows
listed but excluded from the math. Load failure toasts + inline
retry (#463 pattern).
- New pure lib/transcript.ts (buildTranscript/weightedGpa) mirroring
backend gradebook_service.weighted_gpa exactly (null grade_points
skipped, null/zero credits count as 1, empty -> null); term ordering
reuses lib/semesters (new compareTermLabels export).
- Testid surface `gradebook` registered in docs/frontend-testids.md +
the four owning files joined the eslint no-restricted-syntax block;
suppressions baseline regenerated for the pre-existing untagged
elements (Course.tsx 9, Landing.tsx 1).
- e2e/gradebook.spec.ts (authored, not run here): the promoted #139
regression journey (DB-truth precondition via queryRaw, then both
chips' CS101 cards resolve their own term's categories) + the
transcript journey.
- vitest: lib/transcript.test.ts, TranscriptModal.test.tsx (dialog
contract + loading/error/retry), Course.semester.test.tsx (param
plumbing), Landing.test.tsx extended (term GPA, real CourseCard href
under test via a next/link stub).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…lay (#468 review)
Closes the three findings from the #468 5-agent review of the #139 work.
1 (major) — read/write term split: getGradebookCourse was semester-aware
but every course-keyed mutation from the Course screen still resolved
term-blind, so editing a Fall 2025 CS101 page wrote into the Spring 2026
enrollment silently (backend _resolve_enrollment(course, None) = current
term). The backend body models already accept `semester`; now the
frontend sends it: optional `semester` on createCategory,
bulkUpdateCategories, createGradedAssignment, setLetterScale and
setCurveSettings in lib/api.ts (body field, JSON.stringify drops it when
unset), passed at every Course.tsx call site (curve toggle + curve
settings + weights + create-assignment + letter scale). The id-keyed
calls (deleteCategory, update/deleteGradedAssignment) resolve by row
ownership — verified against routes/gradebook.py — and stay as-is.
- Course.semester.test.tsx: 4 new tests drive the REAL
EditWeightsModal/AssignmentModal down to Save and assert
bulkUpdateCategories/createGradedAssignment get the URL's semester
when ?semester= is set, and undefined when absent.
- e2e/gradebook.spec.ts: the Fall leg now also creates an assignment
through the UI and queryRaw-polls that the new assignments row hangs
off rich-enr-active-cs101-f25, not the spring enrollment. New testids
gradebook-add-assignment / gradebook-assignment-title /
gradebook-assignment-save; AssignmentList.tsx + AssignmentModal.tsx
joined the eslint enforcement array (pre-existing untagged elements
baselined: 7 + 12) and the docs inventory.
2 — transcript credits display: the modal showed `credits ?? 1` while
the GPA math treated 0/negative as 1. New effectiveCredits() export in
lib/transcript.ts is now the ONE place the rule lives, used by both
weightedGpa and the "N cr" display; tests cover 0/negative/null and the
rendered "1 cr" for a zero-credit row.
3 — ?semester= is read at FETCH time: the mount-frozen useState
initializer became currentSemesterParam(), read inside refresh AND
inside every mutation callback (keeps the no-Suspense plain-location
pattern).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ing contract (ToastProvider stub, getGpa mock, summary gpa/semester fields)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
frontend/src/components/screens/Gradebook/Landing.tsx (1)

159-184: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Guard against stale getGradebookSummary responses on rapid term switches.

Switching selected quickly fires overlapping requests with no sequencing; a slower response for an earlier term can resolve after a newer one and overwrite courses/termGpa with stale data for the wrong term. This affects the newly-added termGpa display as well.

♻️ Proposed fix: ignore stale responses
 React.useEffect(() => {
if (!termsReady) return;
+ let cancelled = false;
if (!selected) {
setCourses([]);
setTermGpa(null);
setLoading(false);
return;
}
if (!userId) {
setCourses(SAMPLE_COURSES[selected] ?? []);
setTermGpa(null);
setLoading(false);
return;
}
setLoading(true);
getGradebookSummary(userId, selected)
.then((res) => {
+ if (cancelled) return;
setCourses(res.courses.length ? res.courses : []);
setTermGpa(res.gpa ?? null);
})
.catch(() => {
+ if (cancelled) return;
setCourses([]);
setTermGpa(null);
})
- .finally(() => setLoading(false));+ .finally(() => { if (!cancelled) setLoading(false); });+ return () => { cancelled = true; };
}, [userId, selected, termsReady]);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@frontend/src/components/screens/Gradebook/Landing.tsx` around lines 159 -
184, Update the useEffect that calls getGradebookSummary to ignore responses
from requests started for an earlier selected term. Track the active request or
selected-term identity and only apply courses, termGpa, error, and loading
updates when the response belongs to the current request; preserve the existing
reset, sample-course, and cleanup behavior.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@frontend/src/components/screens/Gradebook/Course.tsx`:
- Around line 98-109: Make the semester selection reactive in
GradebookCourseScreen instead of relying only on currentSemesterParam() at call
time: derive it from a reactive URL/search-parameter source, use that same value
for reads and course-keyed mutations, and include it in the relevant fetch/mount
dependencies. When the semester changes for the same courseId, clear or reload
the existing course state so stale term data is not displayed.
---
Nitpick comments:
In `@frontend/src/components/screens/Gradebook/Landing.tsx`:
- Around line 159-184: Update the useEffect that calls getGradebookSummary to
ignore responses from requests started for an earlier selected term. Track the
active request or selected-term identity and only apply courses, termGpa, error,
and loading updates when the response belongs to the current request; preserve
the existing reset, sample-course, and cleanup behavior.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: d910b0c7-0c93-4b8c-b3cc-b86db7f13777

📥 Commits

Reviewing files that changed from the base of the PR and between d0cd993 and 1831442.

📒 Files selected for processing (19)
  • docs/frontend-testids.md
  • frontend/e2e/gradebook.spec.ts
  • frontend/eslint-suppressions.json
  • frontend/eslint.config.mjs
  • frontend/src/components/Gradebook/AssignmentList.tsx
  • frontend/src/components/Gradebook/AssignmentModal.tsx
  • frontend/src/components/Gradebook/CourseCard.tsx
  • frontend/src/components/Gradebook/TranscriptModal.test.tsx
  • frontend/src/components/Gradebook/TranscriptModal.tsx
  • frontend/src/components/screens/Gradebook/Course.semester.test.tsx
  • frontend/src/components/screens/Gradebook/Course.tsx
  • frontend/src/components/screens/Gradebook/Landing.test.tsx
  • frontend/src/components/screens/Gradebook/Landing.testmode.test.tsx
  • frontend/src/components/screens/Gradebook/Landing.tsx
  • frontend/src/lib/api.ts
  • frontend/src/lib/semesters.ts
  • frontend/src/lib/transcript.test.ts
  • frontend/src/lib/transcript.ts
  • frontend/src/lib/types.ts

Comment on lines +98 to +109
// /gradebook/<id>?semester=<label> — the landing's cards carry the selected
// term so a course enrolled in several terms resolves to that term's
// enrollment instead of 404ing off the current one (#139). Read straight off
// location (useSearchParams() would need a Suspense boundary in the route
// shell, which this screen doesn't own), and read it at CALL time — every
// fetch AND every course-keyed mutation below must see the URL the user is
// actually on, not a mount-frozen copy. Writes that drop the term would
// silently land on the CURRENT term's enrollment (#468 review).
function currentSemesterParam(): string | undefined {
if (typeof window === "undefined") return undefined;
return new URLSearchParams(window.location.search).get("semester") || undefined;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bashset -euo pipefail
echo"Locate file"
git ls-files | rg 'frontend/src/components/screens/Gradebook/Course\.tsx$|Course\.tsx$'||trueechoecho"Outline"
ast-grep outline frontend/src/components/screens/Gradebook/Course.tsx --view compact ||trueechoecho"Relevant sections"
sed -n '1,320p' frontend/src/components/screens/Gradebook/Course.tsx
echo"---"
sed -n '430,720p' frontend/src/components/screens/Gradebook/Course.tsx
echoecho"Search for currentSemesterParam and refresh dependencies"
rg -n "currentSemesterParam|useEffect|currentSemester|semester|CourseEnrollment|student|record|mutation|useMutation|refresh" frontend/src/components/screens/Gradebook/Course.tsx

Repository: SaplingLearn/Sapling

Length of output: 25705


🏁 Script executed:

#!/bin/bashset -euo pipefail
echo"Inspect API helpers and mutation signatures"
fd -a 'api\.(ts|tsx|js)$|types\.(ts|tsx|js)$' frontend/src | sed 's#^./##'echo
rg -n "export async function getGradebookCourse|const getGradebookCourse|function getGradebookCourse|getGradebookCourse" frontend/src/lib frontend/src -g '*.ts' -g '*.tsx'echo
sed -n '1,220p' frontend/src/lib/api.ts
echo"---"
rg -n "createGradedAssignment|updateGradedAssignment|deleteGradedAssignment|bulkUpdateCategories|setLetterScale|setCurveSettings" frontend/src/lib/api.ts

Repository: SaplingLearn/Sapling

Length of output: 9818


🏁 Script executed:

#!/bin/bashset -euo pipefail
echo"API route/path parameters"
sed -n '1160,1300p' frontend/src/lib/api.ts
echoecho"GradebookCourseScreen effects/mutations around line 220-245"
sed -n '220,245p' frontend/src/components/screens/Gradebook/Course.tsx
echoecho"Semester-specific tests"
sed -n '1,180p' frontend/src/components/screens/Gradebook/Course.semester.test.tsx

Repository: SaplingLearn/Sapling

Length of output: 13171


🏁 Script executed:

#!/bin/bashset -euo pipefail
echo"Find gradebook route files"
git ls-files | rg 'frontend/src/(app|routes|pages).*(gradebook|gradebook/.*course|gradebook|Course.page|page\.(tsx|ts|js))'| head -80
echoecho"Search route declarations for /gradebook/[id]"
rg -n 'gradebook/.*/course|gradebook/\[|gradebook/\\\[|courseId|GradebookCourseScreen|semester=' frontend/src -g '*.tsx' -g '*.ts'echo
git ls-files 'frontend/src/app/**/page.tsx''frontend/src/pages/**'| xargs rg -n "courseId|GradebookCourseScreen|semester="||true

Repository: SaplingLearn/Sapling

Length of output: 24931


Make the semester param a reactive dependency before relying on it.

For the same course ID, navigating /gradebook/c1?semester=Fall+2025 to /gradebook/c1?semester=Spring+2026 reuses GradebookCourseScreen because query changes don’t trigger the existing courseId-only fetch/mount effect. That leaves Fall grade state on screen while course-keyed mutations send Spring to the backend. Derive the selected term reactively (read and write with the same value) and reload/clear course data when it changes.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@frontend/src/components/screens/Gradebook/Course.tsx` around lines 98 - 109,
Make the semester selection reactive in GradebookCourseScreen instead of relying
only on currentSemesterParam() at call time: derive it from a reactive
URL/search-parameter source, use that same value for reads and course-keyed
mutations, and include it in the relevant fetch/mount dependencies. When the
semester changes for the same courseId, clear or reload the existing course
state so stale term data is not displayed.

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[P2] Semesters: gradebook UI — term switcher + transcript / cumulative GPA

1 participant

@AndresL230
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

feat(gradebook): term-aware course links + transcript & GPA (B6: #139) - #468

Merged
AndresL230 merged 3 commits into
mainfrom
b6-semesters
Jul 30, 2026
Merged

feat(gradebook): term-aware course links + transcript & GPA (B6: #139)#468
AndresL230 merged 3 commits into
mainfrom
b6-semesters

Conversation

@AndresL230

@AndresL230AndresL230 commented Jul 30, 2026

Copy link
Copy Markdown
Collaborator

Bundle B6 — Semesters completion (#139)

Closes#139.

The issue body predates the terms/offerings redesign; built against shipped reality (epic #142's model is stale). The Landing term switcher already existed (#140); what this PR adds:

The bug fix (term switcher half)

  • Course cards now carry ?semester=<label> and getGradebookCourse passes it through — fixing a live 404: with multiple offerings of one course (seeded CS101 is in fall-2025 AND spring-2026) and no term hint, _resolve_enrollment resolves the current term and 404s ("Course not in your gradebook") from either chip. Course.tsx reads the param exactly like Landing's deep-link handling.

Transcript / GPA half

  • GradebookSummary stops discarding the gpa/semester fields the backend already returns; Landing shows "Term GPA" (gradebook-term-gpa).
  • New TranscriptModal (gradebook-transcript-open) consuming the existing-but-unconsumed GET /api/gradebook/gpa: cumulative GPA + per-semester sections with course rows (letter or "in progress", credits). Per-semester GPA computed in a pure lib/transcript.ts that mirrors gradebook_service.weighted_gpa exactly (lock-step comment; null grade_points listed-but-excluded, null credits → 1).
  • Error path follows the fix(frontend): surface the five swallowed failures — closes the #113 audit tail (#166 #185 #184 #183 #186) #463 pattern: toast + inline retry, dialog stays open.

Surface registration + journey

  • docs/frontend-testids.md gains the gradebook surface; the four owning files joined the eslint testid enforcement block; eslint-suppressions.json regenerated for the pre-existing untagged elements (delta: Course.tsx 9, Landing.tsx 1).
  • Promoted journey e2e/gradebook.spec.ts (first gradebook journey): (a) the multi-term regression with a DB-truth precondition via queryRaw (asserts the two CS101 enrollments' term ids), Fall→"Exams" / Spring→"Projects" discriminators from the rich seed; (b) transcript open → GPA + both term sections.

Gates

Note: sibling issue #141 got a corrected-state STOP-note instead of code (design conflict with the shipped #360 contract + file collision with #465/B5) — see the issue comment.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • View GPA for the selected academic term directly from the Gradebook.
    • Open a transcript showing cumulative GPA, semester GPAs, course grades, credits, and in-progress courses.
    • Gradebook course links and actions now remain scoped to the selected term.
  • Bug Fixes

    • Fixed term selection being lost when opening courses.
    • Fixed new assignments and grading updates being saved to the wrong enrollment.
    • Added retry handling when transcript data fails to load.

@supabase

supabaseBot commented Jul 30, 2026

Copy link
Copy Markdown

This pull request has been ignored for the connected project ybgqdonkoqftwrmweuyv because there are no changes detected in supabase directory. You can change this behaviour in Project Integrations Settings ↗︎.


Preview Branches by Supabase.
Learn more about Supabase Branching ↗︎.

@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented Jul 30, 2026

Copy link
Copy Markdown

Deploying with Cloudflare Workers Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

StatusNameLatest CommitPreview URLUpdated (UTC)
✅ Deployment successful!
View logs
frontend-staging1831442Commit Preview URL

Branch Preview URL
Jul 30 2026, 07:51 AM

@coderabbitai

coderabbitaiBot commented Jul 30, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Gradebook now supports semester-scoped course loading and mutations, displays term and cumulative GPA transcript data, preserves selected terms in course links, adds stable E2E test IDs, and includes unit, component, and browser-level coverage.

Changes

Gradebook semester and transcript flow

Layer / File(s)Summary
GPA contracts and transcript calculations
frontend/src/lib/types.ts, frontend/src/lib/semesters.ts, frontend/src/lib/transcript.ts, frontend/src/lib/*test.ts
Adds GPA response types, term ordering, transcript grouping, effective-credit normalization, weighted GPA calculation, and corresponding tests.
Semester-scoped API and course operations
frontend/src/lib/api.ts, frontend/src/components/screens/Gradebook/Course.tsx, frontend/src/components/screens/Gradebook/Course.semester.test.tsx
Passes the URL-selected semester through gradebook reads and mutations, with tests covering present and absent semester parameters.
Landing and transcript UI
frontend/src/components/Gradebook/CourseCard.tsx, frontend/src/components/Gradebook/TranscriptModal.tsx, frontend/src/components/screens/Gradebook/Landing.tsx, frontend/src/components/screens/Gradebook/*test.tsx
Displays term GPA, preserves semester course links, opens a cumulative transcript modal, and covers loading, failure, retry, and interaction behavior.
E2E hooks and Gradebook validation
frontend/src/components/Gradebook/Assignment*.tsx, frontend/eslint*, docs/frontend-testids.md, frontend/e2e/gradebook.spec.ts
Adds Gradebook test IDs, configures enforcement and suppressions, documents the inventory, and validates term-specific navigation, assignment persistence, and transcript sections.

Estimated code review effort: 4 (Complex) | ~60 minutes

Possibly related issues

  • SaplingLearn/Sapling issue 142 — Covers the same Gradebook term switcher, per-semester GPA, and cumulative transcript objectives.

Possibly related PRs

Suggested reviewers:jose-gael-cruz-lopez, darkest-teddy

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check nameStatusExplanationResolution
Docstring Coverage⚠️ WarningDocstring coverage is 47.37% which is insufficient. The required threshold is 80.00%.Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check nameStatusExplanation
Title check✅ PassedThe title clearly summarizes the gradebook term-link and transcript/GPA additions.
Description check✅ PassedThe description covers the main changes, related issue, testing gates, and reviewer notes, though it doesn't use the exact template headings.
Linked Issues check✅ PassedThe PR implements term-scoped gradebook fetches, transcript/cumulative GPA UI, and error/loading behavior required by #139.
Out of Scope Changes check✅ PassedThe changes stay focused on gradebook semester support, test coverage, and related enforcement/docs updates.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch b6-semesters

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@AndresL230

Copy link
Copy Markdown
CollaboratorAuthor

Code review

Found 1 issue:

  1. Read/write term split: the PR makes the course-page read term-aware (getGradebookCourse(userId, courseId, semester) from ?semester=), which for the first time surfaces an archived-term enrollment — but every mutation from that screen (bulkUpdateCategories, createGradedAssignment, setLetterScale, setCurveSettings) still calls the API without semester, and _resolve_enrollment(course, None) resolves the CURRENT term. Viewing Fall 2025 CS101 (seeded in both fall-2025 and spring-2026; e2e clock 2026-03-11 → spring is current) and saving weights/an assignment/scale/curve silently writes into the Spring enrollment with a 200. The backend bodies (BulkUpdateCategoriesBody, CreateAssignmentBody, SetLetterScaleBody, SetCurveBody) already accept semester and pass it into _resolve_enrollment — the frontend just never sends it (bug due to Course.tsx mutation call sites omitting the term the screen is scoped to)

}
awaitbulkUpdateCategories(userId,courseId,drafts);
awaitrefresh();
}}
/>
<AssignmentModal
open={assignModal.open}
initial={assignModal.initial}
categories={data.categories}
onClose={()=>setAssignModal({open: false,initial: null})}
onSave={async(draft: AssignmentDraft)=>{
if(assignModal.initial){
awaitupdateGradedAssignment(userId,assignModal.initial.id,draft);
}else{
awaitcreateGradedAssignment(userId,courseId,draft);
}
awaitrefresh();
}}
onDelete={
assignModal.initial
? async()=>{
awaitdeleteGradedAssignment(userId,assignModal.initial!.id);
awaitrefresh();
}
: null
}
/>
<LetterScaleEditor
open={editScale}
initial={data.letter_scale}
onClose={()=>setEditScale(false)}
onSave={async(scale)=>{
awaitsetLetterScale(userId,courseId,scale);
awaitrefresh();
}}

Sub-threshold notes (verified, fixed in a follow-up commit, scores <80): transcript rows render {credits ?? 1} cr while the GPA math treats 0/negative as 1 (60); requestedSemester is mount-frozen per the #368 no-Suspense precedent — made fetch-time instead (35); the promoted journey still needs its pre-merge stack run — queued in this session's flock'd cycle (25).

🤖 Generated with Claude Code

- If this code review was useful, please react with 👍. Otherwise, react with 👎.

AndresL230and others added 3 commits July 30, 2026 00:47
The gradebook's term switcher was half-wired: the landing had semester
chips, but the course-card link and getGradebookCourse dropped the
selected term, so _resolve_enrollment(user, course, None) resolved the
CURRENT term — a course also taken in an archived term (rich seed:
CS101 in both fall-2025 and spring-2026) 404'd from that term's chip.
And the backend's GPA endpoints had no frontend consumer at all.
- CourseCard carries the selected term (?semester=<label>) on the card
href; the course screen reads it off location (same pattern as
Landing's deep-link param) and passes it through getGradebookCourse.
- GradebookSummary stops discarding the summary's gpa/semester; the
landing surfaces "Term GPA x.xx" next to the chips
(gradebook-term-gpa, hidden while null).
- New TranscriptModal (gradebook-transcript-open) over the previously
unconsumed GET /api/gradebook/gpa: cumulative GPA
(gradebook-transcript-gpa) + per-semester sections, in-progress rows
listed but excluded from the math. Load failure toasts + inline
retry (#463 pattern).
- New pure lib/transcript.ts (buildTranscript/weightedGpa) mirroring
backend gradebook_service.weighted_gpa exactly (null grade_points
skipped, null/zero credits count as 1, empty -> null); term ordering
reuses lib/semesters (new compareTermLabels export).
- Testid surface `gradebook` registered in docs/frontend-testids.md +
the four owning files joined the eslint no-restricted-syntax block;
suppressions baseline regenerated for the pre-existing untagged
elements (Course.tsx 9, Landing.tsx 1).
- e2e/gradebook.spec.ts (authored, not run here): the promoted #139
regression journey (DB-truth precondition via queryRaw, then both
chips' CS101 cards resolve their own term's categories) + the
transcript journey.
- vitest: lib/transcript.test.ts, TranscriptModal.test.tsx (dialog
contract + loading/error/retry), Course.semester.test.tsx (param
plumbing), Landing.test.tsx extended (term GPA, real CourseCard href
under test via a next/link stub).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…lay (#468 review)
Closes the three findings from the #468 5-agent review of the #139 work.
1 (major) — read/write term split: getGradebookCourse was semester-aware
but every course-keyed mutation from the Course screen still resolved
term-blind, so editing a Fall 2025 CS101 page wrote into the Spring 2026
enrollment silently (backend _resolve_enrollment(course, None) = current
term). The backend body models already accept `semester`; now the
frontend sends it: optional `semester` on createCategory,
bulkUpdateCategories, createGradedAssignment, setLetterScale and
setCurveSettings in lib/api.ts (body field, JSON.stringify drops it when
unset), passed at every Course.tsx call site (curve toggle + curve
settings + weights + create-assignment + letter scale). The id-keyed
calls (deleteCategory, update/deleteGradedAssignment) resolve by row
ownership — verified against routes/gradebook.py — and stay as-is.
- Course.semester.test.tsx: 4 new tests drive the REAL
EditWeightsModal/AssignmentModal down to Save and assert
bulkUpdateCategories/createGradedAssignment get the URL's semester
when ?semester= is set, and undefined when absent.
- e2e/gradebook.spec.ts: the Fall leg now also creates an assignment
through the UI and queryRaw-polls that the new assignments row hangs
off rich-enr-active-cs101-f25, not the spring enrollment. New testids
gradebook-add-assignment / gradebook-assignment-title /
gradebook-assignment-save; AssignmentList.tsx + AssignmentModal.tsx
joined the eslint enforcement array (pre-existing untagged elements
baselined: 7 + 12) and the docs inventory.
2 — transcript credits display: the modal showed `credits ?? 1` while
the GPA math treated 0/negative as 1. New effectiveCredits() export in
lib/transcript.ts is now the ONE place the rule lives, used by both
weightedGpa and the "N cr" display; tests cover 0/negative/null and the
rendered "1 cr" for a zero-credit row.
3 — ?semester= is read at FETCH time: the mount-frozen useState
initializer became currentSemesterParam(), read inside refresh AND
inside every mutation callback (keeps the no-Suspense plain-location
pattern).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ing contract (ToastProvider stub, getGpa mock, summary gpa/semester fields)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
frontend/src/components/screens/Gradebook/Landing.tsx (1)

159-184: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Guard against stale getGradebookSummary responses on rapid term switches.

Switching selected quickly fires overlapping requests with no sequencing; a slower response for an earlier term can resolve after a newer one and overwrite courses/termGpa with stale data for the wrong term. This affects the newly-added termGpa display as well.

♻️ Proposed fix: ignore stale responses
 React.useEffect(() => {
if (!termsReady) return;
+ let cancelled = false;
if (!selected) {
setCourses([]);
setTermGpa(null);
setLoading(false);
return;
}
if (!userId) {
setCourses(SAMPLE_COURSES[selected] ?? []);
setTermGpa(null);
setLoading(false);
return;
}
setLoading(true);
getGradebookSummary(userId, selected)
.then((res) => {
+ if (cancelled) return;
setCourses(res.courses.length ? res.courses : []);
setTermGpa(res.gpa ?? null);
})
.catch(() => {
+ if (cancelled) return;
setCourses([]);
setTermGpa(null);
})
- .finally(() => setLoading(false));+ .finally(() => { if (!cancelled) setLoading(false); });+ return () => { cancelled = true; };
}, [userId, selected, termsReady]);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@frontend/src/components/screens/Gradebook/Landing.tsx` around lines 159 -
184, Update the useEffect that calls getGradebookSummary to ignore responses
from requests started for an earlier selected term. Track the active request or
selected-term identity and only apply courses, termGpa, error, and loading
updates when the response belongs to the current request; preserve the existing
reset, sample-course, and cleanup behavior.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@frontend/src/components/screens/Gradebook/Course.tsx`:
- Around line 98-109: Make the semester selection reactive in
GradebookCourseScreen instead of relying only on currentSemesterParam() at call
time: derive it from a reactive URL/search-parameter source, use that same value
for reads and course-keyed mutations, and include it in the relevant fetch/mount
dependencies. When the semester changes for the same courseId, clear or reload
the existing course state so stale term data is not displayed.
---
Nitpick comments:
In `@frontend/src/components/screens/Gradebook/Landing.tsx`:
- Around line 159-184: Update the useEffect that calls getGradebookSummary to
ignore responses from requests started for an earlier selected term. Track the
active request or selected-term identity and only apply courses, termGpa, error,
and loading updates when the response belongs to the current request; preserve
the existing reset, sample-course, and cleanup behavior.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: d910b0c7-0c93-4b8c-b3cc-b86db7f13777

📥 Commits

Reviewing files that changed from the base of the PR and between d0cd993 and 1831442.

📒 Files selected for processing (19)
  • docs/frontend-testids.md
  • frontend/e2e/gradebook.spec.ts
  • frontend/eslint-suppressions.json
  • frontend/eslint.config.mjs
  • frontend/src/components/Gradebook/AssignmentList.tsx
  • frontend/src/components/Gradebook/AssignmentModal.tsx
  • frontend/src/components/Gradebook/CourseCard.tsx
  • frontend/src/components/Gradebook/TranscriptModal.test.tsx
  • frontend/src/components/Gradebook/TranscriptModal.tsx
  • frontend/src/components/screens/Gradebook/Course.semester.test.tsx
  • frontend/src/components/screens/Gradebook/Course.tsx
  • frontend/src/components/screens/Gradebook/Landing.test.tsx
  • frontend/src/components/screens/Gradebook/Landing.testmode.test.tsx
  • frontend/src/components/screens/Gradebook/Landing.tsx
  • frontend/src/lib/api.ts
  • frontend/src/lib/semesters.ts
  • frontend/src/lib/transcript.test.ts
  • frontend/src/lib/transcript.ts
  • frontend/src/lib/types.ts

Comment on lines +98 to +109
// /gradebook/<id>?semester=<label> — the landing's cards carry the selected
// term so a course enrolled in several terms resolves to that term's
// enrollment instead of 404ing off the current one (#139). Read straight off
// location (useSearchParams() would need a Suspense boundary in the route
// shell, which this screen doesn't own), and read it at CALL time — every
// fetch AND every course-keyed mutation below must see the URL the user is
// actually on, not a mount-frozen copy. Writes that drop the term would
// silently land on the CURRENT term's enrollment (#468 review).
function currentSemesterParam(): string | undefined {
if (typeof window === "undefined") return undefined;
return new URLSearchParams(window.location.search).get("semester") || undefined;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bashset -euo pipefail
echo"Locate file"
git ls-files | rg 'frontend/src/components/screens/Gradebook/Course\.tsx$|Course\.tsx$'||trueechoecho"Outline"
ast-grep outline frontend/src/components/screens/Gradebook/Course.tsx --view compact ||trueechoecho"Relevant sections"
sed -n '1,320p' frontend/src/components/screens/Gradebook/Course.tsx
echo"---"
sed -n '430,720p' frontend/src/components/screens/Gradebook/Course.tsx
echoecho"Search for currentSemesterParam and refresh dependencies"
rg -n "currentSemesterParam|useEffect|currentSemester|semester|CourseEnrollment|student|record|mutation|useMutation|refresh" frontend/src/components/screens/Gradebook/Course.tsx

Repository: SaplingLearn/Sapling

Length of output: 25705


🏁 Script executed:

#!/bin/bashset -euo pipefail
echo"Inspect API helpers and mutation signatures"
fd -a 'api\.(ts|tsx|js)$|types\.(ts|tsx|js)$' frontend/src | sed 's#^./##'echo
rg -n "export async function getGradebookCourse|const getGradebookCourse|function getGradebookCourse|getGradebookCourse" frontend/src/lib frontend/src -g '*.ts' -g '*.tsx'echo
sed -n '1,220p' frontend/src/lib/api.ts
echo"---"
rg -n "createGradedAssignment|updateGradedAssignment|deleteGradedAssignment|bulkUpdateCategories|setLetterScale|setCurveSettings" frontend/src/lib/api.ts

Repository: SaplingLearn/Sapling

Length of output: 9818


🏁 Script executed:

#!/bin/bashset -euo pipefail
echo"API route/path parameters"
sed -n '1160,1300p' frontend/src/lib/api.ts
echoecho"GradebookCourseScreen effects/mutations around line 220-245"
sed -n '220,245p' frontend/src/components/screens/Gradebook/Course.tsx
echoecho"Semester-specific tests"
sed -n '1,180p' frontend/src/components/screens/Gradebook/Course.semester.test.tsx

Repository: SaplingLearn/Sapling

Length of output: 13171


🏁 Script executed:

#!/bin/bashset -euo pipefail
echo"Find gradebook route files"
git ls-files | rg 'frontend/src/(app|routes|pages).*(gradebook|gradebook/.*course|gradebook|Course.page|page\.(tsx|ts|js))'| head -80
echoecho"Search route declarations for /gradebook/[id]"
rg -n 'gradebook/.*/course|gradebook/\[|gradebook/\\\[|courseId|GradebookCourseScreen|semester=' frontend/src -g '*.tsx' -g '*.ts'echo
git ls-files 'frontend/src/app/**/page.tsx''frontend/src/pages/**'| xargs rg -n "courseId|GradebookCourseScreen|semester="||true

Repository: SaplingLearn/Sapling

Length of output: 24931


Make the semester param a reactive dependency before relying on it.

For the same course ID, navigating /gradebook/c1?semester=Fall+2025 to /gradebook/c1?semester=Spring+2026 reuses GradebookCourseScreen because query changes don’t trigger the existing courseId-only fetch/mount effect. That leaves Fall grade state on screen while course-keyed mutations send Spring to the backend. Derive the selected term reactively (read and write with the same value) and reload/clear course data when it changes.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@frontend/src/components/screens/Gradebook/Course.tsx` around lines 98 - 109,
Make the semester selection reactive in GradebookCourseScreen instead of relying
only on currentSemesterParam() at call time: derive it from a reactive
URL/search-parameter source, use that same value for reads and course-keyed
mutations, and include it in the relevant fetch/mount dependencies. When the
semester changes for the same courseId, clear or reload the existing course
state so stale term data is not displayed.

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[P2] Semesters: gradebook UI — term switcher + transcript / cumulative GPA

1 participant

@AndresL230
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

feat(gradebook): term-aware course links + transcript & GPA (B6: #139) - #468

Merged
AndresL230 merged 3 commits into
mainfrom
b6-semesters
Jul 30, 2026
Merged

feat(gradebook): term-aware course links + transcript & GPA (B6: #139)#468
AndresL230 merged 3 commits into
mainfrom
b6-semesters

Conversation

@AndresL230

@AndresL230AndresL230 commented Jul 30, 2026

Copy link
Copy Markdown
Collaborator

Bundle B6 — Semesters completion (#139)

Closes#139.

The issue body predates the terms/offerings redesign; built against shipped reality (epic #142's model is stale). The Landing term switcher already existed (#140); what this PR adds:

The bug fix (term switcher half)

  • Course cards now carry ?semester=<label> and getGradebookCourse passes it through — fixing a live 404: with multiple offerings of one course (seeded CS101 is in fall-2025 AND spring-2026) and no term hint, _resolve_enrollment resolves the current term and 404s ("Course not in your gradebook") from either chip. Course.tsx reads the param exactly like Landing's deep-link handling.

Transcript / GPA half

  • GradebookSummary stops discarding the gpa/semester fields the backend already returns; Landing shows "Term GPA" (gradebook-term-gpa).
  • New TranscriptModal (gradebook-transcript-open) consuming the existing-but-unconsumed GET /api/gradebook/gpa: cumulative GPA + per-semester sections with course rows (letter or "in progress", credits). Per-semester GPA computed in a pure lib/transcript.ts that mirrors gradebook_service.weighted_gpa exactly (lock-step comment; null grade_points listed-but-excluded, null credits → 1).
  • Error path follows the fix(frontend): surface the five swallowed failures — closes the #113 audit tail (#166 #185 #184 #183 #186) #463 pattern: toast + inline retry, dialog stays open.

Surface registration + journey

  • docs/frontend-testids.md gains the gradebook surface; the four owning files joined the eslint testid enforcement block; eslint-suppressions.json regenerated for the pre-existing untagged elements (delta: Course.tsx 9, Landing.tsx 1).
  • Promoted journey e2e/gradebook.spec.ts (first gradebook journey): (a) the multi-term regression with a DB-truth precondition via queryRaw (asserts the two CS101 enrollments' term ids), Fall→"Exams" / Spring→"Projects" discriminators from the rich seed; (b) transcript open → GPA + both term sections.

Gates

Note: sibling issue #141 got a corrected-state STOP-note instead of code (design conflict with the shipped #360 contract + file collision with #465/B5) — see the issue comment.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • View GPA for the selected academic term directly from the Gradebook.
    • Open a transcript showing cumulative GPA, semester GPAs, course grades, credits, and in-progress courses.
    • Gradebook course links and actions now remain scoped to the selected term.
  • Bug Fixes

    • Fixed term selection being lost when opening courses.
    • Fixed new assignments and grading updates being saved to the wrong enrollment.
    • Added retry handling when transcript data fails to load.

@supabase

supabaseBot commented Jul 30, 2026

Copy link
Copy Markdown

This pull request has been ignored for the connected project ybgqdonkoqftwrmweuyv because there are no changes detected in supabase directory. You can change this behaviour in Project Integrations Settings ↗︎.


Preview Branches by Supabase.
Learn more about Supabase Branching ↗︎.

@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented Jul 30, 2026

Copy link
Copy Markdown

Deploying with Cloudflare Workers Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

StatusNameLatest CommitPreview URLUpdated (UTC)
✅ Deployment successful!
View logs
frontend-staging1831442Commit Preview URL

Branch Preview URL
Jul 30 2026, 07:51 AM

@coderabbitai

coderabbitaiBot commented Jul 30, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Gradebook now supports semester-scoped course loading and mutations, displays term and cumulative GPA transcript data, preserves selected terms in course links, adds stable E2E test IDs, and includes unit, component, and browser-level coverage.

Changes

Gradebook semester and transcript flow

Layer / File(s)Summary
GPA contracts and transcript calculations
frontend/src/lib/types.ts, frontend/src/lib/semesters.ts, frontend/src/lib/transcript.ts, frontend/src/lib/*test.ts
Adds GPA response types, term ordering, transcript grouping, effective-credit normalization, weighted GPA calculation, and corresponding tests.
Semester-scoped API and course operations
frontend/src/lib/api.ts, frontend/src/components/screens/Gradebook/Course.tsx, frontend/src/components/screens/Gradebook/Course.semester.test.tsx
Passes the URL-selected semester through gradebook reads and mutations, with tests covering present and absent semester parameters.
Landing and transcript UI
frontend/src/components/Gradebook/CourseCard.tsx, frontend/src/components/Gradebook/TranscriptModal.tsx, frontend/src/components/screens/Gradebook/Landing.tsx, frontend/src/components/screens/Gradebook/*test.tsx
Displays term GPA, preserves semester course links, opens a cumulative transcript modal, and covers loading, failure, retry, and interaction behavior.
E2E hooks and Gradebook validation
frontend/src/components/Gradebook/Assignment*.tsx, frontend/eslint*, docs/frontend-testids.md, frontend/e2e/gradebook.spec.ts
Adds Gradebook test IDs, configures enforcement and suppressions, documents the inventory, and validates term-specific navigation, assignment persistence, and transcript sections.

Estimated code review effort: 4 (Complex) | ~60 minutes

Possibly related issues

  • SaplingLearn/Sapling issue 142 — Covers the same Gradebook term switcher, per-semester GPA, and cumulative transcript objectives.

Possibly related PRs

Suggested reviewers:jose-gael-cruz-lopez, darkest-teddy

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check nameStatusExplanationResolution
Docstring Coverage⚠️ WarningDocstring coverage is 47.37% which is insufficient. The required threshold is 80.00%.Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check nameStatusExplanation
Title check✅ PassedThe title clearly summarizes the gradebook term-link and transcript/GPA additions.
Description check✅ PassedThe description covers the main changes, related issue, testing gates, and reviewer notes, though it doesn't use the exact template headings.
Linked Issues check✅ PassedThe PR implements term-scoped gradebook fetches, transcript/cumulative GPA UI, and error/loading behavior required by #139.
Out of Scope Changes check✅ PassedThe changes stay focused on gradebook semester support, test coverage, and related enforcement/docs updates.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch b6-semesters

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@AndresL230

Copy link
Copy Markdown
CollaboratorAuthor

Code review

Found 1 issue:

  1. Read/write term split: the PR makes the course-page read term-aware (getGradebookCourse(userId, courseId, semester) from ?semester=), which for the first time surfaces an archived-term enrollment — but every mutation from that screen (bulkUpdateCategories, createGradedAssignment, setLetterScale, setCurveSettings) still calls the API without semester, and _resolve_enrollment(course, None) resolves the CURRENT term. Viewing Fall 2025 CS101 (seeded in both fall-2025 and spring-2026; e2e clock 2026-03-11 → spring is current) and saving weights/an assignment/scale/curve silently writes into the Spring enrollment with a 200. The backend bodies (BulkUpdateCategoriesBody, CreateAssignmentBody, SetLetterScaleBody, SetCurveBody) already accept semester and pass it into _resolve_enrollment — the frontend just never sends it (bug due to Course.tsx mutation call sites omitting the term the screen is scoped to)

}
awaitbulkUpdateCategories(userId,courseId,drafts);
awaitrefresh();
}}
/>
<AssignmentModal
open={assignModal.open}
initial={assignModal.initial}
categories={data.categories}
onClose={()=>setAssignModal({open: false,initial: null})}
onSave={async(draft: AssignmentDraft)=>{
if(assignModal.initial){
awaitupdateGradedAssignment(userId,assignModal.initial.id,draft);
}else{
awaitcreateGradedAssignment(userId,courseId,draft);
}
awaitrefresh();
}}
onDelete={
assignModal.initial
? async()=>{
awaitdeleteGradedAssignment(userId,assignModal.initial!.id);
awaitrefresh();
}
: null
}
/>
<LetterScaleEditor
open={editScale}
initial={data.letter_scale}
onClose={()=>setEditScale(false)}
onSave={async(scale)=>{
awaitsetLetterScale(userId,courseId,scale);
awaitrefresh();
}}

Sub-threshold notes (verified, fixed in a follow-up commit, scores <80): transcript rows render {credits ?? 1} cr while the GPA math treats 0/negative as 1 (60); requestedSemester is mount-frozen per the #368 no-Suspense precedent — made fetch-time instead (35); the promoted journey still needs its pre-merge stack run — queued in this session's flock'd cycle (25).

🤖 Generated with Claude Code

- If this code review was useful, please react with 👍. Otherwise, react with 👎.

AndresL230and others added 3 commits July 30, 2026 00:47
The gradebook's term switcher was half-wired: the landing had semester
chips, but the course-card link and getGradebookCourse dropped the
selected term, so _resolve_enrollment(user, course, None) resolved the
CURRENT term — a course also taken in an archived term (rich seed:
CS101 in both fall-2025 and spring-2026) 404'd from that term's chip.
And the backend's GPA endpoints had no frontend consumer at all.
- CourseCard carries the selected term (?semester=<label>) on the card
href; the course screen reads it off location (same pattern as
Landing's deep-link param) and passes it through getGradebookCourse.
- GradebookSummary stops discarding the summary's gpa/semester; the
landing surfaces "Term GPA x.xx" next to the chips
(gradebook-term-gpa, hidden while null).
- New TranscriptModal (gradebook-transcript-open) over the previously
unconsumed GET /api/gradebook/gpa: cumulative GPA
(gradebook-transcript-gpa) + per-semester sections, in-progress rows
listed but excluded from the math. Load failure toasts + inline
retry (#463 pattern).
- New pure lib/transcript.ts (buildTranscript/weightedGpa) mirroring
backend gradebook_service.weighted_gpa exactly (null grade_points
skipped, null/zero credits count as 1, empty -> null); term ordering
reuses lib/semesters (new compareTermLabels export).
- Testid surface `gradebook` registered in docs/frontend-testids.md +
the four owning files joined the eslint no-restricted-syntax block;
suppressions baseline regenerated for the pre-existing untagged
elements (Course.tsx 9, Landing.tsx 1).
- e2e/gradebook.spec.ts (authored, not run here): the promoted #139
regression journey (DB-truth precondition via queryRaw, then both
chips' CS101 cards resolve their own term's categories) + the
transcript journey.
- vitest: lib/transcript.test.ts, TranscriptModal.test.tsx (dialog
contract + loading/error/retry), Course.semester.test.tsx (param
plumbing), Landing.test.tsx extended (term GPA, real CourseCard href
under test via a next/link stub).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…lay (#468 review)
Closes the three findings from the #468 5-agent review of the #139 work.
1 (major) — read/write term split: getGradebookCourse was semester-aware
but every course-keyed mutation from the Course screen still resolved
term-blind, so editing a Fall 2025 CS101 page wrote into the Spring 2026
enrollment silently (backend _resolve_enrollment(course, None) = current
term). The backend body models already accept `semester`; now the
frontend sends it: optional `semester` on createCategory,
bulkUpdateCategories, createGradedAssignment, setLetterScale and
setCurveSettings in lib/api.ts (body field, JSON.stringify drops it when
unset), passed at every Course.tsx call site (curve toggle + curve
settings + weights + create-assignment + letter scale). The id-keyed
calls (deleteCategory, update/deleteGradedAssignment) resolve by row
ownership — verified against routes/gradebook.py — and stay as-is.
- Course.semester.test.tsx: 4 new tests drive the REAL
EditWeightsModal/AssignmentModal down to Save and assert
bulkUpdateCategories/createGradedAssignment get the URL's semester
when ?semester= is set, and undefined when absent.
- e2e/gradebook.spec.ts: the Fall leg now also creates an assignment
through the UI and queryRaw-polls that the new assignments row hangs
off rich-enr-active-cs101-f25, not the spring enrollment. New testids
gradebook-add-assignment / gradebook-assignment-title /
gradebook-assignment-save; AssignmentList.tsx + AssignmentModal.tsx
joined the eslint enforcement array (pre-existing untagged elements
baselined: 7 + 12) and the docs inventory.
2 — transcript credits display: the modal showed `credits ?? 1` while
the GPA math treated 0/negative as 1. New effectiveCredits() export in
lib/transcript.ts is now the ONE place the rule lives, used by both
weightedGpa and the "N cr" display; tests cover 0/negative/null and the
rendered "1 cr" for a zero-credit row.
3 — ?semester= is read at FETCH time: the mount-frozen useState
initializer became currentSemesterParam(), read inside refresh AND
inside every mutation callback (keeps the no-Suspense plain-location
pattern).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ing contract (ToastProvider stub, getGpa mock, summary gpa/semester fields)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
frontend/src/components/screens/Gradebook/Landing.tsx (1)

159-184: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Guard against stale getGradebookSummary responses on rapid term switches.

Switching selected quickly fires overlapping requests with no sequencing; a slower response for an earlier term can resolve after a newer one and overwrite courses/termGpa with stale data for the wrong term. This affects the newly-added termGpa display as well.

♻️ Proposed fix: ignore stale responses
 React.useEffect(() => {
if (!termsReady) return;
+ let cancelled = false;
if (!selected) {
setCourses([]);
setTermGpa(null);
setLoading(false);
return;
}
if (!userId) {
setCourses(SAMPLE_COURSES[selected] ?? []);
setTermGpa(null);
setLoading(false);
return;
}
setLoading(true);
getGradebookSummary(userId, selected)
.then((res) => {
+ if (cancelled) return;
setCourses(res.courses.length ? res.courses : []);
setTermGpa(res.gpa ?? null);
})
.catch(() => {
+ if (cancelled) return;
setCourses([]);
setTermGpa(null);
})
- .finally(() => setLoading(false));+ .finally(() => { if (!cancelled) setLoading(false); });+ return () => { cancelled = true; };
}, [userId, selected, termsReady]);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@frontend/src/components/screens/Gradebook/Landing.tsx` around lines 159 -
184, Update the useEffect that calls getGradebookSummary to ignore responses
from requests started for an earlier selected term. Track the active request or
selected-term identity and only apply courses, termGpa, error, and loading
updates when the response belongs to the current request; preserve the existing
reset, sample-course, and cleanup behavior.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@frontend/src/components/screens/Gradebook/Course.tsx`:
- Around line 98-109: Make the semester selection reactive in
GradebookCourseScreen instead of relying only on currentSemesterParam() at call
time: derive it from a reactive URL/search-parameter source, use that same value
for reads and course-keyed mutations, and include it in the relevant fetch/mount
dependencies. When the semester changes for the same courseId, clear or reload
the existing course state so stale term data is not displayed.
---
Nitpick comments:
In `@frontend/src/components/screens/Gradebook/Landing.tsx`:
- Around line 159-184: Update the useEffect that calls getGradebookSummary to
ignore responses from requests started for an earlier selected term. Track the
active request or selected-term identity and only apply courses, termGpa, error,
and loading updates when the response belongs to the current request; preserve
the existing reset, sample-course, and cleanup behavior.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: d910b0c7-0c93-4b8c-b3cc-b86db7f13777

📥 Commits

Reviewing files that changed from the base of the PR and between d0cd993 and 1831442.

📒 Files selected for processing (19)
  • docs/frontend-testids.md
  • frontend/e2e/gradebook.spec.ts
  • frontend/eslint-suppressions.json
  • frontend/eslint.config.mjs
  • frontend/src/components/Gradebook/AssignmentList.tsx
  • frontend/src/components/Gradebook/AssignmentModal.tsx
  • frontend/src/components/Gradebook/CourseCard.tsx
  • frontend/src/components/Gradebook/TranscriptModal.test.tsx
  • frontend/src/components/Gradebook/TranscriptModal.tsx
  • frontend/src/components/screens/Gradebook/Course.semester.test.tsx
  • frontend/src/components/screens/Gradebook/Course.tsx
  • frontend/src/components/screens/Gradebook/Landing.test.tsx
  • frontend/src/components/screens/Gradebook/Landing.testmode.test.tsx
  • frontend/src/components/screens/Gradebook/Landing.tsx
  • frontend/src/lib/api.ts
  • frontend/src/lib/semesters.ts
  • frontend/src/lib/transcript.test.ts
  • frontend/src/lib/transcript.ts
  • frontend/src/lib/types.ts

Comment on lines +98 to +109
// /gradebook/<id>?semester=<label> — the landing's cards carry the selected
// term so a course enrolled in several terms resolves to that term's
// enrollment instead of 404ing off the current one (#139). Read straight off
// location (useSearchParams() would need a Suspense boundary in the route
// shell, which this screen doesn't own), and read it at CALL time — every
// fetch AND every course-keyed mutation below must see the URL the user is
// actually on, not a mount-frozen copy. Writes that drop the term would
// silently land on the CURRENT term's enrollment (#468 review).
function currentSemesterParam(): string | undefined {
if (typeof window === "undefined") return undefined;
return new URLSearchParams(window.location.search).get("semester") || undefined;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bashset -euo pipefail
echo"Locate file"
git ls-files | rg 'frontend/src/components/screens/Gradebook/Course\.tsx$|Course\.tsx$'||trueechoecho"Outline"
ast-grep outline frontend/src/components/screens/Gradebook/Course.tsx --view compact ||trueechoecho"Relevant sections"
sed -n '1,320p' frontend/src/components/screens/Gradebook/Course.tsx
echo"---"
sed -n '430,720p' frontend/src/components/screens/Gradebook/Course.tsx
echoecho"Search for currentSemesterParam and refresh dependencies"
rg -n "currentSemesterParam|useEffect|currentSemester|semester|CourseEnrollment|student|record|mutation|useMutation|refresh" frontend/src/components/screens/Gradebook/Course.tsx

Repository: SaplingLearn/Sapling

Length of output: 25705


🏁 Script executed:

#!/bin/bashset -euo pipefail
echo"Inspect API helpers and mutation signatures"
fd -a 'api\.(ts|tsx|js)$|types\.(ts|tsx|js)$' frontend/src | sed 's#^./##'echo
rg -n "export async function getGradebookCourse|const getGradebookCourse|function getGradebookCourse|getGradebookCourse" frontend/src/lib frontend/src -g '*.ts' -g '*.tsx'echo
sed -n '1,220p' frontend/src/lib/api.ts
echo"---"
rg -n "createGradedAssignment|updateGradedAssignment|deleteGradedAssignment|bulkUpdateCategories|setLetterScale|setCurveSettings" frontend/src/lib/api.ts

Repository: SaplingLearn/Sapling

Length of output: 9818


🏁 Script executed:

#!/bin/bashset -euo pipefail
echo"API route/path parameters"
sed -n '1160,1300p' frontend/src/lib/api.ts
echoecho"GradebookCourseScreen effects/mutations around line 220-245"
sed -n '220,245p' frontend/src/components/screens/Gradebook/Course.tsx
echoecho"Semester-specific tests"
sed -n '1,180p' frontend/src/components/screens/Gradebook/Course.semester.test.tsx

Repository: SaplingLearn/Sapling

Length of output: 13171


🏁 Script executed:

#!/bin/bashset -euo pipefail
echo"Find gradebook route files"
git ls-files | rg 'frontend/src/(app|routes|pages).*(gradebook|gradebook/.*course|gradebook|Course.page|page\.(tsx|ts|js))'| head -80
echoecho"Search route declarations for /gradebook/[id]"
rg -n 'gradebook/.*/course|gradebook/\[|gradebook/\\\[|courseId|GradebookCourseScreen|semester=' frontend/src -g '*.tsx' -g '*.ts'echo
git ls-files 'frontend/src/app/**/page.tsx''frontend/src/pages/**'| xargs rg -n "courseId|GradebookCourseScreen|semester="||true

Repository: SaplingLearn/Sapling

Length of output: 24931


Make the semester param a reactive dependency before relying on it.

For the same course ID, navigating /gradebook/c1?semester=Fall+2025 to /gradebook/c1?semester=Spring+2026 reuses GradebookCourseScreen because query changes don’t trigger the existing courseId-only fetch/mount effect. That leaves Fall grade state on screen while course-keyed mutations send Spring to the backend. Derive the selected term reactively (read and write with the same value) and reload/clear course data when it changes.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@frontend/src/components/screens/Gradebook/Course.tsx` around lines 98 - 109,
Make the semester selection reactive in GradebookCourseScreen instead of relying
only on currentSemesterParam() at call time: derive it from a reactive
URL/search-parameter source, use that same value for reads and course-keyed
mutations, and include it in the relevant fetch/mount dependencies. When the
semester changes for the same courseId, clear or reload the existing course
state so stale term data is not displayed.

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[P2] Semesters: gradebook UI — term switcher + transcript / cumulative GPA

1 participant

@AndresL230
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

feat(gradebook): term-aware course links + transcript & GPA (B6: #139) - #468

Merged
AndresL230 merged 3 commits into
mainfrom
b6-semesters
Jul 30, 2026
Merged

feat(gradebook): term-aware course links + transcript & GPA (B6: #139)#468
AndresL230 merged 3 commits into
mainfrom
b6-semesters

Conversation

@AndresL230

@AndresL230AndresL230 commented Jul 30, 2026

Copy link
Copy Markdown
Collaborator

Bundle B6 — Semesters completion (#139)

Closes#139.

The issue body predates the terms/offerings redesign; built against shipped reality (epic #142's model is stale). The Landing term switcher already existed (#140); what this PR adds:

The bug fix (term switcher half)

  • Course cards now carry ?semester=<label> and getGradebookCourse passes it through — fixing a live 404: with multiple offerings of one course (seeded CS101 is in fall-2025 AND spring-2026) and no term hint, _resolve_enrollment resolves the current term and 404s ("Course not in your gradebook") from either chip. Course.tsx reads the param exactly like Landing's deep-link handling.

Transcript / GPA half

  • GradebookSummary stops discarding the gpa/semester fields the backend already returns; Landing shows "Term GPA" (gradebook-term-gpa).
  • New TranscriptModal (gradebook-transcript-open) consuming the existing-but-unconsumed GET /api/gradebook/gpa: cumulative GPA + per-semester sections with course rows (letter or "in progress", credits). Per-semester GPA computed in a pure lib/transcript.ts that mirrors gradebook_service.weighted_gpa exactly (lock-step comment; null grade_points listed-but-excluded, null credits → 1).
  • Error path follows the fix(frontend): surface the five swallowed failures — closes the #113 audit tail (#166 #185 #184 #183 #186) #463 pattern: toast + inline retry, dialog stays open.

Surface registration + journey

  • docs/frontend-testids.md gains the gradebook surface; the four owning files joined the eslint testid enforcement block; eslint-suppressions.json regenerated for the pre-existing untagged elements (delta: Course.tsx 9, Landing.tsx 1).
  • Promoted journey e2e/gradebook.spec.ts (first gradebook journey): (a) the multi-term regression with a DB-truth precondition via queryRaw (asserts the two CS101 enrollments' term ids), Fall→"Exams" / Spring→"Projects" discriminators from the rich seed; (b) transcript open → GPA + both term sections.

Gates

Note: sibling issue #141 got a corrected-state STOP-note instead of code (design conflict with the shipped #360 contract + file collision with #465/B5) — see the issue comment.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • View GPA for the selected academic term directly from the Gradebook.
    • Open a transcript showing cumulative GPA, semester GPAs, course grades, credits, and in-progress courses.
    • Gradebook course links and actions now remain scoped to the selected term.
  • Bug Fixes

    • Fixed term selection being lost when opening courses.
    • Fixed new assignments and grading updates being saved to the wrong enrollment.
    • Added retry handling when transcript data fails to load.

@supabase

supabaseBot commented Jul 30, 2026

Copy link
Copy Markdown

This pull request has been ignored for the connected project ybgqdonkoqftwrmweuyv because there are no changes detected in supabase directory. You can change this behaviour in Project Integrations Settings ↗︎.


Preview Branches by Supabase.
Learn more about Supabase Branching ↗︎.

@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented Jul 30, 2026

Copy link
Copy Markdown

Deploying with Cloudflare Workers Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

StatusNameLatest CommitPreview URLUpdated (UTC)
✅ Deployment successful!
View logs
frontend-staging1831442Commit Preview URL

Branch Preview URL
Jul 30 2026, 07:51 AM

@coderabbitai

coderabbitaiBot commented Jul 30, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Gradebook now supports semester-scoped course loading and mutations, displays term and cumulative GPA transcript data, preserves selected terms in course links, adds stable E2E test IDs, and includes unit, component, and browser-level coverage.

Changes

Gradebook semester and transcript flow

Layer / File(s)Summary
GPA contracts and transcript calculations
frontend/src/lib/types.ts, frontend/src/lib/semesters.ts, frontend/src/lib/transcript.ts, frontend/src/lib/*test.ts
Adds GPA response types, term ordering, transcript grouping, effective-credit normalization, weighted GPA calculation, and corresponding tests.
Semester-scoped API and course operations
frontend/src/lib/api.ts, frontend/src/components/screens/Gradebook/Course.tsx, frontend/src/components/screens/Gradebook/Course.semester.test.tsx
Passes the URL-selected semester through gradebook reads and mutations, with tests covering present and absent semester parameters.
Landing and transcript UI
frontend/src/components/Gradebook/CourseCard.tsx, frontend/src/components/Gradebook/TranscriptModal.tsx, frontend/src/components/screens/Gradebook/Landing.tsx, frontend/src/components/screens/Gradebook/*test.tsx
Displays term GPA, preserves semester course links, opens a cumulative transcript modal, and covers loading, failure, retry, and interaction behavior.
E2E hooks and Gradebook validation
frontend/src/components/Gradebook/Assignment*.tsx, frontend/eslint*, docs/frontend-testids.md, frontend/e2e/gradebook.spec.ts
Adds Gradebook test IDs, configures enforcement and suppressions, documents the inventory, and validates term-specific navigation, assignment persistence, and transcript sections.

Estimated code review effort: 4 (Complex) | ~60 minutes

Possibly related issues

  • SaplingLearn/Sapling issue 142 — Covers the same Gradebook term switcher, per-semester GPA, and cumulative transcript objectives.

Possibly related PRs

Suggested reviewers:jose-gael-cruz-lopez, darkest-teddy

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check nameStatusExplanationResolution
Docstring Coverage⚠️ WarningDocstring coverage is 47.37% which is insufficient. The required threshold is 80.00%.Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check nameStatusExplanation
Title check✅ PassedThe title clearly summarizes the gradebook term-link and transcript/GPA additions.
Description check✅ PassedThe description covers the main changes, related issue, testing gates, and reviewer notes, though it doesn't use the exact template headings.
Linked Issues check✅ PassedThe PR implements term-scoped gradebook fetches, transcript/cumulative GPA UI, and error/loading behavior required by #139.
Out of Scope Changes check✅ PassedThe changes stay focused on gradebook semester support, test coverage, and related enforcement/docs updates.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch b6-semesters

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@AndresL230

Copy link
Copy Markdown
CollaboratorAuthor

Code review

Found 1 issue:

  1. Read/write term split: the PR makes the course-page read term-aware (getGradebookCourse(userId, courseId, semester) from ?semester=), which for the first time surfaces an archived-term enrollment — but every mutation from that screen (bulkUpdateCategories, createGradedAssignment, setLetterScale, setCurveSettings) still calls the API without semester, and _resolve_enrollment(course, None) resolves the CURRENT term. Viewing Fall 2025 CS101 (seeded in both fall-2025 and spring-2026; e2e clock 2026-03-11 → spring is current) and saving weights/an assignment/scale/curve silently writes into the Spring enrollment with a 200. The backend bodies (BulkUpdateCategoriesBody, CreateAssignmentBody, SetLetterScaleBody, SetCurveBody) already accept semester and pass it into _resolve_enrollment — the frontend just never sends it (bug due to Course.tsx mutation call sites omitting the term the screen is scoped to)

}
awaitbulkUpdateCategories(userId,courseId,drafts);
awaitrefresh();
}}
/>
<AssignmentModal
open={assignModal.open}
initial={assignModal.initial}
categories={data.categories}
onClose={()=>setAssignModal({open: false,initial: null})}
onSave={async(draft: AssignmentDraft)=>{
if(assignModal.initial){
awaitupdateGradedAssignment(userId,assignModal.initial.id,draft);
}else{
awaitcreateGradedAssignment(userId,courseId,draft);
}
awaitrefresh();
}}
onDelete={
assignModal.initial
? async()=>{
awaitdeleteGradedAssignment(userId,assignModal.initial!.id);
awaitrefresh();
}
: null
}
/>
<LetterScaleEditor
open={editScale}
initial={data.letter_scale}
onClose={()=>setEditScale(false)}
onSave={async(scale)=>{
awaitsetLetterScale(userId,courseId,scale);
awaitrefresh();
}}

Sub-threshold notes (verified, fixed in a follow-up commit, scores <80): transcript rows render {credits ?? 1} cr while the GPA math treats 0/negative as 1 (60); requestedSemester is mount-frozen per the #368 no-Suspense precedent — made fetch-time instead (35); the promoted journey still needs its pre-merge stack run — queued in this session's flock'd cycle (25).

🤖 Generated with Claude Code

- If this code review was useful, please react with 👍. Otherwise, react with 👎.

AndresL230and others added 3 commits July 30, 2026 00:47
The gradebook's term switcher was half-wired: the landing had semester
chips, but the course-card link and getGradebookCourse dropped the
selected term, so _resolve_enrollment(user, course, None) resolved the
CURRENT term — a course also taken in an archived term (rich seed:
CS101 in both fall-2025 and spring-2026) 404'd from that term's chip.
And the backend's GPA endpoints had no frontend consumer at all.
- CourseCard carries the selected term (?semester=<label>) on the card
href; the course screen reads it off location (same pattern as
Landing's deep-link param) and passes it through getGradebookCourse.
- GradebookSummary stops discarding the summary's gpa/semester; the
landing surfaces "Term GPA x.xx" next to the chips
(gradebook-term-gpa, hidden while null).
- New TranscriptModal (gradebook-transcript-open) over the previously
unconsumed GET /api/gradebook/gpa: cumulative GPA
(gradebook-transcript-gpa) + per-semester sections, in-progress rows
listed but excluded from the math. Load failure toasts + inline
retry (#463 pattern).
- New pure lib/transcript.ts (buildTranscript/weightedGpa) mirroring
backend gradebook_service.weighted_gpa exactly (null grade_points
skipped, null/zero credits count as 1, empty -> null); term ordering
reuses lib/semesters (new compareTermLabels export).
- Testid surface `gradebook` registered in docs/frontend-testids.md +
the four owning files joined the eslint no-restricted-syntax block;
suppressions baseline regenerated for the pre-existing untagged
elements (Course.tsx 9, Landing.tsx 1).
- e2e/gradebook.spec.ts (authored, not run here): the promoted #139
regression journey (DB-truth precondition via queryRaw, then both
chips' CS101 cards resolve their own term's categories) + the
transcript journey.
- vitest: lib/transcript.test.ts, TranscriptModal.test.tsx (dialog
contract + loading/error/retry), Course.semester.test.tsx (param
plumbing), Landing.test.tsx extended (term GPA, real CourseCard href
under test via a next/link stub).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…lay (#468 review)
Closes the three findings from the #468 5-agent review of the #139 work.
1 (major) — read/write term split: getGradebookCourse was semester-aware
but every course-keyed mutation from the Course screen still resolved
term-blind, so editing a Fall 2025 CS101 page wrote into the Spring 2026
enrollment silently (backend _resolve_enrollment(course, None) = current
term). The backend body models already accept `semester`; now the
frontend sends it: optional `semester` on createCategory,
bulkUpdateCategories, createGradedAssignment, setLetterScale and
setCurveSettings in lib/api.ts (body field, JSON.stringify drops it when
unset), passed at every Course.tsx call site (curve toggle + curve
settings + weights + create-assignment + letter scale). The id-keyed
calls (deleteCategory, update/deleteGradedAssignment) resolve by row
ownership — verified against routes/gradebook.py — and stay as-is.
- Course.semester.test.tsx: 4 new tests drive the REAL
EditWeightsModal/AssignmentModal down to Save and assert
bulkUpdateCategories/createGradedAssignment get the URL's semester
when ?semester= is set, and undefined when absent.
- e2e/gradebook.spec.ts: the Fall leg now also creates an assignment
through the UI and queryRaw-polls that the new assignments row hangs
off rich-enr-active-cs101-f25, not the spring enrollment. New testids
gradebook-add-assignment / gradebook-assignment-title /
gradebook-assignment-save; AssignmentList.tsx + AssignmentModal.tsx
joined the eslint enforcement array (pre-existing untagged elements
baselined: 7 + 12) and the docs inventory.
2 — transcript credits display: the modal showed `credits ?? 1` while
the GPA math treated 0/negative as 1. New effectiveCredits() export in
lib/transcript.ts is now the ONE place the rule lives, used by both
weightedGpa and the "N cr" display; tests cover 0/negative/null and the
rendered "1 cr" for a zero-credit row.
3 — ?semester= is read at FETCH time: the mount-frozen useState
initializer became currentSemesterParam(), read inside refresh AND
inside every mutation callback (keeps the no-Suspense plain-location
pattern).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ing contract (ToastProvider stub, getGpa mock, summary gpa/semester fields)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
frontend/src/components/screens/Gradebook/Landing.tsx (1)

159-184: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Guard against stale getGradebookSummary responses on rapid term switches.

Switching selected quickly fires overlapping requests with no sequencing; a slower response for an earlier term can resolve after a newer one and overwrite courses/termGpa with stale data for the wrong term. This affects the newly-added termGpa display as well.

♻️ Proposed fix: ignore stale responses
 React.useEffect(() => {
if (!termsReady) return;
+ let cancelled = false;
if (!selected) {
setCourses([]);
setTermGpa(null);
setLoading(false);
return;
}
if (!userId) {
setCourses(SAMPLE_COURSES[selected] ?? []);
setTermGpa(null);
setLoading(false);
return;
}
setLoading(true);
getGradebookSummary(userId, selected)
.then((res) => {
+ if (cancelled) return;
setCourses(res.courses.length ? res.courses : []);
setTermGpa(res.gpa ?? null);
})
.catch(() => {
+ if (cancelled) return;
setCourses([]);
setTermGpa(null);
})
- .finally(() => setLoading(false));+ .finally(() => { if (!cancelled) setLoading(false); });+ return () => { cancelled = true; };
}, [userId, selected, termsReady]);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@frontend/src/components/screens/Gradebook/Landing.tsx` around lines 159 -
184, Update the useEffect that calls getGradebookSummary to ignore responses
from requests started for an earlier selected term. Track the active request or
selected-term identity and only apply courses, termGpa, error, and loading
updates when the response belongs to the current request; preserve the existing
reset, sample-course, and cleanup behavior.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@frontend/src/components/screens/Gradebook/Course.tsx`:
- Around line 98-109: Make the semester selection reactive in
GradebookCourseScreen instead of relying only on currentSemesterParam() at call
time: derive it from a reactive URL/search-parameter source, use that same value
for reads and course-keyed mutations, and include it in the relevant fetch/mount
dependencies. When the semester changes for the same courseId, clear or reload
the existing course state so stale term data is not displayed.
---
Nitpick comments:
In `@frontend/src/components/screens/Gradebook/Landing.tsx`:
- Around line 159-184: Update the useEffect that calls getGradebookSummary to
ignore responses from requests started for an earlier selected term. Track the
active request or selected-term identity and only apply courses, termGpa, error,
and loading updates when the response belongs to the current request; preserve
the existing reset, sample-course, and cleanup behavior.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: d910b0c7-0c93-4b8c-b3cc-b86db7f13777

📥 Commits

Reviewing files that changed from the base of the PR and between d0cd993 and 1831442.

📒 Files selected for processing (19)
  • docs/frontend-testids.md
  • frontend/e2e/gradebook.spec.ts
  • frontend/eslint-suppressions.json
  • frontend/eslint.config.mjs
  • frontend/src/components/Gradebook/AssignmentList.tsx
  • frontend/src/components/Gradebook/AssignmentModal.tsx
  • frontend/src/components/Gradebook/CourseCard.tsx
  • frontend/src/components/Gradebook/TranscriptModal.test.tsx
  • frontend/src/components/Gradebook/TranscriptModal.tsx
  • frontend/src/components/screens/Gradebook/Course.semester.test.tsx
  • frontend/src/components/screens/Gradebook/Course.tsx
  • frontend/src/components/screens/Gradebook/Landing.test.tsx
  • frontend/src/components/screens/Gradebook/Landing.testmode.test.tsx
  • frontend/src/components/screens/Gradebook/Landing.tsx
  • frontend/src/lib/api.ts
  • frontend/src/lib/semesters.ts
  • frontend/src/lib/transcript.test.ts
  • frontend/src/lib/transcript.ts
  • frontend/src/lib/types.ts

Comment on lines +98 to +109
// /gradebook/<id>?semester=<label> — the landing's cards carry the selected
// term so a course enrolled in several terms resolves to that term's
// enrollment instead of 404ing off the current one (#139). Read straight off
// location (useSearchParams() would need a Suspense boundary in the route
// shell, which this screen doesn't own), and read it at CALL time — every
// fetch AND every course-keyed mutation below must see the URL the user is
// actually on, not a mount-frozen copy. Writes that drop the term would
// silently land on the CURRENT term's enrollment (#468 review).
function currentSemesterParam(): string | undefined {
if (typeof window === "undefined") return undefined;
return new URLSearchParams(window.location.search).get("semester") || undefined;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bashset -euo pipefail
echo"Locate file"
git ls-files | rg 'frontend/src/components/screens/Gradebook/Course\.tsx$|Course\.tsx$'||trueechoecho"Outline"
ast-grep outline frontend/src/components/screens/Gradebook/Course.tsx --view compact ||trueechoecho"Relevant sections"
sed -n '1,320p' frontend/src/components/screens/Gradebook/Course.tsx
echo"---"
sed -n '430,720p' frontend/src/components/screens/Gradebook/Course.tsx
echoecho"Search for currentSemesterParam and refresh dependencies"
rg -n "currentSemesterParam|useEffect|currentSemester|semester|CourseEnrollment|student|record|mutation|useMutation|refresh" frontend/src/components/screens/Gradebook/Course.tsx

Repository: SaplingLearn/Sapling

Length of output: 25705


🏁 Script executed:

#!/bin/bashset -euo pipefail
echo"Inspect API helpers and mutation signatures"
fd -a 'api\.(ts|tsx|js)$|types\.(ts|tsx|js)$' frontend/src | sed 's#^./##'echo
rg -n "export async function getGradebookCourse|const getGradebookCourse|function getGradebookCourse|getGradebookCourse" frontend/src/lib frontend/src -g '*.ts' -g '*.tsx'echo
sed -n '1,220p' frontend/src/lib/api.ts
echo"---"
rg -n "createGradedAssignment|updateGradedAssignment|deleteGradedAssignment|bulkUpdateCategories|setLetterScale|setCurveSettings" frontend/src/lib/api.ts

Repository: SaplingLearn/Sapling

Length of output: 9818


🏁 Script executed:

#!/bin/bashset -euo pipefail
echo"API route/path parameters"
sed -n '1160,1300p' frontend/src/lib/api.ts
echoecho"GradebookCourseScreen effects/mutations around line 220-245"
sed -n '220,245p' frontend/src/components/screens/Gradebook/Course.tsx
echoecho"Semester-specific tests"
sed -n '1,180p' frontend/src/components/screens/Gradebook/Course.semester.test.tsx

Repository: SaplingLearn/Sapling

Length of output: 13171


🏁 Script executed:

#!/bin/bashset -euo pipefail
echo"Find gradebook route files"
git ls-files | rg 'frontend/src/(app|routes|pages).*(gradebook|gradebook/.*course|gradebook|Course.page|page\.(tsx|ts|js))'| head -80
echoecho"Search route declarations for /gradebook/[id]"
rg -n 'gradebook/.*/course|gradebook/\[|gradebook/\\\[|courseId|GradebookCourseScreen|semester=' frontend/src -g '*.tsx' -g '*.ts'echo
git ls-files 'frontend/src/app/**/page.tsx''frontend/src/pages/**'| xargs rg -n "courseId|GradebookCourseScreen|semester="||true

Repository: SaplingLearn/Sapling

Length of output: 24931


Make the semester param a reactive dependency before relying on it.

For the same course ID, navigating /gradebook/c1?semester=Fall+2025 to /gradebook/c1?semester=Spring+2026 reuses GradebookCourseScreen because query changes don’t trigger the existing courseId-only fetch/mount effect. That leaves Fall grade state on screen while course-keyed mutations send Spring to the backend. Derive the selected term reactively (read and write with the same value) and reload/clear course data when it changes.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@frontend/src/components/screens/Gradebook/Course.tsx` around lines 98 - 109,
Make the semester selection reactive in GradebookCourseScreen instead of relying
only on currentSemesterParam() at call time: derive it from a reactive
URL/search-parameter source, use that same value for reads and course-keyed
mutations, and include it in the relevant fetch/mount dependencies. When the
semester changes for the same courseId, clear or reload the existing course
state so stale term data is not displayed.

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[P2] Semesters: gradebook UI — term switcher + transcript / cumulative GPA

1 participant

@AndresL230
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

feat(gradebook): term-aware course links + transcript & GPA (B6: #139) - #468

Merged
AndresL230 merged 3 commits into
mainfrom
b6-semesters
Jul 30, 2026
Merged

feat(gradebook): term-aware course links + transcript & GPA (B6: #139)#468
AndresL230 merged 3 commits into
mainfrom
b6-semesters

Conversation

@AndresL230

@AndresL230AndresL230 commented Jul 30, 2026

Copy link
Copy Markdown
Collaborator

Bundle B6 — Semesters completion (#139)

Closes#139.

The issue body predates the terms/offerings redesign; built against shipped reality (epic #142's model is stale). The Landing term switcher already existed (#140); what this PR adds:

The bug fix (term switcher half)

  • Course cards now carry ?semester=<label> and getGradebookCourse passes it through — fixing a live 404: with multiple offerings of one course (seeded CS101 is in fall-2025 AND spring-2026) and no term hint, _resolve_enrollment resolves the current term and 404s ("Course not in your gradebook") from either chip. Course.tsx reads the param exactly like Landing's deep-link handling.

Transcript / GPA half

  • GradebookSummary stops discarding the gpa/semester fields the backend already returns; Landing shows "Term GPA" (gradebook-term-gpa).
  • New TranscriptModal (gradebook-transcript-open) consuming the existing-but-unconsumed GET /api/gradebook/gpa: cumulative GPA + per-semester sections with course rows (letter or "in progress", credits). Per-semester GPA computed in a pure lib/transcript.ts that mirrors gradebook_service.weighted_gpa exactly (lock-step comment; null grade_points listed-but-excluded, null credits → 1).
  • Error path follows the fix(frontend): surface the five swallowed failures — closes the #113 audit tail (#166 #185 #184 #183 #186) #463 pattern: toast + inline retry, dialog stays open.

Surface registration + journey

  • docs/frontend-testids.md gains the gradebook surface; the four owning files joined the eslint testid enforcement block; eslint-suppressions.json regenerated for the pre-existing untagged elements (delta: Course.tsx 9, Landing.tsx 1).
  • Promoted journey e2e/gradebook.spec.ts (first gradebook journey): (a) the multi-term regression with a DB-truth precondition via queryRaw (asserts the two CS101 enrollments' term ids), Fall→"Exams" / Spring→"Projects" discriminators from the rich seed; (b) transcript open → GPA + both term sections.

Gates

Note: sibling issue #141 got a corrected-state STOP-note instead of code (design conflict with the shipped #360 contract + file collision with #465/B5) — see the issue comment.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • View GPA for the selected academic term directly from the Gradebook.
    • Open a transcript showing cumulative GPA, semester GPAs, course grades, credits, and in-progress courses.
    • Gradebook course links and actions now remain scoped to the selected term.
  • Bug Fixes

    • Fixed term selection being lost when opening courses.
    • Fixed new assignments and grading updates being saved to the wrong enrollment.
    • Added retry handling when transcript data fails to load.

@supabase

supabaseBot commented Jul 30, 2026

Copy link
Copy Markdown

This pull request has been ignored for the connected project ybgqdonkoqftwrmweuyv because there are no changes detected in supabase directory. You can change this behaviour in Project Integrations Settings ↗︎.


Preview Branches by Supabase.
Learn more about Supabase Branching ↗︎.

@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented Jul 30, 2026

Copy link
Copy Markdown

Deploying with Cloudflare Workers Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

StatusNameLatest CommitPreview URLUpdated (UTC)
✅ Deployment successful!
View logs
frontend-staging1831442Commit Preview URL

Branch Preview URL
Jul 30 2026, 07:51 AM

@coderabbitai

coderabbitaiBot commented Jul 30, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Gradebook now supports semester-scoped course loading and mutations, displays term and cumulative GPA transcript data, preserves selected terms in course links, adds stable E2E test IDs, and includes unit, component, and browser-level coverage.

Changes

Gradebook semester and transcript flow

Layer / File(s)Summary
GPA contracts and transcript calculations
frontend/src/lib/types.ts, frontend/src/lib/semesters.ts, frontend/src/lib/transcript.ts, frontend/src/lib/*test.ts
Adds GPA response types, term ordering, transcript grouping, effective-credit normalization, weighted GPA calculation, and corresponding tests.
Semester-scoped API and course operations
frontend/src/lib/api.ts, frontend/src/components/screens/Gradebook/Course.tsx, frontend/src/components/screens/Gradebook/Course.semester.test.tsx
Passes the URL-selected semester through gradebook reads and mutations, with tests covering present and absent semester parameters.
Landing and transcript UI
frontend/src/components/Gradebook/CourseCard.tsx, frontend/src/components/Gradebook/TranscriptModal.tsx, frontend/src/components/screens/Gradebook/Landing.tsx, frontend/src/components/screens/Gradebook/*test.tsx
Displays term GPA, preserves semester course links, opens a cumulative transcript modal, and covers loading, failure, retry, and interaction behavior.
E2E hooks and Gradebook validation
frontend/src/components/Gradebook/Assignment*.tsx, frontend/eslint*, docs/frontend-testids.md, frontend/e2e/gradebook.spec.ts
Adds Gradebook test IDs, configures enforcement and suppressions, documents the inventory, and validates term-specific navigation, assignment persistence, and transcript sections.

Estimated code review effort: 4 (Complex) | ~60 minutes

Possibly related issues

  • SaplingLearn/Sapling issue 142 — Covers the same Gradebook term switcher, per-semester GPA, and cumulative transcript objectives.

Possibly related PRs

Suggested reviewers:jose-gael-cruz-lopez, darkest-teddy

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check nameStatusExplanationResolution
Docstring Coverage⚠️ WarningDocstring coverage is 47.37% which is insufficient. The required threshold is 80.00%.Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check nameStatusExplanation
Title check✅ PassedThe title clearly summarizes the gradebook term-link and transcript/GPA additions.
Description check✅ PassedThe description covers the main changes, related issue, testing gates, and reviewer notes, though it doesn't use the exact template headings.
Linked Issues check✅ PassedThe PR implements term-scoped gradebook fetches, transcript/cumulative GPA UI, and error/loading behavior required by #139.
Out of Scope Changes check✅ PassedThe changes stay focused on gradebook semester support, test coverage, and related enforcement/docs updates.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch b6-semesters

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@AndresL230

Copy link
Copy Markdown
CollaboratorAuthor

Code review

Found 1 issue:

  1. Read/write term split: the PR makes the course-page read term-aware (getGradebookCourse(userId, courseId, semester) from ?semester=), which for the first time surfaces an archived-term enrollment — but every mutation from that screen (bulkUpdateCategories, createGradedAssignment, setLetterScale, setCurveSettings) still calls the API without semester, and _resolve_enrollment(course, None) resolves the CURRENT term. Viewing Fall 2025 CS101 (seeded in both fall-2025 and spring-2026; e2e clock 2026-03-11 → spring is current) and saving weights/an assignment/scale/curve silently writes into the Spring enrollment with a 200. The backend bodies (BulkUpdateCategoriesBody, CreateAssignmentBody, SetLetterScaleBody, SetCurveBody) already accept semester and pass it into _resolve_enrollment — the frontend just never sends it (bug due to Course.tsx mutation call sites omitting the term the screen is scoped to)

}
awaitbulkUpdateCategories(userId,courseId,drafts);
awaitrefresh();
}}
/>
<AssignmentModal
open={assignModal.open}
initial={assignModal.initial}
categories={data.categories}
onClose={()=>setAssignModal({open: false,initial: null})}
onSave={async(draft: AssignmentDraft)=>{
if(assignModal.initial){
awaitupdateGradedAssignment(userId,assignModal.initial.id,draft);
}else{
awaitcreateGradedAssignment(userId,courseId,draft);
}
awaitrefresh();
}}
onDelete={
assignModal.initial
? async()=>{
awaitdeleteGradedAssignment(userId,assignModal.initial!.id);
awaitrefresh();
}
: null
}
/>
<LetterScaleEditor
open={editScale}
initial={data.letter_scale}
onClose={()=>setEditScale(false)}
onSave={async(scale)=>{
awaitsetLetterScale(userId,courseId,scale);
awaitrefresh();
}}

Sub-threshold notes (verified, fixed in a follow-up commit, scores <80): transcript rows render {credits ?? 1} cr while the GPA math treats 0/negative as 1 (60); requestedSemester is mount-frozen per the #368 no-Suspense precedent — made fetch-time instead (35); the promoted journey still needs its pre-merge stack run — queued in this session's flock'd cycle (25).

🤖 Generated with Claude Code

- If this code review was useful, please react with 👍. Otherwise, react with 👎.

AndresL230and others added 3 commits July 30, 2026 00:47
The gradebook's term switcher was half-wired: the landing had semester
chips, but the course-card link and getGradebookCourse dropped the
selected term, so _resolve_enrollment(user, course, None) resolved the
CURRENT term — a course also taken in an archived term (rich seed:
CS101 in both fall-2025 and spring-2026) 404'd from that term's chip.
And the backend's GPA endpoints had no frontend consumer at all.
- CourseCard carries the selected term (?semester=<label>) on the card
href; the course screen reads it off location (same pattern as
Landing's deep-link param) and passes it through getGradebookCourse.
- GradebookSummary stops discarding the summary's gpa/semester; the
landing surfaces "Term GPA x.xx" next to the chips
(gradebook-term-gpa, hidden while null).
- New TranscriptModal (gradebook-transcript-open) over the previously
unconsumed GET /api/gradebook/gpa: cumulative GPA
(gradebook-transcript-gpa) + per-semester sections, in-progress rows
listed but excluded from the math. Load failure toasts + inline
retry (#463 pattern).
- New pure lib/transcript.ts (buildTranscript/weightedGpa) mirroring
backend gradebook_service.weighted_gpa exactly (null grade_points
skipped, null/zero credits count as 1, empty -> null); term ordering
reuses lib/semesters (new compareTermLabels export).
- Testid surface `gradebook` registered in docs/frontend-testids.md +
the four owning files joined the eslint no-restricted-syntax block;
suppressions baseline regenerated for the pre-existing untagged
elements (Course.tsx 9, Landing.tsx 1).
- e2e/gradebook.spec.ts (authored, not run here): the promoted #139
regression journey (DB-truth precondition via queryRaw, then both
chips' CS101 cards resolve their own term's categories) + the
transcript journey.
- vitest: lib/transcript.test.ts, TranscriptModal.test.tsx (dialog
contract + loading/error/retry), Course.semester.test.tsx (param
plumbing), Landing.test.tsx extended (term GPA, real CourseCard href
under test via a next/link stub).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…lay (#468 review)
Closes the three findings from the #468 5-agent review of the #139 work.
1 (major) — read/write term split: getGradebookCourse was semester-aware
but every course-keyed mutation from the Course screen still resolved
term-blind, so editing a Fall 2025 CS101 page wrote into the Spring 2026
enrollment silently (backend _resolve_enrollment(course, None) = current
term). The backend body models already accept `semester`; now the
frontend sends it: optional `semester` on createCategory,
bulkUpdateCategories, createGradedAssignment, setLetterScale and
setCurveSettings in lib/api.ts (body field, JSON.stringify drops it when
unset), passed at every Course.tsx call site (curve toggle + curve
settings + weights + create-assignment + letter scale). The id-keyed
calls (deleteCategory, update/deleteGradedAssignment) resolve by row
ownership — verified against routes/gradebook.py — and stay as-is.
- Course.semester.test.tsx: 4 new tests drive the REAL
EditWeightsModal/AssignmentModal down to Save and assert
bulkUpdateCategories/createGradedAssignment get the URL's semester
when ?semester= is set, and undefined when absent.
- e2e/gradebook.spec.ts: the Fall leg now also creates an assignment
through the UI and queryRaw-polls that the new assignments row hangs
off rich-enr-active-cs101-f25, not the spring enrollment. New testids
gradebook-add-assignment / gradebook-assignment-title /
gradebook-assignment-save; AssignmentList.tsx + AssignmentModal.tsx
joined the eslint enforcement array (pre-existing untagged elements
baselined: 7 + 12) and the docs inventory.
2 — transcript credits display: the modal showed `credits ?? 1` while
the GPA math treated 0/negative as 1. New effectiveCredits() export in
lib/transcript.ts is now the ONE place the rule lives, used by both
weightedGpa and the "N cr" display; tests cover 0/negative/null and the
rendered "1 cr" for a zero-credit row.
3 — ?semester= is read at FETCH time: the mount-frozen useState
initializer became currentSemesterParam(), read inside refresh AND
inside every mutation callback (keeps the no-Suspense plain-location
pattern).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ing contract (ToastProvider stub, getGpa mock, summary gpa/semester fields)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
frontend/src/components/screens/Gradebook/Landing.tsx (1)

159-184: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Guard against stale getGradebookSummary responses on rapid term switches.

Switching selected quickly fires overlapping requests with no sequencing; a slower response for an earlier term can resolve after a newer one and overwrite courses/termGpa with stale data for the wrong term. This affects the newly-added termGpa display as well.

♻️ Proposed fix: ignore stale responses
 React.useEffect(() => {
if (!termsReady) return;
+ let cancelled = false;
if (!selected) {
setCourses([]);
setTermGpa(null);
setLoading(false);
return;
}
if (!userId) {
setCourses(SAMPLE_COURSES[selected] ?? []);
setTermGpa(null);
setLoading(false);
return;
}
setLoading(true);
getGradebookSummary(userId, selected)
.then((res) => {
+ if (cancelled) return;
setCourses(res.courses.length ? res.courses : []);
setTermGpa(res.gpa ?? null);
})
.catch(() => {
+ if (cancelled) return;
setCourses([]);
setTermGpa(null);
})
- .finally(() => setLoading(false));+ .finally(() => { if (!cancelled) setLoading(false); });+ return () => { cancelled = true; };
}, [userId, selected, termsReady]);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@frontend/src/components/screens/Gradebook/Landing.tsx` around lines 159 -
184, Update the useEffect that calls getGradebookSummary to ignore responses
from requests started for an earlier selected term. Track the active request or
selected-term identity and only apply courses, termGpa, error, and loading
updates when the response belongs to the current request; preserve the existing
reset, sample-course, and cleanup behavior.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@frontend/src/components/screens/Gradebook/Course.tsx`:
- Around line 98-109: Make the semester selection reactive in
GradebookCourseScreen instead of relying only on currentSemesterParam() at call
time: derive it from a reactive URL/search-parameter source, use that same value
for reads and course-keyed mutations, and include it in the relevant fetch/mount
dependencies. When the semester changes for the same courseId, clear or reload
the existing course state so stale term data is not displayed.
---
Nitpick comments:
In `@frontend/src/components/screens/Gradebook/Landing.tsx`:
- Around line 159-184: Update the useEffect that calls getGradebookSummary to
ignore responses from requests started for an earlier selected term. Track the
active request or selected-term identity and only apply courses, termGpa, error,
and loading updates when the response belongs to the current request; preserve
the existing reset, sample-course, and cleanup behavior.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: d910b0c7-0c93-4b8c-b3cc-b86db7f13777

📥 Commits

Reviewing files that changed from the base of the PR and between d0cd993 and 1831442.

📒 Files selected for processing (19)
  • docs/frontend-testids.md
  • frontend/e2e/gradebook.spec.ts
  • frontend/eslint-suppressions.json
  • frontend/eslint.config.mjs
  • frontend/src/components/Gradebook/AssignmentList.tsx
  • frontend/src/components/Gradebook/AssignmentModal.tsx
  • frontend/src/components/Gradebook/CourseCard.tsx
  • frontend/src/components/Gradebook/TranscriptModal.test.tsx
  • frontend/src/components/Gradebook/TranscriptModal.tsx
  • frontend/src/components/screens/Gradebook/Course.semester.test.tsx
  • frontend/src/components/screens/Gradebook/Course.tsx
  • frontend/src/components/screens/Gradebook/Landing.test.tsx
  • frontend/src/components/screens/Gradebook/Landing.testmode.test.tsx
  • frontend/src/components/screens/Gradebook/Landing.tsx
  • frontend/src/lib/api.ts
  • frontend/src/lib/semesters.ts
  • frontend/src/lib/transcript.test.ts
  • frontend/src/lib/transcript.ts
  • frontend/src/lib/types.ts

Comment on lines +98 to +109
// /gradebook/<id>?semester=<label> — the landing's cards carry the selected
// term so a course enrolled in several terms resolves to that term's
// enrollment instead of 404ing off the current one (#139). Read straight off
// location (useSearchParams() would need a Suspense boundary in the route
// shell, which this screen doesn't own), and read it at CALL time — every
// fetch AND every course-keyed mutation below must see the URL the user is
// actually on, not a mount-frozen copy. Writes that drop the term would
// silently land on the CURRENT term's enrollment (#468 review).
function currentSemesterParam(): string | undefined {
if (typeof window === "undefined") return undefined;
return new URLSearchParams(window.location.search).get("semester") || undefined;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bashset -euo pipefail
echo"Locate file"
git ls-files | rg 'frontend/src/components/screens/Gradebook/Course\.tsx$|Course\.tsx$'||trueechoecho"Outline"
ast-grep outline frontend/src/components/screens/Gradebook/Course.tsx --view compact ||trueechoecho"Relevant sections"
sed -n '1,320p' frontend/src/components/screens/Gradebook/Course.tsx
echo"---"
sed -n '430,720p' frontend/src/components/screens/Gradebook/Course.tsx
echoecho"Search for currentSemesterParam and refresh dependencies"
rg -n "currentSemesterParam|useEffect|currentSemester|semester|CourseEnrollment|student|record|mutation|useMutation|refresh" frontend/src/components/screens/Gradebook/Course.tsx

Repository: SaplingLearn/Sapling

Length of output: 25705


🏁 Script executed:

#!/bin/bashset -euo pipefail
echo"Inspect API helpers and mutation signatures"
fd -a 'api\.(ts|tsx|js)$|types\.(ts|tsx|js)$' frontend/src | sed 's#^./##'echo
rg -n "export async function getGradebookCourse|const getGradebookCourse|function getGradebookCourse|getGradebookCourse" frontend/src/lib frontend/src -g '*.ts' -g '*.tsx'echo
sed -n '1,220p' frontend/src/lib/api.ts
echo"---"
rg -n "createGradedAssignment|updateGradedAssignment|deleteGradedAssignment|bulkUpdateCategories|setLetterScale|setCurveSettings" frontend/src/lib/api.ts

Repository: SaplingLearn/Sapling

Length of output: 9818


🏁 Script executed:

#!/bin/bashset -euo pipefail
echo"API route/path parameters"
sed -n '1160,1300p' frontend/src/lib/api.ts
echoecho"GradebookCourseScreen effects/mutations around line 220-245"
sed -n '220,245p' frontend/src/components/screens/Gradebook/Course.tsx
echoecho"Semester-specific tests"
sed -n '1,180p' frontend/src/components/screens/Gradebook/Course.semester.test.tsx

Repository: SaplingLearn/Sapling

Length of output: 13171


🏁 Script executed:

#!/bin/bashset -euo pipefail
echo"Find gradebook route files"
git ls-files | rg 'frontend/src/(app|routes|pages).*(gradebook|gradebook/.*course|gradebook|Course.page|page\.(tsx|ts|js))'| head -80
echoecho"Search route declarations for /gradebook/[id]"
rg -n 'gradebook/.*/course|gradebook/\[|gradebook/\\\[|courseId|GradebookCourseScreen|semester=' frontend/src -g '*.tsx' -g '*.ts'echo
git ls-files 'frontend/src/app/**/page.tsx''frontend/src/pages/**'| xargs rg -n "courseId|GradebookCourseScreen|semester="||true

Repository: SaplingLearn/Sapling

Length of output: 24931


Make the semester param a reactive dependency before relying on it.

For the same course ID, navigating /gradebook/c1?semester=Fall+2025 to /gradebook/c1?semester=Spring+2026 reuses GradebookCourseScreen because query changes don’t trigger the existing courseId-only fetch/mount effect. That leaves Fall grade state on screen while course-keyed mutations send Spring to the backend. Derive the selected term reactively (read and write with the same value) and reload/clear course data when it changes.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@frontend/src/components/screens/Gradebook/Course.tsx` around lines 98 - 109,
Make the semester selection reactive in GradebookCourseScreen instead of relying
only on currentSemesterParam() at call time: derive it from a reactive
URL/search-parameter source, use that same value for reads and course-keyed
mutations, and include it in the relevant fetch/mount dependencies. When the
semester changes for the same courseId, clear or reload the existing course
state so stale term data is not displayed.

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[P2] Semesters: gradebook UI — term switcher + transcript / cumulative GPA

1 participant

@AndresL230
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

feat(gradebook): term-aware course links + transcript & GPA (B6: #139) - #468

Merged
AndresL230 merged 3 commits into
mainfrom
b6-semesters
Jul 30, 2026
Merged

feat(gradebook): term-aware course links + transcript & GPA (B6: #139)#468
AndresL230 merged 3 commits into
mainfrom
b6-semesters

Conversation

@AndresL230

@AndresL230AndresL230 commented Jul 30, 2026

Copy link
Copy Markdown
Collaborator

Bundle B6 — Semesters completion (#139)

Closes#139.

The issue body predates the terms/offerings redesign; built against shipped reality (epic #142's model is stale). The Landing term switcher already existed (#140); what this PR adds:

The bug fix (term switcher half)

  • Course cards now carry ?semester=<label> and getGradebookCourse passes it through — fixing a live 404: with multiple offerings of one course (seeded CS101 is in fall-2025 AND spring-2026) and no term hint, _resolve_enrollment resolves the current term and 404s ("Course not in your gradebook") from either chip. Course.tsx reads the param exactly like Landing's deep-link handling.

Transcript / GPA half

  • GradebookSummary stops discarding the gpa/semester fields the backend already returns; Landing shows "Term GPA" (gradebook-term-gpa).
  • New TranscriptModal (gradebook-transcript-open) consuming the existing-but-unconsumed GET /api/gradebook/gpa: cumulative GPA + per-semester sections with course rows (letter or "in progress", credits). Per-semester GPA computed in a pure lib/transcript.ts that mirrors gradebook_service.weighted_gpa exactly (lock-step comment; null grade_points listed-but-excluded, null credits → 1).
  • Error path follows the fix(frontend): surface the five swallowed failures — closes the #113 audit tail (#166 #185 #184 #183 #186) #463 pattern: toast + inline retry, dialog stays open.

Surface registration + journey

  • docs/frontend-testids.md gains the gradebook surface; the four owning files joined the eslint testid enforcement block; eslint-suppressions.json regenerated for the pre-existing untagged elements (delta: Course.tsx 9, Landing.tsx 1).
  • Promoted journey e2e/gradebook.spec.ts (first gradebook journey): (a) the multi-term regression with a DB-truth precondition via queryRaw (asserts the two CS101 enrollments' term ids), Fall→"Exams" / Spring→"Projects" discriminators from the rich seed; (b) transcript open → GPA + both term sections.

Gates

Note: sibling issue #141 got a corrected-state STOP-note instead of code (design conflict with the shipped #360 contract + file collision with #465/B5) — see the issue comment.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • View GPA for the selected academic term directly from the Gradebook.
    • Open a transcript showing cumulative GPA, semester GPAs, course grades, credits, and in-progress courses.
    • Gradebook course links and actions now remain scoped to the selected term.
  • Bug Fixes

    • Fixed term selection being lost when opening courses.
    • Fixed new assignments and grading updates being saved to the wrong enrollment.
    • Added retry handling when transcript data fails to load.

@supabase

supabaseBot commented Jul 30, 2026

Copy link
Copy Markdown

This pull request has been ignored for the connected project ybgqdonkoqftwrmweuyv because there are no changes detected in supabase directory. You can change this behaviour in Project Integrations Settings ↗︎.


Preview Branches by Supabase.
Learn more about Supabase Branching ↗︎.

@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented Jul 30, 2026

Copy link
Copy Markdown

Deploying with Cloudflare Workers Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

StatusNameLatest CommitPreview URLUpdated (UTC)
✅ Deployment successful!
View logs
frontend-staging1831442Commit Preview URL

Branch Preview URL
Jul 30 2026, 07:51 AM

@coderabbitai

coderabbitaiBot commented Jul 30, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Gradebook now supports semester-scoped course loading and mutations, displays term and cumulative GPA transcript data, preserves selected terms in course links, adds stable E2E test IDs, and includes unit, component, and browser-level coverage.

Changes

Gradebook semester and transcript flow

Layer / File(s)Summary
GPA contracts and transcript calculations
frontend/src/lib/types.ts, frontend/src/lib/semesters.ts, frontend/src/lib/transcript.ts, frontend/src/lib/*test.ts
Adds GPA response types, term ordering, transcript grouping, effective-credit normalization, weighted GPA calculation, and corresponding tests.
Semester-scoped API and course operations
frontend/src/lib/api.ts, frontend/src/components/screens/Gradebook/Course.tsx, frontend/src/components/screens/Gradebook/Course.semester.test.tsx
Passes the URL-selected semester through gradebook reads and mutations, with tests covering present and absent semester parameters.
Landing and transcript UI
frontend/src/components/Gradebook/CourseCard.tsx, frontend/src/components/Gradebook/TranscriptModal.tsx, frontend/src/components/screens/Gradebook/Landing.tsx, frontend/src/components/screens/Gradebook/*test.tsx
Displays term GPA, preserves semester course links, opens a cumulative transcript modal, and covers loading, failure, retry, and interaction behavior.
E2E hooks and Gradebook validation
frontend/src/components/Gradebook/Assignment*.tsx, frontend/eslint*, docs/frontend-testids.md, frontend/e2e/gradebook.spec.ts
Adds Gradebook test IDs, configures enforcement and suppressions, documents the inventory, and validates term-specific navigation, assignment persistence, and transcript sections.

Estimated code review effort: 4 (Complex) | ~60 minutes

Possibly related issues

  • SaplingLearn/Sapling issue 142 — Covers the same Gradebook term switcher, per-semester GPA, and cumulative transcript objectives.

Possibly related PRs

Suggested reviewers:jose-gael-cruz-lopez, darkest-teddy

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check nameStatusExplanationResolution
Docstring Coverage⚠️ WarningDocstring coverage is 47.37% which is insufficient. The required threshold is 80.00%.Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check nameStatusExplanation
Title check✅ PassedThe title clearly summarizes the gradebook term-link and transcript/GPA additions.
Description check✅ PassedThe description covers the main changes, related issue, testing gates, and reviewer notes, though it doesn't use the exact template headings.
Linked Issues check✅ PassedThe PR implements term-scoped gradebook fetches, transcript/cumulative GPA UI, and error/loading behavior required by #139.
Out of Scope Changes check✅ PassedThe changes stay focused on gradebook semester support, test coverage, and related enforcement/docs updates.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch b6-semesters

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@AndresL230

Copy link
Copy Markdown
CollaboratorAuthor

Code review

Found 1 issue:

  1. Read/write term split: the PR makes the course-page read term-aware (getGradebookCourse(userId, courseId, semester) from ?semester=), which for the first time surfaces an archived-term enrollment — but every mutation from that screen (bulkUpdateCategories, createGradedAssignment, setLetterScale, setCurveSettings) still calls the API without semester, and _resolve_enrollment(course, None) resolves the CURRENT term. Viewing Fall 2025 CS101 (seeded in both fall-2025 and spring-2026; e2e clock 2026-03-11 → spring is current) and saving weights/an assignment/scale/curve silently writes into the Spring enrollment with a 200. The backend bodies (BulkUpdateCategoriesBody, CreateAssignmentBody, SetLetterScaleBody, SetCurveBody) already accept semester and pass it into _resolve_enrollment — the frontend just never sends it (bug due to Course.tsx mutation call sites omitting the term the screen is scoped to)

}
awaitbulkUpdateCategories(userId,courseId,drafts);
awaitrefresh();
}}
/>
<AssignmentModal
open={assignModal.open}
initial={assignModal.initial}
categories={data.categories}
onClose={()=>setAssignModal({open: false,initial: null})}
onSave={async(draft: AssignmentDraft)=>{
if(assignModal.initial){
awaitupdateGradedAssignment(userId,assignModal.initial.id,draft);
}else{
awaitcreateGradedAssignment(userId,courseId,draft);
}
awaitrefresh();
}}
onDelete={
assignModal.initial
? async()=>{
awaitdeleteGradedAssignment(userId,assignModal.initial!.id);
awaitrefresh();
}
: null
}
/>
<LetterScaleEditor
open={editScale}
initial={data.letter_scale}
onClose={()=>setEditScale(false)}
onSave={async(scale)=>{
awaitsetLetterScale(userId,courseId,scale);
awaitrefresh();
}}

Sub-threshold notes (verified, fixed in a follow-up commit, scores <80): transcript rows render {credits ?? 1} cr while the GPA math treats 0/negative as 1 (60); requestedSemester is mount-frozen per the #368 no-Suspense precedent — made fetch-time instead (35); the promoted journey still needs its pre-merge stack run — queued in this session's flock'd cycle (25).

🤖 Generated with Claude Code

- If this code review was useful, please react with 👍. Otherwise, react with 👎.

AndresL230and others added 3 commits July 30, 2026 00:47
The gradebook's term switcher was half-wired: the landing had semester
chips, but the course-card link and getGradebookCourse dropped the
selected term, so _resolve_enrollment(user, course, None) resolved the
CURRENT term — a course also taken in an archived term (rich seed:
CS101 in both fall-2025 and spring-2026) 404'd from that term's chip.
And the backend's GPA endpoints had no frontend consumer at all.
- CourseCard carries the selected term (?semester=<label>) on the card
href; the course screen reads it off location (same pattern as
Landing's deep-link param) and passes it through getGradebookCourse.
- GradebookSummary stops discarding the summary's gpa/semester; the
landing surfaces "Term GPA x.xx" next to the chips
(gradebook-term-gpa, hidden while null).
- New TranscriptModal (gradebook-transcript-open) over the previously
unconsumed GET /api/gradebook/gpa: cumulative GPA
(gradebook-transcript-gpa) + per-semester sections, in-progress rows
listed but excluded from the math. Load failure toasts + inline
retry (#463 pattern).
- New pure lib/transcript.ts (buildTranscript/weightedGpa) mirroring
backend gradebook_service.weighted_gpa exactly (null grade_points
skipped, null/zero credits count as 1, empty -> null); term ordering
reuses lib/semesters (new compareTermLabels export).
- Testid surface `gradebook` registered in docs/frontend-testids.md +
the four owning files joined the eslint no-restricted-syntax block;
suppressions baseline regenerated for the pre-existing untagged
elements (Course.tsx 9, Landing.tsx 1).
- e2e/gradebook.spec.ts (authored, not run here): the promoted #139
regression journey (DB-truth precondition via queryRaw, then both
chips' CS101 cards resolve their own term's categories) + the
transcript journey.
- vitest: lib/transcript.test.ts, TranscriptModal.test.tsx (dialog
contract + loading/error/retry), Course.semester.test.tsx (param
plumbing), Landing.test.tsx extended (term GPA, real CourseCard href
under test via a next/link stub).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…lay (#468 review)
Closes the three findings from the #468 5-agent review of the #139 work.
1 (major) — read/write term split: getGradebookCourse was semester-aware
but every course-keyed mutation from the Course screen still resolved
term-blind, so editing a Fall 2025 CS101 page wrote into the Spring 2026
enrollment silently (backend _resolve_enrollment(course, None) = current
term). The backend body models already accept `semester`; now the
frontend sends it: optional `semester` on createCategory,
bulkUpdateCategories, createGradedAssignment, setLetterScale and
setCurveSettings in lib/api.ts (body field, JSON.stringify drops it when
unset), passed at every Course.tsx call site (curve toggle + curve
settings + weights + create-assignment + letter scale). The id-keyed
calls (deleteCategory, update/deleteGradedAssignment) resolve by row
ownership — verified against routes/gradebook.py — and stay as-is.
- Course.semester.test.tsx: 4 new tests drive the REAL
EditWeightsModal/AssignmentModal down to Save and assert
bulkUpdateCategories/createGradedAssignment get the URL's semester
when ?semester= is set, and undefined when absent.
- e2e/gradebook.spec.ts: the Fall leg now also creates an assignment
through the UI and queryRaw-polls that the new assignments row hangs
off rich-enr-active-cs101-f25, not the spring enrollment. New testids
gradebook-add-assignment / gradebook-assignment-title /
gradebook-assignment-save; AssignmentList.tsx + AssignmentModal.tsx
joined the eslint enforcement array (pre-existing untagged elements
baselined: 7 + 12) and the docs inventory.
2 — transcript credits display: the modal showed `credits ?? 1` while
the GPA math treated 0/negative as 1. New effectiveCredits() export in
lib/transcript.ts is now the ONE place the rule lives, used by both
weightedGpa and the "N cr" display; tests cover 0/negative/null and the
rendered "1 cr" for a zero-credit row.
3 — ?semester= is read at FETCH time: the mount-frozen useState
initializer became currentSemesterParam(), read inside refresh AND
inside every mutation callback (keeps the no-Suspense plain-location
pattern).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ing contract (ToastProvider stub, getGpa mock, summary gpa/semester fields)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
frontend/src/components/screens/Gradebook/Landing.tsx (1)

159-184: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Guard against stale getGradebookSummary responses on rapid term switches.

Switching selected quickly fires overlapping requests with no sequencing; a slower response for an earlier term can resolve after a newer one and overwrite courses/termGpa with stale data for the wrong term. This affects the newly-added termGpa display as well.

♻️ Proposed fix: ignore stale responses
 React.useEffect(() => {
if (!termsReady) return;
+ let cancelled = false;
if (!selected) {
setCourses([]);
setTermGpa(null);
setLoading(false);
return;
}
if (!userId) {
setCourses(SAMPLE_COURSES[selected] ?? []);
setTermGpa(null);
setLoading(false);
return;
}
setLoading(true);
getGradebookSummary(userId, selected)
.then((res) => {
+ if (cancelled) return;
setCourses(res.courses.length ? res.courses : []);
setTermGpa(res.gpa ?? null);
})
.catch(() => {
+ if (cancelled) return;
setCourses([]);
setTermGpa(null);
})
- .finally(() => setLoading(false));+ .finally(() => { if (!cancelled) setLoading(false); });+ return () => { cancelled = true; };
}, [userId, selected, termsReady]);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@frontend/src/components/screens/Gradebook/Landing.tsx` around lines 159 -
184, Update the useEffect that calls getGradebookSummary to ignore responses
from requests started for an earlier selected term. Track the active request or
selected-term identity and only apply courses, termGpa, error, and loading
updates when the response belongs to the current request; preserve the existing
reset, sample-course, and cleanup behavior.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@frontend/src/components/screens/Gradebook/Course.tsx`:
- Around line 98-109: Make the semester selection reactive in
GradebookCourseScreen instead of relying only on currentSemesterParam() at call
time: derive it from a reactive URL/search-parameter source, use that same value
for reads and course-keyed mutations, and include it in the relevant fetch/mount
dependencies. When the semester changes for the same courseId, clear or reload
the existing course state so stale term data is not displayed.
---
Nitpick comments:
In `@frontend/src/components/screens/Gradebook/Landing.tsx`:
- Around line 159-184: Update the useEffect that calls getGradebookSummary to
ignore responses from requests started for an earlier selected term. Track the
active request or selected-term identity and only apply courses, termGpa, error,
and loading updates when the response belongs to the current request; preserve
the existing reset, sample-course, and cleanup behavior.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: d910b0c7-0c93-4b8c-b3cc-b86db7f13777

📥 Commits

Reviewing files that changed from the base of the PR and between d0cd993 and 1831442.

📒 Files selected for processing (19)
  • docs/frontend-testids.md
  • frontend/e2e/gradebook.spec.ts
  • frontend/eslint-suppressions.json
  • frontend/eslint.config.mjs
  • frontend/src/components/Gradebook/AssignmentList.tsx
  • frontend/src/components/Gradebook/AssignmentModal.tsx
  • frontend/src/components/Gradebook/CourseCard.tsx
  • frontend/src/components/Gradebook/TranscriptModal.test.tsx
  • frontend/src/components/Gradebook/TranscriptModal.tsx
  • frontend/src/components/screens/Gradebook/Course.semester.test.tsx
  • frontend/src/components/screens/Gradebook/Course.tsx
  • frontend/src/components/screens/Gradebook/Landing.test.tsx
  • frontend/src/components/screens/Gradebook/Landing.testmode.test.tsx
  • frontend/src/components/screens/Gradebook/Landing.tsx
  • frontend/src/lib/api.ts
  • frontend/src/lib/semesters.ts
  • frontend/src/lib/transcript.test.ts
  • frontend/src/lib/transcript.ts
  • frontend/src/lib/types.ts

Comment on lines +98 to +109
// /gradebook/<id>?semester=<label> — the landing's cards carry the selected
// term so a course enrolled in several terms resolves to that term's
// enrollment instead of 404ing off the current one (#139). Read straight off
// location (useSearchParams() would need a Suspense boundary in the route
// shell, which this screen doesn't own), and read it at CALL time — every
// fetch AND every course-keyed mutation below must see the URL the user is
// actually on, not a mount-frozen copy. Writes that drop the term would
// silently land on the CURRENT term's enrollment (#468 review).
function currentSemesterParam(): string | undefined {
if (typeof window === "undefined") return undefined;
return new URLSearchParams(window.location.search).get("semester") || undefined;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bashset -euo pipefail
echo"Locate file"
git ls-files | rg 'frontend/src/components/screens/Gradebook/Course\.tsx$|Course\.tsx$'||trueechoecho"Outline"
ast-grep outline frontend/src/components/screens/Gradebook/Course.tsx --view compact ||trueechoecho"Relevant sections"
sed -n '1,320p' frontend/src/components/screens/Gradebook/Course.tsx
echo"---"
sed -n '430,720p' frontend/src/components/screens/Gradebook/Course.tsx
echoecho"Search for currentSemesterParam and refresh dependencies"
rg -n "currentSemesterParam|useEffect|currentSemester|semester|CourseEnrollment|student|record|mutation|useMutation|refresh" frontend/src/components/screens/Gradebook/Course.tsx

Repository: SaplingLearn/Sapling

Length of output: 25705


🏁 Script executed:

#!/bin/bashset -euo pipefail
echo"Inspect API helpers and mutation signatures"
fd -a 'api\.(ts|tsx|js)$|types\.(ts|tsx|js)$' frontend/src | sed 's#^./##'echo
rg -n "export async function getGradebookCourse|const getGradebookCourse|function getGradebookCourse|getGradebookCourse" frontend/src/lib frontend/src -g '*.ts' -g '*.tsx'echo
sed -n '1,220p' frontend/src/lib/api.ts
echo"---"
rg -n "createGradedAssignment|updateGradedAssignment|deleteGradedAssignment|bulkUpdateCategories|setLetterScale|setCurveSettings" frontend/src/lib/api.ts

Repository: SaplingLearn/Sapling

Length of output: 9818


🏁 Script executed:

#!/bin/bashset -euo pipefail
echo"API route/path parameters"
sed -n '1160,1300p' frontend/src/lib/api.ts
echoecho"GradebookCourseScreen effects/mutations around line 220-245"
sed -n '220,245p' frontend/src/components/screens/Gradebook/Course.tsx
echoecho"Semester-specific tests"
sed -n '1,180p' frontend/src/components/screens/Gradebook/Course.semester.test.tsx

Repository: SaplingLearn/Sapling

Length of output: 13171


🏁 Script executed:

#!/bin/bashset -euo pipefail
echo"Find gradebook route files"
git ls-files | rg 'frontend/src/(app|routes|pages).*(gradebook|gradebook/.*course|gradebook|Course.page|page\.(tsx|ts|js))'| head -80
echoecho"Search route declarations for /gradebook/[id]"
rg -n 'gradebook/.*/course|gradebook/\[|gradebook/\\\[|courseId|GradebookCourseScreen|semester=' frontend/src -g '*.tsx' -g '*.ts'echo
git ls-files 'frontend/src/app/**/page.tsx''frontend/src/pages/**'| xargs rg -n "courseId|GradebookCourseScreen|semester="||true

Repository: SaplingLearn/Sapling

Length of output: 24931


Make the semester param a reactive dependency before relying on it.

For the same course ID, navigating /gradebook/c1?semester=Fall+2025 to /gradebook/c1?semester=Spring+2026 reuses GradebookCourseScreen because query changes don’t trigger the existing courseId-only fetch/mount effect. That leaves Fall grade state on screen while course-keyed mutations send Spring to the backend. Derive the selected term reactively (read and write with the same value) and reload/clear course data when it changes.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@frontend/src/components/screens/Gradebook/Course.tsx` around lines 98 - 109,
Make the semester selection reactive in GradebookCourseScreen instead of relying
only on currentSemesterParam() at call time: derive it from a reactive
URL/search-parameter source, use that same value for reads and course-keyed
mutations, and include it in the relevant fetch/mount dependencies. When the
semester changes for the same courseId, clear or reload the existing course
state so stale term data is not displayed.

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[P2] Semesters: gradebook UI — term switcher + transcript / cumulative GPA

1 participant

@AndresL230