Skip to content

Gradebook Feature - #241

Merged
Darkest-Teddy merged 53 commits into
mainfrom
Gradebook
Jun 28, 2026
Merged

Gradebook Feature#241
Darkest-Teddy merged 53 commits into
mainfrom
Gradebook

Conversation

@Darkest-Teddy

@Darkest-TeddyDarkest-Teddy commented Jun 18, 2026

Copy link
Copy Markdown
Collaborator

Description

Major Gradebook feature release. Adds a bell curve grading system, a grade predictor panel, category tab navigation, and a large set of UI polish across the course page.

Changes Made

  • Bell curve grading: apply_curve backend function, per-assignment class stats (entered via assignment modal), course-level curve policy (avg target + SD delta), Raw/Curved toggle persisted per-course, curved score chip on assignment rows
  • Grade predictor panel: expandable panel below composition bar with per-assignment hypothetical sliders, Raw/Curved toggle inside predictor, class stats override for curve computation
  • Grade projector & composition bar: instant update on Raw/Curved toggle, isPredicted visual mode, computeCurrentGrade mirrors backend logic exactly
  • Letter scale fix: frontend DEFAULT_SCALE updated to match backend full 12-tier A+/A-/B+ scale; floating-point boundary fix (round to 1dp before comparisons)
  • Category navigation: horizontal tab strip above assignment list with hover states and assignment counts
  • Assignment rows: hover highlight extends full width, clean straight dividers, Dropped/Curved chips inline with title, tightened fraction display (30/34 format)
  • Category colors: 10 distinct hues evenly spaced around the hue wheel, no repeats
  • Auth fix: SECURE_COOKIES env flag, SESSION_SECRET wired to frontend, secure=false on localhost for OAuth cookies
  • Drag-to-reorder: categories in Edit Weights modal support drag-and-drop reordering
  • Drop policy: renamed "drops x/x" → "N Drops"
  • Course name: no longer truncated with ellipsis

Related Issues

Closes #

Testing

  • Tested locally
  • Added/updated tests

Screenshots (if applicable)

Notes for Reviewers

Schema changes ship as ordered migrations (backend/db/migrations/00190021) applied by the migration runner (backend/db/migrate.py --apply, run from backend/) — no manual Supabase SQL-editor steps required. The runner is idempotent and records applied versions in schema_migrations. The migrations cover the bell-curve columns (assignments.curve_*, user_courses.curve_*), course_categories.drop_lowest, and the Gradescope tables/columns.

(The previous manual ALTER TABLE block here was stale — it omitted drop_lowest and the Gradescope schema — and has been removed in favor of the runner.)

Summary by CodeRabbit

  • New Features

    • Added Gradescope account connection, course linking, and sync support from Settings.
    • Added course-level curve settings and per-category “drop lowest” grading support.
    • Introduced grade prediction tools for ungraded assignments and curved projections.
    • Added a dedicated Connected Accounts page.
  • Bug Fixes

    • Improved cookie handling for login/session flows across secure and non-secure environments.
    • Refined grading calculations and grade display behavior for curved, dropped, and predicted scores.

Darkest-Teddyand others added 26 commits May 10, 2026 00:36
…n grid
Replace the gradient course cards with the V1b layout from the design
handoff: solid course-color band, course code top-left in JetBrains Mono,
Playfair letter grade bottom-left, and an overlapping-discs watermark.
Footer percentage is now color-graded green→amber→rust→rose→crimson.
Also:
- storage_service: recognize Supabase's HTTP 400 + statusCode:"409" body
as "bucket already exists" so startup stops warning on every restart.
- localData: stub /api/gradebook/summary so the landing page works under
NEXT_PUBLIC_LOCAL_MODE.
googleapiclient routes through httplib2, which ignores HTTPS_PROXY and
times out (WinError 10060) on dev machines and proxy-bound deployments.
Swap to a direct httpx GET and wrap in _fail_redirect("userinfo_fetch_failed")
to match the existing oauth_exchange_failed error pattern.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Course detail page restructure
- Collapse the masthead from a ~340px hero into a single identity row
(kicker + name + letter+percent pill; letter scale lives in the
pill's hover tooltip instead of burning vertical space).
- Replace the side-by-side DistributionStrip + GradeProjector pair
with a hero-scale GradeCompositionBar. Each category gets a
weight-proportional slot whose sub-segments encode earned (solid),
lost (faded), and still-reachable (hatched). Letter cutoffs overlay
the bar at 60/70/80/90, and a "Now" pin marks current %.
- Cursor-following tooltips on every sub-segment explain the math
behind that region (Earned 18.5 of 20 pts, contributing 18.50% to
final, etc.); the tooltip auto-flips when near viewport edges.
- Single-column page below the masthead — Masthead -> Composition ->
Assignments — so the assignment list (the actual data-entry surface)
sits above the fold instead of buried under stat duplication.
Drop-lowest grading policy
- New course_categories.drop_lowest column
(migration_gradebook_drops.sql, idempotent + non-negative CHECK).
- gradebook_service.category_grade drops the N lowest graded items by
earned/possible before averaging; new dropped_assignment_ids /
all_dropped_ids helpers expose which IDs are currently excluded per
category and across the whole course (returned on /courses/:id).
- projectGrade re-runs the same drop logic for the floor (ungraded ->
0) and ceiling (ungraded -> full) scenarios, so projection respects
drops in every direction; droppedAssignmentIds computes the same set
client-side so optimistic grade edits update immediately.
- EditWeightsModal gains a Drop column next to Weight.
- AssignmentList mutes dropped rows (55% opacity + strikethrough) with
an inline "dropped" chip, and each category header shows a
"drops X/N" progress chip when the policy is active.
Cosmetic
- Course-card orb watermark seeds via min-distance rejection sampling
(62px between disc centers) so the three discs never land
near-coincident.
Run backend/db/migration_gradebook_drops.sql in Supabase before
deploying — the new SELECT on course_categories includes drop_lowest
and PostgREST will 500 otherwise.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ring
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ve mode
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
… endpoint
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…eral for curve_mode
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ettingsModal to course page
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…improvements
- Fix auth cookies (SECURE_COOKIES flag, SESSION_SECRET wiring frontend+backend)
- Grade predictor panel with hypothetical scores and Raw/Curved toggle
- Bell curve grading: apply_curve function, per-assignment and course policy
- Category color palette expanded to 10 distinct hues
- Category tab navigation above assignment list with hover states
- Assignment row hover highlight extends left/right with clean dividers
- Dropped and Curved chips on assignment rows
- Fraction score display tightened (30/34 format)
- Letter scale fixed to match backend full A+/A-/B+ 12-tier default
- Float precision fix: round to 1dp before letter-scale comparisons
- Composition bar instant update on Raw/Curved toggle
- Drop policy renamed to N Drops
- Course name no longer truncated
@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented Jun 18, 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
frontend39e2197Commit Preview URL

Branch Preview URL
Jun 22 2026, 03:35 AM

@coderabbitai

coderabbitaiBot commented Jun 18, 2026

Copy link
Copy Markdown

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Adds bell-curve grading and per-category drop-lowest scoring to the gradebook service, routes, and UI. Introduces a Grade Predictor panel for hypothetical scoring. Integrates Gradescope sync via password, session-cookie, and BU SSO/Duo auth modes with credential storage and course-link management. Updates auth cookie security to be configurable and replaces Google userinfo fetching with httpx.

Changes

Gradebook Enhancements: Drop-Lowest, Bell Curve & Grade Predictor

Layer / File(s)Summary
DB migrations and shared type contracts
backend/db/migrations/0001_baseline_schema.sql, backend/db/migrations/0019_gradebook_drops.sql, backend/db/migrations/0021_gradebook_curve.sql, backend/models/__init__.py, frontend/src/lib/types.ts
Adds drop_lowest to course_categories, curve-policy fields to user_courses, and curve-stat columns to assignments; mirrors these in Pydantic models and frontend TypeScript interfaces.
Gradebook service calculations and tests
backend/services/gradebook_service.py, backend/tests/test_gradebook_service.py
Replaces category_grade with a drop-lowest-aware, bell-curve-aware implementation; adds apply_curve, dropped_assignment_ids, all_dropped_ids helpers; rounds letter_for to 4dp; adds unit tests for curve mapping and curved category-grade paths.
Gradebook route/query/write flow updates
backend/routes/gradebook.py, frontend/src/lib/api.ts, frontend/src/lib/localData.ts
Extends summary/course queries and CRUD writes for drop-lowest and curve fields; adds PATCH /courses/{course_id}/curve endpoint; updates frontend API types and local-mode handler for new payloads.
Frontend curve utilities and projector math
frontend/src/components/Gradebook/curveUtils.ts, frontend/src/components/Gradebook/categoryColor.ts, frontend/src/components/Gradebook/GradeProjector.tsx
Adds applyCurve/applyCurveToAssignment/hasCurveData utilities, deterministic categoryColor mapping, and the GradeProjector component with projectGrade/droppedAssignmentIds math.
Assignment and category editing/list UI
frontend/src/components/Gradebook/AssignmentModal.tsx, frontend/src/components/Gradebook/EditWeightsModal.tsx, frontend/src/components/Gradebook/AssignmentList.tsx
Adds bell-curve and due-date validation to AssignmentModal, adds drag-reorder and drop_lowest field to EditWeightsModal, and rewrites AssignmentList into grouped category sections with dropped/curved indicators.
Grade Predictor panel component
frontend/src/components/Gradebook/GradePredictorPanel.tsx
Adds expandable predictor panel with per-assignment slider/input rows, optional predictor curve-mode controls, and reset behavior wired to parent hypotheticals state.
Course cards, visuals, landing interactions
frontend/src/app/globals.css, frontend/src/components/Gradebook/AmbientOrbs.tsx, frontend/src/components/Gradebook/CourseCard.tsx, frontend/src/components/Gradebook/SemesterChips.tsx, frontend/src/components/Gradebook/SyllabusUploadFlow.tsx, frontend/src/components/ToastProvider.tsx, frontend/src/components/screens/Gradebook/Landing.tsx
Adds CSS layout/animation tokens, ambient orb overlay, deterministic SVG watermark course cards, keyboard-navigable landing grid with color maps, and accessibility/styling tweaks.
Course screen orchestration rewrite
frontend/src/components/screens/Gradebook/Course.tsx
Reworks the course page to orchestrate curve-settings modal, predictor state, Gradescope sync polling, optimistic grade editing, GradeCompositionBar with per-category earned/lost/remaining and tooltip, and loading/error skeletons.
Bell-curve and predictor docs
docs/superpowers/plans/2026-06-16-bell-curve-grading.md, docs/superpowers/plans/2026-06-16-grade-predictor.md, docs/superpowers/specs/2026-06-16-bell-curve-grading-design.md, docs/superpowers/specs/2026-06-16-grade-predictor-design.md
Adds design specs and implementation plans for bell-curve grading and grade predictor panel features.

Gradescope Sync Integration

Layer / File(s)Summary
Gradescope schema and constraints
backend/db/migrations/0001_baseline_schema.sql, backend/db/migrations/0020_gradescope.sql
Creates gradescope_credentials with auth-mode payload constraints, gradescope_course_links with uniqueness enforcement, and a partial unique index on assignments(course_id, gradescope_assignment_id).
Gradescope service and dependency wiring
backend/services/gradescope_service.py, backend/requirements.txt
Adds gated gradescopeapi/Playwright imports, custom exceptions, password and cookie-based login flows, gradebook data accessors, and a Playwright-driven BU SSO + Duo orchestration function.
Gradescope route surface, app wiring, and tests
backend/routes/gradescope.py, backend/main.py, frontend/src/lib/api.ts, backend/tests/test_gradescope.py
Implements all /api/gradescope endpoints (credentials, BU SSO, status, course listing, links, sync), mounts the router, adds frontend API exports, and adds backend tests for parsing, authorization, rate limiting, and per-user isolation.
Gradescope modal and connected-accounts settings UI
frontend/src/components/Gradebook/GradescopeSyncModal.tsx, frontend/src/components/screens/ConnectedAccounts.tsx, frontend/src/app/(shell)/settings/connections/page.tsx, frontend/src/components/screens/Settings.tsx
Adds multi-stage connection/sync modal (password/cookies/BU SSO), connected-accounts screen with connect/reconnect/disconnect flows, and settings sidebar navigation to the new connections page.

Auth Secure-Cookie and Storage Response Handling

Layer / File(s)Summary
Secure cookie configuration and OAuth/session updates
backend/config.py, backend/routes/auth.py, frontend/src/app/api/auth/session/route.ts
Adds SECURE_COOKIES config derived from env or HTTPS URL prefix, applies it to all OAuth state cookie set/clear paths, and switches frontend session cookie secure to NODE_ENV === 'production'; replaces Google userinfo fetch with httpx.
Storage duplicate-bucket detection helper
backend/services/storage_service.py
Adds _is_duplicate_bucket to detect HTTP 409 and HTTP 400 JSON duplicate signals from Supabase.

CI Lint and ESLint Suppression Cleanup

Layer / File(s)Summary
Suppressions pruning and lint command change
.github/workflows/ci.yml, frontend/eslint-suppressions.json
Removes stale react-hooks/set-state-in-effect and related suppressions across multiple files and changes lint invocation from --max-warnings -1 to default npx eslint ..

Sequence Diagram(s)

sequenceDiagram
participant User
participant GradescopeSyncModal
participant ConnectedAccounts
participant GradescopeRoute as /api/gradescope
participant GradescopeService
participant GradescopeAPI as Gradescope.com
User->>ConnectedAccounts: Click "Connect"
ConnectedAccounts->>GradescopeSyncModal: open modal
User->>GradescopeSyncModal: Choose BU SSO mode
GradescopeSyncModal->>GradescopeRoute: POST /credentials/bu-sso
GradescopeRoute->>GradescopeService: login_via_bu_sso (asyncio.to_thread)
GradescopeService->>GradescopeAPI: Playwright BU Shibboleth + Duo flow
GradescopeAPI-->>GradescopeService: session cookies
GradescopeService-->>GradescopeRoute: {_gradescope_session, signed_token}
GradescopeRoute-->>GradescopeSyncModal: 200 OK
User->>GradescopeSyncModal: Select course + Sync
GradescopeSyncModal->>GradescopeRoute: POST /sync/{sapling_course_id}
GradescopeRoute->>GradescopeService: list_assignments
GradescopeService->>GradescopeAPI: scrape assignments
GradescopeAPI-->>GradescopeService: assignment list
GradescopeService-->>GradescopeRoute: normalized assignments
GradescopeRoute-->>GradescopeSyncModal: SyncResult {inserted, updated, skipped, failed}
GradescopeSyncModal-->>ConnectedAccounts: onSynced()
Loading

Estimated code review effort

🎯 5 (Critical) | ⏱️ ~120 minutes

Possibly related issues

Possibly related PRs

  • SaplingLearn/Sapling#56: Both modify the Google OAuth /google/callback flow in backend/routes/auth.py, with overlapping cookie and redirect handling logic.
  • SaplingLearn/Sapling#279: Directly overlaps in bell-curve and drop-lowest implementation in backend/services/gradebook_service.py and backend/routes/gradebook.py.
  • SaplingLearn/Sapling#262: Both touch .github/workflows/ci.yml ESLint command syntax and frontend/eslint-suppressions.json pruning.

Poem

🐰 A bunny once studied with grades in a pile,
Curves and predictions stretched out a full mile.
Gradescope was synced with a Duo-approved hop,
Drop-lowest? No problem — just skip to the top!
Now cookies are secure and the orbs gently drift,
Each semester, a colorful gradebook uplift. 🌟

🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (1 warning, 1 inconclusive)

Check nameStatusExplanationResolution
Docstring Coverage⚠️ WarningDocstring coverage is 42.18% which is insufficient. The required threshold is 80.00%.Write docstrings for the functions missing them to satisfy the coverage threshold.
Title check❓ InconclusiveThe title is too vague to describe the main Gradebook changes.Use a concise title that names the primary change, such as "Add bell-curve grading and Gradescope sync to gradebook".
✅ Passed checks (3 passed)
Check nameStatusExplanation
Description check✅ PassedThe description covers the main feature areas and notes, with only minor template gaps like testing and related issues.
Linked Issues check✅ PassedCheck skipped because no linked issues were found for this pull request.
Out of Scope Changes check✅ PassedCheck skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch Gradebook

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.

Comment threadfrontend/src/components/screens/Gradebook/Landing.tsx Fixed
Comment threadfrontend/src/components/screens/Gradebook/Course.tsx Fixed
Comment threadbackend/services/gradescope_service.py Fixed
Comment threadbackend/services/gradescope_service.py Fixed
Comment threadbackend/tests/test_gradebook_service.py Fixed
import requests
from bs4 import BeautifulSoup

from gradescopeapi import DEFAULT_GRADESCOPE_BASE_URL

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🟠 A missing dependency takes down the whole app, not just this feature.requests, bs4, and gradescopeapi are imported unconditionally here (only Playwright is guarded below), and main.py:24 imports routes.gradescope unconditionally. If any of these newly-added deps isn't installed in a deploy target, import routes.gradescope raises ImportError during router mount and uvicorn never boots — all of /api goes down. Guard these imports the way Playwright already is, or make the router mount degrade gracefully.

Comment threadbackend/routes/gradescope.py Outdated
"id": str(uuid.uuid4()),
"user_id": user_id,
"course_id": sapling_course_id,
"category_id": None, # user can re-categorize later

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🟠 Synced grades never affect the computed grade. Inserted assignments get category_id=None, but current_grade() only buckets assignments whose category_id matches a known category — so freshly-synced grades contribute nothing to the percent until the user manually categorizes each one. A successful sync visibly changes the grade by zero, which reads as a broken sync.

graded.append((float(e) / float(p), float(p), str(aid)))
if not graded:
return []
graded.sort(key=lambda x: (x[0], -x[1], x[2]))

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🟠 Drop-lowest is computed on RAW scores, but the curved display drops by CURVED score.dropped_assignment_ids ranks by raw earned/possible, while the frontend projectGrade/curved path drops based on curved values and the 'dropped' badge (droppedAssignmentIds(..., data.assignments)) uses raw. Under a curve that reorders scores, the server and UI exclude different assignments, and the badge can mark one assignment dropped while the math drops another.

by_cat[cid].append(a)
for c in cats:
c["category_grade"] = gradebook_service.category_grade(by_cat[c["id"]])
c["category_grade"] = gradebook_service.category_grade(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🟠 Per-category grade is always raw, even for curved courses. This call passes no curve args (defaults to curve_mode='raw'), while the overall percent just below (:155) is curved. The per-category numbers shown to the student won't reconcile with the curved headline grade.

Comment threadbackend/config.py
FRONTEND_URL = os.getenv("FRONTEND_URL", "http://localhost:3000")
SESSION_SECRET = os.getenv("SESSION_SECRET", "")
_sc_env = os.getenv("SECURE_COOKIES")
SECURE_COOKIES: bool = _sc_env.lower() == "true" if _sc_env is not None else FRONTEND_URL.startswith("https://")

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🟠 OAuth cookie Secure flag is no longer fail-closed.origin/main hardcoded secure=True; now it's secure=SECURE_COOKIES, which defaults to FRONTEND_URL.startswith('https://') when the env var is unset — and FRONTEND_URL itself defaults to http://localhost:3000. A TLS-fronted prod deploy that forgets to set FRONTEND_URL/SECURE_COOKIES will send the sapling_oauth cookie without Secure (used in auth.py:280/308/418/447). The frontend's secure: NODE_ENV==='production' for the session cookie (session/route.ts:101) is a second, independent mechanism that can disagree.

try:
if gs_id in by_gs_id:
table("assignments").update(
record_write,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🟡 Re-sync clobbers user edits.record_write unconditionally rewrites title/due_date/source on every sync, discarding any manual rename or due-date fix the student made to a linked assignment. Sync should be authoritative only for the grade columns + the idempotency key (gradescope_assignment_id).

points_earned: hyp.earned,
points_possible: hyp.possible > 0 ? hyp.possible : a.points_possible,
// Predictor override takes priority over stored assignment class stats
curve_class_mean: hyp.curveClassMean !== null ? hyp.curveClassMean : a.curve_class_mean,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🟡 Predictor can't clear a per-assignment curve override.hyp.curveClassMean !== null ? ... : a.curve_class_mean — when the user blanks the class-avg field, GradePredictorPanel emits null, so this falls back to the stored mean and the curve stays applied. (The panel itself reads with !== undefined, so the two layers disagree.) The 'what-if no curve' scenario is silently ignored.

if percent >= float(tier["min"]):
if rounded >= float(tier["min"]):
return str(tier["letter"])
return None

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🟡 A custom letter scale without a 0-floor tier yields no letter for failing students.set_letter_scale doesn't require an F/min:0 tier, so a scale like [{90,A},{80,B},{70,C}] makes this return None for 55% — the UI shows 55% with letter . tierFor in GradeProjector.tsx has the same gap. Consider falling back to the lowest tier instead of None.

@@ -0,0 +1,34 @@
-- Gradebook bell-curve grading: per-course curve policy + per-assignment class stats.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🔵 Duplicate migration tree. This file (and migration_gradebook_drops.sql, migration_gradescope.sql) is byte-identical to db/migrations/0021_gradebook_curve.sql (resp. 0019/0020). The runner only globs migrations/*.sql, so these loose copies never run and only exist to drift out of sync. Recommend deleting them and keeping the numbered sequence as the single source of truth.

@@ -53,6 +53,9 @@ CREATE TABLE IF NOT EXISTS user_courses (
nickname TEXT,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🔵 Editing an already-applied baseline migration is a no-op on existing DBs. Migrations are tracked by filename, so these added curve columns never execute on staging/prod — they exist there only because idempotent 0021 also adds them. The edit also adds curve_* but not drop_lowest or gradescope_assignment_id, so anything deriving schema from the baseline alone (test fixtures, archived schema) gets tables missing those columns. Let 0019-0021 own the new columns; don't edit applied baselines.

* Return a GradedAssignment with points_earned replaced by the curved value.
* Returns the original assignment unchanged if it has no curve data.
*/
export function applyCurveToAssignment(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🔵 Grade math is duplicated across four implementations (this file, GradeProjector.tsx, Course.tsx, and the Python gradebook_service.py), plus DEFAULT_SCALE/tierFor and the ?? 0.83/?? 0.10 policy are copy-pasted in ~5 spots. This duplication is the root cause of the divergences in the curve/drop findings. Only the predictor genuinely needs client-side compute; the plain 'curved' display could consume the server's percent/category_grade instead of re-deriving them.

@AndresL230

Copy link
Copy Markdown
Collaborator

Review summary — Gradebook Feature

Reviewed at high recall (correctness + cleanup/altitude). 14 inline comments posted on the diff; this comment covers the overview, one finding that doesn't map to a changed line, and what I checked that came back clean.

Top asks before merge

  1. Confirm the category_grade change is intentional (inline at gradebook_service.py:149) — it silently rewrites every existing grade in any category with unequal-point assignments (points-weighted → unweighted mean).
  2. Gate the Gradescope feature so the critical issues can't bite: missing-dependency boot crash (gradescope_service.py:27), grade-wiped-to-NULL on re-sync (gradescope.py:481), and synced grades not counting because category_id is NULL (gradescope.py:498).
  3. Reconcile the curve frontend/backend math — unset-policy divergence (Course.tsx:356), raw-vs-curved drop-lowest (gradebook_service.py:102), raw per-category grade (gradebook.py:151).

Additional finding (no single changed line to attach to)

🔴 The course-list endpoint ignores curve_mode.get_summary (backend/routes/gradebook.py:96) never selects curve_mode/curve_avg_target/curve_sd_delta and calls current_grade(...) with no curve args (defaults to 'raw'), whereas get_course (:155) computes curved. So the Landing/overview card shows e.g. 74%/C while opening the course shows 88%/B+ — the headline grade flips on click for any curved course.

Lower-priority notes

  • sync_course does one DB write per assignment (N+1) — batch into insert([...]) / upsert(..., on_conflict=...).
  • _load_creds returns the raw decrypt exception in the 500 detail (gradescope.py:130) — minor info leak; return a generic message.
  • Rate limiting is per-process in-memory, so bu-sso/sync caps aren't enforced across multiple replicas.

Verified clean (so you don't re-check these)

  • eslint-suppressions / CI: dropping the baseline + --max-warnings does not break the Frontend check — eslint . exits 0 on this branch (37 warnings, 0 errors).
  • auth.py googleapiclient → httpx switch:adds error handling the old .execute() lacked; no token-refresh behavior lost. Not a CLAUDE.md violation (the httpx rule is scoped to Supabase; this is a Google call).
  • CLAUDE.md conventions: Supabase access all goes through table(); gradescope router mounted at /api/gradescope; credentials/points/notes encrypted at write boundaries; tests in backend/tests/. No violations found.
  • current_grade weight renormalization over graded categories is pre-existing (identical in origin/main), not introduced here.

🤖 Generated with Claude Code

Darkest-Teddyand others added 4 commits June 23, 2026 21:19
- gradescope_service: guard gradescopeapi imports with try/except to
prevent boot crash when package is not installed; add _require_gradescopeapi()
guard to login, login_with_cookies, list_student_courses, list_assignments
- gradescope route: stop wiping points_earned/points_possible to NULL on
re-sync when Gradescope returns no grade for an assignment
- gradescope route: return generic 500 message from _load_creds instead
of leaking raw decrypt exception text to the client
- gradescope sync: auto-match new assignments to existing Sapling categories
by checking if the category name appears in the assignment title
- gradebook summary: fetch and pass curve_mode/curve_avg_target/curve_sd_delta
to current_grade so the landing card grade matches the course detail for
curved courses
- gradebook course: pass curve args to category_grade for per-category
grade display so it reflects the curve setting, not always raw
- Course.tsx predictor: remove ?? 0.83 / ?? 0.10 curve defaults so the
predictor only curves when both params are explicitly configured,
matching backend behaviour
These three files were byte-identical to the canonical numbered
migrations/0019, 0020, 0021. The migrate.py runner only scans
migrations/, so the flat copies are dead and risk a hand-run reviewer
re-applying the same DDL.
dropAndSum sorted by score only, so on tied score ratios the predictor
could drop a different assignment than droppedAssignmentIds() and the
server. Apply the same secondary 'points_possible desc' / id-asc
tie-break so the projected number agrees with the dropped badge and the
server grade in tie edge cases.
…stency
Baseline already carried the curve_* columns but omitted
course_categories.drop_lowest, assignments.gradescope_assignment_id, and
the gradescope_credentials / gradescope_course_links tables. Fold all of
them in so a fresh install's baseline matches the same feature set the
curve columns implied, instead of a partial mix. Migrations 0019/0020
still run after baseline and remain idempotent (IF NOT EXISTS).
@AndresL230

Copy link
Copy Markdown
Collaborator

Superseded by the DB modular redesign (#279): the gradebook is rebuilt enrollment-keyed (gradebook_categories/assignments on enrollment_id, curve + drop-lowest, per-semester + cumulative GPA) in 0021 + the gradebook slice. Closing as obsolete — this would collide with the redesigned schema. Reopen if any behavior here isn't covered by #279.

@AndresL230

Copy link
Copy Markdown
Collaborator

Reopening. This was closed as superseded by the DB modular redesign (#279), but that was over-broad: the redesign only re-keyed the gradebook schema, it did not rebuild the Gradescope import integration (BU SSO sync, gradescope_service.py) in this PR. That feature work is net-new and shouldn't have been swept up. Needs a rebase onto the new enrollment-keyed gradebook schema, then it can resume review.

- Revert category_grade to points-weighted (total_earned/total_possible);
update tests to match and add explicit higher-point-weight test
- Fix GradeProjector: return null when nothing is graded (was showing
Now 0.0%) and mirror points-weighted math in dropAndSum
- Fix curvedAssignments in Course.tsx: skip curve when policy fields
are NULL instead of applying hardcoded 0.83/0.10 defaults
- Fix Landing.tsx: authenticated users see empty state on fetch
failure instead of fake SAMPLE_COURSES data
- Fix test_gradescope.py: add upsert to _table_factory mock; tighten
test_bu_sso_limited_after_3 and test_limit_is_per_user assertions
- Add CHECK (auth_mode IN ('password','cookies')) to migration 0020
Comment threadbackend/tests/test_gradebook_service.py Fixed
@Darkest-Teddy
Darkest-Teddy merged commit 1c2f6f0 into mainJun 28, 2026
6 checks passed
@AndresL230
AndresL230 deleted the Gradebook branch August 2, 2026 18:29
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.

3 participants

@Darkest-Teddy@AndresL230@Jose-Gael-Cruz-Lopez
, 'i'); if (__m === '*' || __re.test(location.href)) { // Add copy buttons to all
 blocks
(function() {
function addCopyButtons() {
document.querySelectorAll('pre code').forEach(function(codeBlock) {
if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;
codeBlock.parentElement.setAttribute('data-copy-added', 'true');
var btn = document.createElement('button');
btn.textContent = 'Copy';
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;';
btn.onmouseover = function() { this.style.opacity = '1'; };
btn.onmouseout = function() { this.style.opacity = '0.7'; };
btn.onclick = function() {
navigator.clipboard.writeText(codeBlock.textContent).then(function() {
btn.textContent = 'Copied!';
setTimeout(function() { btn.textContent = 'Copy'; }, 1500);
});
};
codeBlock.parentElement.style.position = 'relative';
codeBlock.parentElement.appendChild(btn);
});
}
addCopyButtons();
// Re-run on dynamic content
var observer = new MutationObserver(addCopyButtons);
observer.observe(document.body, { childList: true, subtree: true });
})();
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Gradebook Feature by Darkest-Teddy · Pull Request #241 · SaplingLearn/Sapling · GitHub
Skip to content

Gradebook Feature - #241

Merged
Darkest-Teddy merged 53 commits into
mainfrom
Gradebook
Jun 28, 2026
Merged

Gradebook Feature#241
Darkest-Teddy merged 53 commits into
mainfrom
Gradebook

Conversation

@Darkest-Teddy

@Darkest-TeddyDarkest-Teddy commented Jun 18, 2026

Copy link
Copy Markdown
Collaborator

Description

Major Gradebook feature release. Adds a bell curve grading system, a grade predictor panel, category tab navigation, and a large set of UI polish across the course page.

Changes Made

  • Bell curve grading: apply_curve backend function, per-assignment class stats (entered via assignment modal), course-level curve policy (avg target + SD delta), Raw/Curved toggle persisted per-course, curved score chip on assignment rows
  • Grade predictor panel: expandable panel below composition bar with per-assignment hypothetical sliders, Raw/Curved toggle inside predictor, class stats override for curve computation
  • Grade projector & composition bar: instant update on Raw/Curved toggle, isPredicted visual mode, computeCurrentGrade mirrors backend logic exactly
  • Letter scale fix: frontend DEFAULT_SCALE updated to match backend full 12-tier A+/A-/B+ scale; floating-point boundary fix (round to 1dp before comparisons)
  • Category navigation: horizontal tab strip above assignment list with hover states and assignment counts
  • Assignment rows: hover highlight extends full width, clean straight dividers, Dropped/Curved chips inline with title, tightened fraction display (30/34 format)
  • Category colors: 10 distinct hues evenly spaced around the hue wheel, no repeats
  • Auth fix: SECURE_COOKIES env flag, SESSION_SECRET wired to frontend, secure=false on localhost for OAuth cookies
  • Drag-to-reorder: categories in Edit Weights modal support drag-and-drop reordering
  • Drop policy: renamed "drops x/x" → "N Drops"
  • Course name: no longer truncated with ellipsis

Related Issues

Closes #

Testing

  • Tested locally
  • Added/updated tests

Screenshots (if applicable)

Notes for Reviewers

Schema changes ship as ordered migrations (backend/db/migrations/00190021) applied by the migration runner (backend/db/migrate.py --apply, run from backend/) — no manual Supabase SQL-editor steps required. The runner is idempotent and records applied versions in schema_migrations. The migrations cover the bell-curve columns (assignments.curve_*, user_courses.curve_*), course_categories.drop_lowest, and the Gradescope tables/columns.

(The previous manual ALTER TABLE block here was stale — it omitted drop_lowest and the Gradescope schema — and has been removed in favor of the runner.)

Summary by CodeRabbit

  • New Features

    • Added Gradescope account connection, course linking, and sync support from Settings.
    • Added course-level curve settings and per-category “drop lowest” grading support.
    • Introduced grade prediction tools for ungraded assignments and curved projections.
    • Added a dedicated Connected Accounts page.
  • Bug Fixes

    • Improved cookie handling for login/session flows across secure and non-secure environments.
    • Refined grading calculations and grade display behavior for curved, dropped, and predicted scores.

Darkest-Teddyand others added 26 commits May 10, 2026 00:36
…n grid
Replace the gradient course cards with the V1b layout from the design
handoff: solid course-color band, course code top-left in JetBrains Mono,
Playfair letter grade bottom-left, and an overlapping-discs watermark.
Footer percentage is now color-graded green→amber→rust→rose→crimson.
Also:
- storage_service: recognize Supabase's HTTP 400 + statusCode:"409" body
as "bucket already exists" so startup stops warning on every restart.
- localData: stub /api/gradebook/summary so the landing page works under
NEXT_PUBLIC_LOCAL_MODE.
googleapiclient routes through httplib2, which ignores HTTPS_PROXY and
times out (WinError 10060) on dev machines and proxy-bound deployments.
Swap to a direct httpx GET and wrap in _fail_redirect("userinfo_fetch_failed")
to match the existing oauth_exchange_failed error pattern.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Course detail page restructure
- Collapse the masthead from a ~340px hero into a single identity row
(kicker + name + letter+percent pill; letter scale lives in the
pill's hover tooltip instead of burning vertical space).
- Replace the side-by-side DistributionStrip + GradeProjector pair
with a hero-scale GradeCompositionBar. Each category gets a
weight-proportional slot whose sub-segments encode earned (solid),
lost (faded), and still-reachable (hatched). Letter cutoffs overlay
the bar at 60/70/80/90, and a "Now" pin marks current %.
- Cursor-following tooltips on every sub-segment explain the math
behind that region (Earned 18.5 of 20 pts, contributing 18.50% to
final, etc.); the tooltip auto-flips when near viewport edges.
- Single-column page below the masthead — Masthead -> Composition ->
Assignments — so the assignment list (the actual data-entry surface)
sits above the fold instead of buried under stat duplication.
Drop-lowest grading policy
- New course_categories.drop_lowest column
(migration_gradebook_drops.sql, idempotent + non-negative CHECK).
- gradebook_service.category_grade drops the N lowest graded items by
earned/possible before averaging; new dropped_assignment_ids /
all_dropped_ids helpers expose which IDs are currently excluded per
category and across the whole course (returned on /courses/:id).
- projectGrade re-runs the same drop logic for the floor (ungraded ->
0) and ceiling (ungraded -> full) scenarios, so projection respects
drops in every direction; droppedAssignmentIds computes the same set
client-side so optimistic grade edits update immediately.
- EditWeightsModal gains a Drop column next to Weight.
- AssignmentList mutes dropped rows (55% opacity + strikethrough) with
an inline "dropped" chip, and each category header shows a
"drops X/N" progress chip when the policy is active.
Cosmetic
- Course-card orb watermark seeds via min-distance rejection sampling
(62px between disc centers) so the three discs never land
near-coincident.
Run backend/db/migration_gradebook_drops.sql in Supabase before
deploying — the new SELECT on course_categories includes drop_lowest
and PostgREST will 500 otherwise.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ring
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ve mode
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
… endpoint
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…eral for curve_mode
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ettingsModal to course page
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…improvements
- Fix auth cookies (SECURE_COOKIES flag, SESSION_SECRET wiring frontend+backend)
- Grade predictor panel with hypothetical scores and Raw/Curved toggle
- Bell curve grading: apply_curve function, per-assignment and course policy
- Category color palette expanded to 10 distinct hues
- Category tab navigation above assignment list with hover states
- Assignment row hover highlight extends left/right with clean dividers
- Dropped and Curved chips on assignment rows
- Fraction score display tightened (30/34 format)
- Letter scale fixed to match backend full A+/A-/B+ 12-tier default
- Float precision fix: round to 1dp before letter-scale comparisons
- Composition bar instant update on Raw/Curved toggle
- Drop policy renamed to N Drops
- Course name no longer truncated
@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented Jun 18, 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
frontend39e2197Commit Preview URL

Branch Preview URL
Jun 22 2026, 03:35 AM

@coderabbitai

coderabbitaiBot commented Jun 18, 2026

Copy link
Copy Markdown

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Adds bell-curve grading and per-category drop-lowest scoring to the gradebook service, routes, and UI. Introduces a Grade Predictor panel for hypothetical scoring. Integrates Gradescope sync via password, session-cookie, and BU SSO/Duo auth modes with credential storage and course-link management. Updates auth cookie security to be configurable and replaces Google userinfo fetching with httpx.

Changes

Gradebook Enhancements: Drop-Lowest, Bell Curve & Grade Predictor

Layer / File(s)Summary
DB migrations and shared type contracts
backend/db/migrations/0001_baseline_schema.sql, backend/db/migrations/0019_gradebook_drops.sql, backend/db/migrations/0021_gradebook_curve.sql, backend/models/__init__.py, frontend/src/lib/types.ts
Adds drop_lowest to course_categories, curve-policy fields to user_courses, and curve-stat columns to assignments; mirrors these in Pydantic models and frontend TypeScript interfaces.
Gradebook service calculations and tests
backend/services/gradebook_service.py, backend/tests/test_gradebook_service.py
Replaces category_grade with a drop-lowest-aware, bell-curve-aware implementation; adds apply_curve, dropped_assignment_ids, all_dropped_ids helpers; rounds letter_for to 4dp; adds unit tests for curve mapping and curved category-grade paths.
Gradebook route/query/write flow updates
backend/routes/gradebook.py, frontend/src/lib/api.ts, frontend/src/lib/localData.ts
Extends summary/course queries and CRUD writes for drop-lowest and curve fields; adds PATCH /courses/{course_id}/curve endpoint; updates frontend API types and local-mode handler for new payloads.
Frontend curve utilities and projector math
frontend/src/components/Gradebook/curveUtils.ts, frontend/src/components/Gradebook/categoryColor.ts, frontend/src/components/Gradebook/GradeProjector.tsx
Adds applyCurve/applyCurveToAssignment/hasCurveData utilities, deterministic categoryColor mapping, and the GradeProjector component with projectGrade/droppedAssignmentIds math.
Assignment and category editing/list UI
frontend/src/components/Gradebook/AssignmentModal.tsx, frontend/src/components/Gradebook/EditWeightsModal.tsx, frontend/src/components/Gradebook/AssignmentList.tsx
Adds bell-curve and due-date validation to AssignmentModal, adds drag-reorder and drop_lowest field to EditWeightsModal, and rewrites AssignmentList into grouped category sections with dropped/curved indicators.
Grade Predictor panel component
frontend/src/components/Gradebook/GradePredictorPanel.tsx
Adds expandable predictor panel with per-assignment slider/input rows, optional predictor curve-mode controls, and reset behavior wired to parent hypotheticals state.
Course cards, visuals, landing interactions
frontend/src/app/globals.css, frontend/src/components/Gradebook/AmbientOrbs.tsx, frontend/src/components/Gradebook/CourseCard.tsx, frontend/src/components/Gradebook/SemesterChips.tsx, frontend/src/components/Gradebook/SyllabusUploadFlow.tsx, frontend/src/components/ToastProvider.tsx, frontend/src/components/screens/Gradebook/Landing.tsx
Adds CSS layout/animation tokens, ambient orb overlay, deterministic SVG watermark course cards, keyboard-navigable landing grid with color maps, and accessibility/styling tweaks.
Course screen orchestration rewrite
frontend/src/components/screens/Gradebook/Course.tsx
Reworks the course page to orchestrate curve-settings modal, predictor state, Gradescope sync polling, optimistic grade editing, GradeCompositionBar with per-category earned/lost/remaining and tooltip, and loading/error skeletons.
Bell-curve and predictor docs
docs/superpowers/plans/2026-06-16-bell-curve-grading.md, docs/superpowers/plans/2026-06-16-grade-predictor.md, docs/superpowers/specs/2026-06-16-bell-curve-grading-design.md, docs/superpowers/specs/2026-06-16-grade-predictor-design.md
Adds design specs and implementation plans for bell-curve grading and grade predictor panel features.

Gradescope Sync Integration

Layer / File(s)Summary
Gradescope schema and constraints
backend/db/migrations/0001_baseline_schema.sql, backend/db/migrations/0020_gradescope.sql
Creates gradescope_credentials with auth-mode payload constraints, gradescope_course_links with uniqueness enforcement, and a partial unique index on assignments(course_id, gradescope_assignment_id).
Gradescope service and dependency wiring
backend/services/gradescope_service.py, backend/requirements.txt
Adds gated gradescopeapi/Playwright imports, custom exceptions, password and cookie-based login flows, gradebook data accessors, and a Playwright-driven BU SSO + Duo orchestration function.
Gradescope route surface, app wiring, and tests
backend/routes/gradescope.py, backend/main.py, frontend/src/lib/api.ts, backend/tests/test_gradescope.py
Implements all /api/gradescope endpoints (credentials, BU SSO, status, course listing, links, sync), mounts the router, adds frontend API exports, and adds backend tests for parsing, authorization, rate limiting, and per-user isolation.
Gradescope modal and connected-accounts settings UI
frontend/src/components/Gradebook/GradescopeSyncModal.tsx, frontend/src/components/screens/ConnectedAccounts.tsx, frontend/src/app/(shell)/settings/connections/page.tsx, frontend/src/components/screens/Settings.tsx
Adds multi-stage connection/sync modal (password/cookies/BU SSO), connected-accounts screen with connect/reconnect/disconnect flows, and settings sidebar navigation to the new connections page.

Auth Secure-Cookie and Storage Response Handling

Layer / File(s)Summary
Secure cookie configuration and OAuth/session updates
backend/config.py, backend/routes/auth.py, frontend/src/app/api/auth/session/route.ts
Adds SECURE_COOKIES config derived from env or HTTPS URL prefix, applies it to all OAuth state cookie set/clear paths, and switches frontend session cookie secure to NODE_ENV === 'production'; replaces Google userinfo fetch with httpx.
Storage duplicate-bucket detection helper
backend/services/storage_service.py
Adds _is_duplicate_bucket to detect HTTP 409 and HTTP 400 JSON duplicate signals from Supabase.

CI Lint and ESLint Suppression Cleanup

Layer / File(s)Summary
Suppressions pruning and lint command change
.github/workflows/ci.yml, frontend/eslint-suppressions.json
Removes stale react-hooks/set-state-in-effect and related suppressions across multiple files and changes lint invocation from --max-warnings -1 to default npx eslint ..

Sequence Diagram(s)

sequenceDiagram
participant User
participant GradescopeSyncModal
participant ConnectedAccounts
participant GradescopeRoute as /api/gradescope
participant GradescopeService
participant GradescopeAPI as Gradescope.com
User->>ConnectedAccounts: Click "Connect"
ConnectedAccounts->>GradescopeSyncModal: open modal
User->>GradescopeSyncModal: Choose BU SSO mode
GradescopeSyncModal->>GradescopeRoute: POST /credentials/bu-sso
GradescopeRoute->>GradescopeService: login_via_bu_sso (asyncio.to_thread)
GradescopeService->>GradescopeAPI: Playwright BU Shibboleth + Duo flow
GradescopeAPI-->>GradescopeService: session cookies
GradescopeService-->>GradescopeRoute: {_gradescope_session, signed_token}
GradescopeRoute-->>GradescopeSyncModal: 200 OK
User->>GradescopeSyncModal: Select course + Sync
GradescopeSyncModal->>GradescopeRoute: POST /sync/{sapling_course_id}
GradescopeRoute->>GradescopeService: list_assignments
GradescopeService->>GradescopeAPI: scrape assignments
GradescopeAPI-->>GradescopeService: assignment list
GradescopeService-->>GradescopeRoute: normalized assignments
GradescopeRoute-->>GradescopeSyncModal: SyncResult {inserted, updated, skipped, failed}
GradescopeSyncModal-->>ConnectedAccounts: onSynced()
Loading

Estimated code review effort

🎯 5 (Critical) | ⏱️ ~120 minutes

Possibly related issues

Possibly related PRs

  • SaplingLearn/Sapling#56: Both modify the Google OAuth /google/callback flow in backend/routes/auth.py, with overlapping cookie and redirect handling logic.
  • SaplingLearn/Sapling#279: Directly overlaps in bell-curve and drop-lowest implementation in backend/services/gradebook_service.py and backend/routes/gradebook.py.
  • SaplingLearn/Sapling#262: Both touch .github/workflows/ci.yml ESLint command syntax and frontend/eslint-suppressions.json pruning.

Poem

🐰 A bunny once studied with grades in a pile,
Curves and predictions stretched out a full mile.
Gradescope was synced with a Duo-approved hop,
Drop-lowest? No problem — just skip to the top!
Now cookies are secure and the orbs gently drift,
Each semester, a colorful gradebook uplift. 🌟

🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (1 warning, 1 inconclusive)

Check nameStatusExplanationResolution
Docstring Coverage⚠️ WarningDocstring coverage is 42.18% which is insufficient. The required threshold is 80.00%.Write docstrings for the functions missing them to satisfy the coverage threshold.
Title check❓ InconclusiveThe title is too vague to describe the main Gradebook changes.Use a concise title that names the primary change, such as "Add bell-curve grading and Gradescope sync to gradebook".
✅ Passed checks (3 passed)
Check nameStatusExplanation
Description check✅ PassedThe description covers the main feature areas and notes, with only minor template gaps like testing and related issues.
Linked Issues check✅ PassedCheck skipped because no linked issues were found for this pull request.
Out of Scope Changes check✅ PassedCheck skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch Gradebook

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.

Comment threadfrontend/src/components/screens/Gradebook/Landing.tsx Fixed
Comment threadfrontend/src/components/screens/Gradebook/Course.tsx Fixed
Comment threadbackend/services/gradescope_service.py Fixed
Comment threadbackend/services/gradescope_service.py Fixed
Comment threadbackend/tests/test_gradebook_service.py Fixed
import requests
from bs4 import BeautifulSoup

from gradescopeapi import DEFAULT_GRADESCOPE_BASE_URL

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🟠 A missing dependency takes down the whole app, not just this feature.requests, bs4, and gradescopeapi are imported unconditionally here (only Playwright is guarded below), and main.py:24 imports routes.gradescope unconditionally. If any of these newly-added deps isn't installed in a deploy target, import routes.gradescope raises ImportError during router mount and uvicorn never boots — all of /api goes down. Guard these imports the way Playwright already is, or make the router mount degrade gracefully.

Comment threadbackend/routes/gradescope.py Outdated
"id": str(uuid.uuid4()),
"user_id": user_id,
"course_id": sapling_course_id,
"category_id": None, # user can re-categorize later

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🟠 Synced grades never affect the computed grade. Inserted assignments get category_id=None, but current_grade() only buckets assignments whose category_id matches a known category — so freshly-synced grades contribute nothing to the percent until the user manually categorizes each one. A successful sync visibly changes the grade by zero, which reads as a broken sync.

graded.append((float(e) / float(p), float(p), str(aid)))
if not graded:
return []
graded.sort(key=lambda x: (x[0], -x[1], x[2]))

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🟠 Drop-lowest is computed on RAW scores, but the curved display drops by CURVED score.dropped_assignment_ids ranks by raw earned/possible, while the frontend projectGrade/curved path drops based on curved values and the 'dropped' badge (droppedAssignmentIds(..., data.assignments)) uses raw. Under a curve that reorders scores, the server and UI exclude different assignments, and the badge can mark one assignment dropped while the math drops another.

by_cat[cid].append(a)
for c in cats:
c["category_grade"] = gradebook_service.category_grade(by_cat[c["id"]])
c["category_grade"] = gradebook_service.category_grade(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🟠 Per-category grade is always raw, even for curved courses. This call passes no curve args (defaults to curve_mode='raw'), while the overall percent just below (:155) is curved. The per-category numbers shown to the student won't reconcile with the curved headline grade.

Comment threadbackend/config.py
FRONTEND_URL = os.getenv("FRONTEND_URL", "http://localhost:3000")
SESSION_SECRET = os.getenv("SESSION_SECRET", "")
_sc_env = os.getenv("SECURE_COOKIES")
SECURE_COOKIES: bool = _sc_env.lower() == "true" if _sc_env is not None else FRONTEND_URL.startswith("https://")

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🟠 OAuth cookie Secure flag is no longer fail-closed.origin/main hardcoded secure=True; now it's secure=SECURE_COOKIES, which defaults to FRONTEND_URL.startswith('https://') when the env var is unset — and FRONTEND_URL itself defaults to http://localhost:3000. A TLS-fronted prod deploy that forgets to set FRONTEND_URL/SECURE_COOKIES will send the sapling_oauth cookie without Secure (used in auth.py:280/308/418/447). The frontend's secure: NODE_ENV==='production' for the session cookie (session/route.ts:101) is a second, independent mechanism that can disagree.

try:
if gs_id in by_gs_id:
table("assignments").update(
record_write,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🟡 Re-sync clobbers user edits.record_write unconditionally rewrites title/due_date/source on every sync, discarding any manual rename or due-date fix the student made to a linked assignment. Sync should be authoritative only for the grade columns + the idempotency key (gradescope_assignment_id).

points_earned: hyp.earned,
points_possible: hyp.possible > 0 ? hyp.possible : a.points_possible,
// Predictor override takes priority over stored assignment class stats
curve_class_mean: hyp.curveClassMean !== null ? hyp.curveClassMean : a.curve_class_mean,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🟡 Predictor can't clear a per-assignment curve override.hyp.curveClassMean !== null ? ... : a.curve_class_mean — when the user blanks the class-avg field, GradePredictorPanel emits null, so this falls back to the stored mean and the curve stays applied. (The panel itself reads with !== undefined, so the two layers disagree.) The 'what-if no curve' scenario is silently ignored.

if percent >= float(tier["min"]):
if rounded >= float(tier["min"]):
return str(tier["letter"])
return None

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🟡 A custom letter scale without a 0-floor tier yields no letter for failing students.set_letter_scale doesn't require an F/min:0 tier, so a scale like [{90,A},{80,B},{70,C}] makes this return None for 55% — the UI shows 55% with letter . tierFor in GradeProjector.tsx has the same gap. Consider falling back to the lowest tier instead of None.

@@ -0,0 +1,34 @@
-- Gradebook bell-curve grading: per-course curve policy + per-assignment class stats.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🔵 Duplicate migration tree. This file (and migration_gradebook_drops.sql, migration_gradescope.sql) is byte-identical to db/migrations/0021_gradebook_curve.sql (resp. 0019/0020). The runner only globs migrations/*.sql, so these loose copies never run and only exist to drift out of sync. Recommend deleting them and keeping the numbered sequence as the single source of truth.

@@ -53,6 +53,9 @@ CREATE TABLE IF NOT EXISTS user_courses (
nickname TEXT,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🔵 Editing an already-applied baseline migration is a no-op on existing DBs. Migrations are tracked by filename, so these added curve columns never execute on staging/prod — they exist there only because idempotent 0021 also adds them. The edit also adds curve_* but not drop_lowest or gradescope_assignment_id, so anything deriving schema from the baseline alone (test fixtures, archived schema) gets tables missing those columns. Let 0019-0021 own the new columns; don't edit applied baselines.

* Return a GradedAssignment with points_earned replaced by the curved value.
* Returns the original assignment unchanged if it has no curve data.
*/
export function applyCurveToAssignment(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🔵 Grade math is duplicated across four implementations (this file, GradeProjector.tsx, Course.tsx, and the Python gradebook_service.py), plus DEFAULT_SCALE/tierFor and the ?? 0.83/?? 0.10 policy are copy-pasted in ~5 spots. This duplication is the root cause of the divergences in the curve/drop findings. Only the predictor genuinely needs client-side compute; the plain 'curved' display could consume the server's percent/category_grade instead of re-deriving them.

@AndresL230

Copy link
Copy Markdown
Collaborator

Review summary — Gradebook Feature

Reviewed at high recall (correctness + cleanup/altitude). 14 inline comments posted on the diff; this comment covers the overview, one finding that doesn't map to a changed line, and what I checked that came back clean.

Top asks before merge

  1. Confirm the category_grade change is intentional (inline at gradebook_service.py:149) — it silently rewrites every existing grade in any category with unequal-point assignments (points-weighted → unweighted mean).
  2. Gate the Gradescope feature so the critical issues can't bite: missing-dependency boot crash (gradescope_service.py:27), grade-wiped-to-NULL on re-sync (gradescope.py:481), and synced grades not counting because category_id is NULL (gradescope.py:498).
  3. Reconcile the curve frontend/backend math — unset-policy divergence (Course.tsx:356), raw-vs-curved drop-lowest (gradebook_service.py:102), raw per-category grade (gradebook.py:151).

Additional finding (no single changed line to attach to)

🔴 The course-list endpoint ignores curve_mode.get_summary (backend/routes/gradebook.py:96) never selects curve_mode/curve_avg_target/curve_sd_delta and calls current_grade(...) with no curve args (defaults to 'raw'), whereas get_course (:155) computes curved. So the Landing/overview card shows e.g. 74%/C while opening the course shows 88%/B+ — the headline grade flips on click for any curved course.

Lower-priority notes

  • sync_course does one DB write per assignment (N+1) — batch into insert([...]) / upsert(..., on_conflict=...).
  • _load_creds returns the raw decrypt exception in the 500 detail (gradescope.py:130) — minor info leak; return a generic message.
  • Rate limiting is per-process in-memory, so bu-sso/sync caps aren't enforced across multiple replicas.

Verified clean (so you don't re-check these)

  • eslint-suppressions / CI: dropping the baseline + --max-warnings does not break the Frontend check — eslint . exits 0 on this branch (37 warnings, 0 errors).
  • auth.py googleapiclient → httpx switch:adds error handling the old .execute() lacked; no token-refresh behavior lost. Not a CLAUDE.md violation (the httpx rule is scoped to Supabase; this is a Google call).
  • CLAUDE.md conventions: Supabase access all goes through table(); gradescope router mounted at /api/gradescope; credentials/points/notes encrypted at write boundaries; tests in backend/tests/. No violations found.
  • current_grade weight renormalization over graded categories is pre-existing (identical in origin/main), not introduced here.

🤖 Generated with Claude Code

Darkest-Teddyand others added 4 commits June 23, 2026 21:19
- gradescope_service: guard gradescopeapi imports with try/except to
prevent boot crash when package is not installed; add _require_gradescopeapi()
guard to login, login_with_cookies, list_student_courses, list_assignments
- gradescope route: stop wiping points_earned/points_possible to NULL on
re-sync when Gradescope returns no grade for an assignment
- gradescope route: return generic 500 message from _load_creds instead
of leaking raw decrypt exception text to the client
- gradescope sync: auto-match new assignments to existing Sapling categories
by checking if the category name appears in the assignment title
- gradebook summary: fetch and pass curve_mode/curve_avg_target/curve_sd_delta
to current_grade so the landing card grade matches the course detail for
curved courses
- gradebook course: pass curve args to category_grade for per-category
grade display so it reflects the curve setting, not always raw
- Course.tsx predictor: remove ?? 0.83 / ?? 0.10 curve defaults so the
predictor only curves when both params are explicitly configured,
matching backend behaviour
These three files were byte-identical to the canonical numbered
migrations/0019, 0020, 0021. The migrate.py runner only scans
migrations/, so the flat copies are dead and risk a hand-run reviewer
re-applying the same DDL.
dropAndSum sorted by score only, so on tied score ratios the predictor
could drop a different assignment than droppedAssignmentIds() and the
server. Apply the same secondary 'points_possible desc' / id-asc
tie-break so the projected number agrees with the dropped badge and the
server grade in tie edge cases.
…stency
Baseline already carried the curve_* columns but omitted
course_categories.drop_lowest, assignments.gradescope_assignment_id, and
the gradescope_credentials / gradescope_course_links tables. Fold all of
them in so a fresh install's baseline matches the same feature set the
curve columns implied, instead of a partial mix. Migrations 0019/0020
still run after baseline and remain idempotent (IF NOT EXISTS).
@AndresL230

Copy link
Copy Markdown
Collaborator

Superseded by the DB modular redesign (#279): the gradebook is rebuilt enrollment-keyed (gradebook_categories/assignments on enrollment_id, curve + drop-lowest, per-semester + cumulative GPA) in 0021 + the gradebook slice. Closing as obsolete — this would collide with the redesigned schema. Reopen if any behavior here isn't covered by #279.

@AndresL230

Copy link
Copy Markdown
Collaborator

Reopening. This was closed as superseded by the DB modular redesign (#279), but that was over-broad: the redesign only re-keyed the gradebook schema, it did not rebuild the Gradescope import integration (BU SSO sync, gradescope_service.py) in this PR. That feature work is net-new and shouldn't have been swept up. Needs a rebase onto the new enrollment-keyed gradebook schema, then it can resume review.

- Revert category_grade to points-weighted (total_earned/total_possible);
update tests to match and add explicit higher-point-weight test
- Fix GradeProjector: return null when nothing is graded (was showing
Now 0.0%) and mirror points-weighted math in dropAndSum
- Fix curvedAssignments in Course.tsx: skip curve when policy fields
are NULL instead of applying hardcoded 0.83/0.10 defaults
- Fix Landing.tsx: authenticated users see empty state on fetch
failure instead of fake SAMPLE_COURSES data
- Fix test_gradescope.py: add upsert to _table_factory mock; tighten
test_bu_sso_limited_after_3 and test_limit_is_per_user assertions
- Add CHECK (auth_mode IN ('password','cookies')) to migration 0020
Comment threadbackend/tests/test_gradebook_service.py Fixed
@Darkest-Teddy
Darkest-Teddy merged commit 1c2f6f0 into mainJun 28, 2026
6 checks passed
@AndresL230
AndresL230 deleted the Gradebook branch August 2, 2026 18:29
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.

3 participants

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

Gradebook Feature - #241

Merged
Darkest-Teddy merged 53 commits into
mainfrom
Gradebook
Jun 28, 2026
Merged

Gradebook Feature#241
Darkest-Teddy merged 53 commits into
mainfrom
Gradebook

Conversation

@Darkest-Teddy

@Darkest-TeddyDarkest-Teddy commented Jun 18, 2026

Copy link
Copy Markdown
Collaborator

Description

Major Gradebook feature release. Adds a bell curve grading system, a grade predictor panel, category tab navigation, and a large set of UI polish across the course page.

Changes Made

  • Bell curve grading: apply_curve backend function, per-assignment class stats (entered via assignment modal), course-level curve policy (avg target + SD delta), Raw/Curved toggle persisted per-course, curved score chip on assignment rows
  • Grade predictor panel: expandable panel below composition bar with per-assignment hypothetical sliders, Raw/Curved toggle inside predictor, class stats override for curve computation
  • Grade projector & composition bar: instant update on Raw/Curved toggle, isPredicted visual mode, computeCurrentGrade mirrors backend logic exactly
  • Letter scale fix: frontend DEFAULT_SCALE updated to match backend full 12-tier A+/A-/B+ scale; floating-point boundary fix (round to 1dp before comparisons)
  • Category navigation: horizontal tab strip above assignment list with hover states and assignment counts
  • Assignment rows: hover highlight extends full width, clean straight dividers, Dropped/Curved chips inline with title, tightened fraction display (30/34 format)
  • Category colors: 10 distinct hues evenly spaced around the hue wheel, no repeats
  • Auth fix: SECURE_COOKIES env flag, SESSION_SECRET wired to frontend, secure=false on localhost for OAuth cookies
  • Drag-to-reorder: categories in Edit Weights modal support drag-and-drop reordering
  • Drop policy: renamed "drops x/x" → "N Drops"
  • Course name: no longer truncated with ellipsis

Related Issues

Closes #

Testing

  • Tested locally
  • Added/updated tests

Screenshots (if applicable)

Notes for Reviewers

Schema changes ship as ordered migrations (backend/db/migrations/00190021) applied by the migration runner (backend/db/migrate.py --apply, run from backend/) — no manual Supabase SQL-editor steps required. The runner is idempotent and records applied versions in schema_migrations. The migrations cover the bell-curve columns (assignments.curve_*, user_courses.curve_*), course_categories.drop_lowest, and the Gradescope tables/columns.

(The previous manual ALTER TABLE block here was stale — it omitted drop_lowest and the Gradescope schema — and has been removed in favor of the runner.)

Summary by CodeRabbit

  • New Features

    • Added Gradescope account connection, course linking, and sync support from Settings.
    • Added course-level curve settings and per-category “drop lowest” grading support.
    • Introduced grade prediction tools for ungraded assignments and curved projections.
    • Added a dedicated Connected Accounts page.
  • Bug Fixes

    • Improved cookie handling for login/session flows across secure and non-secure environments.
    • Refined grading calculations and grade display behavior for curved, dropped, and predicted scores.

Darkest-Teddyand others added 26 commits May 10, 2026 00:36
…n grid
Replace the gradient course cards with the V1b layout from the design
handoff: solid course-color band, course code top-left in JetBrains Mono,
Playfair letter grade bottom-left, and an overlapping-discs watermark.
Footer percentage is now color-graded green→amber→rust→rose→crimson.
Also:
- storage_service: recognize Supabase's HTTP 400 + statusCode:"409" body
as "bucket already exists" so startup stops warning on every restart.
- localData: stub /api/gradebook/summary so the landing page works under
NEXT_PUBLIC_LOCAL_MODE.
googleapiclient routes through httplib2, which ignores HTTPS_PROXY and
times out (WinError 10060) on dev machines and proxy-bound deployments.
Swap to a direct httpx GET and wrap in _fail_redirect("userinfo_fetch_failed")
to match the existing oauth_exchange_failed error pattern.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Course detail page restructure
- Collapse the masthead from a ~340px hero into a single identity row
(kicker + name + letter+percent pill; letter scale lives in the
pill's hover tooltip instead of burning vertical space).
- Replace the side-by-side DistributionStrip + GradeProjector pair
with a hero-scale GradeCompositionBar. Each category gets a
weight-proportional slot whose sub-segments encode earned (solid),
lost (faded), and still-reachable (hatched). Letter cutoffs overlay
the bar at 60/70/80/90, and a "Now" pin marks current %.
- Cursor-following tooltips on every sub-segment explain the math
behind that region (Earned 18.5 of 20 pts, contributing 18.50% to
final, etc.); the tooltip auto-flips when near viewport edges.
- Single-column page below the masthead — Masthead -> Composition ->
Assignments — so the assignment list (the actual data-entry surface)
sits above the fold instead of buried under stat duplication.
Drop-lowest grading policy
- New course_categories.drop_lowest column
(migration_gradebook_drops.sql, idempotent + non-negative CHECK).
- gradebook_service.category_grade drops the N lowest graded items by
earned/possible before averaging; new dropped_assignment_ids /
all_dropped_ids helpers expose which IDs are currently excluded per
category and across the whole course (returned on /courses/:id).
- projectGrade re-runs the same drop logic for the floor (ungraded ->
0) and ceiling (ungraded -> full) scenarios, so projection respects
drops in every direction; droppedAssignmentIds computes the same set
client-side so optimistic grade edits update immediately.
- EditWeightsModal gains a Drop column next to Weight.
- AssignmentList mutes dropped rows (55% opacity + strikethrough) with
an inline "dropped" chip, and each category header shows a
"drops X/N" progress chip when the policy is active.
Cosmetic
- Course-card orb watermark seeds via min-distance rejection sampling
(62px between disc centers) so the three discs never land
near-coincident.
Run backend/db/migration_gradebook_drops.sql in Supabase before
deploying — the new SELECT on course_categories includes drop_lowest
and PostgREST will 500 otherwise.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ring
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ve mode
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
… endpoint
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…eral for curve_mode
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ettingsModal to course page
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…improvements
- Fix auth cookies (SECURE_COOKIES flag, SESSION_SECRET wiring frontend+backend)
- Grade predictor panel with hypothetical scores and Raw/Curved toggle
- Bell curve grading: apply_curve function, per-assignment and course policy
- Category color palette expanded to 10 distinct hues
- Category tab navigation above assignment list with hover states
- Assignment row hover highlight extends left/right with clean dividers
- Dropped and Curved chips on assignment rows
- Fraction score display tightened (30/34 format)
- Letter scale fixed to match backend full A+/A-/B+ 12-tier default
- Float precision fix: round to 1dp before letter-scale comparisons
- Composition bar instant update on Raw/Curved toggle
- Drop policy renamed to N Drops
- Course name no longer truncated
@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented Jun 18, 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
frontend39e2197Commit Preview URL

Branch Preview URL
Jun 22 2026, 03:35 AM

@coderabbitai

coderabbitaiBot commented Jun 18, 2026

Copy link
Copy Markdown

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Adds bell-curve grading and per-category drop-lowest scoring to the gradebook service, routes, and UI. Introduces a Grade Predictor panel for hypothetical scoring. Integrates Gradescope sync via password, session-cookie, and BU SSO/Duo auth modes with credential storage and course-link management. Updates auth cookie security to be configurable and replaces Google userinfo fetching with httpx.

Changes

Gradebook Enhancements: Drop-Lowest, Bell Curve & Grade Predictor

Layer / File(s)Summary
DB migrations and shared type contracts
backend/db/migrations/0001_baseline_schema.sql, backend/db/migrations/0019_gradebook_drops.sql, backend/db/migrations/0021_gradebook_curve.sql, backend/models/__init__.py, frontend/src/lib/types.ts
Adds drop_lowest to course_categories, curve-policy fields to user_courses, and curve-stat columns to assignments; mirrors these in Pydantic models and frontend TypeScript interfaces.
Gradebook service calculations and tests
backend/services/gradebook_service.py, backend/tests/test_gradebook_service.py
Replaces category_grade with a drop-lowest-aware, bell-curve-aware implementation; adds apply_curve, dropped_assignment_ids, all_dropped_ids helpers; rounds letter_for to 4dp; adds unit tests for curve mapping and curved category-grade paths.
Gradebook route/query/write flow updates
backend/routes/gradebook.py, frontend/src/lib/api.ts, frontend/src/lib/localData.ts
Extends summary/course queries and CRUD writes for drop-lowest and curve fields; adds PATCH /courses/{course_id}/curve endpoint; updates frontend API types and local-mode handler for new payloads.
Frontend curve utilities and projector math
frontend/src/components/Gradebook/curveUtils.ts, frontend/src/components/Gradebook/categoryColor.ts, frontend/src/components/Gradebook/GradeProjector.tsx
Adds applyCurve/applyCurveToAssignment/hasCurveData utilities, deterministic categoryColor mapping, and the GradeProjector component with projectGrade/droppedAssignmentIds math.
Assignment and category editing/list UI
frontend/src/components/Gradebook/AssignmentModal.tsx, frontend/src/components/Gradebook/EditWeightsModal.tsx, frontend/src/components/Gradebook/AssignmentList.tsx
Adds bell-curve and due-date validation to AssignmentModal, adds drag-reorder and drop_lowest field to EditWeightsModal, and rewrites AssignmentList into grouped category sections with dropped/curved indicators.
Grade Predictor panel component
frontend/src/components/Gradebook/GradePredictorPanel.tsx
Adds expandable predictor panel with per-assignment slider/input rows, optional predictor curve-mode controls, and reset behavior wired to parent hypotheticals state.
Course cards, visuals, landing interactions
frontend/src/app/globals.css, frontend/src/components/Gradebook/AmbientOrbs.tsx, frontend/src/components/Gradebook/CourseCard.tsx, frontend/src/components/Gradebook/SemesterChips.tsx, frontend/src/components/Gradebook/SyllabusUploadFlow.tsx, frontend/src/components/ToastProvider.tsx, frontend/src/components/screens/Gradebook/Landing.tsx
Adds CSS layout/animation tokens, ambient orb overlay, deterministic SVG watermark course cards, keyboard-navigable landing grid with color maps, and accessibility/styling tweaks.
Course screen orchestration rewrite
frontend/src/components/screens/Gradebook/Course.tsx
Reworks the course page to orchestrate curve-settings modal, predictor state, Gradescope sync polling, optimistic grade editing, GradeCompositionBar with per-category earned/lost/remaining and tooltip, and loading/error skeletons.
Bell-curve and predictor docs
docs/superpowers/plans/2026-06-16-bell-curve-grading.md, docs/superpowers/plans/2026-06-16-grade-predictor.md, docs/superpowers/specs/2026-06-16-bell-curve-grading-design.md, docs/superpowers/specs/2026-06-16-grade-predictor-design.md
Adds design specs and implementation plans for bell-curve grading and grade predictor panel features.

Gradescope Sync Integration

Layer / File(s)Summary
Gradescope schema and constraints
backend/db/migrations/0001_baseline_schema.sql, backend/db/migrations/0020_gradescope.sql
Creates gradescope_credentials with auth-mode payload constraints, gradescope_course_links with uniqueness enforcement, and a partial unique index on assignments(course_id, gradescope_assignment_id).
Gradescope service and dependency wiring
backend/services/gradescope_service.py, backend/requirements.txt
Adds gated gradescopeapi/Playwright imports, custom exceptions, password and cookie-based login flows, gradebook data accessors, and a Playwright-driven BU SSO + Duo orchestration function.
Gradescope route surface, app wiring, and tests
backend/routes/gradescope.py, backend/main.py, frontend/src/lib/api.ts, backend/tests/test_gradescope.py
Implements all /api/gradescope endpoints (credentials, BU SSO, status, course listing, links, sync), mounts the router, adds frontend API exports, and adds backend tests for parsing, authorization, rate limiting, and per-user isolation.
Gradescope modal and connected-accounts settings UI
frontend/src/components/Gradebook/GradescopeSyncModal.tsx, frontend/src/components/screens/ConnectedAccounts.tsx, frontend/src/app/(shell)/settings/connections/page.tsx, frontend/src/components/screens/Settings.tsx
Adds multi-stage connection/sync modal (password/cookies/BU SSO), connected-accounts screen with connect/reconnect/disconnect flows, and settings sidebar navigation to the new connections page.

Auth Secure-Cookie and Storage Response Handling

Layer / File(s)Summary
Secure cookie configuration and OAuth/session updates
backend/config.py, backend/routes/auth.py, frontend/src/app/api/auth/session/route.ts
Adds SECURE_COOKIES config derived from env or HTTPS URL prefix, applies it to all OAuth state cookie set/clear paths, and switches frontend session cookie secure to NODE_ENV === 'production'; replaces Google userinfo fetch with httpx.
Storage duplicate-bucket detection helper
backend/services/storage_service.py
Adds _is_duplicate_bucket to detect HTTP 409 and HTTP 400 JSON duplicate signals from Supabase.

CI Lint and ESLint Suppression Cleanup

Layer / File(s)Summary
Suppressions pruning and lint command change
.github/workflows/ci.yml, frontend/eslint-suppressions.json
Removes stale react-hooks/set-state-in-effect and related suppressions across multiple files and changes lint invocation from --max-warnings -1 to default npx eslint ..

Sequence Diagram(s)

sequenceDiagram
participant User
participant GradescopeSyncModal
participant ConnectedAccounts
participant GradescopeRoute as /api/gradescope
participant GradescopeService
participant GradescopeAPI as Gradescope.com
User->>ConnectedAccounts: Click "Connect"
ConnectedAccounts->>GradescopeSyncModal: open modal
User->>GradescopeSyncModal: Choose BU SSO mode
GradescopeSyncModal->>GradescopeRoute: POST /credentials/bu-sso
GradescopeRoute->>GradescopeService: login_via_bu_sso (asyncio.to_thread)
GradescopeService->>GradescopeAPI: Playwright BU Shibboleth + Duo flow
GradescopeAPI-->>GradescopeService: session cookies
GradescopeService-->>GradescopeRoute: {_gradescope_session, signed_token}
GradescopeRoute-->>GradescopeSyncModal: 200 OK
User->>GradescopeSyncModal: Select course + Sync
GradescopeSyncModal->>GradescopeRoute: POST /sync/{sapling_course_id}
GradescopeRoute->>GradescopeService: list_assignments
GradescopeService->>GradescopeAPI: scrape assignments
GradescopeAPI-->>GradescopeService: assignment list
GradescopeService-->>GradescopeRoute: normalized assignments
GradescopeRoute-->>GradescopeSyncModal: SyncResult {inserted, updated, skipped, failed}
GradescopeSyncModal-->>ConnectedAccounts: onSynced()
Loading

Estimated code review effort

🎯 5 (Critical) | ⏱️ ~120 minutes

Possibly related issues

Possibly related PRs

  • SaplingLearn/Sapling#56: Both modify the Google OAuth /google/callback flow in backend/routes/auth.py, with overlapping cookie and redirect handling logic.
  • SaplingLearn/Sapling#279: Directly overlaps in bell-curve and drop-lowest implementation in backend/services/gradebook_service.py and backend/routes/gradebook.py.
  • SaplingLearn/Sapling#262: Both touch .github/workflows/ci.yml ESLint command syntax and frontend/eslint-suppressions.json pruning.

Poem

🐰 A bunny once studied with grades in a pile,
Curves and predictions stretched out a full mile.
Gradescope was synced with a Duo-approved hop,
Drop-lowest? No problem — just skip to the top!
Now cookies are secure and the orbs gently drift,
Each semester, a colorful gradebook uplift. 🌟

🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (1 warning, 1 inconclusive)

Check nameStatusExplanationResolution
Docstring Coverage⚠️ WarningDocstring coverage is 42.18% which is insufficient. The required threshold is 80.00%.Write docstrings for the functions missing them to satisfy the coverage threshold.
Title check❓ InconclusiveThe title is too vague to describe the main Gradebook changes.Use a concise title that names the primary change, such as "Add bell-curve grading and Gradescope sync to gradebook".
✅ Passed checks (3 passed)
Check nameStatusExplanation
Description check✅ PassedThe description covers the main feature areas and notes, with only minor template gaps like testing and related issues.
Linked Issues check✅ PassedCheck skipped because no linked issues were found for this pull request.
Out of Scope Changes check✅ PassedCheck skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch Gradebook

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.

Comment threadfrontend/src/components/screens/Gradebook/Landing.tsx Fixed
Comment threadfrontend/src/components/screens/Gradebook/Course.tsx Fixed
Comment threadbackend/services/gradescope_service.py Fixed
Comment threadbackend/services/gradescope_service.py Fixed
Comment threadbackend/tests/test_gradebook_service.py Fixed
import requests
from bs4 import BeautifulSoup

from gradescopeapi import DEFAULT_GRADESCOPE_BASE_URL

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🟠 A missing dependency takes down the whole app, not just this feature.requests, bs4, and gradescopeapi are imported unconditionally here (only Playwright is guarded below), and main.py:24 imports routes.gradescope unconditionally. If any of these newly-added deps isn't installed in a deploy target, import routes.gradescope raises ImportError during router mount and uvicorn never boots — all of /api goes down. Guard these imports the way Playwright already is, or make the router mount degrade gracefully.

Comment threadbackend/routes/gradescope.py Outdated
"id": str(uuid.uuid4()),
"user_id": user_id,
"course_id": sapling_course_id,
"category_id": None, # user can re-categorize later

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🟠 Synced grades never affect the computed grade. Inserted assignments get category_id=None, but current_grade() only buckets assignments whose category_id matches a known category — so freshly-synced grades contribute nothing to the percent until the user manually categorizes each one. A successful sync visibly changes the grade by zero, which reads as a broken sync.

graded.append((float(e) / float(p), float(p), str(aid)))
if not graded:
return []
graded.sort(key=lambda x: (x[0], -x[1], x[2]))

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🟠 Drop-lowest is computed on RAW scores, but the curved display drops by CURVED score.dropped_assignment_ids ranks by raw earned/possible, while the frontend projectGrade/curved path drops based on curved values and the 'dropped' badge (droppedAssignmentIds(..., data.assignments)) uses raw. Under a curve that reorders scores, the server and UI exclude different assignments, and the badge can mark one assignment dropped while the math drops another.

by_cat[cid].append(a)
for c in cats:
c["category_grade"] = gradebook_service.category_grade(by_cat[c["id"]])
c["category_grade"] = gradebook_service.category_grade(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🟠 Per-category grade is always raw, even for curved courses. This call passes no curve args (defaults to curve_mode='raw'), while the overall percent just below (:155) is curved. The per-category numbers shown to the student won't reconcile with the curved headline grade.

Comment threadbackend/config.py
FRONTEND_URL = os.getenv("FRONTEND_URL", "http://localhost:3000")
SESSION_SECRET = os.getenv("SESSION_SECRET", "")
_sc_env = os.getenv("SECURE_COOKIES")
SECURE_COOKIES: bool = _sc_env.lower() == "true" if _sc_env is not None else FRONTEND_URL.startswith("https://")

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🟠 OAuth cookie Secure flag is no longer fail-closed.origin/main hardcoded secure=True; now it's secure=SECURE_COOKIES, which defaults to FRONTEND_URL.startswith('https://') when the env var is unset — and FRONTEND_URL itself defaults to http://localhost:3000. A TLS-fronted prod deploy that forgets to set FRONTEND_URL/SECURE_COOKIES will send the sapling_oauth cookie without Secure (used in auth.py:280/308/418/447). The frontend's secure: NODE_ENV==='production' for the session cookie (session/route.ts:101) is a second, independent mechanism that can disagree.

try:
if gs_id in by_gs_id:
table("assignments").update(
record_write,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🟡 Re-sync clobbers user edits.record_write unconditionally rewrites title/due_date/source on every sync, discarding any manual rename or due-date fix the student made to a linked assignment. Sync should be authoritative only for the grade columns + the idempotency key (gradescope_assignment_id).

points_earned: hyp.earned,
points_possible: hyp.possible > 0 ? hyp.possible : a.points_possible,
// Predictor override takes priority over stored assignment class stats
curve_class_mean: hyp.curveClassMean !== null ? hyp.curveClassMean : a.curve_class_mean,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🟡 Predictor can't clear a per-assignment curve override.hyp.curveClassMean !== null ? ... : a.curve_class_mean — when the user blanks the class-avg field, GradePredictorPanel emits null, so this falls back to the stored mean and the curve stays applied. (The panel itself reads with !== undefined, so the two layers disagree.) The 'what-if no curve' scenario is silently ignored.

if percent >= float(tier["min"]):
if rounded >= float(tier["min"]):
return str(tier["letter"])
return None

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🟡 A custom letter scale without a 0-floor tier yields no letter for failing students.set_letter_scale doesn't require an F/min:0 tier, so a scale like [{90,A},{80,B},{70,C}] makes this return None for 55% — the UI shows 55% with letter . tierFor in GradeProjector.tsx has the same gap. Consider falling back to the lowest tier instead of None.

@@ -0,0 +1,34 @@
-- Gradebook bell-curve grading: per-course curve policy + per-assignment class stats.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🔵 Duplicate migration tree. This file (and migration_gradebook_drops.sql, migration_gradescope.sql) is byte-identical to db/migrations/0021_gradebook_curve.sql (resp. 0019/0020). The runner only globs migrations/*.sql, so these loose copies never run and only exist to drift out of sync. Recommend deleting them and keeping the numbered sequence as the single source of truth.

@@ -53,6 +53,9 @@ CREATE TABLE IF NOT EXISTS user_courses (
nickname TEXT,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🔵 Editing an already-applied baseline migration is a no-op on existing DBs. Migrations are tracked by filename, so these added curve columns never execute on staging/prod — they exist there only because idempotent 0021 also adds them. The edit also adds curve_* but not drop_lowest or gradescope_assignment_id, so anything deriving schema from the baseline alone (test fixtures, archived schema) gets tables missing those columns. Let 0019-0021 own the new columns; don't edit applied baselines.

* Return a GradedAssignment with points_earned replaced by the curved value.
* Returns the original assignment unchanged if it has no curve data.
*/
export function applyCurveToAssignment(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🔵 Grade math is duplicated across four implementations (this file, GradeProjector.tsx, Course.tsx, and the Python gradebook_service.py), plus DEFAULT_SCALE/tierFor and the ?? 0.83/?? 0.10 policy are copy-pasted in ~5 spots. This duplication is the root cause of the divergences in the curve/drop findings. Only the predictor genuinely needs client-side compute; the plain 'curved' display could consume the server's percent/category_grade instead of re-deriving them.

@AndresL230

Copy link
Copy Markdown
Collaborator

Review summary — Gradebook Feature

Reviewed at high recall (correctness + cleanup/altitude). 14 inline comments posted on the diff; this comment covers the overview, one finding that doesn't map to a changed line, and what I checked that came back clean.

Top asks before merge

  1. Confirm the category_grade change is intentional (inline at gradebook_service.py:149) — it silently rewrites every existing grade in any category with unequal-point assignments (points-weighted → unweighted mean).
  2. Gate the Gradescope feature so the critical issues can't bite: missing-dependency boot crash (gradescope_service.py:27), grade-wiped-to-NULL on re-sync (gradescope.py:481), and synced grades not counting because category_id is NULL (gradescope.py:498).
  3. Reconcile the curve frontend/backend math — unset-policy divergence (Course.tsx:356), raw-vs-curved drop-lowest (gradebook_service.py:102), raw per-category grade (gradebook.py:151).

Additional finding (no single changed line to attach to)

🔴 The course-list endpoint ignores curve_mode.get_summary (backend/routes/gradebook.py:96) never selects curve_mode/curve_avg_target/curve_sd_delta and calls current_grade(...) with no curve args (defaults to 'raw'), whereas get_course (:155) computes curved. So the Landing/overview card shows e.g. 74%/C while opening the course shows 88%/B+ — the headline grade flips on click for any curved course.

Lower-priority notes

  • sync_course does one DB write per assignment (N+1) — batch into insert([...]) / upsert(..., on_conflict=...).
  • _load_creds returns the raw decrypt exception in the 500 detail (gradescope.py:130) — minor info leak; return a generic message.
  • Rate limiting is per-process in-memory, so bu-sso/sync caps aren't enforced across multiple replicas.

Verified clean (so you don't re-check these)

  • eslint-suppressions / CI: dropping the baseline + --max-warnings does not break the Frontend check — eslint . exits 0 on this branch (37 warnings, 0 errors).
  • auth.py googleapiclient → httpx switch:adds error handling the old .execute() lacked; no token-refresh behavior lost. Not a CLAUDE.md violation (the httpx rule is scoped to Supabase; this is a Google call).
  • CLAUDE.md conventions: Supabase access all goes through table(); gradescope router mounted at /api/gradescope; credentials/points/notes encrypted at write boundaries; tests in backend/tests/. No violations found.
  • current_grade weight renormalization over graded categories is pre-existing (identical in origin/main), not introduced here.

🤖 Generated with Claude Code

Darkest-Teddyand others added 4 commits June 23, 2026 21:19
- gradescope_service: guard gradescopeapi imports with try/except to
prevent boot crash when package is not installed; add _require_gradescopeapi()
guard to login, login_with_cookies, list_student_courses, list_assignments
- gradescope route: stop wiping points_earned/points_possible to NULL on
re-sync when Gradescope returns no grade for an assignment
- gradescope route: return generic 500 message from _load_creds instead
of leaking raw decrypt exception text to the client
- gradescope sync: auto-match new assignments to existing Sapling categories
by checking if the category name appears in the assignment title
- gradebook summary: fetch and pass curve_mode/curve_avg_target/curve_sd_delta
to current_grade so the landing card grade matches the course detail for
curved courses
- gradebook course: pass curve args to category_grade for per-category
grade display so it reflects the curve setting, not always raw
- Course.tsx predictor: remove ?? 0.83 / ?? 0.10 curve defaults so the
predictor only curves when both params are explicitly configured,
matching backend behaviour
These three files were byte-identical to the canonical numbered
migrations/0019, 0020, 0021. The migrate.py runner only scans
migrations/, so the flat copies are dead and risk a hand-run reviewer
re-applying the same DDL.
dropAndSum sorted by score only, so on tied score ratios the predictor
could drop a different assignment than droppedAssignmentIds() and the
server. Apply the same secondary 'points_possible desc' / id-asc
tie-break so the projected number agrees with the dropped badge and the
server grade in tie edge cases.
…stency
Baseline already carried the curve_* columns but omitted
course_categories.drop_lowest, assignments.gradescope_assignment_id, and
the gradescope_credentials / gradescope_course_links tables. Fold all of
them in so a fresh install's baseline matches the same feature set the
curve columns implied, instead of a partial mix. Migrations 0019/0020
still run after baseline and remain idempotent (IF NOT EXISTS).
@AndresL230

Copy link
Copy Markdown
Collaborator

Superseded by the DB modular redesign (#279): the gradebook is rebuilt enrollment-keyed (gradebook_categories/assignments on enrollment_id, curve + drop-lowest, per-semester + cumulative GPA) in 0021 + the gradebook slice. Closing as obsolete — this would collide with the redesigned schema. Reopen if any behavior here isn't covered by #279.

@AndresL230

Copy link
Copy Markdown
Collaborator

Reopening. This was closed as superseded by the DB modular redesign (#279), but that was over-broad: the redesign only re-keyed the gradebook schema, it did not rebuild the Gradescope import integration (BU SSO sync, gradescope_service.py) in this PR. That feature work is net-new and shouldn't have been swept up. Needs a rebase onto the new enrollment-keyed gradebook schema, then it can resume review.

- Revert category_grade to points-weighted (total_earned/total_possible);
update tests to match and add explicit higher-point-weight test
- Fix GradeProjector: return null when nothing is graded (was showing
Now 0.0%) and mirror points-weighted math in dropAndSum
- Fix curvedAssignments in Course.tsx: skip curve when policy fields
are NULL instead of applying hardcoded 0.83/0.10 defaults
- Fix Landing.tsx: authenticated users see empty state on fetch
failure instead of fake SAMPLE_COURSES data
- Fix test_gradescope.py: add upsert to _table_factory mock; tighten
test_bu_sso_limited_after_3 and test_limit_is_per_user assertions
- Add CHECK (auth_mode IN ('password','cookies')) to migration 0020
Comment threadbackend/tests/test_gradebook_service.py Fixed
@Darkest-Teddy
Darkest-Teddy merged commit 1c2f6f0 into mainJun 28, 2026
6 checks passed
@AndresL230
AndresL230 deleted the Gradebook branch August 2, 2026 18:29
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.

3 participants

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

Gradebook Feature - #241

Merged
Darkest-Teddy merged 53 commits into
mainfrom
Gradebook
Jun 28, 2026
Merged

Gradebook Feature#241
Darkest-Teddy merged 53 commits into
mainfrom
Gradebook

Conversation

@Darkest-Teddy

@Darkest-TeddyDarkest-Teddy commented Jun 18, 2026

Copy link
Copy Markdown
Collaborator

Description

Major Gradebook feature release. Adds a bell curve grading system, a grade predictor panel, category tab navigation, and a large set of UI polish across the course page.

Changes Made

  • Bell curve grading: apply_curve backend function, per-assignment class stats (entered via assignment modal), course-level curve policy (avg target + SD delta), Raw/Curved toggle persisted per-course, curved score chip on assignment rows
  • Grade predictor panel: expandable panel below composition bar with per-assignment hypothetical sliders, Raw/Curved toggle inside predictor, class stats override for curve computation
  • Grade projector & composition bar: instant update on Raw/Curved toggle, isPredicted visual mode, computeCurrentGrade mirrors backend logic exactly
  • Letter scale fix: frontend DEFAULT_SCALE updated to match backend full 12-tier A+/A-/B+ scale; floating-point boundary fix (round to 1dp before comparisons)
  • Category navigation: horizontal tab strip above assignment list with hover states and assignment counts
  • Assignment rows: hover highlight extends full width, clean straight dividers, Dropped/Curved chips inline with title, tightened fraction display (30/34 format)
  • Category colors: 10 distinct hues evenly spaced around the hue wheel, no repeats
  • Auth fix: SECURE_COOKIES env flag, SESSION_SECRET wired to frontend, secure=false on localhost for OAuth cookies
  • Drag-to-reorder: categories in Edit Weights modal support drag-and-drop reordering
  • Drop policy: renamed "drops x/x" → "N Drops"
  • Course name: no longer truncated with ellipsis

Related Issues

Closes #

Testing

  • Tested locally
  • Added/updated tests

Screenshots (if applicable)

Notes for Reviewers

Schema changes ship as ordered migrations (backend/db/migrations/00190021) applied by the migration runner (backend/db/migrate.py --apply, run from backend/) — no manual Supabase SQL-editor steps required. The runner is idempotent and records applied versions in schema_migrations. The migrations cover the bell-curve columns (assignments.curve_*, user_courses.curve_*), course_categories.drop_lowest, and the Gradescope tables/columns.

(The previous manual ALTER TABLE block here was stale — it omitted drop_lowest and the Gradescope schema — and has been removed in favor of the runner.)

Summary by CodeRabbit

  • New Features

    • Added Gradescope account connection, course linking, and sync support from Settings.
    • Added course-level curve settings and per-category “drop lowest” grading support.
    • Introduced grade prediction tools for ungraded assignments and curved projections.
    • Added a dedicated Connected Accounts page.
  • Bug Fixes

    • Improved cookie handling for login/session flows across secure and non-secure environments.
    • Refined grading calculations and grade display behavior for curved, dropped, and predicted scores.

Darkest-Teddyand others added 26 commits May 10, 2026 00:36
…n grid
Replace the gradient course cards with the V1b layout from the design
handoff: solid course-color band, course code top-left in JetBrains Mono,
Playfair letter grade bottom-left, and an overlapping-discs watermark.
Footer percentage is now color-graded green→amber→rust→rose→crimson.
Also:
- storage_service: recognize Supabase's HTTP 400 + statusCode:"409" body
as "bucket already exists" so startup stops warning on every restart.
- localData: stub /api/gradebook/summary so the landing page works under
NEXT_PUBLIC_LOCAL_MODE.
googleapiclient routes through httplib2, which ignores HTTPS_PROXY and
times out (WinError 10060) on dev machines and proxy-bound deployments.
Swap to a direct httpx GET and wrap in _fail_redirect("userinfo_fetch_failed")
to match the existing oauth_exchange_failed error pattern.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Course detail page restructure
- Collapse the masthead from a ~340px hero into a single identity row
(kicker + name + letter+percent pill; letter scale lives in the
pill's hover tooltip instead of burning vertical space).
- Replace the side-by-side DistributionStrip + GradeProjector pair
with a hero-scale GradeCompositionBar. Each category gets a
weight-proportional slot whose sub-segments encode earned (solid),
lost (faded), and still-reachable (hatched). Letter cutoffs overlay
the bar at 60/70/80/90, and a "Now" pin marks current %.
- Cursor-following tooltips on every sub-segment explain the math
behind that region (Earned 18.5 of 20 pts, contributing 18.50% to
final, etc.); the tooltip auto-flips when near viewport edges.
- Single-column page below the masthead — Masthead -> Composition ->
Assignments — so the assignment list (the actual data-entry surface)
sits above the fold instead of buried under stat duplication.
Drop-lowest grading policy
- New course_categories.drop_lowest column
(migration_gradebook_drops.sql, idempotent + non-negative CHECK).
- gradebook_service.category_grade drops the N lowest graded items by
earned/possible before averaging; new dropped_assignment_ids /
all_dropped_ids helpers expose which IDs are currently excluded per
category and across the whole course (returned on /courses/:id).
- projectGrade re-runs the same drop logic for the floor (ungraded ->
0) and ceiling (ungraded -> full) scenarios, so projection respects
drops in every direction; droppedAssignmentIds computes the same set
client-side so optimistic grade edits update immediately.
- EditWeightsModal gains a Drop column next to Weight.
- AssignmentList mutes dropped rows (55% opacity + strikethrough) with
an inline "dropped" chip, and each category header shows a
"drops X/N" progress chip when the policy is active.
Cosmetic
- Course-card orb watermark seeds via min-distance rejection sampling
(62px between disc centers) so the three discs never land
near-coincident.
Run backend/db/migration_gradebook_drops.sql in Supabase before
deploying — the new SELECT on course_categories includes drop_lowest
and PostgREST will 500 otherwise.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ring
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ve mode
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
… endpoint
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…eral for curve_mode
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ettingsModal to course page
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…improvements
- Fix auth cookies (SECURE_COOKIES flag, SESSION_SECRET wiring frontend+backend)
- Grade predictor panel with hypothetical scores and Raw/Curved toggle
- Bell curve grading: apply_curve function, per-assignment and course policy
- Category color palette expanded to 10 distinct hues
- Category tab navigation above assignment list with hover states
- Assignment row hover highlight extends left/right with clean dividers
- Dropped and Curved chips on assignment rows
- Fraction score display tightened (30/34 format)
- Letter scale fixed to match backend full A+/A-/B+ 12-tier default
- Float precision fix: round to 1dp before letter-scale comparisons
- Composition bar instant update on Raw/Curved toggle
- Drop policy renamed to N Drops
- Course name no longer truncated
@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented Jun 18, 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
frontend39e2197Commit Preview URL

Branch Preview URL
Jun 22 2026, 03:35 AM

@coderabbitai

coderabbitaiBot commented Jun 18, 2026

Copy link
Copy Markdown

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Adds bell-curve grading and per-category drop-lowest scoring to the gradebook service, routes, and UI. Introduces a Grade Predictor panel for hypothetical scoring. Integrates Gradescope sync via password, session-cookie, and BU SSO/Duo auth modes with credential storage and course-link management. Updates auth cookie security to be configurable and replaces Google userinfo fetching with httpx.

Changes

Gradebook Enhancements: Drop-Lowest, Bell Curve & Grade Predictor

Layer / File(s)Summary
DB migrations and shared type contracts
backend/db/migrations/0001_baseline_schema.sql, backend/db/migrations/0019_gradebook_drops.sql, backend/db/migrations/0021_gradebook_curve.sql, backend/models/__init__.py, frontend/src/lib/types.ts
Adds drop_lowest to course_categories, curve-policy fields to user_courses, and curve-stat columns to assignments; mirrors these in Pydantic models and frontend TypeScript interfaces.
Gradebook service calculations and tests
backend/services/gradebook_service.py, backend/tests/test_gradebook_service.py
Replaces category_grade with a drop-lowest-aware, bell-curve-aware implementation; adds apply_curve, dropped_assignment_ids, all_dropped_ids helpers; rounds letter_for to 4dp; adds unit tests for curve mapping and curved category-grade paths.
Gradebook route/query/write flow updates
backend/routes/gradebook.py, frontend/src/lib/api.ts, frontend/src/lib/localData.ts
Extends summary/course queries and CRUD writes for drop-lowest and curve fields; adds PATCH /courses/{course_id}/curve endpoint; updates frontend API types and local-mode handler for new payloads.
Frontend curve utilities and projector math
frontend/src/components/Gradebook/curveUtils.ts, frontend/src/components/Gradebook/categoryColor.ts, frontend/src/components/Gradebook/GradeProjector.tsx
Adds applyCurve/applyCurveToAssignment/hasCurveData utilities, deterministic categoryColor mapping, and the GradeProjector component with projectGrade/droppedAssignmentIds math.
Assignment and category editing/list UI
frontend/src/components/Gradebook/AssignmentModal.tsx, frontend/src/components/Gradebook/EditWeightsModal.tsx, frontend/src/components/Gradebook/AssignmentList.tsx
Adds bell-curve and due-date validation to AssignmentModal, adds drag-reorder and drop_lowest field to EditWeightsModal, and rewrites AssignmentList into grouped category sections with dropped/curved indicators.
Grade Predictor panel component
frontend/src/components/Gradebook/GradePredictorPanel.tsx
Adds expandable predictor panel with per-assignment slider/input rows, optional predictor curve-mode controls, and reset behavior wired to parent hypotheticals state.
Course cards, visuals, landing interactions
frontend/src/app/globals.css, frontend/src/components/Gradebook/AmbientOrbs.tsx, frontend/src/components/Gradebook/CourseCard.tsx, frontend/src/components/Gradebook/SemesterChips.tsx, frontend/src/components/Gradebook/SyllabusUploadFlow.tsx, frontend/src/components/ToastProvider.tsx, frontend/src/components/screens/Gradebook/Landing.tsx
Adds CSS layout/animation tokens, ambient orb overlay, deterministic SVG watermark course cards, keyboard-navigable landing grid with color maps, and accessibility/styling tweaks.
Course screen orchestration rewrite
frontend/src/components/screens/Gradebook/Course.tsx
Reworks the course page to orchestrate curve-settings modal, predictor state, Gradescope sync polling, optimistic grade editing, GradeCompositionBar with per-category earned/lost/remaining and tooltip, and loading/error skeletons.
Bell-curve and predictor docs
docs/superpowers/plans/2026-06-16-bell-curve-grading.md, docs/superpowers/plans/2026-06-16-grade-predictor.md, docs/superpowers/specs/2026-06-16-bell-curve-grading-design.md, docs/superpowers/specs/2026-06-16-grade-predictor-design.md
Adds design specs and implementation plans for bell-curve grading and grade predictor panel features.

Gradescope Sync Integration

Layer / File(s)Summary
Gradescope schema and constraints
backend/db/migrations/0001_baseline_schema.sql, backend/db/migrations/0020_gradescope.sql
Creates gradescope_credentials with auth-mode payload constraints, gradescope_course_links with uniqueness enforcement, and a partial unique index on assignments(course_id, gradescope_assignment_id).
Gradescope service and dependency wiring
backend/services/gradescope_service.py, backend/requirements.txt
Adds gated gradescopeapi/Playwright imports, custom exceptions, password and cookie-based login flows, gradebook data accessors, and a Playwright-driven BU SSO + Duo orchestration function.
Gradescope route surface, app wiring, and tests
backend/routes/gradescope.py, backend/main.py, frontend/src/lib/api.ts, backend/tests/test_gradescope.py
Implements all /api/gradescope endpoints (credentials, BU SSO, status, course listing, links, sync), mounts the router, adds frontend API exports, and adds backend tests for parsing, authorization, rate limiting, and per-user isolation.
Gradescope modal and connected-accounts settings UI
frontend/src/components/Gradebook/GradescopeSyncModal.tsx, frontend/src/components/screens/ConnectedAccounts.tsx, frontend/src/app/(shell)/settings/connections/page.tsx, frontend/src/components/screens/Settings.tsx
Adds multi-stage connection/sync modal (password/cookies/BU SSO), connected-accounts screen with connect/reconnect/disconnect flows, and settings sidebar navigation to the new connections page.

Auth Secure-Cookie and Storage Response Handling

Layer / File(s)Summary
Secure cookie configuration and OAuth/session updates
backend/config.py, backend/routes/auth.py, frontend/src/app/api/auth/session/route.ts
Adds SECURE_COOKIES config derived from env or HTTPS URL prefix, applies it to all OAuth state cookie set/clear paths, and switches frontend session cookie secure to NODE_ENV === 'production'; replaces Google userinfo fetch with httpx.
Storage duplicate-bucket detection helper
backend/services/storage_service.py
Adds _is_duplicate_bucket to detect HTTP 409 and HTTP 400 JSON duplicate signals from Supabase.

CI Lint and ESLint Suppression Cleanup

Layer / File(s)Summary
Suppressions pruning and lint command change
.github/workflows/ci.yml, frontend/eslint-suppressions.json
Removes stale react-hooks/set-state-in-effect and related suppressions across multiple files and changes lint invocation from --max-warnings -1 to default npx eslint ..

Sequence Diagram(s)

sequenceDiagram
participant User
participant GradescopeSyncModal
participant ConnectedAccounts
participant GradescopeRoute as /api/gradescope
participant GradescopeService
participant GradescopeAPI as Gradescope.com
User->>ConnectedAccounts: Click "Connect"
ConnectedAccounts->>GradescopeSyncModal: open modal
User->>GradescopeSyncModal: Choose BU SSO mode
GradescopeSyncModal->>GradescopeRoute: POST /credentials/bu-sso
GradescopeRoute->>GradescopeService: login_via_bu_sso (asyncio.to_thread)
GradescopeService->>GradescopeAPI: Playwright BU Shibboleth + Duo flow
GradescopeAPI-->>GradescopeService: session cookies
GradescopeService-->>GradescopeRoute: {_gradescope_session, signed_token}
GradescopeRoute-->>GradescopeSyncModal: 200 OK
User->>GradescopeSyncModal: Select course + Sync
GradescopeSyncModal->>GradescopeRoute: POST /sync/{sapling_course_id}
GradescopeRoute->>GradescopeService: list_assignments
GradescopeService->>GradescopeAPI: scrape assignments
GradescopeAPI-->>GradescopeService: assignment list
GradescopeService-->>GradescopeRoute: normalized assignments
GradescopeRoute-->>GradescopeSyncModal: SyncResult {inserted, updated, skipped, failed}
GradescopeSyncModal-->>ConnectedAccounts: onSynced()
Loading

Estimated code review effort

🎯 5 (Critical) | ⏱️ ~120 minutes

Possibly related issues

Possibly related PRs

  • SaplingLearn/Sapling#56: Both modify the Google OAuth /google/callback flow in backend/routes/auth.py, with overlapping cookie and redirect handling logic.
  • SaplingLearn/Sapling#279: Directly overlaps in bell-curve and drop-lowest implementation in backend/services/gradebook_service.py and backend/routes/gradebook.py.
  • SaplingLearn/Sapling#262: Both touch .github/workflows/ci.yml ESLint command syntax and frontend/eslint-suppressions.json pruning.

Poem

🐰 A bunny once studied with grades in a pile,
Curves and predictions stretched out a full mile.
Gradescope was synced with a Duo-approved hop,
Drop-lowest? No problem — just skip to the top!
Now cookies are secure and the orbs gently drift,
Each semester, a colorful gradebook uplift. 🌟

🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (1 warning, 1 inconclusive)

Check nameStatusExplanationResolution
Docstring Coverage⚠️ WarningDocstring coverage is 42.18% which is insufficient. The required threshold is 80.00%.Write docstrings for the functions missing them to satisfy the coverage threshold.
Title check❓ InconclusiveThe title is too vague to describe the main Gradebook changes.Use a concise title that names the primary change, such as "Add bell-curve grading and Gradescope sync to gradebook".
✅ Passed checks (3 passed)
Check nameStatusExplanation
Description check✅ PassedThe description covers the main feature areas and notes, with only minor template gaps like testing and related issues.
Linked Issues check✅ PassedCheck skipped because no linked issues were found for this pull request.
Out of Scope Changes check✅ PassedCheck skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch Gradebook

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.

Comment threadfrontend/src/components/screens/Gradebook/Landing.tsx Fixed
Comment threadfrontend/src/components/screens/Gradebook/Course.tsx Fixed
Comment threadbackend/services/gradescope_service.py Fixed
Comment threadbackend/services/gradescope_service.py Fixed
Comment threadbackend/tests/test_gradebook_service.py Fixed
import requests
from bs4 import BeautifulSoup

from gradescopeapi import DEFAULT_GRADESCOPE_BASE_URL

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🟠 A missing dependency takes down the whole app, not just this feature.requests, bs4, and gradescopeapi are imported unconditionally here (only Playwright is guarded below), and main.py:24 imports routes.gradescope unconditionally. If any of these newly-added deps isn't installed in a deploy target, import routes.gradescope raises ImportError during router mount and uvicorn never boots — all of /api goes down. Guard these imports the way Playwright already is, or make the router mount degrade gracefully.

Comment threadbackend/routes/gradescope.py Outdated
"id": str(uuid.uuid4()),
"user_id": user_id,
"course_id": sapling_course_id,
"category_id": None, # user can re-categorize later

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🟠 Synced grades never affect the computed grade. Inserted assignments get category_id=None, but current_grade() only buckets assignments whose category_id matches a known category — so freshly-synced grades contribute nothing to the percent until the user manually categorizes each one. A successful sync visibly changes the grade by zero, which reads as a broken sync.

graded.append((float(e) / float(p), float(p), str(aid)))
if not graded:
return []
graded.sort(key=lambda x: (x[0], -x[1], x[2]))

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🟠 Drop-lowest is computed on RAW scores, but the curved display drops by CURVED score.dropped_assignment_ids ranks by raw earned/possible, while the frontend projectGrade/curved path drops based on curved values and the 'dropped' badge (droppedAssignmentIds(..., data.assignments)) uses raw. Under a curve that reorders scores, the server and UI exclude different assignments, and the badge can mark one assignment dropped while the math drops another.

by_cat[cid].append(a)
for c in cats:
c["category_grade"] = gradebook_service.category_grade(by_cat[c["id"]])
c["category_grade"] = gradebook_service.category_grade(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🟠 Per-category grade is always raw, even for curved courses. This call passes no curve args (defaults to curve_mode='raw'), while the overall percent just below (:155) is curved. The per-category numbers shown to the student won't reconcile with the curved headline grade.

Comment threadbackend/config.py
FRONTEND_URL = os.getenv("FRONTEND_URL", "http://localhost:3000")
SESSION_SECRET = os.getenv("SESSION_SECRET", "")
_sc_env = os.getenv("SECURE_COOKIES")
SECURE_COOKIES: bool = _sc_env.lower() == "true" if _sc_env is not None else FRONTEND_URL.startswith("https://")

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🟠 OAuth cookie Secure flag is no longer fail-closed.origin/main hardcoded secure=True; now it's secure=SECURE_COOKIES, which defaults to FRONTEND_URL.startswith('https://') when the env var is unset — and FRONTEND_URL itself defaults to http://localhost:3000. A TLS-fronted prod deploy that forgets to set FRONTEND_URL/SECURE_COOKIES will send the sapling_oauth cookie without Secure (used in auth.py:280/308/418/447). The frontend's secure: NODE_ENV==='production' for the session cookie (session/route.ts:101) is a second, independent mechanism that can disagree.

try:
if gs_id in by_gs_id:
table("assignments").update(
record_write,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🟡 Re-sync clobbers user edits.record_write unconditionally rewrites title/due_date/source on every sync, discarding any manual rename or due-date fix the student made to a linked assignment. Sync should be authoritative only for the grade columns + the idempotency key (gradescope_assignment_id).

points_earned: hyp.earned,
points_possible: hyp.possible > 0 ? hyp.possible : a.points_possible,
// Predictor override takes priority over stored assignment class stats
curve_class_mean: hyp.curveClassMean !== null ? hyp.curveClassMean : a.curve_class_mean,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🟡 Predictor can't clear a per-assignment curve override.hyp.curveClassMean !== null ? ... : a.curve_class_mean — when the user blanks the class-avg field, GradePredictorPanel emits null, so this falls back to the stored mean and the curve stays applied. (The panel itself reads with !== undefined, so the two layers disagree.) The 'what-if no curve' scenario is silently ignored.

if percent >= float(tier["min"]):
if rounded >= float(tier["min"]):
return str(tier["letter"])
return None

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🟡 A custom letter scale without a 0-floor tier yields no letter for failing students.set_letter_scale doesn't require an F/min:0 tier, so a scale like [{90,A},{80,B},{70,C}] makes this return None for 55% — the UI shows 55% with letter . tierFor in GradeProjector.tsx has the same gap. Consider falling back to the lowest tier instead of None.

@@ -0,0 +1,34 @@
-- Gradebook bell-curve grading: per-course curve policy + per-assignment class stats.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🔵 Duplicate migration tree. This file (and migration_gradebook_drops.sql, migration_gradescope.sql) is byte-identical to db/migrations/0021_gradebook_curve.sql (resp. 0019/0020). The runner only globs migrations/*.sql, so these loose copies never run and only exist to drift out of sync. Recommend deleting them and keeping the numbered sequence as the single source of truth.

@@ -53,6 +53,9 @@ CREATE TABLE IF NOT EXISTS user_courses (
nickname TEXT,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🔵 Editing an already-applied baseline migration is a no-op on existing DBs. Migrations are tracked by filename, so these added curve columns never execute on staging/prod — they exist there only because idempotent 0021 also adds them. The edit also adds curve_* but not drop_lowest or gradescope_assignment_id, so anything deriving schema from the baseline alone (test fixtures, archived schema) gets tables missing those columns. Let 0019-0021 own the new columns; don't edit applied baselines.

* Return a GradedAssignment with points_earned replaced by the curved value.
* Returns the original assignment unchanged if it has no curve data.
*/
export function applyCurveToAssignment(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🔵 Grade math is duplicated across four implementations (this file, GradeProjector.tsx, Course.tsx, and the Python gradebook_service.py), plus DEFAULT_SCALE/tierFor and the ?? 0.83/?? 0.10 policy are copy-pasted in ~5 spots. This duplication is the root cause of the divergences in the curve/drop findings. Only the predictor genuinely needs client-side compute; the plain 'curved' display could consume the server's percent/category_grade instead of re-deriving them.

@AndresL230

Copy link
Copy Markdown
Collaborator

Review summary — Gradebook Feature

Reviewed at high recall (correctness + cleanup/altitude). 14 inline comments posted on the diff; this comment covers the overview, one finding that doesn't map to a changed line, and what I checked that came back clean.

Top asks before merge

  1. Confirm the category_grade change is intentional (inline at gradebook_service.py:149) — it silently rewrites every existing grade in any category with unequal-point assignments (points-weighted → unweighted mean).
  2. Gate the Gradescope feature so the critical issues can't bite: missing-dependency boot crash (gradescope_service.py:27), grade-wiped-to-NULL on re-sync (gradescope.py:481), and synced grades not counting because category_id is NULL (gradescope.py:498).
  3. Reconcile the curve frontend/backend math — unset-policy divergence (Course.tsx:356), raw-vs-curved drop-lowest (gradebook_service.py:102), raw per-category grade (gradebook.py:151).

Additional finding (no single changed line to attach to)

🔴 The course-list endpoint ignores curve_mode.get_summary (backend/routes/gradebook.py:96) never selects curve_mode/curve_avg_target/curve_sd_delta and calls current_grade(...) with no curve args (defaults to 'raw'), whereas get_course (:155) computes curved. So the Landing/overview card shows e.g. 74%/C while opening the course shows 88%/B+ — the headline grade flips on click for any curved course.

Lower-priority notes

  • sync_course does one DB write per assignment (N+1) — batch into insert([...]) / upsert(..., on_conflict=...).
  • _load_creds returns the raw decrypt exception in the 500 detail (gradescope.py:130) — minor info leak; return a generic message.
  • Rate limiting is per-process in-memory, so bu-sso/sync caps aren't enforced across multiple replicas.

Verified clean (so you don't re-check these)

  • eslint-suppressions / CI: dropping the baseline + --max-warnings does not break the Frontend check — eslint . exits 0 on this branch (37 warnings, 0 errors).
  • auth.py googleapiclient → httpx switch:adds error handling the old .execute() lacked; no token-refresh behavior lost. Not a CLAUDE.md violation (the httpx rule is scoped to Supabase; this is a Google call).
  • CLAUDE.md conventions: Supabase access all goes through table(); gradescope router mounted at /api/gradescope; credentials/points/notes encrypted at write boundaries; tests in backend/tests/. No violations found.
  • current_grade weight renormalization over graded categories is pre-existing (identical in origin/main), not introduced here.

🤖 Generated with Claude Code

Darkest-Teddyand others added 4 commits June 23, 2026 21:19
- gradescope_service: guard gradescopeapi imports with try/except to
prevent boot crash when package is not installed; add _require_gradescopeapi()
guard to login, login_with_cookies, list_student_courses, list_assignments
- gradescope route: stop wiping points_earned/points_possible to NULL on
re-sync when Gradescope returns no grade for an assignment
- gradescope route: return generic 500 message from _load_creds instead
of leaking raw decrypt exception text to the client
- gradescope sync: auto-match new assignments to existing Sapling categories
by checking if the category name appears in the assignment title
- gradebook summary: fetch and pass curve_mode/curve_avg_target/curve_sd_delta
to current_grade so the landing card grade matches the course detail for
curved courses
- gradebook course: pass curve args to category_grade for per-category
grade display so it reflects the curve setting, not always raw
- Course.tsx predictor: remove ?? 0.83 / ?? 0.10 curve defaults so the
predictor only curves when both params are explicitly configured,
matching backend behaviour
These three files were byte-identical to the canonical numbered
migrations/0019, 0020, 0021. The migrate.py runner only scans
migrations/, so the flat copies are dead and risk a hand-run reviewer
re-applying the same DDL.
dropAndSum sorted by score only, so on tied score ratios the predictor
could drop a different assignment than droppedAssignmentIds() and the
server. Apply the same secondary 'points_possible desc' / id-asc
tie-break so the projected number agrees with the dropped badge and the
server grade in tie edge cases.
…stency
Baseline already carried the curve_* columns but omitted
course_categories.drop_lowest, assignments.gradescope_assignment_id, and
the gradescope_credentials / gradescope_course_links tables. Fold all of
them in so a fresh install's baseline matches the same feature set the
curve columns implied, instead of a partial mix. Migrations 0019/0020
still run after baseline and remain idempotent (IF NOT EXISTS).
@AndresL230

Copy link
Copy Markdown
Collaborator

Superseded by the DB modular redesign (#279): the gradebook is rebuilt enrollment-keyed (gradebook_categories/assignments on enrollment_id, curve + drop-lowest, per-semester + cumulative GPA) in 0021 + the gradebook slice. Closing as obsolete — this would collide with the redesigned schema. Reopen if any behavior here isn't covered by #279.

@AndresL230

Copy link
Copy Markdown
Collaborator

Reopening. This was closed as superseded by the DB modular redesign (#279), but that was over-broad: the redesign only re-keyed the gradebook schema, it did not rebuild the Gradescope import integration (BU SSO sync, gradescope_service.py) in this PR. That feature work is net-new and shouldn't have been swept up. Needs a rebase onto the new enrollment-keyed gradebook schema, then it can resume review.

- Revert category_grade to points-weighted (total_earned/total_possible);
update tests to match and add explicit higher-point-weight test
- Fix GradeProjector: return null when nothing is graded (was showing
Now 0.0%) and mirror points-weighted math in dropAndSum
- Fix curvedAssignments in Course.tsx: skip curve when policy fields
are NULL instead of applying hardcoded 0.83/0.10 defaults
- Fix Landing.tsx: authenticated users see empty state on fetch
failure instead of fake SAMPLE_COURSES data
- Fix test_gradescope.py: add upsert to _table_factory mock; tighten
test_bu_sso_limited_after_3 and test_limit_is_per_user assertions
- Add CHECK (auth_mode IN ('password','cookies')) to migration 0020
Comment threadbackend/tests/test_gradebook_service.py Fixed
@Darkest-Teddy
Darkest-Teddy merged commit 1c2f6f0 into mainJun 28, 2026
6 checks passed
@AndresL230
AndresL230 deleted the Gradebook branch August 2, 2026 18:29
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.

3 participants

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

Gradebook Feature - #241

Merged
Darkest-Teddy merged 53 commits into
mainfrom
Gradebook
Jun 28, 2026
Merged

Gradebook Feature#241
Darkest-Teddy merged 53 commits into
mainfrom
Gradebook

Conversation

@Darkest-Teddy

@Darkest-TeddyDarkest-Teddy commented Jun 18, 2026

Copy link
Copy Markdown
Collaborator

Description

Major Gradebook feature release. Adds a bell curve grading system, a grade predictor panel, category tab navigation, and a large set of UI polish across the course page.

Changes Made

  • Bell curve grading: apply_curve backend function, per-assignment class stats (entered via assignment modal), course-level curve policy (avg target + SD delta), Raw/Curved toggle persisted per-course, curved score chip on assignment rows
  • Grade predictor panel: expandable panel below composition bar with per-assignment hypothetical sliders, Raw/Curved toggle inside predictor, class stats override for curve computation
  • Grade projector & composition bar: instant update on Raw/Curved toggle, isPredicted visual mode, computeCurrentGrade mirrors backend logic exactly
  • Letter scale fix: frontend DEFAULT_SCALE updated to match backend full 12-tier A+/A-/B+ scale; floating-point boundary fix (round to 1dp before comparisons)
  • Category navigation: horizontal tab strip above assignment list with hover states and assignment counts
  • Assignment rows: hover highlight extends full width, clean straight dividers, Dropped/Curved chips inline with title, tightened fraction display (30/34 format)
  • Category colors: 10 distinct hues evenly spaced around the hue wheel, no repeats
  • Auth fix: SECURE_COOKIES env flag, SESSION_SECRET wired to frontend, secure=false on localhost for OAuth cookies
  • Drag-to-reorder: categories in Edit Weights modal support drag-and-drop reordering
  • Drop policy: renamed "drops x/x" → "N Drops"
  • Course name: no longer truncated with ellipsis

Related Issues

Closes #

Testing

  • Tested locally
  • Added/updated tests

Screenshots (if applicable)

Notes for Reviewers

Schema changes ship as ordered migrations (backend/db/migrations/00190021) applied by the migration runner (backend/db/migrate.py --apply, run from backend/) — no manual Supabase SQL-editor steps required. The runner is idempotent and records applied versions in schema_migrations. The migrations cover the bell-curve columns (assignments.curve_*, user_courses.curve_*), course_categories.drop_lowest, and the Gradescope tables/columns.

(The previous manual ALTER TABLE block here was stale — it omitted drop_lowest and the Gradescope schema — and has been removed in favor of the runner.)

Summary by CodeRabbit

  • New Features

    • Added Gradescope account connection, course linking, and sync support from Settings.
    • Added course-level curve settings and per-category “drop lowest” grading support.
    • Introduced grade prediction tools for ungraded assignments and curved projections.
    • Added a dedicated Connected Accounts page.
  • Bug Fixes

    • Improved cookie handling for login/session flows across secure and non-secure environments.
    • Refined grading calculations and grade display behavior for curved, dropped, and predicted scores.

Darkest-Teddyand others added 26 commits May 10, 2026 00:36
…n grid
Replace the gradient course cards with the V1b layout from the design
handoff: solid course-color band, course code top-left in JetBrains Mono,
Playfair letter grade bottom-left, and an overlapping-discs watermark.
Footer percentage is now color-graded green→amber→rust→rose→crimson.
Also:
- storage_service: recognize Supabase's HTTP 400 + statusCode:"409" body
as "bucket already exists" so startup stops warning on every restart.
- localData: stub /api/gradebook/summary so the landing page works under
NEXT_PUBLIC_LOCAL_MODE.
googleapiclient routes through httplib2, which ignores HTTPS_PROXY and
times out (WinError 10060) on dev machines and proxy-bound deployments.
Swap to a direct httpx GET and wrap in _fail_redirect("userinfo_fetch_failed")
to match the existing oauth_exchange_failed error pattern.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Course detail page restructure
- Collapse the masthead from a ~340px hero into a single identity row
(kicker + name + letter+percent pill; letter scale lives in the
pill's hover tooltip instead of burning vertical space).
- Replace the side-by-side DistributionStrip + GradeProjector pair
with a hero-scale GradeCompositionBar. Each category gets a
weight-proportional slot whose sub-segments encode earned (solid),
lost (faded), and still-reachable (hatched). Letter cutoffs overlay
the bar at 60/70/80/90, and a "Now" pin marks current %.
- Cursor-following tooltips on every sub-segment explain the math
behind that region (Earned 18.5 of 20 pts, contributing 18.50% to
final, etc.); the tooltip auto-flips when near viewport edges.
- Single-column page below the masthead — Masthead -> Composition ->
Assignments — so the assignment list (the actual data-entry surface)
sits above the fold instead of buried under stat duplication.
Drop-lowest grading policy
- New course_categories.drop_lowest column
(migration_gradebook_drops.sql, idempotent + non-negative CHECK).
- gradebook_service.category_grade drops the N lowest graded items by
earned/possible before averaging; new dropped_assignment_ids /
all_dropped_ids helpers expose which IDs are currently excluded per
category and across the whole course (returned on /courses/:id).
- projectGrade re-runs the same drop logic for the floor (ungraded ->
0) and ceiling (ungraded -> full) scenarios, so projection respects
drops in every direction; droppedAssignmentIds computes the same set
client-side so optimistic grade edits update immediately.
- EditWeightsModal gains a Drop column next to Weight.
- AssignmentList mutes dropped rows (55% opacity + strikethrough) with
an inline "dropped" chip, and each category header shows a
"drops X/N" progress chip when the policy is active.
Cosmetic
- Course-card orb watermark seeds via min-distance rejection sampling
(62px between disc centers) so the three discs never land
near-coincident.
Run backend/db/migration_gradebook_drops.sql in Supabase before
deploying — the new SELECT on course_categories includes drop_lowest
and PostgREST will 500 otherwise.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ring
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ve mode
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
… endpoint
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…eral for curve_mode
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ettingsModal to course page
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…improvements
- Fix auth cookies (SECURE_COOKIES flag, SESSION_SECRET wiring frontend+backend)
- Grade predictor panel with hypothetical scores and Raw/Curved toggle
- Bell curve grading: apply_curve function, per-assignment and course policy
- Category color palette expanded to 10 distinct hues
- Category tab navigation above assignment list with hover states
- Assignment row hover highlight extends left/right with clean dividers
- Dropped and Curved chips on assignment rows
- Fraction score display tightened (30/34 format)
- Letter scale fixed to match backend full A+/A-/B+ 12-tier default
- Float precision fix: round to 1dp before letter-scale comparisons
- Composition bar instant update on Raw/Curved toggle
- Drop policy renamed to N Drops
- Course name no longer truncated
@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented Jun 18, 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
frontend39e2197Commit Preview URL

Branch Preview URL
Jun 22 2026, 03:35 AM

@coderabbitai

coderabbitaiBot commented Jun 18, 2026

Copy link
Copy Markdown

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Adds bell-curve grading and per-category drop-lowest scoring to the gradebook service, routes, and UI. Introduces a Grade Predictor panel for hypothetical scoring. Integrates Gradescope sync via password, session-cookie, and BU SSO/Duo auth modes with credential storage and course-link management. Updates auth cookie security to be configurable and replaces Google userinfo fetching with httpx.

Changes

Gradebook Enhancements: Drop-Lowest, Bell Curve & Grade Predictor

Layer / File(s)Summary
DB migrations and shared type contracts
backend/db/migrations/0001_baseline_schema.sql, backend/db/migrations/0019_gradebook_drops.sql, backend/db/migrations/0021_gradebook_curve.sql, backend/models/__init__.py, frontend/src/lib/types.ts
Adds drop_lowest to course_categories, curve-policy fields to user_courses, and curve-stat columns to assignments; mirrors these in Pydantic models and frontend TypeScript interfaces.
Gradebook service calculations and tests
backend/services/gradebook_service.py, backend/tests/test_gradebook_service.py
Replaces category_grade with a drop-lowest-aware, bell-curve-aware implementation; adds apply_curve, dropped_assignment_ids, all_dropped_ids helpers; rounds letter_for to 4dp; adds unit tests for curve mapping and curved category-grade paths.
Gradebook route/query/write flow updates
backend/routes/gradebook.py, frontend/src/lib/api.ts, frontend/src/lib/localData.ts
Extends summary/course queries and CRUD writes for drop-lowest and curve fields; adds PATCH /courses/{course_id}/curve endpoint; updates frontend API types and local-mode handler for new payloads.
Frontend curve utilities and projector math
frontend/src/components/Gradebook/curveUtils.ts, frontend/src/components/Gradebook/categoryColor.ts, frontend/src/components/Gradebook/GradeProjector.tsx
Adds applyCurve/applyCurveToAssignment/hasCurveData utilities, deterministic categoryColor mapping, and the GradeProjector component with projectGrade/droppedAssignmentIds math.
Assignment and category editing/list UI
frontend/src/components/Gradebook/AssignmentModal.tsx, frontend/src/components/Gradebook/EditWeightsModal.tsx, frontend/src/components/Gradebook/AssignmentList.tsx
Adds bell-curve and due-date validation to AssignmentModal, adds drag-reorder and drop_lowest field to EditWeightsModal, and rewrites AssignmentList into grouped category sections with dropped/curved indicators.
Grade Predictor panel component
frontend/src/components/Gradebook/GradePredictorPanel.tsx
Adds expandable predictor panel with per-assignment slider/input rows, optional predictor curve-mode controls, and reset behavior wired to parent hypotheticals state.
Course cards, visuals, landing interactions
frontend/src/app/globals.css, frontend/src/components/Gradebook/AmbientOrbs.tsx, frontend/src/components/Gradebook/CourseCard.tsx, frontend/src/components/Gradebook/SemesterChips.tsx, frontend/src/components/Gradebook/SyllabusUploadFlow.tsx, frontend/src/components/ToastProvider.tsx, frontend/src/components/screens/Gradebook/Landing.tsx
Adds CSS layout/animation tokens, ambient orb overlay, deterministic SVG watermark course cards, keyboard-navigable landing grid with color maps, and accessibility/styling tweaks.
Course screen orchestration rewrite
frontend/src/components/screens/Gradebook/Course.tsx
Reworks the course page to orchestrate curve-settings modal, predictor state, Gradescope sync polling, optimistic grade editing, GradeCompositionBar with per-category earned/lost/remaining and tooltip, and loading/error skeletons.
Bell-curve and predictor docs
docs/superpowers/plans/2026-06-16-bell-curve-grading.md, docs/superpowers/plans/2026-06-16-grade-predictor.md, docs/superpowers/specs/2026-06-16-bell-curve-grading-design.md, docs/superpowers/specs/2026-06-16-grade-predictor-design.md
Adds design specs and implementation plans for bell-curve grading and grade predictor panel features.

Gradescope Sync Integration

Layer / File(s)Summary
Gradescope schema and constraints
backend/db/migrations/0001_baseline_schema.sql, backend/db/migrations/0020_gradescope.sql
Creates gradescope_credentials with auth-mode payload constraints, gradescope_course_links with uniqueness enforcement, and a partial unique index on assignments(course_id, gradescope_assignment_id).
Gradescope service and dependency wiring
backend/services/gradescope_service.py, backend/requirements.txt
Adds gated gradescopeapi/Playwright imports, custom exceptions, password and cookie-based login flows, gradebook data accessors, and a Playwright-driven BU SSO + Duo orchestration function.
Gradescope route surface, app wiring, and tests
backend/routes/gradescope.py, backend/main.py, frontend/src/lib/api.ts, backend/tests/test_gradescope.py
Implements all /api/gradescope endpoints (credentials, BU SSO, status, course listing, links, sync), mounts the router, adds frontend API exports, and adds backend tests for parsing, authorization, rate limiting, and per-user isolation.
Gradescope modal and connected-accounts settings UI
frontend/src/components/Gradebook/GradescopeSyncModal.tsx, frontend/src/components/screens/ConnectedAccounts.tsx, frontend/src/app/(shell)/settings/connections/page.tsx, frontend/src/components/screens/Settings.tsx
Adds multi-stage connection/sync modal (password/cookies/BU SSO), connected-accounts screen with connect/reconnect/disconnect flows, and settings sidebar navigation to the new connections page.

Auth Secure-Cookie and Storage Response Handling

Layer / File(s)Summary
Secure cookie configuration and OAuth/session updates
backend/config.py, backend/routes/auth.py, frontend/src/app/api/auth/session/route.ts
Adds SECURE_COOKIES config derived from env or HTTPS URL prefix, applies it to all OAuth state cookie set/clear paths, and switches frontend session cookie secure to NODE_ENV === 'production'; replaces Google userinfo fetch with httpx.
Storage duplicate-bucket detection helper
backend/services/storage_service.py
Adds _is_duplicate_bucket to detect HTTP 409 and HTTP 400 JSON duplicate signals from Supabase.

CI Lint and ESLint Suppression Cleanup

Layer / File(s)Summary
Suppressions pruning and lint command change
.github/workflows/ci.yml, frontend/eslint-suppressions.json
Removes stale react-hooks/set-state-in-effect and related suppressions across multiple files and changes lint invocation from --max-warnings -1 to default npx eslint ..

Sequence Diagram(s)

sequenceDiagram
participant User
participant GradescopeSyncModal
participant ConnectedAccounts
participant GradescopeRoute as /api/gradescope
participant GradescopeService
participant GradescopeAPI as Gradescope.com
User->>ConnectedAccounts: Click "Connect"
ConnectedAccounts->>GradescopeSyncModal: open modal
User->>GradescopeSyncModal: Choose BU SSO mode
GradescopeSyncModal->>GradescopeRoute: POST /credentials/bu-sso
GradescopeRoute->>GradescopeService: login_via_bu_sso (asyncio.to_thread)
GradescopeService->>GradescopeAPI: Playwright BU Shibboleth + Duo flow
GradescopeAPI-->>GradescopeService: session cookies
GradescopeService-->>GradescopeRoute: {_gradescope_session, signed_token}
GradescopeRoute-->>GradescopeSyncModal: 200 OK
User->>GradescopeSyncModal: Select course + Sync
GradescopeSyncModal->>GradescopeRoute: POST /sync/{sapling_course_id}
GradescopeRoute->>GradescopeService: list_assignments
GradescopeService->>GradescopeAPI: scrape assignments
GradescopeAPI-->>GradescopeService: assignment list
GradescopeService-->>GradescopeRoute: normalized assignments
GradescopeRoute-->>GradescopeSyncModal: SyncResult {inserted, updated, skipped, failed}
GradescopeSyncModal-->>ConnectedAccounts: onSynced()
Loading

Estimated code review effort

🎯 5 (Critical) | ⏱️ ~120 minutes

Possibly related issues

Possibly related PRs

  • SaplingLearn/Sapling#56: Both modify the Google OAuth /google/callback flow in backend/routes/auth.py, with overlapping cookie and redirect handling logic.
  • SaplingLearn/Sapling#279: Directly overlaps in bell-curve and drop-lowest implementation in backend/services/gradebook_service.py and backend/routes/gradebook.py.
  • SaplingLearn/Sapling#262: Both touch .github/workflows/ci.yml ESLint command syntax and frontend/eslint-suppressions.json pruning.

Poem

🐰 A bunny once studied with grades in a pile,
Curves and predictions stretched out a full mile.
Gradescope was synced with a Duo-approved hop,
Drop-lowest? No problem — just skip to the top!
Now cookies are secure and the orbs gently drift,
Each semester, a colorful gradebook uplift. 🌟

🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (1 warning, 1 inconclusive)

Check nameStatusExplanationResolution
Docstring Coverage⚠️ WarningDocstring coverage is 42.18% which is insufficient. The required threshold is 80.00%.Write docstrings for the functions missing them to satisfy the coverage threshold.
Title check❓ InconclusiveThe title is too vague to describe the main Gradebook changes.Use a concise title that names the primary change, such as "Add bell-curve grading and Gradescope sync to gradebook".
✅ Passed checks (3 passed)
Check nameStatusExplanation
Description check✅ PassedThe description covers the main feature areas and notes, with only minor template gaps like testing and related issues.
Linked Issues check✅ PassedCheck skipped because no linked issues were found for this pull request.
Out of Scope Changes check✅ PassedCheck skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch Gradebook

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.

Comment threadfrontend/src/components/screens/Gradebook/Landing.tsx Fixed
Comment threadfrontend/src/components/screens/Gradebook/Course.tsx Fixed
Comment threadbackend/services/gradescope_service.py Fixed
Comment threadbackend/services/gradescope_service.py Fixed
Comment threadbackend/tests/test_gradebook_service.py Fixed
import requests
from bs4 import BeautifulSoup

from gradescopeapi import DEFAULT_GRADESCOPE_BASE_URL

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🟠 A missing dependency takes down the whole app, not just this feature.requests, bs4, and gradescopeapi are imported unconditionally here (only Playwright is guarded below), and main.py:24 imports routes.gradescope unconditionally. If any of these newly-added deps isn't installed in a deploy target, import routes.gradescope raises ImportError during router mount and uvicorn never boots — all of /api goes down. Guard these imports the way Playwright already is, or make the router mount degrade gracefully.

Comment threadbackend/routes/gradescope.py Outdated
"id": str(uuid.uuid4()),
"user_id": user_id,
"course_id": sapling_course_id,
"category_id": None, # user can re-categorize later

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🟠 Synced grades never affect the computed grade. Inserted assignments get category_id=None, but current_grade() only buckets assignments whose category_id matches a known category — so freshly-synced grades contribute nothing to the percent until the user manually categorizes each one. A successful sync visibly changes the grade by zero, which reads as a broken sync.

graded.append((float(e) / float(p), float(p), str(aid)))
if not graded:
return []
graded.sort(key=lambda x: (x[0], -x[1], x[2]))

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🟠 Drop-lowest is computed on RAW scores, but the curved display drops by CURVED score.dropped_assignment_ids ranks by raw earned/possible, while the frontend projectGrade/curved path drops based on curved values and the 'dropped' badge (droppedAssignmentIds(..., data.assignments)) uses raw. Under a curve that reorders scores, the server and UI exclude different assignments, and the badge can mark one assignment dropped while the math drops another.

by_cat[cid].append(a)
for c in cats:
c["category_grade"] = gradebook_service.category_grade(by_cat[c["id"]])
c["category_grade"] = gradebook_service.category_grade(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🟠 Per-category grade is always raw, even for curved courses. This call passes no curve args (defaults to curve_mode='raw'), while the overall percent just below (:155) is curved. The per-category numbers shown to the student won't reconcile with the curved headline grade.

Comment threadbackend/config.py
FRONTEND_URL = os.getenv("FRONTEND_URL", "http://localhost:3000")
SESSION_SECRET = os.getenv("SESSION_SECRET", "")
_sc_env = os.getenv("SECURE_COOKIES")
SECURE_COOKIES: bool = _sc_env.lower() == "true" if _sc_env is not None else FRONTEND_URL.startswith("https://")

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🟠 OAuth cookie Secure flag is no longer fail-closed.origin/main hardcoded secure=True; now it's secure=SECURE_COOKIES, which defaults to FRONTEND_URL.startswith('https://') when the env var is unset — and FRONTEND_URL itself defaults to http://localhost:3000. A TLS-fronted prod deploy that forgets to set FRONTEND_URL/SECURE_COOKIES will send the sapling_oauth cookie without Secure (used in auth.py:280/308/418/447). The frontend's secure: NODE_ENV==='production' for the session cookie (session/route.ts:101) is a second, independent mechanism that can disagree.

try:
if gs_id in by_gs_id:
table("assignments").update(
record_write,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🟡 Re-sync clobbers user edits.record_write unconditionally rewrites title/due_date/source on every sync, discarding any manual rename or due-date fix the student made to a linked assignment. Sync should be authoritative only for the grade columns + the idempotency key (gradescope_assignment_id).

points_earned: hyp.earned,
points_possible: hyp.possible > 0 ? hyp.possible : a.points_possible,
// Predictor override takes priority over stored assignment class stats
curve_class_mean: hyp.curveClassMean !== null ? hyp.curveClassMean : a.curve_class_mean,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🟡 Predictor can't clear a per-assignment curve override.hyp.curveClassMean !== null ? ... : a.curve_class_mean — when the user blanks the class-avg field, GradePredictorPanel emits null, so this falls back to the stored mean and the curve stays applied. (The panel itself reads with !== undefined, so the two layers disagree.) The 'what-if no curve' scenario is silently ignored.

if percent >= float(tier["min"]):
if rounded >= float(tier["min"]):
return str(tier["letter"])
return None

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🟡 A custom letter scale without a 0-floor tier yields no letter for failing students.set_letter_scale doesn't require an F/min:0 tier, so a scale like [{90,A},{80,B},{70,C}] makes this return None for 55% — the UI shows 55% with letter . tierFor in GradeProjector.tsx has the same gap. Consider falling back to the lowest tier instead of None.

@@ -0,0 +1,34 @@
-- Gradebook bell-curve grading: per-course curve policy + per-assignment class stats.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🔵 Duplicate migration tree. This file (and migration_gradebook_drops.sql, migration_gradescope.sql) is byte-identical to db/migrations/0021_gradebook_curve.sql (resp. 0019/0020). The runner only globs migrations/*.sql, so these loose copies never run and only exist to drift out of sync. Recommend deleting them and keeping the numbered sequence as the single source of truth.

@@ -53,6 +53,9 @@ CREATE TABLE IF NOT EXISTS user_courses (
nickname TEXT,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🔵 Editing an already-applied baseline migration is a no-op on existing DBs. Migrations are tracked by filename, so these added curve columns never execute on staging/prod — they exist there only because idempotent 0021 also adds them. The edit also adds curve_* but not drop_lowest or gradescope_assignment_id, so anything deriving schema from the baseline alone (test fixtures, archived schema) gets tables missing those columns. Let 0019-0021 own the new columns; don't edit applied baselines.

* Return a GradedAssignment with points_earned replaced by the curved value.
* Returns the original assignment unchanged if it has no curve data.
*/
export function applyCurveToAssignment(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🔵 Grade math is duplicated across four implementations (this file, GradeProjector.tsx, Course.tsx, and the Python gradebook_service.py), plus DEFAULT_SCALE/tierFor and the ?? 0.83/?? 0.10 policy are copy-pasted in ~5 spots. This duplication is the root cause of the divergences in the curve/drop findings. Only the predictor genuinely needs client-side compute; the plain 'curved' display could consume the server's percent/category_grade instead of re-deriving them.

@AndresL230

Copy link
Copy Markdown
Collaborator

Review summary — Gradebook Feature

Reviewed at high recall (correctness + cleanup/altitude). 14 inline comments posted on the diff; this comment covers the overview, one finding that doesn't map to a changed line, and what I checked that came back clean.

Top asks before merge

  1. Confirm the category_grade change is intentional (inline at gradebook_service.py:149) — it silently rewrites every existing grade in any category with unequal-point assignments (points-weighted → unweighted mean).
  2. Gate the Gradescope feature so the critical issues can't bite: missing-dependency boot crash (gradescope_service.py:27), grade-wiped-to-NULL on re-sync (gradescope.py:481), and synced grades not counting because category_id is NULL (gradescope.py:498).
  3. Reconcile the curve frontend/backend math — unset-policy divergence (Course.tsx:356), raw-vs-curved drop-lowest (gradebook_service.py:102), raw per-category grade (gradebook.py:151).

Additional finding (no single changed line to attach to)

🔴 The course-list endpoint ignores curve_mode.get_summary (backend/routes/gradebook.py:96) never selects curve_mode/curve_avg_target/curve_sd_delta and calls current_grade(...) with no curve args (defaults to 'raw'), whereas get_course (:155) computes curved. So the Landing/overview card shows e.g. 74%/C while opening the course shows 88%/B+ — the headline grade flips on click for any curved course.

Lower-priority notes

  • sync_course does one DB write per assignment (N+1) — batch into insert([...]) / upsert(..., on_conflict=...).
  • _load_creds returns the raw decrypt exception in the 500 detail (gradescope.py:130) — minor info leak; return a generic message.
  • Rate limiting is per-process in-memory, so bu-sso/sync caps aren't enforced across multiple replicas.

Verified clean (so you don't re-check these)

  • eslint-suppressions / CI: dropping the baseline + --max-warnings does not break the Frontend check — eslint . exits 0 on this branch (37 warnings, 0 errors).
  • auth.py googleapiclient → httpx switch:adds error handling the old .execute() lacked; no token-refresh behavior lost. Not a CLAUDE.md violation (the httpx rule is scoped to Supabase; this is a Google call).
  • CLAUDE.md conventions: Supabase access all goes through table(); gradescope router mounted at /api/gradescope; credentials/points/notes encrypted at write boundaries; tests in backend/tests/. No violations found.
  • current_grade weight renormalization over graded categories is pre-existing (identical in origin/main), not introduced here.

🤖 Generated with Claude Code

Darkest-Teddyand others added 4 commits June 23, 2026 21:19
- gradescope_service: guard gradescopeapi imports with try/except to
prevent boot crash when package is not installed; add _require_gradescopeapi()
guard to login, login_with_cookies, list_student_courses, list_assignments
- gradescope route: stop wiping points_earned/points_possible to NULL on
re-sync when Gradescope returns no grade for an assignment
- gradescope route: return generic 500 message from _load_creds instead
of leaking raw decrypt exception text to the client
- gradescope sync: auto-match new assignments to existing Sapling categories
by checking if the category name appears in the assignment title
- gradebook summary: fetch and pass curve_mode/curve_avg_target/curve_sd_delta
to current_grade so the landing card grade matches the course detail for
curved courses
- gradebook course: pass curve args to category_grade for per-category
grade display so it reflects the curve setting, not always raw
- Course.tsx predictor: remove ?? 0.83 / ?? 0.10 curve defaults so the
predictor only curves when both params are explicitly configured,
matching backend behaviour
These three files were byte-identical to the canonical numbered
migrations/0019, 0020, 0021. The migrate.py runner only scans
migrations/, so the flat copies are dead and risk a hand-run reviewer
re-applying the same DDL.
dropAndSum sorted by score only, so on tied score ratios the predictor
could drop a different assignment than droppedAssignmentIds() and the
server. Apply the same secondary 'points_possible desc' / id-asc
tie-break so the projected number agrees with the dropped badge and the
server grade in tie edge cases.
…stency
Baseline already carried the curve_* columns but omitted
course_categories.drop_lowest, assignments.gradescope_assignment_id, and
the gradescope_credentials / gradescope_course_links tables. Fold all of
them in so a fresh install's baseline matches the same feature set the
curve columns implied, instead of a partial mix. Migrations 0019/0020
still run after baseline and remain idempotent (IF NOT EXISTS).
@AndresL230

Copy link
Copy Markdown
Collaborator

Superseded by the DB modular redesign (#279): the gradebook is rebuilt enrollment-keyed (gradebook_categories/assignments on enrollment_id, curve + drop-lowest, per-semester + cumulative GPA) in 0021 + the gradebook slice. Closing as obsolete — this would collide with the redesigned schema. Reopen if any behavior here isn't covered by #279.

@AndresL230

Copy link
Copy Markdown
Collaborator

Reopening. This was closed as superseded by the DB modular redesign (#279), but that was over-broad: the redesign only re-keyed the gradebook schema, it did not rebuild the Gradescope import integration (BU SSO sync, gradescope_service.py) in this PR. That feature work is net-new and shouldn't have been swept up. Needs a rebase onto the new enrollment-keyed gradebook schema, then it can resume review.

- Revert category_grade to points-weighted (total_earned/total_possible);
update tests to match and add explicit higher-point-weight test
- Fix GradeProjector: return null when nothing is graded (was showing
Now 0.0%) and mirror points-weighted math in dropAndSum
- Fix curvedAssignments in Course.tsx: skip curve when policy fields
are NULL instead of applying hardcoded 0.83/0.10 defaults
- Fix Landing.tsx: authenticated users see empty state on fetch
failure instead of fake SAMPLE_COURSES data
- Fix test_gradescope.py: add upsert to _table_factory mock; tighten
test_bu_sso_limited_after_3 and test_limit_is_per_user assertions
- Add CHECK (auth_mode IN ('password','cookies')) to migration 0020
Comment threadbackend/tests/test_gradebook_service.py Fixed
@Darkest-Teddy
Darkest-Teddy merged commit 1c2f6f0 into mainJun 28, 2026
6 checks passed
@AndresL230
AndresL230 deleted the Gradebook branch August 2, 2026 18:29
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.

3 participants

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

Gradebook Feature - #241

Merged
Darkest-Teddy merged 53 commits into
mainfrom
Gradebook
Jun 28, 2026
Merged

Gradebook Feature#241
Darkest-Teddy merged 53 commits into
mainfrom
Gradebook

Conversation

@Darkest-Teddy

@Darkest-TeddyDarkest-Teddy commented Jun 18, 2026

Copy link
Copy Markdown
Collaborator

Description

Major Gradebook feature release. Adds a bell curve grading system, a grade predictor panel, category tab navigation, and a large set of UI polish across the course page.

Changes Made

  • Bell curve grading: apply_curve backend function, per-assignment class stats (entered via assignment modal), course-level curve policy (avg target + SD delta), Raw/Curved toggle persisted per-course, curved score chip on assignment rows
  • Grade predictor panel: expandable panel below composition bar with per-assignment hypothetical sliders, Raw/Curved toggle inside predictor, class stats override for curve computation
  • Grade projector & composition bar: instant update on Raw/Curved toggle, isPredicted visual mode, computeCurrentGrade mirrors backend logic exactly
  • Letter scale fix: frontend DEFAULT_SCALE updated to match backend full 12-tier A+/A-/B+ scale; floating-point boundary fix (round to 1dp before comparisons)
  • Category navigation: horizontal tab strip above assignment list with hover states and assignment counts
  • Assignment rows: hover highlight extends full width, clean straight dividers, Dropped/Curved chips inline with title, tightened fraction display (30/34 format)
  • Category colors: 10 distinct hues evenly spaced around the hue wheel, no repeats
  • Auth fix: SECURE_COOKIES env flag, SESSION_SECRET wired to frontend, secure=false on localhost for OAuth cookies
  • Drag-to-reorder: categories in Edit Weights modal support drag-and-drop reordering
  • Drop policy: renamed "drops x/x" → "N Drops"
  • Course name: no longer truncated with ellipsis

Related Issues

Closes #

Testing

  • Tested locally
  • Added/updated tests

Screenshots (if applicable)

Notes for Reviewers

Schema changes ship as ordered migrations (backend/db/migrations/00190021) applied by the migration runner (backend/db/migrate.py --apply, run from backend/) — no manual Supabase SQL-editor steps required. The runner is idempotent and records applied versions in schema_migrations. The migrations cover the bell-curve columns (assignments.curve_*, user_courses.curve_*), course_categories.drop_lowest, and the Gradescope tables/columns.

(The previous manual ALTER TABLE block here was stale — it omitted drop_lowest and the Gradescope schema — and has been removed in favor of the runner.)

Summary by CodeRabbit

  • New Features

    • Added Gradescope account connection, course linking, and sync support from Settings.
    • Added course-level curve settings and per-category “drop lowest” grading support.
    • Introduced grade prediction tools for ungraded assignments and curved projections.
    • Added a dedicated Connected Accounts page.
  • Bug Fixes

    • Improved cookie handling for login/session flows across secure and non-secure environments.
    • Refined grading calculations and grade display behavior for curved, dropped, and predicted scores.

Darkest-Teddyand others added 26 commits May 10, 2026 00:36
…n grid
Replace the gradient course cards with the V1b layout from the design
handoff: solid course-color band, course code top-left in JetBrains Mono,
Playfair letter grade bottom-left, and an overlapping-discs watermark.
Footer percentage is now color-graded green→amber→rust→rose→crimson.
Also:
- storage_service: recognize Supabase's HTTP 400 + statusCode:"409" body
as "bucket already exists" so startup stops warning on every restart.
- localData: stub /api/gradebook/summary so the landing page works under
NEXT_PUBLIC_LOCAL_MODE.
googleapiclient routes through httplib2, which ignores HTTPS_PROXY and
times out (WinError 10060) on dev machines and proxy-bound deployments.
Swap to a direct httpx GET and wrap in _fail_redirect("userinfo_fetch_failed")
to match the existing oauth_exchange_failed error pattern.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Course detail page restructure
- Collapse the masthead from a ~340px hero into a single identity row
(kicker + name + letter+percent pill; letter scale lives in the
pill's hover tooltip instead of burning vertical space).
- Replace the side-by-side DistributionStrip + GradeProjector pair
with a hero-scale GradeCompositionBar. Each category gets a
weight-proportional slot whose sub-segments encode earned (solid),
lost (faded), and still-reachable (hatched). Letter cutoffs overlay
the bar at 60/70/80/90, and a "Now" pin marks current %.
- Cursor-following tooltips on every sub-segment explain the math
behind that region (Earned 18.5 of 20 pts, contributing 18.50% to
final, etc.); the tooltip auto-flips when near viewport edges.
- Single-column page below the masthead — Masthead -> Composition ->
Assignments — so the assignment list (the actual data-entry surface)
sits above the fold instead of buried under stat duplication.
Drop-lowest grading policy
- New course_categories.drop_lowest column
(migration_gradebook_drops.sql, idempotent + non-negative CHECK).
- gradebook_service.category_grade drops the N lowest graded items by
earned/possible before averaging; new dropped_assignment_ids /
all_dropped_ids helpers expose which IDs are currently excluded per
category and across the whole course (returned on /courses/:id).
- projectGrade re-runs the same drop logic for the floor (ungraded ->
0) and ceiling (ungraded -> full) scenarios, so projection respects
drops in every direction; droppedAssignmentIds computes the same set
client-side so optimistic grade edits update immediately.
- EditWeightsModal gains a Drop column next to Weight.
- AssignmentList mutes dropped rows (55% opacity + strikethrough) with
an inline "dropped" chip, and each category header shows a
"drops X/N" progress chip when the policy is active.
Cosmetic
- Course-card orb watermark seeds via min-distance rejection sampling
(62px between disc centers) so the three discs never land
near-coincident.
Run backend/db/migration_gradebook_drops.sql in Supabase before
deploying — the new SELECT on course_categories includes drop_lowest
and PostgREST will 500 otherwise.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ring
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ve mode
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
… endpoint
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…eral for curve_mode
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ettingsModal to course page
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…improvements
- Fix auth cookies (SECURE_COOKIES flag, SESSION_SECRET wiring frontend+backend)
- Grade predictor panel with hypothetical scores and Raw/Curved toggle
- Bell curve grading: apply_curve function, per-assignment and course policy
- Category color palette expanded to 10 distinct hues
- Category tab navigation above assignment list with hover states
- Assignment row hover highlight extends left/right with clean dividers
- Dropped and Curved chips on assignment rows
- Fraction score display tightened (30/34 format)
- Letter scale fixed to match backend full A+/A-/B+ 12-tier default
- Float precision fix: round to 1dp before letter-scale comparisons
- Composition bar instant update on Raw/Curved toggle
- Drop policy renamed to N Drops
- Course name no longer truncated
@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented Jun 18, 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
frontend39e2197Commit Preview URL

Branch Preview URL
Jun 22 2026, 03:35 AM

@coderabbitai

coderabbitaiBot commented Jun 18, 2026

Copy link
Copy Markdown

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Adds bell-curve grading and per-category drop-lowest scoring to the gradebook service, routes, and UI. Introduces a Grade Predictor panel for hypothetical scoring. Integrates Gradescope sync via password, session-cookie, and BU SSO/Duo auth modes with credential storage and course-link management. Updates auth cookie security to be configurable and replaces Google userinfo fetching with httpx.

Changes

Gradebook Enhancements: Drop-Lowest, Bell Curve & Grade Predictor

Layer / File(s)Summary
DB migrations and shared type contracts
backend/db/migrations/0001_baseline_schema.sql, backend/db/migrations/0019_gradebook_drops.sql, backend/db/migrations/0021_gradebook_curve.sql, backend/models/__init__.py, frontend/src/lib/types.ts
Adds drop_lowest to course_categories, curve-policy fields to user_courses, and curve-stat columns to assignments; mirrors these in Pydantic models and frontend TypeScript interfaces.
Gradebook service calculations and tests
backend/services/gradebook_service.py, backend/tests/test_gradebook_service.py
Replaces category_grade with a drop-lowest-aware, bell-curve-aware implementation; adds apply_curve, dropped_assignment_ids, all_dropped_ids helpers; rounds letter_for to 4dp; adds unit tests for curve mapping and curved category-grade paths.
Gradebook route/query/write flow updates
backend/routes/gradebook.py, frontend/src/lib/api.ts, frontend/src/lib/localData.ts
Extends summary/course queries and CRUD writes for drop-lowest and curve fields; adds PATCH /courses/{course_id}/curve endpoint; updates frontend API types and local-mode handler for new payloads.
Frontend curve utilities and projector math
frontend/src/components/Gradebook/curveUtils.ts, frontend/src/components/Gradebook/categoryColor.ts, frontend/src/components/Gradebook/GradeProjector.tsx
Adds applyCurve/applyCurveToAssignment/hasCurveData utilities, deterministic categoryColor mapping, and the GradeProjector component with projectGrade/droppedAssignmentIds math.
Assignment and category editing/list UI
frontend/src/components/Gradebook/AssignmentModal.tsx, frontend/src/components/Gradebook/EditWeightsModal.tsx, frontend/src/components/Gradebook/AssignmentList.tsx
Adds bell-curve and due-date validation to AssignmentModal, adds drag-reorder and drop_lowest field to EditWeightsModal, and rewrites AssignmentList into grouped category sections with dropped/curved indicators.
Grade Predictor panel component
frontend/src/components/Gradebook/GradePredictorPanel.tsx
Adds expandable predictor panel with per-assignment slider/input rows, optional predictor curve-mode controls, and reset behavior wired to parent hypotheticals state.
Course cards, visuals, landing interactions
frontend/src/app/globals.css, frontend/src/components/Gradebook/AmbientOrbs.tsx, frontend/src/components/Gradebook/CourseCard.tsx, frontend/src/components/Gradebook/SemesterChips.tsx, frontend/src/components/Gradebook/SyllabusUploadFlow.tsx, frontend/src/components/ToastProvider.tsx, frontend/src/components/screens/Gradebook/Landing.tsx
Adds CSS layout/animation tokens, ambient orb overlay, deterministic SVG watermark course cards, keyboard-navigable landing grid with color maps, and accessibility/styling tweaks.
Course screen orchestration rewrite
frontend/src/components/screens/Gradebook/Course.tsx
Reworks the course page to orchestrate curve-settings modal, predictor state, Gradescope sync polling, optimistic grade editing, GradeCompositionBar with per-category earned/lost/remaining and tooltip, and loading/error skeletons.
Bell-curve and predictor docs
docs/superpowers/plans/2026-06-16-bell-curve-grading.md, docs/superpowers/plans/2026-06-16-grade-predictor.md, docs/superpowers/specs/2026-06-16-bell-curve-grading-design.md, docs/superpowers/specs/2026-06-16-grade-predictor-design.md
Adds design specs and implementation plans for bell-curve grading and grade predictor panel features.

Gradescope Sync Integration

Layer / File(s)Summary
Gradescope schema and constraints
backend/db/migrations/0001_baseline_schema.sql, backend/db/migrations/0020_gradescope.sql
Creates gradescope_credentials with auth-mode payload constraints, gradescope_course_links with uniqueness enforcement, and a partial unique index on assignments(course_id, gradescope_assignment_id).
Gradescope service and dependency wiring
backend/services/gradescope_service.py, backend/requirements.txt
Adds gated gradescopeapi/Playwright imports, custom exceptions, password and cookie-based login flows, gradebook data accessors, and a Playwright-driven BU SSO + Duo orchestration function.
Gradescope route surface, app wiring, and tests
backend/routes/gradescope.py, backend/main.py, frontend/src/lib/api.ts, backend/tests/test_gradescope.py
Implements all /api/gradescope endpoints (credentials, BU SSO, status, course listing, links, sync), mounts the router, adds frontend API exports, and adds backend tests for parsing, authorization, rate limiting, and per-user isolation.
Gradescope modal and connected-accounts settings UI
frontend/src/components/Gradebook/GradescopeSyncModal.tsx, frontend/src/components/screens/ConnectedAccounts.tsx, frontend/src/app/(shell)/settings/connections/page.tsx, frontend/src/components/screens/Settings.tsx
Adds multi-stage connection/sync modal (password/cookies/BU SSO), connected-accounts screen with connect/reconnect/disconnect flows, and settings sidebar navigation to the new connections page.

Auth Secure-Cookie and Storage Response Handling

Layer / File(s)Summary
Secure cookie configuration and OAuth/session updates
backend/config.py, backend/routes/auth.py, frontend/src/app/api/auth/session/route.ts
Adds SECURE_COOKIES config derived from env or HTTPS URL prefix, applies it to all OAuth state cookie set/clear paths, and switches frontend session cookie secure to NODE_ENV === 'production'; replaces Google userinfo fetch with httpx.
Storage duplicate-bucket detection helper
backend/services/storage_service.py
Adds _is_duplicate_bucket to detect HTTP 409 and HTTP 400 JSON duplicate signals from Supabase.

CI Lint and ESLint Suppression Cleanup

Layer / File(s)Summary
Suppressions pruning and lint command change
.github/workflows/ci.yml, frontend/eslint-suppressions.json
Removes stale react-hooks/set-state-in-effect and related suppressions across multiple files and changes lint invocation from --max-warnings -1 to default npx eslint ..

Sequence Diagram(s)

sequenceDiagram
participant User
participant GradescopeSyncModal
participant ConnectedAccounts
participant GradescopeRoute as /api/gradescope
participant GradescopeService
participant GradescopeAPI as Gradescope.com
User->>ConnectedAccounts: Click "Connect"
ConnectedAccounts->>GradescopeSyncModal: open modal
User->>GradescopeSyncModal: Choose BU SSO mode
GradescopeSyncModal->>GradescopeRoute: POST /credentials/bu-sso
GradescopeRoute->>GradescopeService: login_via_bu_sso (asyncio.to_thread)
GradescopeService->>GradescopeAPI: Playwright BU Shibboleth + Duo flow
GradescopeAPI-->>GradescopeService: session cookies
GradescopeService-->>GradescopeRoute: {_gradescope_session, signed_token}
GradescopeRoute-->>GradescopeSyncModal: 200 OK
User->>GradescopeSyncModal: Select course + Sync
GradescopeSyncModal->>GradescopeRoute: POST /sync/{sapling_course_id}
GradescopeRoute->>GradescopeService: list_assignments
GradescopeService->>GradescopeAPI: scrape assignments
GradescopeAPI-->>GradescopeService: assignment list
GradescopeService-->>GradescopeRoute: normalized assignments
GradescopeRoute-->>GradescopeSyncModal: SyncResult {inserted, updated, skipped, failed}
GradescopeSyncModal-->>ConnectedAccounts: onSynced()
Loading

Estimated code review effort

🎯 5 (Critical) | ⏱️ ~120 minutes

Possibly related issues

Possibly related PRs

  • SaplingLearn/Sapling#56: Both modify the Google OAuth /google/callback flow in backend/routes/auth.py, with overlapping cookie and redirect handling logic.
  • SaplingLearn/Sapling#279: Directly overlaps in bell-curve and drop-lowest implementation in backend/services/gradebook_service.py and backend/routes/gradebook.py.
  • SaplingLearn/Sapling#262: Both touch .github/workflows/ci.yml ESLint command syntax and frontend/eslint-suppressions.json pruning.

Poem

🐰 A bunny once studied with grades in a pile,
Curves and predictions stretched out a full mile.
Gradescope was synced with a Duo-approved hop,
Drop-lowest? No problem — just skip to the top!
Now cookies are secure and the orbs gently drift,
Each semester, a colorful gradebook uplift. 🌟

🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (1 warning, 1 inconclusive)

Check nameStatusExplanationResolution
Docstring Coverage⚠️ WarningDocstring coverage is 42.18% which is insufficient. The required threshold is 80.00%.Write docstrings for the functions missing them to satisfy the coverage threshold.
Title check❓ InconclusiveThe title is too vague to describe the main Gradebook changes.Use a concise title that names the primary change, such as "Add bell-curve grading and Gradescope sync to gradebook".
✅ Passed checks (3 passed)
Check nameStatusExplanation
Description check✅ PassedThe description covers the main feature areas and notes, with only minor template gaps like testing and related issues.
Linked Issues check✅ PassedCheck skipped because no linked issues were found for this pull request.
Out of Scope Changes check✅ PassedCheck skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch Gradebook

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.

Comment threadfrontend/src/components/screens/Gradebook/Landing.tsx Fixed
Comment threadfrontend/src/components/screens/Gradebook/Course.tsx Fixed
Comment threadbackend/services/gradescope_service.py Fixed
Comment threadbackend/services/gradescope_service.py Fixed
Comment threadbackend/tests/test_gradebook_service.py Fixed
import requests
from bs4 import BeautifulSoup

from gradescopeapi import DEFAULT_GRADESCOPE_BASE_URL

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🟠 A missing dependency takes down the whole app, not just this feature.requests, bs4, and gradescopeapi are imported unconditionally here (only Playwright is guarded below), and main.py:24 imports routes.gradescope unconditionally. If any of these newly-added deps isn't installed in a deploy target, import routes.gradescope raises ImportError during router mount and uvicorn never boots — all of /api goes down. Guard these imports the way Playwright already is, or make the router mount degrade gracefully.

Comment threadbackend/routes/gradescope.py Outdated
"id": str(uuid.uuid4()),
"user_id": user_id,
"course_id": sapling_course_id,
"category_id": None, # user can re-categorize later

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🟠 Synced grades never affect the computed grade. Inserted assignments get category_id=None, but current_grade() only buckets assignments whose category_id matches a known category — so freshly-synced grades contribute nothing to the percent until the user manually categorizes each one. A successful sync visibly changes the grade by zero, which reads as a broken sync.

graded.append((float(e) / float(p), float(p), str(aid)))
if not graded:
return []
graded.sort(key=lambda x: (x[0], -x[1], x[2]))

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🟠 Drop-lowest is computed on RAW scores, but the curved display drops by CURVED score.dropped_assignment_ids ranks by raw earned/possible, while the frontend projectGrade/curved path drops based on curved values and the 'dropped' badge (droppedAssignmentIds(..., data.assignments)) uses raw. Under a curve that reorders scores, the server and UI exclude different assignments, and the badge can mark one assignment dropped while the math drops another.

by_cat[cid].append(a)
for c in cats:
c["category_grade"] = gradebook_service.category_grade(by_cat[c["id"]])
c["category_grade"] = gradebook_service.category_grade(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🟠 Per-category grade is always raw, even for curved courses. This call passes no curve args (defaults to curve_mode='raw'), while the overall percent just below (:155) is curved. The per-category numbers shown to the student won't reconcile with the curved headline grade.

Comment threadbackend/config.py
FRONTEND_URL = os.getenv("FRONTEND_URL", "http://localhost:3000")
SESSION_SECRET = os.getenv("SESSION_SECRET", "")
_sc_env = os.getenv("SECURE_COOKIES")
SECURE_COOKIES: bool = _sc_env.lower() == "true" if _sc_env is not None else FRONTEND_URL.startswith("https://")

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🟠 OAuth cookie Secure flag is no longer fail-closed.origin/main hardcoded secure=True; now it's secure=SECURE_COOKIES, which defaults to FRONTEND_URL.startswith('https://') when the env var is unset — and FRONTEND_URL itself defaults to http://localhost:3000. A TLS-fronted prod deploy that forgets to set FRONTEND_URL/SECURE_COOKIES will send the sapling_oauth cookie without Secure (used in auth.py:280/308/418/447). The frontend's secure: NODE_ENV==='production' for the session cookie (session/route.ts:101) is a second, independent mechanism that can disagree.

try:
if gs_id in by_gs_id:
table("assignments").update(
record_write,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🟡 Re-sync clobbers user edits.record_write unconditionally rewrites title/due_date/source on every sync, discarding any manual rename or due-date fix the student made to a linked assignment. Sync should be authoritative only for the grade columns + the idempotency key (gradescope_assignment_id).

points_earned: hyp.earned,
points_possible: hyp.possible > 0 ? hyp.possible : a.points_possible,
// Predictor override takes priority over stored assignment class stats
curve_class_mean: hyp.curveClassMean !== null ? hyp.curveClassMean : a.curve_class_mean,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🟡 Predictor can't clear a per-assignment curve override.hyp.curveClassMean !== null ? ... : a.curve_class_mean — when the user blanks the class-avg field, GradePredictorPanel emits null, so this falls back to the stored mean and the curve stays applied. (The panel itself reads with !== undefined, so the two layers disagree.) The 'what-if no curve' scenario is silently ignored.

if percent >= float(tier["min"]):
if rounded >= float(tier["min"]):
return str(tier["letter"])
return None

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🟡 A custom letter scale without a 0-floor tier yields no letter for failing students.set_letter_scale doesn't require an F/min:0 tier, so a scale like [{90,A},{80,B},{70,C}] makes this return None for 55% — the UI shows 55% with letter . tierFor in GradeProjector.tsx has the same gap. Consider falling back to the lowest tier instead of None.

@@ -0,0 +1,34 @@
-- Gradebook bell-curve grading: per-course curve policy + per-assignment class stats.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🔵 Duplicate migration tree. This file (and migration_gradebook_drops.sql, migration_gradescope.sql) is byte-identical to db/migrations/0021_gradebook_curve.sql (resp. 0019/0020). The runner only globs migrations/*.sql, so these loose copies never run and only exist to drift out of sync. Recommend deleting them and keeping the numbered sequence as the single source of truth.

@@ -53,6 +53,9 @@ CREATE TABLE IF NOT EXISTS user_courses (
nickname TEXT,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🔵 Editing an already-applied baseline migration is a no-op on existing DBs. Migrations are tracked by filename, so these added curve columns never execute on staging/prod — they exist there only because idempotent 0021 also adds them. The edit also adds curve_* but not drop_lowest or gradescope_assignment_id, so anything deriving schema from the baseline alone (test fixtures, archived schema) gets tables missing those columns. Let 0019-0021 own the new columns; don't edit applied baselines.

* Return a GradedAssignment with points_earned replaced by the curved value.
* Returns the original assignment unchanged if it has no curve data.
*/
export function applyCurveToAssignment(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🔵 Grade math is duplicated across four implementations (this file, GradeProjector.tsx, Course.tsx, and the Python gradebook_service.py), plus DEFAULT_SCALE/tierFor and the ?? 0.83/?? 0.10 policy are copy-pasted in ~5 spots. This duplication is the root cause of the divergences in the curve/drop findings. Only the predictor genuinely needs client-side compute; the plain 'curved' display could consume the server's percent/category_grade instead of re-deriving them.

@AndresL230

Copy link
Copy Markdown
Collaborator

Review summary — Gradebook Feature

Reviewed at high recall (correctness + cleanup/altitude). 14 inline comments posted on the diff; this comment covers the overview, one finding that doesn't map to a changed line, and what I checked that came back clean.

Top asks before merge

  1. Confirm the category_grade change is intentional (inline at gradebook_service.py:149) — it silently rewrites every existing grade in any category with unequal-point assignments (points-weighted → unweighted mean).
  2. Gate the Gradescope feature so the critical issues can't bite: missing-dependency boot crash (gradescope_service.py:27), grade-wiped-to-NULL on re-sync (gradescope.py:481), and synced grades not counting because category_id is NULL (gradescope.py:498).
  3. Reconcile the curve frontend/backend math — unset-policy divergence (Course.tsx:356), raw-vs-curved drop-lowest (gradebook_service.py:102), raw per-category grade (gradebook.py:151).

Additional finding (no single changed line to attach to)

🔴 The course-list endpoint ignores curve_mode.get_summary (backend/routes/gradebook.py:96) never selects curve_mode/curve_avg_target/curve_sd_delta and calls current_grade(...) with no curve args (defaults to 'raw'), whereas get_course (:155) computes curved. So the Landing/overview card shows e.g. 74%/C while opening the course shows 88%/B+ — the headline grade flips on click for any curved course.

Lower-priority notes

  • sync_course does one DB write per assignment (N+1) — batch into insert([...]) / upsert(..., on_conflict=...).
  • _load_creds returns the raw decrypt exception in the 500 detail (gradescope.py:130) — minor info leak; return a generic message.
  • Rate limiting is per-process in-memory, so bu-sso/sync caps aren't enforced across multiple replicas.

Verified clean (so you don't re-check these)

  • eslint-suppressions / CI: dropping the baseline + --max-warnings does not break the Frontend check — eslint . exits 0 on this branch (37 warnings, 0 errors).
  • auth.py googleapiclient → httpx switch:adds error handling the old .execute() lacked; no token-refresh behavior lost. Not a CLAUDE.md violation (the httpx rule is scoped to Supabase; this is a Google call).
  • CLAUDE.md conventions: Supabase access all goes through table(); gradescope router mounted at /api/gradescope; credentials/points/notes encrypted at write boundaries; tests in backend/tests/. No violations found.
  • current_grade weight renormalization over graded categories is pre-existing (identical in origin/main), not introduced here.

🤖 Generated with Claude Code

Darkest-Teddyand others added 4 commits June 23, 2026 21:19
- gradescope_service: guard gradescopeapi imports with try/except to
prevent boot crash when package is not installed; add _require_gradescopeapi()
guard to login, login_with_cookies, list_student_courses, list_assignments
- gradescope route: stop wiping points_earned/points_possible to NULL on
re-sync when Gradescope returns no grade for an assignment
- gradescope route: return generic 500 message from _load_creds instead
of leaking raw decrypt exception text to the client
- gradescope sync: auto-match new assignments to existing Sapling categories
by checking if the category name appears in the assignment title
- gradebook summary: fetch and pass curve_mode/curve_avg_target/curve_sd_delta
to current_grade so the landing card grade matches the course detail for
curved courses
- gradebook course: pass curve args to category_grade for per-category
grade display so it reflects the curve setting, not always raw
- Course.tsx predictor: remove ?? 0.83 / ?? 0.10 curve defaults so the
predictor only curves when both params are explicitly configured,
matching backend behaviour
These three files were byte-identical to the canonical numbered
migrations/0019, 0020, 0021. The migrate.py runner only scans
migrations/, so the flat copies are dead and risk a hand-run reviewer
re-applying the same DDL.
dropAndSum sorted by score only, so on tied score ratios the predictor
could drop a different assignment than droppedAssignmentIds() and the
server. Apply the same secondary 'points_possible desc' / id-asc
tie-break so the projected number agrees with the dropped badge and the
server grade in tie edge cases.
…stency
Baseline already carried the curve_* columns but omitted
course_categories.drop_lowest, assignments.gradescope_assignment_id, and
the gradescope_credentials / gradescope_course_links tables. Fold all of
them in so a fresh install's baseline matches the same feature set the
curve columns implied, instead of a partial mix. Migrations 0019/0020
still run after baseline and remain idempotent (IF NOT EXISTS).
@AndresL230

Copy link
Copy Markdown
Collaborator

Superseded by the DB modular redesign (#279): the gradebook is rebuilt enrollment-keyed (gradebook_categories/assignments on enrollment_id, curve + drop-lowest, per-semester + cumulative GPA) in 0021 + the gradebook slice. Closing as obsolete — this would collide with the redesigned schema. Reopen if any behavior here isn't covered by #279.

@AndresL230

Copy link
Copy Markdown
Collaborator

Reopening. This was closed as superseded by the DB modular redesign (#279), but that was over-broad: the redesign only re-keyed the gradebook schema, it did not rebuild the Gradescope import integration (BU SSO sync, gradescope_service.py) in this PR. That feature work is net-new and shouldn't have been swept up. Needs a rebase onto the new enrollment-keyed gradebook schema, then it can resume review.

- Revert category_grade to points-weighted (total_earned/total_possible);
update tests to match and add explicit higher-point-weight test
- Fix GradeProjector: return null when nothing is graded (was showing
Now 0.0%) and mirror points-weighted math in dropAndSum
- Fix curvedAssignments in Course.tsx: skip curve when policy fields
are NULL instead of applying hardcoded 0.83/0.10 defaults
- Fix Landing.tsx: authenticated users see empty state on fetch
failure instead of fake SAMPLE_COURSES data
- Fix test_gradescope.py: add upsert to _table_factory mock; tighten
test_bu_sso_limited_after_3 and test_limit_is_per_user assertions
- Add CHECK (auth_mode IN ('password','cookies')) to migration 0020
Comment threadbackend/tests/test_gradebook_service.py Fixed
@Darkest-Teddy
Darkest-Teddy merged commit 1c2f6f0 into mainJun 28, 2026
6 checks passed
@AndresL230
AndresL230 deleted the Gradebook branch August 2, 2026 18:29
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.

3 participants

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

Gradebook Feature - #241

Merged
Darkest-Teddy merged 53 commits into
mainfrom
Gradebook
Jun 28, 2026
Merged

Gradebook Feature#241
Darkest-Teddy merged 53 commits into
mainfrom
Gradebook

Conversation

@Darkest-Teddy

@Darkest-TeddyDarkest-Teddy commented Jun 18, 2026

Copy link
Copy Markdown
Collaborator

Description

Major Gradebook feature release. Adds a bell curve grading system, a grade predictor panel, category tab navigation, and a large set of UI polish across the course page.

Changes Made

  • Bell curve grading: apply_curve backend function, per-assignment class stats (entered via assignment modal), course-level curve policy (avg target + SD delta), Raw/Curved toggle persisted per-course, curved score chip on assignment rows
  • Grade predictor panel: expandable panel below composition bar with per-assignment hypothetical sliders, Raw/Curved toggle inside predictor, class stats override for curve computation
  • Grade projector & composition bar: instant update on Raw/Curved toggle, isPredicted visual mode, computeCurrentGrade mirrors backend logic exactly
  • Letter scale fix: frontend DEFAULT_SCALE updated to match backend full 12-tier A+/A-/B+ scale; floating-point boundary fix (round to 1dp before comparisons)
  • Category navigation: horizontal tab strip above assignment list with hover states and assignment counts
  • Assignment rows: hover highlight extends full width, clean straight dividers, Dropped/Curved chips inline with title, tightened fraction display (30/34 format)
  • Category colors: 10 distinct hues evenly spaced around the hue wheel, no repeats
  • Auth fix: SECURE_COOKIES env flag, SESSION_SECRET wired to frontend, secure=false on localhost for OAuth cookies
  • Drag-to-reorder: categories in Edit Weights modal support drag-and-drop reordering
  • Drop policy: renamed "drops x/x" → "N Drops"
  • Course name: no longer truncated with ellipsis

Related Issues

Closes #

Testing

  • Tested locally
  • Added/updated tests

Screenshots (if applicable)

Notes for Reviewers

Schema changes ship as ordered migrations (backend/db/migrations/00190021) applied by the migration runner (backend/db/migrate.py --apply, run from backend/) — no manual Supabase SQL-editor steps required. The runner is idempotent and records applied versions in schema_migrations. The migrations cover the bell-curve columns (assignments.curve_*, user_courses.curve_*), course_categories.drop_lowest, and the Gradescope tables/columns.

(The previous manual ALTER TABLE block here was stale — it omitted drop_lowest and the Gradescope schema — and has been removed in favor of the runner.)

Summary by CodeRabbit

  • New Features

    • Added Gradescope account connection, course linking, and sync support from Settings.
    • Added course-level curve settings and per-category “drop lowest” grading support.
    • Introduced grade prediction tools for ungraded assignments and curved projections.
    • Added a dedicated Connected Accounts page.
  • Bug Fixes

    • Improved cookie handling for login/session flows across secure and non-secure environments.
    • Refined grading calculations and grade display behavior for curved, dropped, and predicted scores.

Darkest-Teddyand others added 26 commits May 10, 2026 00:36
…n grid
Replace the gradient course cards with the V1b layout from the design
handoff: solid course-color band, course code top-left in JetBrains Mono,
Playfair letter grade bottom-left, and an overlapping-discs watermark.
Footer percentage is now color-graded green→amber→rust→rose→crimson.
Also:
- storage_service: recognize Supabase's HTTP 400 + statusCode:"409" body
as "bucket already exists" so startup stops warning on every restart.
- localData: stub /api/gradebook/summary so the landing page works under
NEXT_PUBLIC_LOCAL_MODE.
googleapiclient routes through httplib2, which ignores HTTPS_PROXY and
times out (WinError 10060) on dev machines and proxy-bound deployments.
Swap to a direct httpx GET and wrap in _fail_redirect("userinfo_fetch_failed")
to match the existing oauth_exchange_failed error pattern.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Course detail page restructure
- Collapse the masthead from a ~340px hero into a single identity row
(kicker + name + letter+percent pill; letter scale lives in the
pill's hover tooltip instead of burning vertical space).
- Replace the side-by-side DistributionStrip + GradeProjector pair
with a hero-scale GradeCompositionBar. Each category gets a
weight-proportional slot whose sub-segments encode earned (solid),
lost (faded), and still-reachable (hatched). Letter cutoffs overlay
the bar at 60/70/80/90, and a "Now" pin marks current %.
- Cursor-following tooltips on every sub-segment explain the math
behind that region (Earned 18.5 of 20 pts, contributing 18.50% to
final, etc.); the tooltip auto-flips when near viewport edges.
- Single-column page below the masthead — Masthead -> Composition ->
Assignments — so the assignment list (the actual data-entry surface)
sits above the fold instead of buried under stat duplication.
Drop-lowest grading policy
- New course_categories.drop_lowest column
(migration_gradebook_drops.sql, idempotent + non-negative CHECK).
- gradebook_service.category_grade drops the N lowest graded items by
earned/possible before averaging; new dropped_assignment_ids /
all_dropped_ids helpers expose which IDs are currently excluded per
category and across the whole course (returned on /courses/:id).
- projectGrade re-runs the same drop logic for the floor (ungraded ->
0) and ceiling (ungraded -> full) scenarios, so projection respects
drops in every direction; droppedAssignmentIds computes the same set
client-side so optimistic grade edits update immediately.
- EditWeightsModal gains a Drop column next to Weight.
- AssignmentList mutes dropped rows (55% opacity + strikethrough) with
an inline "dropped" chip, and each category header shows a
"drops X/N" progress chip when the policy is active.
Cosmetic
- Course-card orb watermark seeds via min-distance rejection sampling
(62px between disc centers) so the three discs never land
near-coincident.
Run backend/db/migration_gradebook_drops.sql in Supabase before
deploying — the new SELECT on course_categories includes drop_lowest
and PostgREST will 500 otherwise.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ring
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ve mode
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
… endpoint
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…eral for curve_mode
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ettingsModal to course page
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…improvements
- Fix auth cookies (SECURE_COOKIES flag, SESSION_SECRET wiring frontend+backend)
- Grade predictor panel with hypothetical scores and Raw/Curved toggle
- Bell curve grading: apply_curve function, per-assignment and course policy
- Category color palette expanded to 10 distinct hues
- Category tab navigation above assignment list with hover states
- Assignment row hover highlight extends left/right with clean dividers
- Dropped and Curved chips on assignment rows
- Fraction score display tightened (30/34 format)
- Letter scale fixed to match backend full A+/A-/B+ 12-tier default
- Float precision fix: round to 1dp before letter-scale comparisons
- Composition bar instant update on Raw/Curved toggle
- Drop policy renamed to N Drops
- Course name no longer truncated
@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented Jun 18, 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
frontend39e2197Commit Preview URL

Branch Preview URL
Jun 22 2026, 03:35 AM

@coderabbitai

coderabbitaiBot commented Jun 18, 2026

Copy link
Copy Markdown

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Adds bell-curve grading and per-category drop-lowest scoring to the gradebook service, routes, and UI. Introduces a Grade Predictor panel for hypothetical scoring. Integrates Gradescope sync via password, session-cookie, and BU SSO/Duo auth modes with credential storage and course-link management. Updates auth cookie security to be configurable and replaces Google userinfo fetching with httpx.

Changes

Gradebook Enhancements: Drop-Lowest, Bell Curve & Grade Predictor

Layer / File(s)Summary
DB migrations and shared type contracts
backend/db/migrations/0001_baseline_schema.sql, backend/db/migrations/0019_gradebook_drops.sql, backend/db/migrations/0021_gradebook_curve.sql, backend/models/__init__.py, frontend/src/lib/types.ts
Adds drop_lowest to course_categories, curve-policy fields to user_courses, and curve-stat columns to assignments; mirrors these in Pydantic models and frontend TypeScript interfaces.
Gradebook service calculations and tests
backend/services/gradebook_service.py, backend/tests/test_gradebook_service.py
Replaces category_grade with a drop-lowest-aware, bell-curve-aware implementation; adds apply_curve, dropped_assignment_ids, all_dropped_ids helpers; rounds letter_for to 4dp; adds unit tests for curve mapping and curved category-grade paths.
Gradebook route/query/write flow updates
backend/routes/gradebook.py, frontend/src/lib/api.ts, frontend/src/lib/localData.ts
Extends summary/course queries and CRUD writes for drop-lowest and curve fields; adds PATCH /courses/{course_id}/curve endpoint; updates frontend API types and local-mode handler for new payloads.
Frontend curve utilities and projector math
frontend/src/components/Gradebook/curveUtils.ts, frontend/src/components/Gradebook/categoryColor.ts, frontend/src/components/Gradebook/GradeProjector.tsx
Adds applyCurve/applyCurveToAssignment/hasCurveData utilities, deterministic categoryColor mapping, and the GradeProjector component with projectGrade/droppedAssignmentIds math.
Assignment and category editing/list UI
frontend/src/components/Gradebook/AssignmentModal.tsx, frontend/src/components/Gradebook/EditWeightsModal.tsx, frontend/src/components/Gradebook/AssignmentList.tsx
Adds bell-curve and due-date validation to AssignmentModal, adds drag-reorder and drop_lowest field to EditWeightsModal, and rewrites AssignmentList into grouped category sections with dropped/curved indicators.
Grade Predictor panel component
frontend/src/components/Gradebook/GradePredictorPanel.tsx
Adds expandable predictor panel with per-assignment slider/input rows, optional predictor curve-mode controls, and reset behavior wired to parent hypotheticals state.
Course cards, visuals, landing interactions
frontend/src/app/globals.css, frontend/src/components/Gradebook/AmbientOrbs.tsx, frontend/src/components/Gradebook/CourseCard.tsx, frontend/src/components/Gradebook/SemesterChips.tsx, frontend/src/components/Gradebook/SyllabusUploadFlow.tsx, frontend/src/components/ToastProvider.tsx, frontend/src/components/screens/Gradebook/Landing.tsx
Adds CSS layout/animation tokens, ambient orb overlay, deterministic SVG watermark course cards, keyboard-navigable landing grid with color maps, and accessibility/styling tweaks.
Course screen orchestration rewrite
frontend/src/components/screens/Gradebook/Course.tsx
Reworks the course page to orchestrate curve-settings modal, predictor state, Gradescope sync polling, optimistic grade editing, GradeCompositionBar with per-category earned/lost/remaining and tooltip, and loading/error skeletons.
Bell-curve and predictor docs
docs/superpowers/plans/2026-06-16-bell-curve-grading.md, docs/superpowers/plans/2026-06-16-grade-predictor.md, docs/superpowers/specs/2026-06-16-bell-curve-grading-design.md, docs/superpowers/specs/2026-06-16-grade-predictor-design.md
Adds design specs and implementation plans for bell-curve grading and grade predictor panel features.

Gradescope Sync Integration

Layer / File(s)Summary
Gradescope schema and constraints
backend/db/migrations/0001_baseline_schema.sql, backend/db/migrations/0020_gradescope.sql
Creates gradescope_credentials with auth-mode payload constraints, gradescope_course_links with uniqueness enforcement, and a partial unique index on assignments(course_id, gradescope_assignment_id).
Gradescope service and dependency wiring
backend/services/gradescope_service.py, backend/requirements.txt
Adds gated gradescopeapi/Playwright imports, custom exceptions, password and cookie-based login flows, gradebook data accessors, and a Playwright-driven BU SSO + Duo orchestration function.
Gradescope route surface, app wiring, and tests
backend/routes/gradescope.py, backend/main.py, frontend/src/lib/api.ts, backend/tests/test_gradescope.py
Implements all /api/gradescope endpoints (credentials, BU SSO, status, course listing, links, sync), mounts the router, adds frontend API exports, and adds backend tests for parsing, authorization, rate limiting, and per-user isolation.
Gradescope modal and connected-accounts settings UI
frontend/src/components/Gradebook/GradescopeSyncModal.tsx, frontend/src/components/screens/ConnectedAccounts.tsx, frontend/src/app/(shell)/settings/connections/page.tsx, frontend/src/components/screens/Settings.tsx
Adds multi-stage connection/sync modal (password/cookies/BU SSO), connected-accounts screen with connect/reconnect/disconnect flows, and settings sidebar navigation to the new connections page.

Auth Secure-Cookie and Storage Response Handling

Layer / File(s)Summary
Secure cookie configuration and OAuth/session updates
backend/config.py, backend/routes/auth.py, frontend/src/app/api/auth/session/route.ts
Adds SECURE_COOKIES config derived from env or HTTPS URL prefix, applies it to all OAuth state cookie set/clear paths, and switches frontend session cookie secure to NODE_ENV === 'production'; replaces Google userinfo fetch with httpx.
Storage duplicate-bucket detection helper
backend/services/storage_service.py
Adds _is_duplicate_bucket to detect HTTP 409 and HTTP 400 JSON duplicate signals from Supabase.

CI Lint and ESLint Suppression Cleanup

Layer / File(s)Summary
Suppressions pruning and lint command change
.github/workflows/ci.yml, frontend/eslint-suppressions.json
Removes stale react-hooks/set-state-in-effect and related suppressions across multiple files and changes lint invocation from --max-warnings -1 to default npx eslint ..

Sequence Diagram(s)

sequenceDiagram
participant User
participant GradescopeSyncModal
participant ConnectedAccounts
participant GradescopeRoute as /api/gradescope
participant GradescopeService
participant GradescopeAPI as Gradescope.com
User->>ConnectedAccounts: Click "Connect"
ConnectedAccounts->>GradescopeSyncModal: open modal
User->>GradescopeSyncModal: Choose BU SSO mode
GradescopeSyncModal->>GradescopeRoute: POST /credentials/bu-sso
GradescopeRoute->>GradescopeService: login_via_bu_sso (asyncio.to_thread)
GradescopeService->>GradescopeAPI: Playwright BU Shibboleth + Duo flow
GradescopeAPI-->>GradescopeService: session cookies
GradescopeService-->>GradescopeRoute: {_gradescope_session, signed_token}
GradescopeRoute-->>GradescopeSyncModal: 200 OK
User->>GradescopeSyncModal: Select course + Sync
GradescopeSyncModal->>GradescopeRoute: POST /sync/{sapling_course_id}
GradescopeRoute->>GradescopeService: list_assignments
GradescopeService->>GradescopeAPI: scrape assignments
GradescopeAPI-->>GradescopeService: assignment list
GradescopeService-->>GradescopeRoute: normalized assignments
GradescopeRoute-->>GradescopeSyncModal: SyncResult {inserted, updated, skipped, failed}
GradescopeSyncModal-->>ConnectedAccounts: onSynced()
Loading

Estimated code review effort

🎯 5 (Critical) | ⏱️ ~120 minutes

Possibly related issues

Possibly related PRs

  • SaplingLearn/Sapling#56: Both modify the Google OAuth /google/callback flow in backend/routes/auth.py, with overlapping cookie and redirect handling logic.
  • SaplingLearn/Sapling#279: Directly overlaps in bell-curve and drop-lowest implementation in backend/services/gradebook_service.py and backend/routes/gradebook.py.
  • SaplingLearn/Sapling#262: Both touch .github/workflows/ci.yml ESLint command syntax and frontend/eslint-suppressions.json pruning.

Poem

🐰 A bunny once studied with grades in a pile,
Curves and predictions stretched out a full mile.
Gradescope was synced with a Duo-approved hop,
Drop-lowest? No problem — just skip to the top!
Now cookies are secure and the orbs gently drift,
Each semester, a colorful gradebook uplift. 🌟

🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (1 warning, 1 inconclusive)

Check nameStatusExplanationResolution
Docstring Coverage⚠️ WarningDocstring coverage is 42.18% which is insufficient. The required threshold is 80.00%.Write docstrings for the functions missing them to satisfy the coverage threshold.
Title check❓ InconclusiveThe title is too vague to describe the main Gradebook changes.Use a concise title that names the primary change, such as "Add bell-curve grading and Gradescope sync to gradebook".
✅ Passed checks (3 passed)
Check nameStatusExplanation
Description check✅ PassedThe description covers the main feature areas and notes, with only minor template gaps like testing and related issues.
Linked Issues check✅ PassedCheck skipped because no linked issues were found for this pull request.
Out of Scope Changes check✅ PassedCheck skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch Gradebook

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.

Comment threadfrontend/src/components/screens/Gradebook/Landing.tsx Fixed
Comment threadfrontend/src/components/screens/Gradebook/Course.tsx Fixed
Comment threadbackend/services/gradescope_service.py Fixed
Comment threadbackend/services/gradescope_service.py Fixed
Comment threadbackend/tests/test_gradebook_service.py Fixed
import requests
from bs4 import BeautifulSoup

from gradescopeapi import DEFAULT_GRADESCOPE_BASE_URL

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🟠 A missing dependency takes down the whole app, not just this feature.requests, bs4, and gradescopeapi are imported unconditionally here (only Playwright is guarded below), and main.py:24 imports routes.gradescope unconditionally. If any of these newly-added deps isn't installed in a deploy target, import routes.gradescope raises ImportError during router mount and uvicorn never boots — all of /api goes down. Guard these imports the way Playwright already is, or make the router mount degrade gracefully.

Comment threadbackend/routes/gradescope.py Outdated
"id": str(uuid.uuid4()),
"user_id": user_id,
"course_id": sapling_course_id,
"category_id": None, # user can re-categorize later

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🟠 Synced grades never affect the computed grade. Inserted assignments get category_id=None, but current_grade() only buckets assignments whose category_id matches a known category — so freshly-synced grades contribute nothing to the percent until the user manually categorizes each one. A successful sync visibly changes the grade by zero, which reads as a broken sync.

graded.append((float(e) / float(p), float(p), str(aid)))
if not graded:
return []
graded.sort(key=lambda x: (x[0], -x[1], x[2]))

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🟠 Drop-lowest is computed on RAW scores, but the curved display drops by CURVED score.dropped_assignment_ids ranks by raw earned/possible, while the frontend projectGrade/curved path drops based on curved values and the 'dropped' badge (droppedAssignmentIds(..., data.assignments)) uses raw. Under a curve that reorders scores, the server and UI exclude different assignments, and the badge can mark one assignment dropped while the math drops another.

by_cat[cid].append(a)
for c in cats:
c["category_grade"] = gradebook_service.category_grade(by_cat[c["id"]])
c["category_grade"] = gradebook_service.category_grade(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🟠 Per-category grade is always raw, even for curved courses. This call passes no curve args (defaults to curve_mode='raw'), while the overall percent just below (:155) is curved. The per-category numbers shown to the student won't reconcile with the curved headline grade.

Comment threadbackend/config.py
FRONTEND_URL = os.getenv("FRONTEND_URL", "http://localhost:3000")
SESSION_SECRET = os.getenv("SESSION_SECRET", "")
_sc_env = os.getenv("SECURE_COOKIES")
SECURE_COOKIES: bool = _sc_env.lower() == "true" if _sc_env is not None else FRONTEND_URL.startswith("https://")

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🟠 OAuth cookie Secure flag is no longer fail-closed.origin/main hardcoded secure=True; now it's secure=SECURE_COOKIES, which defaults to FRONTEND_URL.startswith('https://') when the env var is unset — and FRONTEND_URL itself defaults to http://localhost:3000. A TLS-fronted prod deploy that forgets to set FRONTEND_URL/SECURE_COOKIES will send the sapling_oauth cookie without Secure (used in auth.py:280/308/418/447). The frontend's secure: NODE_ENV==='production' for the session cookie (session/route.ts:101) is a second, independent mechanism that can disagree.

try:
if gs_id in by_gs_id:
table("assignments").update(
record_write,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🟡 Re-sync clobbers user edits.record_write unconditionally rewrites title/due_date/source on every sync, discarding any manual rename or due-date fix the student made to a linked assignment. Sync should be authoritative only for the grade columns + the idempotency key (gradescope_assignment_id).

points_earned: hyp.earned,
points_possible: hyp.possible > 0 ? hyp.possible : a.points_possible,
// Predictor override takes priority over stored assignment class stats
curve_class_mean: hyp.curveClassMean !== null ? hyp.curveClassMean : a.curve_class_mean,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🟡 Predictor can't clear a per-assignment curve override.hyp.curveClassMean !== null ? ... : a.curve_class_mean — when the user blanks the class-avg field, GradePredictorPanel emits null, so this falls back to the stored mean and the curve stays applied. (The panel itself reads with !== undefined, so the two layers disagree.) The 'what-if no curve' scenario is silently ignored.

if percent >= float(tier["min"]):
if rounded >= float(tier["min"]):
return str(tier["letter"])
return None

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🟡 A custom letter scale without a 0-floor tier yields no letter for failing students.set_letter_scale doesn't require an F/min:0 tier, so a scale like [{90,A},{80,B},{70,C}] makes this return None for 55% — the UI shows 55% with letter . tierFor in GradeProjector.tsx has the same gap. Consider falling back to the lowest tier instead of None.

@@ -0,0 +1,34 @@
-- Gradebook bell-curve grading: per-course curve policy + per-assignment class stats.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🔵 Duplicate migration tree. This file (and migration_gradebook_drops.sql, migration_gradescope.sql) is byte-identical to db/migrations/0021_gradebook_curve.sql (resp. 0019/0020). The runner only globs migrations/*.sql, so these loose copies never run and only exist to drift out of sync. Recommend deleting them and keeping the numbered sequence as the single source of truth.

@@ -53,6 +53,9 @@ CREATE TABLE IF NOT EXISTS user_courses (
nickname TEXT,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🔵 Editing an already-applied baseline migration is a no-op on existing DBs. Migrations are tracked by filename, so these added curve columns never execute on staging/prod — they exist there only because idempotent 0021 also adds them. The edit also adds curve_* but not drop_lowest or gradescope_assignment_id, so anything deriving schema from the baseline alone (test fixtures, archived schema) gets tables missing those columns. Let 0019-0021 own the new columns; don't edit applied baselines.

* Return a GradedAssignment with points_earned replaced by the curved value.
* Returns the original assignment unchanged if it has no curve data.
*/
export function applyCurveToAssignment(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🔵 Grade math is duplicated across four implementations (this file, GradeProjector.tsx, Course.tsx, and the Python gradebook_service.py), plus DEFAULT_SCALE/tierFor and the ?? 0.83/?? 0.10 policy are copy-pasted in ~5 spots. This duplication is the root cause of the divergences in the curve/drop findings. Only the predictor genuinely needs client-side compute; the plain 'curved' display could consume the server's percent/category_grade instead of re-deriving them.

@AndresL230

Copy link
Copy Markdown
Collaborator

Review summary — Gradebook Feature

Reviewed at high recall (correctness + cleanup/altitude). 14 inline comments posted on the diff; this comment covers the overview, one finding that doesn't map to a changed line, and what I checked that came back clean.

Top asks before merge

  1. Confirm the category_grade change is intentional (inline at gradebook_service.py:149) — it silently rewrites every existing grade in any category with unequal-point assignments (points-weighted → unweighted mean).
  2. Gate the Gradescope feature so the critical issues can't bite: missing-dependency boot crash (gradescope_service.py:27), grade-wiped-to-NULL on re-sync (gradescope.py:481), and synced grades not counting because category_id is NULL (gradescope.py:498).
  3. Reconcile the curve frontend/backend math — unset-policy divergence (Course.tsx:356), raw-vs-curved drop-lowest (gradebook_service.py:102), raw per-category grade (gradebook.py:151).

Additional finding (no single changed line to attach to)

🔴 The course-list endpoint ignores curve_mode.get_summary (backend/routes/gradebook.py:96) never selects curve_mode/curve_avg_target/curve_sd_delta and calls current_grade(...) with no curve args (defaults to 'raw'), whereas get_course (:155) computes curved. So the Landing/overview card shows e.g. 74%/C while opening the course shows 88%/B+ — the headline grade flips on click for any curved course.

Lower-priority notes

  • sync_course does one DB write per assignment (N+1) — batch into insert([...]) / upsert(..., on_conflict=...).
  • _load_creds returns the raw decrypt exception in the 500 detail (gradescope.py:130) — minor info leak; return a generic message.
  • Rate limiting is per-process in-memory, so bu-sso/sync caps aren't enforced across multiple replicas.

Verified clean (so you don't re-check these)

  • eslint-suppressions / CI: dropping the baseline + --max-warnings does not break the Frontend check — eslint . exits 0 on this branch (37 warnings, 0 errors).
  • auth.py googleapiclient → httpx switch:adds error handling the old .execute() lacked; no token-refresh behavior lost. Not a CLAUDE.md violation (the httpx rule is scoped to Supabase; this is a Google call).
  • CLAUDE.md conventions: Supabase access all goes through table(); gradescope router mounted at /api/gradescope; credentials/points/notes encrypted at write boundaries; tests in backend/tests/. No violations found.
  • current_grade weight renormalization over graded categories is pre-existing (identical in origin/main), not introduced here.

🤖 Generated with Claude Code

Darkest-Teddyand others added 4 commits June 23, 2026 21:19
- gradescope_service: guard gradescopeapi imports with try/except to
prevent boot crash when package is not installed; add _require_gradescopeapi()
guard to login, login_with_cookies, list_student_courses, list_assignments
- gradescope route: stop wiping points_earned/points_possible to NULL on
re-sync when Gradescope returns no grade for an assignment
- gradescope route: return generic 500 message from _load_creds instead
of leaking raw decrypt exception text to the client
- gradescope sync: auto-match new assignments to existing Sapling categories
by checking if the category name appears in the assignment title
- gradebook summary: fetch and pass curve_mode/curve_avg_target/curve_sd_delta
to current_grade so the landing card grade matches the course detail for
curved courses
- gradebook course: pass curve args to category_grade for per-category
grade display so it reflects the curve setting, not always raw
- Course.tsx predictor: remove ?? 0.83 / ?? 0.10 curve defaults so the
predictor only curves when both params are explicitly configured,
matching backend behaviour
These three files were byte-identical to the canonical numbered
migrations/0019, 0020, 0021. The migrate.py runner only scans
migrations/, so the flat copies are dead and risk a hand-run reviewer
re-applying the same DDL.
dropAndSum sorted by score only, so on tied score ratios the predictor
could drop a different assignment than droppedAssignmentIds() and the
server. Apply the same secondary 'points_possible desc' / id-asc
tie-break so the projected number agrees with the dropped badge and the
server grade in tie edge cases.
…stency
Baseline already carried the curve_* columns but omitted
course_categories.drop_lowest, assignments.gradescope_assignment_id, and
the gradescope_credentials / gradescope_course_links tables. Fold all of
them in so a fresh install's baseline matches the same feature set the
curve columns implied, instead of a partial mix. Migrations 0019/0020
still run after baseline and remain idempotent (IF NOT EXISTS).
@AndresL230

Copy link
Copy Markdown
Collaborator

Superseded by the DB modular redesign (#279): the gradebook is rebuilt enrollment-keyed (gradebook_categories/assignments on enrollment_id, curve + drop-lowest, per-semester + cumulative GPA) in 0021 + the gradebook slice. Closing as obsolete — this would collide with the redesigned schema. Reopen if any behavior here isn't covered by #279.

@AndresL230

Copy link
Copy Markdown
Collaborator

Reopening. This was closed as superseded by the DB modular redesign (#279), but that was over-broad: the redesign only re-keyed the gradebook schema, it did not rebuild the Gradescope import integration (BU SSO sync, gradescope_service.py) in this PR. That feature work is net-new and shouldn't have been swept up. Needs a rebase onto the new enrollment-keyed gradebook schema, then it can resume review.

- Revert category_grade to points-weighted (total_earned/total_possible);
update tests to match and add explicit higher-point-weight test
- Fix GradeProjector: return null when nothing is graded (was showing
Now 0.0%) and mirror points-weighted math in dropAndSum
- Fix curvedAssignments in Course.tsx: skip curve when policy fields
are NULL instead of applying hardcoded 0.83/0.10 defaults
- Fix Landing.tsx: authenticated users see empty state on fetch
failure instead of fake SAMPLE_COURSES data
- Fix test_gradescope.py: add upsert to _table_factory mock; tighten
test_bu_sso_limited_after_3 and test_limit_is_per_user assertions
- Add CHECK (auth_mode IN ('password','cookies')) to migration 0020
Comment threadbackend/tests/test_gradebook_service.py Fixed
@Darkest-Teddy
Darkest-Teddy merged commit 1c2f6f0 into mainJun 28, 2026
6 checks passed
@AndresL230
AndresL230 deleted the Gradebook branch August 2, 2026 18:29
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.

3 participants

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

Gradebook Feature - #241

Merged
Darkest-Teddy merged 53 commits into
mainfrom
Gradebook
Jun 28, 2026
Merged

Gradebook Feature#241
Darkest-Teddy merged 53 commits into
mainfrom
Gradebook

Conversation

@Darkest-Teddy

@Darkest-TeddyDarkest-Teddy commented Jun 18, 2026

Copy link
Copy Markdown
Collaborator

Description

Major Gradebook feature release. Adds a bell curve grading system, a grade predictor panel, category tab navigation, and a large set of UI polish across the course page.

Changes Made

  • Bell curve grading: apply_curve backend function, per-assignment class stats (entered via assignment modal), course-level curve policy (avg target + SD delta), Raw/Curved toggle persisted per-course, curved score chip on assignment rows
  • Grade predictor panel: expandable panel below composition bar with per-assignment hypothetical sliders, Raw/Curved toggle inside predictor, class stats override for curve computation
  • Grade projector & composition bar: instant update on Raw/Curved toggle, isPredicted visual mode, computeCurrentGrade mirrors backend logic exactly
  • Letter scale fix: frontend DEFAULT_SCALE updated to match backend full 12-tier A+/A-/B+ scale; floating-point boundary fix (round to 1dp before comparisons)
  • Category navigation: horizontal tab strip above assignment list with hover states and assignment counts
  • Assignment rows: hover highlight extends full width, clean straight dividers, Dropped/Curved chips inline with title, tightened fraction display (30/34 format)
  • Category colors: 10 distinct hues evenly spaced around the hue wheel, no repeats
  • Auth fix: SECURE_COOKIES env flag, SESSION_SECRET wired to frontend, secure=false on localhost for OAuth cookies
  • Drag-to-reorder: categories in Edit Weights modal support drag-and-drop reordering
  • Drop policy: renamed "drops x/x" → "N Drops"
  • Course name: no longer truncated with ellipsis

Related Issues

Closes #

Testing

  • Tested locally
  • Added/updated tests

Screenshots (if applicable)

Notes for Reviewers

Schema changes ship as ordered migrations (backend/db/migrations/00190021) applied by the migration runner (backend/db/migrate.py --apply, run from backend/) — no manual Supabase SQL-editor steps required. The runner is idempotent and records applied versions in schema_migrations. The migrations cover the bell-curve columns (assignments.curve_*, user_courses.curve_*), course_categories.drop_lowest, and the Gradescope tables/columns.

(The previous manual ALTER TABLE block here was stale — it omitted drop_lowest and the Gradescope schema — and has been removed in favor of the runner.)

Summary by CodeRabbit

  • New Features

    • Added Gradescope account connection, course linking, and sync support from Settings.
    • Added course-level curve settings and per-category “drop lowest” grading support.
    • Introduced grade prediction tools for ungraded assignments and curved projections.
    • Added a dedicated Connected Accounts page.
  • Bug Fixes

    • Improved cookie handling for login/session flows across secure and non-secure environments.
    • Refined grading calculations and grade display behavior for curved, dropped, and predicted scores.

Darkest-Teddyand others added 26 commits May 10, 2026 00:36
…n grid
Replace the gradient course cards with the V1b layout from the design
handoff: solid course-color band, course code top-left in JetBrains Mono,
Playfair letter grade bottom-left, and an overlapping-discs watermark.
Footer percentage is now color-graded green→amber→rust→rose→crimson.
Also:
- storage_service: recognize Supabase's HTTP 400 + statusCode:"409" body
as "bucket already exists" so startup stops warning on every restart.
- localData: stub /api/gradebook/summary so the landing page works under
NEXT_PUBLIC_LOCAL_MODE.
googleapiclient routes through httplib2, which ignores HTTPS_PROXY and
times out (WinError 10060) on dev machines and proxy-bound deployments.
Swap to a direct httpx GET and wrap in _fail_redirect("userinfo_fetch_failed")
to match the existing oauth_exchange_failed error pattern.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Course detail page restructure
- Collapse the masthead from a ~340px hero into a single identity row
(kicker + name + letter+percent pill; letter scale lives in the
pill's hover tooltip instead of burning vertical space).
- Replace the side-by-side DistributionStrip + GradeProjector pair
with a hero-scale GradeCompositionBar. Each category gets a
weight-proportional slot whose sub-segments encode earned (solid),
lost (faded), and still-reachable (hatched). Letter cutoffs overlay
the bar at 60/70/80/90, and a "Now" pin marks current %.
- Cursor-following tooltips on every sub-segment explain the math
behind that region (Earned 18.5 of 20 pts, contributing 18.50% to
final, etc.); the tooltip auto-flips when near viewport edges.
- Single-column page below the masthead — Masthead -> Composition ->
Assignments — so the assignment list (the actual data-entry surface)
sits above the fold instead of buried under stat duplication.
Drop-lowest grading policy
- New course_categories.drop_lowest column
(migration_gradebook_drops.sql, idempotent + non-negative CHECK).
- gradebook_service.category_grade drops the N lowest graded items by
earned/possible before averaging; new dropped_assignment_ids /
all_dropped_ids helpers expose which IDs are currently excluded per
category and across the whole course (returned on /courses/:id).
- projectGrade re-runs the same drop logic for the floor (ungraded ->
0) and ceiling (ungraded -> full) scenarios, so projection respects
drops in every direction; droppedAssignmentIds computes the same set
client-side so optimistic grade edits update immediately.
- EditWeightsModal gains a Drop column next to Weight.
- AssignmentList mutes dropped rows (55% opacity + strikethrough) with
an inline "dropped" chip, and each category header shows a
"drops X/N" progress chip when the policy is active.
Cosmetic
- Course-card orb watermark seeds via min-distance rejection sampling
(62px between disc centers) so the three discs never land
near-coincident.
Run backend/db/migration_gradebook_drops.sql in Supabase before
deploying — the new SELECT on course_categories includes drop_lowest
and PostgREST will 500 otherwise.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ring
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ve mode
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
… endpoint
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…eral for curve_mode
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ettingsModal to course page
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…improvements
- Fix auth cookies (SECURE_COOKIES flag, SESSION_SECRET wiring frontend+backend)
- Grade predictor panel with hypothetical scores and Raw/Curved toggle
- Bell curve grading: apply_curve function, per-assignment and course policy
- Category color palette expanded to 10 distinct hues
- Category tab navigation above assignment list with hover states
- Assignment row hover highlight extends left/right with clean dividers
- Dropped and Curved chips on assignment rows
- Fraction score display tightened (30/34 format)
- Letter scale fixed to match backend full A+/A-/B+ 12-tier default
- Float precision fix: round to 1dp before letter-scale comparisons
- Composition bar instant update on Raw/Curved toggle
- Drop policy renamed to N Drops
- Course name no longer truncated
@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented Jun 18, 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
frontend39e2197Commit Preview URL

Branch Preview URL
Jun 22 2026, 03:35 AM

@coderabbitai

coderabbitaiBot commented Jun 18, 2026

Copy link
Copy Markdown

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Adds bell-curve grading and per-category drop-lowest scoring to the gradebook service, routes, and UI. Introduces a Grade Predictor panel for hypothetical scoring. Integrates Gradescope sync via password, session-cookie, and BU SSO/Duo auth modes with credential storage and course-link management. Updates auth cookie security to be configurable and replaces Google userinfo fetching with httpx.

Changes

Gradebook Enhancements: Drop-Lowest, Bell Curve & Grade Predictor

Layer / File(s)Summary
DB migrations and shared type contracts
backend/db/migrations/0001_baseline_schema.sql, backend/db/migrations/0019_gradebook_drops.sql, backend/db/migrations/0021_gradebook_curve.sql, backend/models/__init__.py, frontend/src/lib/types.ts
Adds drop_lowest to course_categories, curve-policy fields to user_courses, and curve-stat columns to assignments; mirrors these in Pydantic models and frontend TypeScript interfaces.
Gradebook service calculations and tests
backend/services/gradebook_service.py, backend/tests/test_gradebook_service.py
Replaces category_grade with a drop-lowest-aware, bell-curve-aware implementation; adds apply_curve, dropped_assignment_ids, all_dropped_ids helpers; rounds letter_for to 4dp; adds unit tests for curve mapping and curved category-grade paths.
Gradebook route/query/write flow updates
backend/routes/gradebook.py, frontend/src/lib/api.ts, frontend/src/lib/localData.ts
Extends summary/course queries and CRUD writes for drop-lowest and curve fields; adds PATCH /courses/{course_id}/curve endpoint; updates frontend API types and local-mode handler for new payloads.
Frontend curve utilities and projector math
frontend/src/components/Gradebook/curveUtils.ts, frontend/src/components/Gradebook/categoryColor.ts, frontend/src/components/Gradebook/GradeProjector.tsx
Adds applyCurve/applyCurveToAssignment/hasCurveData utilities, deterministic categoryColor mapping, and the GradeProjector component with projectGrade/droppedAssignmentIds math.
Assignment and category editing/list UI
frontend/src/components/Gradebook/AssignmentModal.tsx, frontend/src/components/Gradebook/EditWeightsModal.tsx, frontend/src/components/Gradebook/AssignmentList.tsx
Adds bell-curve and due-date validation to AssignmentModal, adds drag-reorder and drop_lowest field to EditWeightsModal, and rewrites AssignmentList into grouped category sections with dropped/curved indicators.
Grade Predictor panel component
frontend/src/components/Gradebook/GradePredictorPanel.tsx
Adds expandable predictor panel with per-assignment slider/input rows, optional predictor curve-mode controls, and reset behavior wired to parent hypotheticals state.
Course cards, visuals, landing interactions
frontend/src/app/globals.css, frontend/src/components/Gradebook/AmbientOrbs.tsx, frontend/src/components/Gradebook/CourseCard.tsx, frontend/src/components/Gradebook/SemesterChips.tsx, frontend/src/components/Gradebook/SyllabusUploadFlow.tsx, frontend/src/components/ToastProvider.tsx, frontend/src/components/screens/Gradebook/Landing.tsx
Adds CSS layout/animation tokens, ambient orb overlay, deterministic SVG watermark course cards, keyboard-navigable landing grid with color maps, and accessibility/styling tweaks.
Course screen orchestration rewrite
frontend/src/components/screens/Gradebook/Course.tsx
Reworks the course page to orchestrate curve-settings modal, predictor state, Gradescope sync polling, optimistic grade editing, GradeCompositionBar with per-category earned/lost/remaining and tooltip, and loading/error skeletons.
Bell-curve and predictor docs
docs/superpowers/plans/2026-06-16-bell-curve-grading.md, docs/superpowers/plans/2026-06-16-grade-predictor.md, docs/superpowers/specs/2026-06-16-bell-curve-grading-design.md, docs/superpowers/specs/2026-06-16-grade-predictor-design.md
Adds design specs and implementation plans for bell-curve grading and grade predictor panel features.

Gradescope Sync Integration

Layer / File(s)Summary
Gradescope schema and constraints
backend/db/migrations/0001_baseline_schema.sql, backend/db/migrations/0020_gradescope.sql
Creates gradescope_credentials with auth-mode payload constraints, gradescope_course_links with uniqueness enforcement, and a partial unique index on assignments(course_id, gradescope_assignment_id).
Gradescope service and dependency wiring
backend/services/gradescope_service.py, backend/requirements.txt
Adds gated gradescopeapi/Playwright imports, custom exceptions, password and cookie-based login flows, gradebook data accessors, and a Playwright-driven BU SSO + Duo orchestration function.
Gradescope route surface, app wiring, and tests
backend/routes/gradescope.py, backend/main.py, frontend/src/lib/api.ts, backend/tests/test_gradescope.py
Implements all /api/gradescope endpoints (credentials, BU SSO, status, course listing, links, sync), mounts the router, adds frontend API exports, and adds backend tests for parsing, authorization, rate limiting, and per-user isolation.
Gradescope modal and connected-accounts settings UI
frontend/src/components/Gradebook/GradescopeSyncModal.tsx, frontend/src/components/screens/ConnectedAccounts.tsx, frontend/src/app/(shell)/settings/connections/page.tsx, frontend/src/components/screens/Settings.tsx
Adds multi-stage connection/sync modal (password/cookies/BU SSO), connected-accounts screen with connect/reconnect/disconnect flows, and settings sidebar navigation to the new connections page.

Auth Secure-Cookie and Storage Response Handling

Layer / File(s)Summary
Secure cookie configuration and OAuth/session updates
backend/config.py, backend/routes/auth.py, frontend/src/app/api/auth/session/route.ts
Adds SECURE_COOKIES config derived from env or HTTPS URL prefix, applies it to all OAuth state cookie set/clear paths, and switches frontend session cookie secure to NODE_ENV === 'production'; replaces Google userinfo fetch with httpx.
Storage duplicate-bucket detection helper
backend/services/storage_service.py
Adds _is_duplicate_bucket to detect HTTP 409 and HTTP 400 JSON duplicate signals from Supabase.

CI Lint and ESLint Suppression Cleanup

Layer / File(s)Summary
Suppressions pruning and lint command change
.github/workflows/ci.yml, frontend/eslint-suppressions.json
Removes stale react-hooks/set-state-in-effect and related suppressions across multiple files and changes lint invocation from --max-warnings -1 to default npx eslint ..

Sequence Diagram(s)

sequenceDiagram
participant User
participant GradescopeSyncModal
participant ConnectedAccounts
participant GradescopeRoute as /api/gradescope
participant GradescopeService
participant GradescopeAPI as Gradescope.com
User->>ConnectedAccounts: Click "Connect"
ConnectedAccounts->>GradescopeSyncModal: open modal
User->>GradescopeSyncModal: Choose BU SSO mode
GradescopeSyncModal->>GradescopeRoute: POST /credentials/bu-sso
GradescopeRoute->>GradescopeService: login_via_bu_sso (asyncio.to_thread)
GradescopeService->>GradescopeAPI: Playwright BU Shibboleth + Duo flow
GradescopeAPI-->>GradescopeService: session cookies
GradescopeService-->>GradescopeRoute: {_gradescope_session, signed_token}
GradescopeRoute-->>GradescopeSyncModal: 200 OK
User->>GradescopeSyncModal: Select course + Sync
GradescopeSyncModal->>GradescopeRoute: POST /sync/{sapling_course_id}
GradescopeRoute->>GradescopeService: list_assignments
GradescopeService->>GradescopeAPI: scrape assignments
GradescopeAPI-->>GradescopeService: assignment list
GradescopeService-->>GradescopeRoute: normalized assignments
GradescopeRoute-->>GradescopeSyncModal: SyncResult {inserted, updated, skipped, failed}
GradescopeSyncModal-->>ConnectedAccounts: onSynced()
Loading

Estimated code review effort

🎯 5 (Critical) | ⏱️ ~120 minutes

Possibly related issues

Possibly related PRs

  • SaplingLearn/Sapling#56: Both modify the Google OAuth /google/callback flow in backend/routes/auth.py, with overlapping cookie and redirect handling logic.
  • SaplingLearn/Sapling#279: Directly overlaps in bell-curve and drop-lowest implementation in backend/services/gradebook_service.py and backend/routes/gradebook.py.
  • SaplingLearn/Sapling#262: Both touch .github/workflows/ci.yml ESLint command syntax and frontend/eslint-suppressions.json pruning.

Poem

🐰 A bunny once studied with grades in a pile,
Curves and predictions stretched out a full mile.
Gradescope was synced with a Duo-approved hop,
Drop-lowest? No problem — just skip to the top!
Now cookies are secure and the orbs gently drift,
Each semester, a colorful gradebook uplift. 🌟

🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (1 warning, 1 inconclusive)

Check nameStatusExplanationResolution
Docstring Coverage⚠️ WarningDocstring coverage is 42.18% which is insufficient. The required threshold is 80.00%.Write docstrings for the functions missing them to satisfy the coverage threshold.
Title check❓ InconclusiveThe title is too vague to describe the main Gradebook changes.Use a concise title that names the primary change, such as "Add bell-curve grading and Gradescope sync to gradebook".
✅ Passed checks (3 passed)
Check nameStatusExplanation
Description check✅ PassedThe description covers the main feature areas and notes, with only minor template gaps like testing and related issues.
Linked Issues check✅ PassedCheck skipped because no linked issues were found for this pull request.
Out of Scope Changes check✅ PassedCheck skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch Gradebook

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.

Comment threadfrontend/src/components/screens/Gradebook/Landing.tsx Fixed
Comment threadfrontend/src/components/screens/Gradebook/Course.tsx Fixed
Comment threadbackend/services/gradescope_service.py Fixed
Comment threadbackend/services/gradescope_service.py Fixed
Comment threadbackend/tests/test_gradebook_service.py Fixed
import requests
from bs4 import BeautifulSoup

from gradescopeapi import DEFAULT_GRADESCOPE_BASE_URL

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🟠 A missing dependency takes down the whole app, not just this feature.requests, bs4, and gradescopeapi are imported unconditionally here (only Playwright is guarded below), and main.py:24 imports routes.gradescope unconditionally. If any of these newly-added deps isn't installed in a deploy target, import routes.gradescope raises ImportError during router mount and uvicorn never boots — all of /api goes down. Guard these imports the way Playwright already is, or make the router mount degrade gracefully.

Comment threadbackend/routes/gradescope.py Outdated
"id": str(uuid.uuid4()),
"user_id": user_id,
"course_id": sapling_course_id,
"category_id": None, # user can re-categorize later

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🟠 Synced grades never affect the computed grade. Inserted assignments get category_id=None, but current_grade() only buckets assignments whose category_id matches a known category — so freshly-synced grades contribute nothing to the percent until the user manually categorizes each one. A successful sync visibly changes the grade by zero, which reads as a broken sync.

graded.append((float(e) / float(p), float(p), str(aid)))
if not graded:
return []
graded.sort(key=lambda x: (x[0], -x[1], x[2]))

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🟠 Drop-lowest is computed on RAW scores, but the curved display drops by CURVED score.dropped_assignment_ids ranks by raw earned/possible, while the frontend projectGrade/curved path drops based on curved values and the 'dropped' badge (droppedAssignmentIds(..., data.assignments)) uses raw. Under a curve that reorders scores, the server and UI exclude different assignments, and the badge can mark one assignment dropped while the math drops another.

by_cat[cid].append(a)
for c in cats:
c["category_grade"] = gradebook_service.category_grade(by_cat[c["id"]])
c["category_grade"] = gradebook_service.category_grade(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🟠 Per-category grade is always raw, even for curved courses. This call passes no curve args (defaults to curve_mode='raw'), while the overall percent just below (:155) is curved. The per-category numbers shown to the student won't reconcile with the curved headline grade.

Comment threadbackend/config.py
FRONTEND_URL = os.getenv("FRONTEND_URL", "http://localhost:3000")
SESSION_SECRET = os.getenv("SESSION_SECRET", "")
_sc_env = os.getenv("SECURE_COOKIES")
SECURE_COOKIES: bool = _sc_env.lower() == "true" if _sc_env is not None else FRONTEND_URL.startswith("https://")

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🟠 OAuth cookie Secure flag is no longer fail-closed.origin/main hardcoded secure=True; now it's secure=SECURE_COOKIES, which defaults to FRONTEND_URL.startswith('https://') when the env var is unset — and FRONTEND_URL itself defaults to http://localhost:3000. A TLS-fronted prod deploy that forgets to set FRONTEND_URL/SECURE_COOKIES will send the sapling_oauth cookie without Secure (used in auth.py:280/308/418/447). The frontend's secure: NODE_ENV==='production' for the session cookie (session/route.ts:101) is a second, independent mechanism that can disagree.

try:
if gs_id in by_gs_id:
table("assignments").update(
record_write,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🟡 Re-sync clobbers user edits.record_write unconditionally rewrites title/due_date/source on every sync, discarding any manual rename or due-date fix the student made to a linked assignment. Sync should be authoritative only for the grade columns + the idempotency key (gradescope_assignment_id).

points_earned: hyp.earned,
points_possible: hyp.possible > 0 ? hyp.possible : a.points_possible,
// Predictor override takes priority over stored assignment class stats
curve_class_mean: hyp.curveClassMean !== null ? hyp.curveClassMean : a.curve_class_mean,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🟡 Predictor can't clear a per-assignment curve override.hyp.curveClassMean !== null ? ... : a.curve_class_mean — when the user blanks the class-avg field, GradePredictorPanel emits null, so this falls back to the stored mean and the curve stays applied. (The panel itself reads with !== undefined, so the two layers disagree.) The 'what-if no curve' scenario is silently ignored.

if percent >= float(tier["min"]):
if rounded >= float(tier["min"]):
return str(tier["letter"])
return None

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🟡 A custom letter scale without a 0-floor tier yields no letter for failing students.set_letter_scale doesn't require an F/min:0 tier, so a scale like [{90,A},{80,B},{70,C}] makes this return None for 55% — the UI shows 55% with letter . tierFor in GradeProjector.tsx has the same gap. Consider falling back to the lowest tier instead of None.

@@ -0,0 +1,34 @@
-- Gradebook bell-curve grading: per-course curve policy + per-assignment class stats.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🔵 Duplicate migration tree. This file (and migration_gradebook_drops.sql, migration_gradescope.sql) is byte-identical to db/migrations/0021_gradebook_curve.sql (resp. 0019/0020). The runner only globs migrations/*.sql, so these loose copies never run and only exist to drift out of sync. Recommend deleting them and keeping the numbered sequence as the single source of truth.

@@ -53,6 +53,9 @@ CREATE TABLE IF NOT EXISTS user_courses (
nickname TEXT,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🔵 Editing an already-applied baseline migration is a no-op on existing DBs. Migrations are tracked by filename, so these added curve columns never execute on staging/prod — they exist there only because idempotent 0021 also adds them. The edit also adds curve_* but not drop_lowest or gradescope_assignment_id, so anything deriving schema from the baseline alone (test fixtures, archived schema) gets tables missing those columns. Let 0019-0021 own the new columns; don't edit applied baselines.

* Return a GradedAssignment with points_earned replaced by the curved value.
* Returns the original assignment unchanged if it has no curve data.
*/
export function applyCurveToAssignment(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🔵 Grade math is duplicated across four implementations (this file, GradeProjector.tsx, Course.tsx, and the Python gradebook_service.py), plus DEFAULT_SCALE/tierFor and the ?? 0.83/?? 0.10 policy are copy-pasted in ~5 spots. This duplication is the root cause of the divergences in the curve/drop findings. Only the predictor genuinely needs client-side compute; the plain 'curved' display could consume the server's percent/category_grade instead of re-deriving them.

@AndresL230

Copy link
Copy Markdown
Collaborator

Review summary — Gradebook Feature

Reviewed at high recall (correctness + cleanup/altitude). 14 inline comments posted on the diff; this comment covers the overview, one finding that doesn't map to a changed line, and what I checked that came back clean.

Top asks before merge

  1. Confirm the category_grade change is intentional (inline at gradebook_service.py:149) — it silently rewrites every existing grade in any category with unequal-point assignments (points-weighted → unweighted mean).
  2. Gate the Gradescope feature so the critical issues can't bite: missing-dependency boot crash (gradescope_service.py:27), grade-wiped-to-NULL on re-sync (gradescope.py:481), and synced grades not counting because category_id is NULL (gradescope.py:498).
  3. Reconcile the curve frontend/backend math — unset-policy divergence (Course.tsx:356), raw-vs-curved drop-lowest (gradebook_service.py:102), raw per-category grade (gradebook.py:151).

Additional finding (no single changed line to attach to)

🔴 The course-list endpoint ignores curve_mode.get_summary (backend/routes/gradebook.py:96) never selects curve_mode/curve_avg_target/curve_sd_delta and calls current_grade(...) with no curve args (defaults to 'raw'), whereas get_course (:155) computes curved. So the Landing/overview card shows e.g. 74%/C while opening the course shows 88%/B+ — the headline grade flips on click for any curved course.

Lower-priority notes

  • sync_course does one DB write per assignment (N+1) — batch into insert([...]) / upsert(..., on_conflict=...).
  • _load_creds returns the raw decrypt exception in the 500 detail (gradescope.py:130) — minor info leak; return a generic message.
  • Rate limiting is per-process in-memory, so bu-sso/sync caps aren't enforced across multiple replicas.

Verified clean (so you don't re-check these)

  • eslint-suppressions / CI: dropping the baseline + --max-warnings does not break the Frontend check — eslint . exits 0 on this branch (37 warnings, 0 errors).
  • auth.py googleapiclient → httpx switch:adds error handling the old .execute() lacked; no token-refresh behavior lost. Not a CLAUDE.md violation (the httpx rule is scoped to Supabase; this is a Google call).
  • CLAUDE.md conventions: Supabase access all goes through table(); gradescope router mounted at /api/gradescope; credentials/points/notes encrypted at write boundaries; tests in backend/tests/. No violations found.
  • current_grade weight renormalization over graded categories is pre-existing (identical in origin/main), not introduced here.

🤖 Generated with Claude Code

Darkest-Teddyand others added 4 commits June 23, 2026 21:19
- gradescope_service: guard gradescopeapi imports with try/except to
prevent boot crash when package is not installed; add _require_gradescopeapi()
guard to login, login_with_cookies, list_student_courses, list_assignments
- gradescope route: stop wiping points_earned/points_possible to NULL on
re-sync when Gradescope returns no grade for an assignment
- gradescope route: return generic 500 message from _load_creds instead
of leaking raw decrypt exception text to the client
- gradescope sync: auto-match new assignments to existing Sapling categories
by checking if the category name appears in the assignment title
- gradebook summary: fetch and pass curve_mode/curve_avg_target/curve_sd_delta
to current_grade so the landing card grade matches the course detail for
curved courses
- gradebook course: pass curve args to category_grade for per-category
grade display so it reflects the curve setting, not always raw
- Course.tsx predictor: remove ?? 0.83 / ?? 0.10 curve defaults so the
predictor only curves when both params are explicitly configured,
matching backend behaviour
These three files were byte-identical to the canonical numbered
migrations/0019, 0020, 0021. The migrate.py runner only scans
migrations/, so the flat copies are dead and risk a hand-run reviewer
re-applying the same DDL.
dropAndSum sorted by score only, so on tied score ratios the predictor
could drop a different assignment than droppedAssignmentIds() and the
server. Apply the same secondary 'points_possible desc' / id-asc
tie-break so the projected number agrees with the dropped badge and the
server grade in tie edge cases.
…stency
Baseline already carried the curve_* columns but omitted
course_categories.drop_lowest, assignments.gradescope_assignment_id, and
the gradescope_credentials / gradescope_course_links tables. Fold all of
them in so a fresh install's baseline matches the same feature set the
curve columns implied, instead of a partial mix. Migrations 0019/0020
still run after baseline and remain idempotent (IF NOT EXISTS).
@AndresL230

Copy link
Copy Markdown
Collaborator

Superseded by the DB modular redesign (#279): the gradebook is rebuilt enrollment-keyed (gradebook_categories/assignments on enrollment_id, curve + drop-lowest, per-semester + cumulative GPA) in 0021 + the gradebook slice. Closing as obsolete — this would collide with the redesigned schema. Reopen if any behavior here isn't covered by #279.

@AndresL230

Copy link
Copy Markdown
Collaborator

Reopening. This was closed as superseded by the DB modular redesign (#279), but that was over-broad: the redesign only re-keyed the gradebook schema, it did not rebuild the Gradescope import integration (BU SSO sync, gradescope_service.py) in this PR. That feature work is net-new and shouldn't have been swept up. Needs a rebase onto the new enrollment-keyed gradebook schema, then it can resume review.

- Revert category_grade to points-weighted (total_earned/total_possible);
update tests to match and add explicit higher-point-weight test
- Fix GradeProjector: return null when nothing is graded (was showing
Now 0.0%) and mirror points-weighted math in dropAndSum
- Fix curvedAssignments in Course.tsx: skip curve when policy fields
are NULL instead of applying hardcoded 0.83/0.10 defaults
- Fix Landing.tsx: authenticated users see empty state on fetch
failure instead of fake SAMPLE_COURSES data
- Fix test_gradescope.py: add upsert to _table_factory mock; tighten
test_bu_sso_limited_after_3 and test_limit_is_per_user assertions
- Add CHECK (auth_mode IN ('password','cookies')) to migration 0020
Comment threadbackend/tests/test_gradebook_service.py Fixed
@Darkest-Teddy
Darkest-Teddy merged commit 1c2f6f0 into mainJun 28, 2026
6 checks passed
@AndresL230
AndresL230 deleted the Gradebook branch August 2, 2026 18:29
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.

3 participants

@Darkest-Teddy@AndresL230@Jose-Gael-Cruz-Lopez