feat(dashboard): group courses by semester with an Archive for past terms (#140) - #368

Merged
Jose-Gael-Cruz-Lopez merged 26 commits into
mainfrom
feat/140-semester-grouping-archive
Jul 24, 2026
Merged

feat(dashboard): group courses by semester with an Archive for past terms (#140)#368
Jose-Gael-Cruz-Lopez merged 26 commits into
mainfrom
feat/140-semester-grouping-archive

Conversation

@Jose-Gael-Cruz-Lopez

Copy link
Copy Markdown
Member

Closes#140.

Groups enrolled classes by semester, defaults to the current term, and puts past terms behind an Archive.

The dependency was already satisfied

#140 is filed as "depends on the backend API issue", but that's stale — nothing was blocking:

  • GET /api/semesters already exists and serves {id, term, year, label, start_date, end_date, sort_key}.
  • GET /api/graph/{user_id}/courses already returns a term label per course; only the frontend EnrolledCourse interface hadn't declared it.

So this is frontend-only.

It also fixes a live bug

screens/Gradebook/Landing.tsx read (c as any).semester off the courses payload — but the API sends term. The lookup therefore always came back empty and fell through to the hardcoded demo constant:

constSAMPLE_SEMESTERS=["Spring 2026","Fall 2025"];

Every signed-in user was being shown those two fake semester chips instead of their own. The as any is exactly what let the field-name mismatch through the type checker. SAMPLE_SEMESTERS is now strictly the logged-out preview; a signed-in user with no terms gets their own empty state (a termsReady gate keeps that from hanging on the skeleton). Covered by tests.

New src/lib/semesters.ts — one tested source of truth

  • currentTerm(semesters, today) mirrors services/academics.py::current_termexactly: the highest-sort_key term whose [start_date, end_date] contains today, falling back to the highest sort_key overall when today lands in a gap between terms. today is injectable. Dates compare as YYYY-MM-DD strings (lexicographic == chronological), and a Date is read in local time — toISOString() would shift the day either side of midnight.
  • termRankFromLabel("Fall 2025") -> 20253, mirroring migration 0019's year * 10 + ordinal. Only consulted when no real sort_key is available; sort_key always wins.
  • groupCoursesByTerm, courseTermLabels, partitionCurrentAndArchive.

Degradation — the rule is "never hide a course"

If /api/semesters fails or returns nothing, everything collapses to one flat ungrouped list with no Archive control and nothing hidden. Courses with an empty or unparseable term are never dropped and never archived. Future-term courses are never archived. Only a course ranking strictly below the current term is archived — showing a course in the wrong bucket is recoverable, hiding it isn't.

UI

The partition is applied to the "My courses" panel (which is also the mobile courses tab), the graph legend chips, the CoursesKey overlay, and ManageCoursesModal's list. CourseProgressRow was extracted rather than copying that markup a third time. Archived rows route to /gradebook?semester=<label>, which the gradebook landing reads and preselects — so selecting an archived class lands in that semester's gradebook, per the acceptance criteria.

The Calendar is untouched, per the explicit product decision in the issue.

Verification

GateResult
npm test131 passing (baseline 88) — 15 files, +43 tests
npx tsc --noEmit0 errors
npx eslint . --max-warnings=-1exit 0, 36 warnings (baseline)
npm run buildexit 0, compiled successfully

One thing worth flagging because it nearly shipped broken: fixing the as any removed the only no-explicit-any in Landing.tsx, which left a stale entry in eslint-suppressions.json. ESLint exits 2 on a stale suppression even with zero errors — main exits 0, this branch did not — so the lint gate would have failed CI. Pruned in its own commit.

For the reviewer

  1. Two files outside the intended scope got a one-line fixture addition each (src/lib/data.test.ts, src/components/DocumentUploadModal.test.tsx), forced by making term required on EnrolledCourse.
  2. Landing reads ?semester= from window.location.search in a useState initializer rather than useSearchParams(), which would require a Suspense boundary in the route shell this component doesn't own. It never reaches the rendered tree, so there's no hydration mismatch — but adding the boundary instead is a reasonable alternative.
  3. The client's current-term rule is a hand-mirror of the Python one. Test fixtures are migration 0019's four terms verbatim so drift surfaces, but there is no automated cross-language check.
  4. The knowledge graph still renders nodes from all terms — only the course lists and legends are term-scoped. Filtering graph data by term is a separate product call.

🤖 Generated with Claude Code

Jose-Gael-Cruz-Lopezand others added 25 commits July 20, 2026 23:08
/api/graph/{user_id}/courses has always returned the offering's term
label; the client type never declared it, so every consumer had to cast
through any to reach it.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
termRankFromLabel mirrors the sort_key formula from migration 0019 so a
label-only fallback orders identically to the server.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Mirrors services/academics.py::current_term — today within
[start_date, end_date], else the highest sort_key — so client and server
never disagree about which semester is current.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ack (#140)
Fixtures are the four terms seeded by migration 0019 verbatim, so a drift
between this rule and the backend's shows up here.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Ordering keys on sort_key when the semesters payload is available and
degrades to the label-derived rank otherwise. Courses with no term go to
an 'Other' bucket rather than being dropped.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…#140)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Only courses that rank strictly below the current term are archived.
Undatable courses — and every course when /api/semesters gives us
nothing — stay in the default list.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ck (#140)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…140)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
/api/graph/{user_id}/courses emits `term`; the landing read
`(c as any).semester`, which is always undefined. `distinct` was
therefore always empty and every signed-in user silently fell through to
the hardcoded SAMPLE_SEMESTERS demo chips.
The sample chips are now the logged-out preview only — a signed-in user
with no terms gets their own empty state instead of another student's
fake grades.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…140)
Chips now sort by sort_key from /api/semesters and default to the
date-derived current term instead of whichever term the courses payload
happened to list first. A failed semesters fetch degrades to the
label-derived order.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Gives the dashboard archive somewhere to land: selecting an archived
class opens that semester's gradebook rather than the current one.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…140)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…140)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…#140)
Same markup, lifted so the current-term list, the archive and the graph
overlay can all render a course line without a third copy.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ve (#140)
Current-term courses show by default; earlier terms collapse behind an
Archive toggle, grouped by label most recent first. Also covers the
mobile 'My Courses' tab, which renders the same panel.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The floating course key now lists only current-term courses and offers
past terms as a compact Archive that deep-links into each semester's
gradebook.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Headings only appear once a student has courses in more than one term.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Keeps the top-nav legend consistent with the courses key overlay, which
already lists only the current semester.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…tion (#140)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…tech (#140)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Reading `term` instead of `(c as any).semester` removed the only
no-explicit-any in Landing.tsx, so its suppression entry no longer matches
anything. eslint exits 2 on a stale suppression even with zero errors, which
fails the CI lint gate — `main` exits 0, this branch did not.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@coderabbitai

coderabbitaiBot commented Jul 21, 2026

Copy link
Copy Markdown

Warning

Review limit reached

@AndresL230, you've reached your PR review limit, so we couldn't start this review.

Next review available in:54 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Free

Run ID: 39694af7-3514-4235-b33b-03e1297e832a

📥 Commits

Reviewing files that changed from the base of the PR and between 4227689 and c1d6f9b.

📒 Files selected for processing (11)
  • frontend/eslint-suppressions.json
  • frontend/src/components/DocumentUploadModal.test.tsx
  • frontend/src/components/ManageCoursesModal.tsx
  • frontend/src/components/screens/Dashboard.test.tsx
  • frontend/src/components/screens/Dashboard.tsx
  • frontend/src/components/screens/Gradebook/Landing.test.tsx
  • frontend/src/components/screens/Gradebook/Landing.tsx
  • frontend/src/lib/api.ts
  • frontend/src/lib/data.test.ts
  • frontend/src/lib/semesters.test.ts
  • frontend/src/lib/semesters.ts

Note

🎁 Summarized by CodeRabbit Free

Your organization is on the Free plan. CodeRabbit will generate a high-level summary and a walkthrough for each pull request. For a comprehensive line-by-line review, please upgrade your subscription to CodeRabbit Pro by visiting https://app.coderabbit.ai/login.

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

@cloudflare-workers-and-pages

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

Copy link
Copy Markdown

Deploying with Cloudflare Workers Cloudflare Workers

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

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

Branch Preview URL
Jul 22 2026, 04:25 AM

@Jose-Gael-Cruz-Lopez

Copy link
Copy Markdown
MemberAuthor

@coderabbitai review

@coderabbitai

coderabbitaiBot commented Jul 21, 2026

Copy link
Copy Markdown
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

Empty commit to re-run CI after the frontend-staging Workers build-command config fix (npm run cf:build restored; the broken wrangler deploy --env staging skipped the OpenNext build).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@Jose-Gael-Cruz-Lopez
Jose-Gael-Cruz-Lopez merged commit 452c10a into mainJul 24, 2026
6 checks passed
@AndresL230
AndresL230 deleted the feat/140-semester-grouping-archive branch August 2, 2026 18:30
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[P2] Semesters: dashboard/course-list grouping + Archive for past terms

2 participants

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

feat(dashboard): group courses by semester with an Archive for past terms (#140) - #368

Merged
Jose-Gael-Cruz-Lopez merged 26 commits into
mainfrom
feat/140-semester-grouping-archive
Jul 24, 2026
Merged

feat(dashboard): group courses by semester with an Archive for past terms (#140)#368
Jose-Gael-Cruz-Lopez merged 26 commits into
mainfrom
feat/140-semester-grouping-archive

Conversation

@Jose-Gael-Cruz-Lopez

Copy link
Copy Markdown
Member

Closes#140.

Groups enrolled classes by semester, defaults to the current term, and puts past terms behind an Archive.

The dependency was already satisfied

#140 is filed as "depends on the backend API issue", but that's stale — nothing was blocking:

  • GET /api/semesters already exists and serves {id, term, year, label, start_date, end_date, sort_key}.
  • GET /api/graph/{user_id}/courses already returns a term label per course; only the frontend EnrolledCourse interface hadn't declared it.

So this is frontend-only.

It also fixes a live bug

screens/Gradebook/Landing.tsx read (c as any).semester off the courses payload — but the API sends term. The lookup therefore always came back empty and fell through to the hardcoded demo constant:

constSAMPLE_SEMESTERS=["Spring 2026","Fall 2025"];

Every signed-in user was being shown those two fake semester chips instead of their own. The as any is exactly what let the field-name mismatch through the type checker. SAMPLE_SEMESTERS is now strictly the logged-out preview; a signed-in user with no terms gets their own empty state (a termsReady gate keeps that from hanging on the skeleton). Covered by tests.

New src/lib/semesters.ts — one tested source of truth

  • currentTerm(semesters, today) mirrors services/academics.py::current_termexactly: the highest-sort_key term whose [start_date, end_date] contains today, falling back to the highest sort_key overall when today lands in a gap between terms. today is injectable. Dates compare as YYYY-MM-DD strings (lexicographic == chronological), and a Date is read in local time — toISOString() would shift the day either side of midnight.
  • termRankFromLabel("Fall 2025") -> 20253, mirroring migration 0019's year * 10 + ordinal. Only consulted when no real sort_key is available; sort_key always wins.
  • groupCoursesByTerm, courseTermLabels, partitionCurrentAndArchive.

Degradation — the rule is "never hide a course"

If /api/semesters fails or returns nothing, everything collapses to one flat ungrouped list with no Archive control and nothing hidden. Courses with an empty or unparseable term are never dropped and never archived. Future-term courses are never archived. Only a course ranking strictly below the current term is archived — showing a course in the wrong bucket is recoverable, hiding it isn't.

UI

The partition is applied to the "My courses" panel (which is also the mobile courses tab), the graph legend chips, the CoursesKey overlay, and ManageCoursesModal's list. CourseProgressRow was extracted rather than copying that markup a third time. Archived rows route to /gradebook?semester=<label>, which the gradebook landing reads and preselects — so selecting an archived class lands in that semester's gradebook, per the acceptance criteria.

The Calendar is untouched, per the explicit product decision in the issue.

Verification

GateResult
npm test131 passing (baseline 88) — 15 files, +43 tests
npx tsc --noEmit0 errors
npx eslint . --max-warnings=-1exit 0, 36 warnings (baseline)
npm run buildexit 0, compiled successfully

One thing worth flagging because it nearly shipped broken: fixing the as any removed the only no-explicit-any in Landing.tsx, which left a stale entry in eslint-suppressions.json. ESLint exits 2 on a stale suppression even with zero errors — main exits 0, this branch did not — so the lint gate would have failed CI. Pruned in its own commit.

For the reviewer

  1. Two files outside the intended scope got a one-line fixture addition each (src/lib/data.test.ts, src/components/DocumentUploadModal.test.tsx), forced by making term required on EnrolledCourse.
  2. Landing reads ?semester= from window.location.search in a useState initializer rather than useSearchParams(), which would require a Suspense boundary in the route shell this component doesn't own. It never reaches the rendered tree, so there's no hydration mismatch — but adding the boundary instead is a reasonable alternative.
  3. The client's current-term rule is a hand-mirror of the Python one. Test fixtures are migration 0019's four terms verbatim so drift surfaces, but there is no automated cross-language check.
  4. The knowledge graph still renders nodes from all terms — only the course lists and legends are term-scoped. Filtering graph data by term is a separate product call.

🤖 Generated with Claude Code

Jose-Gael-Cruz-Lopezand others added 25 commits July 20, 2026 23:08
/api/graph/{user_id}/courses has always returned the offering's term
label; the client type never declared it, so every consumer had to cast
through any to reach it.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
termRankFromLabel mirrors the sort_key formula from migration 0019 so a
label-only fallback orders identically to the server.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Mirrors services/academics.py::current_term — today within
[start_date, end_date], else the highest sort_key — so client and server
never disagree about which semester is current.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ack (#140)
Fixtures are the four terms seeded by migration 0019 verbatim, so a drift
between this rule and the backend's shows up here.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Ordering keys on sort_key when the semesters payload is available and
degrades to the label-derived rank otherwise. Courses with no term go to
an 'Other' bucket rather than being dropped.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…#140)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Only courses that rank strictly below the current term are archived.
Undatable courses — and every course when /api/semesters gives us
nothing — stay in the default list.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ck (#140)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…140)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
/api/graph/{user_id}/courses emits `term`; the landing read
`(c as any).semester`, which is always undefined. `distinct` was
therefore always empty and every signed-in user silently fell through to
the hardcoded SAMPLE_SEMESTERS demo chips.
The sample chips are now the logged-out preview only — a signed-in user
with no terms gets their own empty state instead of another student's
fake grades.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…140)
Chips now sort by sort_key from /api/semesters and default to the
date-derived current term instead of whichever term the courses payload
happened to list first. A failed semesters fetch degrades to the
label-derived order.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Gives the dashboard archive somewhere to land: selecting an archived
class opens that semester's gradebook rather than the current one.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…140)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…140)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…#140)
Same markup, lifted so the current-term list, the archive and the graph
overlay can all render a course line without a third copy.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ve (#140)
Current-term courses show by default; earlier terms collapse behind an
Archive toggle, grouped by label most recent first. Also covers the
mobile 'My Courses' tab, which renders the same panel.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The floating course key now lists only current-term courses and offers
past terms as a compact Archive that deep-links into each semester's
gradebook.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Headings only appear once a student has courses in more than one term.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Keeps the top-nav legend consistent with the courses key overlay, which
already lists only the current semester.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…tion (#140)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…tech (#140)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Reading `term` instead of `(c as any).semester` removed the only
no-explicit-any in Landing.tsx, so its suppression entry no longer matches
anything. eslint exits 2 on a stale suppression even with zero errors, which
fails the CI lint gate — `main` exits 0, this branch did not.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@coderabbitai

coderabbitaiBot commented Jul 21, 2026

Copy link
Copy Markdown

Warning

Review limit reached

@AndresL230, you've reached your PR review limit, so we couldn't start this review.

Next review available in:54 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Free

Run ID: 39694af7-3514-4235-b33b-03e1297e832a

📥 Commits

Reviewing files that changed from the base of the PR and between 4227689 and c1d6f9b.

📒 Files selected for processing (11)
  • frontend/eslint-suppressions.json
  • frontend/src/components/DocumentUploadModal.test.tsx
  • frontend/src/components/ManageCoursesModal.tsx
  • frontend/src/components/screens/Dashboard.test.tsx
  • frontend/src/components/screens/Dashboard.tsx
  • frontend/src/components/screens/Gradebook/Landing.test.tsx
  • frontend/src/components/screens/Gradebook/Landing.tsx
  • frontend/src/lib/api.ts
  • frontend/src/lib/data.test.ts
  • frontend/src/lib/semesters.test.ts
  • frontend/src/lib/semesters.ts

Note

🎁 Summarized by CodeRabbit Free

Your organization is on the Free plan. CodeRabbit will generate a high-level summary and a walkthrough for each pull request. For a comprehensive line-by-line review, please upgrade your subscription to CodeRabbit Pro by visiting https://app.coderabbit.ai/login.

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

@cloudflare-workers-and-pages

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

Copy link
Copy Markdown

Deploying with Cloudflare Workers Cloudflare Workers

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

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

Branch Preview URL
Jul 22 2026, 04:25 AM

@Jose-Gael-Cruz-Lopez

Copy link
Copy Markdown
MemberAuthor

@coderabbitai review

@coderabbitai

coderabbitaiBot commented Jul 21, 2026

Copy link
Copy Markdown
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

Empty commit to re-run CI after the frontend-staging Workers build-command config fix (npm run cf:build restored; the broken wrangler deploy --env staging skipped the OpenNext build).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@Jose-Gael-Cruz-Lopez
Jose-Gael-Cruz-Lopez merged commit 452c10a into mainJul 24, 2026
6 checks passed
@AndresL230
AndresL230 deleted the feat/140-semester-grouping-archive branch August 2, 2026 18:30
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[P2] Semesters: dashboard/course-list grouping + Archive for past terms

2 participants

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

feat(dashboard): group courses by semester with an Archive for past terms (#140) - #368

Merged
Jose-Gael-Cruz-Lopez merged 26 commits into
mainfrom
feat/140-semester-grouping-archive
Jul 24, 2026
Merged

feat(dashboard): group courses by semester with an Archive for past terms (#140)#368
Jose-Gael-Cruz-Lopez merged 26 commits into
mainfrom
feat/140-semester-grouping-archive

Conversation

@Jose-Gael-Cruz-Lopez

Copy link
Copy Markdown
Member

Closes#140.

Groups enrolled classes by semester, defaults to the current term, and puts past terms behind an Archive.

The dependency was already satisfied

#140 is filed as "depends on the backend API issue", but that's stale — nothing was blocking:

  • GET /api/semesters already exists and serves {id, term, year, label, start_date, end_date, sort_key}.
  • GET /api/graph/{user_id}/courses already returns a term label per course; only the frontend EnrolledCourse interface hadn't declared it.

So this is frontend-only.

It also fixes a live bug

screens/Gradebook/Landing.tsx read (c as any).semester off the courses payload — but the API sends term. The lookup therefore always came back empty and fell through to the hardcoded demo constant:

constSAMPLE_SEMESTERS=["Spring 2026","Fall 2025"];

Every signed-in user was being shown those two fake semester chips instead of their own. The as any is exactly what let the field-name mismatch through the type checker. SAMPLE_SEMESTERS is now strictly the logged-out preview; a signed-in user with no terms gets their own empty state (a termsReady gate keeps that from hanging on the skeleton). Covered by tests.

New src/lib/semesters.ts — one tested source of truth

  • currentTerm(semesters, today) mirrors services/academics.py::current_termexactly: the highest-sort_key term whose [start_date, end_date] contains today, falling back to the highest sort_key overall when today lands in a gap between terms. today is injectable. Dates compare as YYYY-MM-DD strings (lexicographic == chronological), and a Date is read in local time — toISOString() would shift the day either side of midnight.
  • termRankFromLabel("Fall 2025") -> 20253, mirroring migration 0019's year * 10 + ordinal. Only consulted when no real sort_key is available; sort_key always wins.
  • groupCoursesByTerm, courseTermLabels, partitionCurrentAndArchive.

Degradation — the rule is "never hide a course"

If /api/semesters fails or returns nothing, everything collapses to one flat ungrouped list with no Archive control and nothing hidden. Courses with an empty or unparseable term are never dropped and never archived. Future-term courses are never archived. Only a course ranking strictly below the current term is archived — showing a course in the wrong bucket is recoverable, hiding it isn't.

UI

The partition is applied to the "My courses" panel (which is also the mobile courses tab), the graph legend chips, the CoursesKey overlay, and ManageCoursesModal's list. CourseProgressRow was extracted rather than copying that markup a third time. Archived rows route to /gradebook?semester=<label>, which the gradebook landing reads and preselects — so selecting an archived class lands in that semester's gradebook, per the acceptance criteria.

The Calendar is untouched, per the explicit product decision in the issue.

Verification

GateResult
npm test131 passing (baseline 88) — 15 files, +43 tests
npx tsc --noEmit0 errors
npx eslint . --max-warnings=-1exit 0, 36 warnings (baseline)
npm run buildexit 0, compiled successfully

One thing worth flagging because it nearly shipped broken: fixing the as any removed the only no-explicit-any in Landing.tsx, which left a stale entry in eslint-suppressions.json. ESLint exits 2 on a stale suppression even with zero errors — main exits 0, this branch did not — so the lint gate would have failed CI. Pruned in its own commit.

For the reviewer

  1. Two files outside the intended scope got a one-line fixture addition each (src/lib/data.test.ts, src/components/DocumentUploadModal.test.tsx), forced by making term required on EnrolledCourse.
  2. Landing reads ?semester= from window.location.search in a useState initializer rather than useSearchParams(), which would require a Suspense boundary in the route shell this component doesn't own. It never reaches the rendered tree, so there's no hydration mismatch — but adding the boundary instead is a reasonable alternative.
  3. The client's current-term rule is a hand-mirror of the Python one. Test fixtures are migration 0019's four terms verbatim so drift surfaces, but there is no automated cross-language check.
  4. The knowledge graph still renders nodes from all terms — only the course lists and legends are term-scoped. Filtering graph data by term is a separate product call.

🤖 Generated with Claude Code

Jose-Gael-Cruz-Lopezand others added 25 commits July 20, 2026 23:08
/api/graph/{user_id}/courses has always returned the offering's term
label; the client type never declared it, so every consumer had to cast
through any to reach it.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
termRankFromLabel mirrors the sort_key formula from migration 0019 so a
label-only fallback orders identically to the server.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Mirrors services/academics.py::current_term — today within
[start_date, end_date], else the highest sort_key — so client and server
never disagree about which semester is current.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ack (#140)
Fixtures are the four terms seeded by migration 0019 verbatim, so a drift
between this rule and the backend's shows up here.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Ordering keys on sort_key when the semesters payload is available and
degrades to the label-derived rank otherwise. Courses with no term go to
an 'Other' bucket rather than being dropped.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…#140)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Only courses that rank strictly below the current term are archived.
Undatable courses — and every course when /api/semesters gives us
nothing — stay in the default list.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ck (#140)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…140)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
/api/graph/{user_id}/courses emits `term`; the landing read
`(c as any).semester`, which is always undefined. `distinct` was
therefore always empty and every signed-in user silently fell through to
the hardcoded SAMPLE_SEMESTERS demo chips.
The sample chips are now the logged-out preview only — a signed-in user
with no terms gets their own empty state instead of another student's
fake grades.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…140)
Chips now sort by sort_key from /api/semesters and default to the
date-derived current term instead of whichever term the courses payload
happened to list first. A failed semesters fetch degrades to the
label-derived order.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Gives the dashboard archive somewhere to land: selecting an archived
class opens that semester's gradebook rather than the current one.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…140)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…140)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…#140)
Same markup, lifted so the current-term list, the archive and the graph
overlay can all render a course line without a third copy.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ve (#140)
Current-term courses show by default; earlier terms collapse behind an
Archive toggle, grouped by label most recent first. Also covers the
mobile 'My Courses' tab, which renders the same panel.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The floating course key now lists only current-term courses and offers
past terms as a compact Archive that deep-links into each semester's
gradebook.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Headings only appear once a student has courses in more than one term.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Keeps the top-nav legend consistent with the courses key overlay, which
already lists only the current semester.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…tion (#140)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…tech (#140)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Reading `term` instead of `(c as any).semester` removed the only
no-explicit-any in Landing.tsx, so its suppression entry no longer matches
anything. eslint exits 2 on a stale suppression even with zero errors, which
fails the CI lint gate — `main` exits 0, this branch did not.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@coderabbitai

coderabbitaiBot commented Jul 21, 2026

Copy link
Copy Markdown

Warning

Review limit reached

@AndresL230, you've reached your PR review limit, so we couldn't start this review.

Next review available in:54 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Free

Run ID: 39694af7-3514-4235-b33b-03e1297e832a

📥 Commits

Reviewing files that changed from the base of the PR and between 4227689 and c1d6f9b.

📒 Files selected for processing (11)
  • frontend/eslint-suppressions.json
  • frontend/src/components/DocumentUploadModal.test.tsx
  • frontend/src/components/ManageCoursesModal.tsx
  • frontend/src/components/screens/Dashboard.test.tsx
  • frontend/src/components/screens/Dashboard.tsx
  • frontend/src/components/screens/Gradebook/Landing.test.tsx
  • frontend/src/components/screens/Gradebook/Landing.tsx
  • frontend/src/lib/api.ts
  • frontend/src/lib/data.test.ts
  • frontend/src/lib/semesters.test.ts
  • frontend/src/lib/semesters.ts

Note

🎁 Summarized by CodeRabbit Free

Your organization is on the Free plan. CodeRabbit will generate a high-level summary and a walkthrough for each pull request. For a comprehensive line-by-line review, please upgrade your subscription to CodeRabbit Pro by visiting https://app.coderabbit.ai/login.

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

@cloudflare-workers-and-pages

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

Copy link
Copy Markdown

Deploying with Cloudflare Workers Cloudflare Workers

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

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

Branch Preview URL
Jul 22 2026, 04:25 AM

@Jose-Gael-Cruz-Lopez

Copy link
Copy Markdown
MemberAuthor

@coderabbitai review

@coderabbitai

coderabbitaiBot commented Jul 21, 2026

Copy link
Copy Markdown
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

Empty commit to re-run CI after the frontend-staging Workers build-command config fix (npm run cf:build restored; the broken wrangler deploy --env staging skipped the OpenNext build).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@Jose-Gael-Cruz-Lopez
Jose-Gael-Cruz-Lopez merged commit 452c10a into mainJul 24, 2026
6 checks passed
@AndresL230
AndresL230 deleted the feat/140-semester-grouping-archive branch August 2, 2026 18:30
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[P2] Semesters: dashboard/course-list grouping + Archive for past terms

2 participants

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

feat(dashboard): group courses by semester with an Archive for past terms (#140) - #368

Merged
Jose-Gael-Cruz-Lopez merged 26 commits into
mainfrom
feat/140-semester-grouping-archive
Jul 24, 2026
Merged

feat(dashboard): group courses by semester with an Archive for past terms (#140)#368
Jose-Gael-Cruz-Lopez merged 26 commits into
mainfrom
feat/140-semester-grouping-archive

Conversation

@Jose-Gael-Cruz-Lopez

Copy link
Copy Markdown
Member

Closes#140.

Groups enrolled classes by semester, defaults to the current term, and puts past terms behind an Archive.

The dependency was already satisfied

#140 is filed as "depends on the backend API issue", but that's stale — nothing was blocking:

  • GET /api/semesters already exists and serves {id, term, year, label, start_date, end_date, sort_key}.
  • GET /api/graph/{user_id}/courses already returns a term label per course; only the frontend EnrolledCourse interface hadn't declared it.

So this is frontend-only.

It also fixes a live bug

screens/Gradebook/Landing.tsx read (c as any).semester off the courses payload — but the API sends term. The lookup therefore always came back empty and fell through to the hardcoded demo constant:

constSAMPLE_SEMESTERS=["Spring 2026","Fall 2025"];

Every signed-in user was being shown those two fake semester chips instead of their own. The as any is exactly what let the field-name mismatch through the type checker. SAMPLE_SEMESTERS is now strictly the logged-out preview; a signed-in user with no terms gets their own empty state (a termsReady gate keeps that from hanging on the skeleton). Covered by tests.

New src/lib/semesters.ts — one tested source of truth

  • currentTerm(semesters, today) mirrors services/academics.py::current_termexactly: the highest-sort_key term whose [start_date, end_date] contains today, falling back to the highest sort_key overall when today lands in a gap between terms. today is injectable. Dates compare as YYYY-MM-DD strings (lexicographic == chronological), and a Date is read in local time — toISOString() would shift the day either side of midnight.
  • termRankFromLabel("Fall 2025") -> 20253, mirroring migration 0019's year * 10 + ordinal. Only consulted when no real sort_key is available; sort_key always wins.
  • groupCoursesByTerm, courseTermLabels, partitionCurrentAndArchive.

Degradation — the rule is "never hide a course"

If /api/semesters fails or returns nothing, everything collapses to one flat ungrouped list with no Archive control and nothing hidden. Courses with an empty or unparseable term are never dropped and never archived. Future-term courses are never archived. Only a course ranking strictly below the current term is archived — showing a course in the wrong bucket is recoverable, hiding it isn't.

UI

The partition is applied to the "My courses" panel (which is also the mobile courses tab), the graph legend chips, the CoursesKey overlay, and ManageCoursesModal's list. CourseProgressRow was extracted rather than copying that markup a third time. Archived rows route to /gradebook?semester=<label>, which the gradebook landing reads and preselects — so selecting an archived class lands in that semester's gradebook, per the acceptance criteria.

The Calendar is untouched, per the explicit product decision in the issue.

Verification

GateResult
npm test131 passing (baseline 88) — 15 files, +43 tests
npx tsc --noEmit0 errors
npx eslint . --max-warnings=-1exit 0, 36 warnings (baseline)
npm run buildexit 0, compiled successfully

One thing worth flagging because it nearly shipped broken: fixing the as any removed the only no-explicit-any in Landing.tsx, which left a stale entry in eslint-suppressions.json. ESLint exits 2 on a stale suppression even with zero errors — main exits 0, this branch did not — so the lint gate would have failed CI. Pruned in its own commit.

For the reviewer

  1. Two files outside the intended scope got a one-line fixture addition each (src/lib/data.test.ts, src/components/DocumentUploadModal.test.tsx), forced by making term required on EnrolledCourse.
  2. Landing reads ?semester= from window.location.search in a useState initializer rather than useSearchParams(), which would require a Suspense boundary in the route shell this component doesn't own. It never reaches the rendered tree, so there's no hydration mismatch — but adding the boundary instead is a reasonable alternative.
  3. The client's current-term rule is a hand-mirror of the Python one. Test fixtures are migration 0019's four terms verbatim so drift surfaces, but there is no automated cross-language check.
  4. The knowledge graph still renders nodes from all terms — only the course lists and legends are term-scoped. Filtering graph data by term is a separate product call.

🤖 Generated with Claude Code

Jose-Gael-Cruz-Lopezand others added 25 commits July 20, 2026 23:08
/api/graph/{user_id}/courses has always returned the offering's term
label; the client type never declared it, so every consumer had to cast
through any to reach it.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
termRankFromLabel mirrors the sort_key formula from migration 0019 so a
label-only fallback orders identically to the server.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Mirrors services/academics.py::current_term — today within
[start_date, end_date], else the highest sort_key — so client and server
never disagree about which semester is current.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ack (#140)
Fixtures are the four terms seeded by migration 0019 verbatim, so a drift
between this rule and the backend's shows up here.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Ordering keys on sort_key when the semesters payload is available and
degrades to the label-derived rank otherwise. Courses with no term go to
an 'Other' bucket rather than being dropped.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…#140)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Only courses that rank strictly below the current term are archived.
Undatable courses — and every course when /api/semesters gives us
nothing — stay in the default list.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ck (#140)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…140)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
/api/graph/{user_id}/courses emits `term`; the landing read
`(c as any).semester`, which is always undefined. `distinct` was
therefore always empty and every signed-in user silently fell through to
the hardcoded SAMPLE_SEMESTERS demo chips.
The sample chips are now the logged-out preview only — a signed-in user
with no terms gets their own empty state instead of another student's
fake grades.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…140)
Chips now sort by sort_key from /api/semesters and default to the
date-derived current term instead of whichever term the courses payload
happened to list first. A failed semesters fetch degrades to the
label-derived order.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Gives the dashboard archive somewhere to land: selecting an archived
class opens that semester's gradebook rather than the current one.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…140)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…140)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…#140)
Same markup, lifted so the current-term list, the archive and the graph
overlay can all render a course line without a third copy.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ve (#140)
Current-term courses show by default; earlier terms collapse behind an
Archive toggle, grouped by label most recent first. Also covers the
mobile 'My Courses' tab, which renders the same panel.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The floating course key now lists only current-term courses and offers
past terms as a compact Archive that deep-links into each semester's
gradebook.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Headings only appear once a student has courses in more than one term.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Keeps the top-nav legend consistent with the courses key overlay, which
already lists only the current semester.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…tion (#140)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…tech (#140)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Reading `term` instead of `(c as any).semester` removed the only
no-explicit-any in Landing.tsx, so its suppression entry no longer matches
anything. eslint exits 2 on a stale suppression even with zero errors, which
fails the CI lint gate — `main` exits 0, this branch did not.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@coderabbitai

coderabbitaiBot commented Jul 21, 2026

Copy link
Copy Markdown

Warning

Review limit reached

@AndresL230, you've reached your PR review limit, so we couldn't start this review.

Next review available in:54 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Free

Run ID: 39694af7-3514-4235-b33b-03e1297e832a

📥 Commits

Reviewing files that changed from the base of the PR and between 4227689 and c1d6f9b.

📒 Files selected for processing (11)
  • frontend/eslint-suppressions.json
  • frontend/src/components/DocumentUploadModal.test.tsx
  • frontend/src/components/ManageCoursesModal.tsx
  • frontend/src/components/screens/Dashboard.test.tsx
  • frontend/src/components/screens/Dashboard.tsx
  • frontend/src/components/screens/Gradebook/Landing.test.tsx
  • frontend/src/components/screens/Gradebook/Landing.tsx
  • frontend/src/lib/api.ts
  • frontend/src/lib/data.test.ts
  • frontend/src/lib/semesters.test.ts
  • frontend/src/lib/semesters.ts

Note

🎁 Summarized by CodeRabbit Free

Your organization is on the Free plan. CodeRabbit will generate a high-level summary and a walkthrough for each pull request. For a comprehensive line-by-line review, please upgrade your subscription to CodeRabbit Pro by visiting https://app.coderabbit.ai/login.

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

@cloudflare-workers-and-pages

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

Copy link
Copy Markdown

Deploying with Cloudflare Workers Cloudflare Workers

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

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

Branch Preview URL
Jul 22 2026, 04:25 AM

@Jose-Gael-Cruz-Lopez

Copy link
Copy Markdown
MemberAuthor

@coderabbitai review

@coderabbitai

coderabbitaiBot commented Jul 21, 2026

Copy link
Copy Markdown
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

Empty commit to re-run CI after the frontend-staging Workers build-command config fix (npm run cf:build restored; the broken wrangler deploy --env staging skipped the OpenNext build).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@Jose-Gael-Cruz-Lopez
Jose-Gael-Cruz-Lopez merged commit 452c10a into mainJul 24, 2026
6 checks passed
@AndresL230
AndresL230 deleted the feat/140-semester-grouping-archive branch August 2, 2026 18:30
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[P2] Semesters: dashboard/course-list grouping + Archive for past terms

2 participants

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

feat(dashboard): group courses by semester with an Archive for past terms (#140) - #368

Merged
Jose-Gael-Cruz-Lopez merged 26 commits into
mainfrom
feat/140-semester-grouping-archive
Jul 24, 2026
Merged

feat(dashboard): group courses by semester with an Archive for past terms (#140)#368
Jose-Gael-Cruz-Lopez merged 26 commits into
mainfrom
feat/140-semester-grouping-archive

Conversation

@Jose-Gael-Cruz-Lopez

Copy link
Copy Markdown
Member

Closes#140.

Groups enrolled classes by semester, defaults to the current term, and puts past terms behind an Archive.

The dependency was already satisfied

#140 is filed as "depends on the backend API issue", but that's stale — nothing was blocking:

  • GET /api/semesters already exists and serves {id, term, year, label, start_date, end_date, sort_key}.
  • GET /api/graph/{user_id}/courses already returns a term label per course; only the frontend EnrolledCourse interface hadn't declared it.

So this is frontend-only.

It also fixes a live bug

screens/Gradebook/Landing.tsx read (c as any).semester off the courses payload — but the API sends term. The lookup therefore always came back empty and fell through to the hardcoded demo constant:

constSAMPLE_SEMESTERS=["Spring 2026","Fall 2025"];

Every signed-in user was being shown those two fake semester chips instead of their own. The as any is exactly what let the field-name mismatch through the type checker. SAMPLE_SEMESTERS is now strictly the logged-out preview; a signed-in user with no terms gets their own empty state (a termsReady gate keeps that from hanging on the skeleton). Covered by tests.

New src/lib/semesters.ts — one tested source of truth

  • currentTerm(semesters, today) mirrors services/academics.py::current_termexactly: the highest-sort_key term whose [start_date, end_date] contains today, falling back to the highest sort_key overall when today lands in a gap between terms. today is injectable. Dates compare as YYYY-MM-DD strings (lexicographic == chronological), and a Date is read in local time — toISOString() would shift the day either side of midnight.
  • termRankFromLabel("Fall 2025") -> 20253, mirroring migration 0019's year * 10 + ordinal. Only consulted when no real sort_key is available; sort_key always wins.
  • groupCoursesByTerm, courseTermLabels, partitionCurrentAndArchive.

Degradation — the rule is "never hide a course"

If /api/semesters fails or returns nothing, everything collapses to one flat ungrouped list with no Archive control and nothing hidden. Courses with an empty or unparseable term are never dropped and never archived. Future-term courses are never archived. Only a course ranking strictly below the current term is archived — showing a course in the wrong bucket is recoverable, hiding it isn't.

UI

The partition is applied to the "My courses" panel (which is also the mobile courses tab), the graph legend chips, the CoursesKey overlay, and ManageCoursesModal's list. CourseProgressRow was extracted rather than copying that markup a third time. Archived rows route to /gradebook?semester=<label>, which the gradebook landing reads and preselects — so selecting an archived class lands in that semester's gradebook, per the acceptance criteria.

The Calendar is untouched, per the explicit product decision in the issue.

Verification

GateResult
npm test131 passing (baseline 88) — 15 files, +43 tests
npx tsc --noEmit0 errors
npx eslint . --max-warnings=-1exit 0, 36 warnings (baseline)
npm run buildexit 0, compiled successfully

One thing worth flagging because it nearly shipped broken: fixing the as any removed the only no-explicit-any in Landing.tsx, which left a stale entry in eslint-suppressions.json. ESLint exits 2 on a stale suppression even with zero errors — main exits 0, this branch did not — so the lint gate would have failed CI. Pruned in its own commit.

For the reviewer

  1. Two files outside the intended scope got a one-line fixture addition each (src/lib/data.test.ts, src/components/DocumentUploadModal.test.tsx), forced by making term required on EnrolledCourse.
  2. Landing reads ?semester= from window.location.search in a useState initializer rather than useSearchParams(), which would require a Suspense boundary in the route shell this component doesn't own. It never reaches the rendered tree, so there's no hydration mismatch — but adding the boundary instead is a reasonable alternative.
  3. The client's current-term rule is a hand-mirror of the Python one. Test fixtures are migration 0019's four terms verbatim so drift surfaces, but there is no automated cross-language check.
  4. The knowledge graph still renders nodes from all terms — only the course lists and legends are term-scoped. Filtering graph data by term is a separate product call.

🤖 Generated with Claude Code

Jose-Gael-Cruz-Lopezand others added 25 commits July 20, 2026 23:08
/api/graph/{user_id}/courses has always returned the offering's term
label; the client type never declared it, so every consumer had to cast
through any to reach it.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
termRankFromLabel mirrors the sort_key formula from migration 0019 so a
label-only fallback orders identically to the server.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Mirrors services/academics.py::current_term — today within
[start_date, end_date], else the highest sort_key — so client and server
never disagree about which semester is current.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ack (#140)
Fixtures are the four terms seeded by migration 0019 verbatim, so a drift
between this rule and the backend's shows up here.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Ordering keys on sort_key when the semesters payload is available and
degrades to the label-derived rank otherwise. Courses with no term go to
an 'Other' bucket rather than being dropped.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…#140)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Only courses that rank strictly below the current term are archived.
Undatable courses — and every course when /api/semesters gives us
nothing — stay in the default list.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ck (#140)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…140)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
/api/graph/{user_id}/courses emits `term`; the landing read
`(c as any).semester`, which is always undefined. `distinct` was
therefore always empty and every signed-in user silently fell through to
the hardcoded SAMPLE_SEMESTERS demo chips.
The sample chips are now the logged-out preview only — a signed-in user
with no terms gets their own empty state instead of another student's
fake grades.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…140)
Chips now sort by sort_key from /api/semesters and default to the
date-derived current term instead of whichever term the courses payload
happened to list first. A failed semesters fetch degrades to the
label-derived order.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Gives the dashboard archive somewhere to land: selecting an archived
class opens that semester's gradebook rather than the current one.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…140)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…140)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…#140)
Same markup, lifted so the current-term list, the archive and the graph
overlay can all render a course line without a third copy.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ve (#140)
Current-term courses show by default; earlier terms collapse behind an
Archive toggle, grouped by label most recent first. Also covers the
mobile 'My Courses' tab, which renders the same panel.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The floating course key now lists only current-term courses and offers
past terms as a compact Archive that deep-links into each semester's
gradebook.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Headings only appear once a student has courses in more than one term.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Keeps the top-nav legend consistent with the courses key overlay, which
already lists only the current semester.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…tion (#140)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…tech (#140)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Reading `term` instead of `(c as any).semester` removed the only
no-explicit-any in Landing.tsx, so its suppression entry no longer matches
anything. eslint exits 2 on a stale suppression even with zero errors, which
fails the CI lint gate — `main` exits 0, this branch did not.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@coderabbitai

coderabbitaiBot commented Jul 21, 2026

Copy link
Copy Markdown

Warning

Review limit reached

@AndresL230, you've reached your PR review limit, so we couldn't start this review.

Next review available in:54 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Free

Run ID: 39694af7-3514-4235-b33b-03e1297e832a

📥 Commits

Reviewing files that changed from the base of the PR and between 4227689 and c1d6f9b.

📒 Files selected for processing (11)
  • frontend/eslint-suppressions.json
  • frontend/src/components/DocumentUploadModal.test.tsx
  • frontend/src/components/ManageCoursesModal.tsx
  • frontend/src/components/screens/Dashboard.test.tsx
  • frontend/src/components/screens/Dashboard.tsx
  • frontend/src/components/screens/Gradebook/Landing.test.tsx
  • frontend/src/components/screens/Gradebook/Landing.tsx
  • frontend/src/lib/api.ts
  • frontend/src/lib/data.test.ts
  • frontend/src/lib/semesters.test.ts
  • frontend/src/lib/semesters.ts

Note

🎁 Summarized by CodeRabbit Free

Your organization is on the Free plan. CodeRabbit will generate a high-level summary and a walkthrough for each pull request. For a comprehensive line-by-line review, please upgrade your subscription to CodeRabbit Pro by visiting https://app.coderabbit.ai/login.

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

@cloudflare-workers-and-pages

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

Copy link
Copy Markdown

Deploying with Cloudflare Workers Cloudflare Workers

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

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

Branch Preview URL
Jul 22 2026, 04:25 AM

@Jose-Gael-Cruz-Lopez

Copy link
Copy Markdown
MemberAuthor

@coderabbitai review

@coderabbitai

coderabbitaiBot commented Jul 21, 2026

Copy link
Copy Markdown
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

Empty commit to re-run CI after the frontend-staging Workers build-command config fix (npm run cf:build restored; the broken wrangler deploy --env staging skipped the OpenNext build).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@Jose-Gael-Cruz-Lopez
Jose-Gael-Cruz-Lopez merged commit 452c10a into mainJul 24, 2026
6 checks passed
@AndresL230
AndresL230 deleted the feat/140-semester-grouping-archive branch August 2, 2026 18:30
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[P2] Semesters: dashboard/course-list grouping + Archive for past terms

2 participants

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

feat(dashboard): group courses by semester with an Archive for past terms (#140) - #368

Merged
Jose-Gael-Cruz-Lopez merged 26 commits into
mainfrom
feat/140-semester-grouping-archive
Jul 24, 2026
Merged

feat(dashboard): group courses by semester with an Archive for past terms (#140)#368
Jose-Gael-Cruz-Lopez merged 26 commits into
mainfrom
feat/140-semester-grouping-archive

Conversation

@Jose-Gael-Cruz-Lopez

Copy link
Copy Markdown
Member

Closes#140.

Groups enrolled classes by semester, defaults to the current term, and puts past terms behind an Archive.

The dependency was already satisfied

#140 is filed as "depends on the backend API issue", but that's stale — nothing was blocking:

  • GET /api/semesters already exists and serves {id, term, year, label, start_date, end_date, sort_key}.
  • GET /api/graph/{user_id}/courses already returns a term label per course; only the frontend EnrolledCourse interface hadn't declared it.

So this is frontend-only.

It also fixes a live bug

screens/Gradebook/Landing.tsx read (c as any).semester off the courses payload — but the API sends term. The lookup therefore always came back empty and fell through to the hardcoded demo constant:

constSAMPLE_SEMESTERS=["Spring 2026","Fall 2025"];

Every signed-in user was being shown those two fake semester chips instead of their own. The as any is exactly what let the field-name mismatch through the type checker. SAMPLE_SEMESTERS is now strictly the logged-out preview; a signed-in user with no terms gets their own empty state (a termsReady gate keeps that from hanging on the skeleton). Covered by tests.

New src/lib/semesters.ts — one tested source of truth

  • currentTerm(semesters, today) mirrors services/academics.py::current_termexactly: the highest-sort_key term whose [start_date, end_date] contains today, falling back to the highest sort_key overall when today lands in a gap between terms. today is injectable. Dates compare as YYYY-MM-DD strings (lexicographic == chronological), and a Date is read in local time — toISOString() would shift the day either side of midnight.
  • termRankFromLabel("Fall 2025") -> 20253, mirroring migration 0019's year * 10 + ordinal. Only consulted when no real sort_key is available; sort_key always wins.
  • groupCoursesByTerm, courseTermLabels, partitionCurrentAndArchive.

Degradation — the rule is "never hide a course"

If /api/semesters fails or returns nothing, everything collapses to one flat ungrouped list with no Archive control and nothing hidden. Courses with an empty or unparseable term are never dropped and never archived. Future-term courses are never archived. Only a course ranking strictly below the current term is archived — showing a course in the wrong bucket is recoverable, hiding it isn't.

UI

The partition is applied to the "My courses" panel (which is also the mobile courses tab), the graph legend chips, the CoursesKey overlay, and ManageCoursesModal's list. CourseProgressRow was extracted rather than copying that markup a third time. Archived rows route to /gradebook?semester=<label>, which the gradebook landing reads and preselects — so selecting an archived class lands in that semester's gradebook, per the acceptance criteria.

The Calendar is untouched, per the explicit product decision in the issue.

Verification

GateResult
npm test131 passing (baseline 88) — 15 files, +43 tests
npx tsc --noEmit0 errors
npx eslint . --max-warnings=-1exit 0, 36 warnings (baseline)
npm run buildexit 0, compiled successfully

One thing worth flagging because it nearly shipped broken: fixing the as any removed the only no-explicit-any in Landing.tsx, which left a stale entry in eslint-suppressions.json. ESLint exits 2 on a stale suppression even with zero errors — main exits 0, this branch did not — so the lint gate would have failed CI. Pruned in its own commit.

For the reviewer

  1. Two files outside the intended scope got a one-line fixture addition each (src/lib/data.test.ts, src/components/DocumentUploadModal.test.tsx), forced by making term required on EnrolledCourse.
  2. Landing reads ?semester= from window.location.search in a useState initializer rather than useSearchParams(), which would require a Suspense boundary in the route shell this component doesn't own. It never reaches the rendered tree, so there's no hydration mismatch — but adding the boundary instead is a reasonable alternative.
  3. The client's current-term rule is a hand-mirror of the Python one. Test fixtures are migration 0019's four terms verbatim so drift surfaces, but there is no automated cross-language check.
  4. The knowledge graph still renders nodes from all terms — only the course lists and legends are term-scoped. Filtering graph data by term is a separate product call.

🤖 Generated with Claude Code

Jose-Gael-Cruz-Lopezand others added 25 commits July 20, 2026 23:08
/api/graph/{user_id}/courses has always returned the offering's term
label; the client type never declared it, so every consumer had to cast
through any to reach it.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
termRankFromLabel mirrors the sort_key formula from migration 0019 so a
label-only fallback orders identically to the server.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Mirrors services/academics.py::current_term — today within
[start_date, end_date], else the highest sort_key — so client and server
never disagree about which semester is current.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ack (#140)
Fixtures are the four terms seeded by migration 0019 verbatim, so a drift
between this rule and the backend's shows up here.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Ordering keys on sort_key when the semesters payload is available and
degrades to the label-derived rank otherwise. Courses with no term go to
an 'Other' bucket rather than being dropped.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…#140)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Only courses that rank strictly below the current term are archived.
Undatable courses — and every course when /api/semesters gives us
nothing — stay in the default list.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ck (#140)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…140)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
/api/graph/{user_id}/courses emits `term`; the landing read
`(c as any).semester`, which is always undefined. `distinct` was
therefore always empty and every signed-in user silently fell through to
the hardcoded SAMPLE_SEMESTERS demo chips.
The sample chips are now the logged-out preview only — a signed-in user
with no terms gets their own empty state instead of another student's
fake grades.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…140)
Chips now sort by sort_key from /api/semesters and default to the
date-derived current term instead of whichever term the courses payload
happened to list first. A failed semesters fetch degrades to the
label-derived order.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Gives the dashboard archive somewhere to land: selecting an archived
class opens that semester's gradebook rather than the current one.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…140)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…140)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…#140)
Same markup, lifted so the current-term list, the archive and the graph
overlay can all render a course line without a third copy.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ve (#140)
Current-term courses show by default; earlier terms collapse behind an
Archive toggle, grouped by label most recent first. Also covers the
mobile 'My Courses' tab, which renders the same panel.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The floating course key now lists only current-term courses and offers
past terms as a compact Archive that deep-links into each semester's
gradebook.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Headings only appear once a student has courses in more than one term.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Keeps the top-nav legend consistent with the courses key overlay, which
already lists only the current semester.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…tion (#140)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…tech (#140)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Reading `term` instead of `(c as any).semester` removed the only
no-explicit-any in Landing.tsx, so its suppression entry no longer matches
anything. eslint exits 2 on a stale suppression even with zero errors, which
fails the CI lint gate — `main` exits 0, this branch did not.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@coderabbitai

coderabbitaiBot commented Jul 21, 2026

Copy link
Copy Markdown

Warning

Review limit reached

@AndresL230, you've reached your PR review limit, so we couldn't start this review.

Next review available in:54 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Free

Run ID: 39694af7-3514-4235-b33b-03e1297e832a

📥 Commits

Reviewing files that changed from the base of the PR and between 4227689 and c1d6f9b.

📒 Files selected for processing (11)
  • frontend/eslint-suppressions.json
  • frontend/src/components/DocumentUploadModal.test.tsx
  • frontend/src/components/ManageCoursesModal.tsx
  • frontend/src/components/screens/Dashboard.test.tsx
  • frontend/src/components/screens/Dashboard.tsx
  • frontend/src/components/screens/Gradebook/Landing.test.tsx
  • frontend/src/components/screens/Gradebook/Landing.tsx
  • frontend/src/lib/api.ts
  • frontend/src/lib/data.test.ts
  • frontend/src/lib/semesters.test.ts
  • frontend/src/lib/semesters.ts

Note

🎁 Summarized by CodeRabbit Free

Your organization is on the Free plan. CodeRabbit will generate a high-level summary and a walkthrough for each pull request. For a comprehensive line-by-line review, please upgrade your subscription to CodeRabbit Pro by visiting https://app.coderabbit.ai/login.

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

@cloudflare-workers-and-pages

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

Copy link
Copy Markdown

Deploying with Cloudflare Workers Cloudflare Workers

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

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

Branch Preview URL
Jul 22 2026, 04:25 AM

@Jose-Gael-Cruz-Lopez

Copy link
Copy Markdown
MemberAuthor

@coderabbitai review

@coderabbitai

coderabbitaiBot commented Jul 21, 2026

Copy link
Copy Markdown
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

Empty commit to re-run CI after the frontend-staging Workers build-command config fix (npm run cf:build restored; the broken wrangler deploy --env staging skipped the OpenNext build).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@Jose-Gael-Cruz-Lopez
Jose-Gael-Cruz-Lopez merged commit 452c10a into mainJul 24, 2026
6 checks passed
@AndresL230
AndresL230 deleted the feat/140-semester-grouping-archive branch August 2, 2026 18:30
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[P2] Semesters: dashboard/course-list grouping + Archive for past terms

2 participants

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

feat(dashboard): group courses by semester with an Archive for past terms (#140) - #368

Merged
Jose-Gael-Cruz-Lopez merged 26 commits into
mainfrom
feat/140-semester-grouping-archive
Jul 24, 2026
Merged

feat(dashboard): group courses by semester with an Archive for past terms (#140)#368
Jose-Gael-Cruz-Lopez merged 26 commits into
mainfrom
feat/140-semester-grouping-archive

Conversation

@Jose-Gael-Cruz-Lopez

Copy link
Copy Markdown
Member

Closes#140.

Groups enrolled classes by semester, defaults to the current term, and puts past terms behind an Archive.

The dependency was already satisfied

#140 is filed as "depends on the backend API issue", but that's stale — nothing was blocking:

  • GET /api/semesters already exists and serves {id, term, year, label, start_date, end_date, sort_key}.
  • GET /api/graph/{user_id}/courses already returns a term label per course; only the frontend EnrolledCourse interface hadn't declared it.

So this is frontend-only.

It also fixes a live bug

screens/Gradebook/Landing.tsx read (c as any).semester off the courses payload — but the API sends term. The lookup therefore always came back empty and fell through to the hardcoded demo constant:

constSAMPLE_SEMESTERS=["Spring 2026","Fall 2025"];

Every signed-in user was being shown those two fake semester chips instead of their own. The as any is exactly what let the field-name mismatch through the type checker. SAMPLE_SEMESTERS is now strictly the logged-out preview; a signed-in user with no terms gets their own empty state (a termsReady gate keeps that from hanging on the skeleton). Covered by tests.

New src/lib/semesters.ts — one tested source of truth

  • currentTerm(semesters, today) mirrors services/academics.py::current_termexactly: the highest-sort_key term whose [start_date, end_date] contains today, falling back to the highest sort_key overall when today lands in a gap between terms. today is injectable. Dates compare as YYYY-MM-DD strings (lexicographic == chronological), and a Date is read in local time — toISOString() would shift the day either side of midnight.
  • termRankFromLabel("Fall 2025") -> 20253, mirroring migration 0019's year * 10 + ordinal. Only consulted when no real sort_key is available; sort_key always wins.
  • groupCoursesByTerm, courseTermLabels, partitionCurrentAndArchive.

Degradation — the rule is "never hide a course"

If /api/semesters fails or returns nothing, everything collapses to one flat ungrouped list with no Archive control and nothing hidden. Courses with an empty or unparseable term are never dropped and never archived. Future-term courses are never archived. Only a course ranking strictly below the current term is archived — showing a course in the wrong bucket is recoverable, hiding it isn't.

UI

The partition is applied to the "My courses" panel (which is also the mobile courses tab), the graph legend chips, the CoursesKey overlay, and ManageCoursesModal's list. CourseProgressRow was extracted rather than copying that markup a third time. Archived rows route to /gradebook?semester=<label>, which the gradebook landing reads and preselects — so selecting an archived class lands in that semester's gradebook, per the acceptance criteria.

The Calendar is untouched, per the explicit product decision in the issue.

Verification

GateResult
npm test131 passing (baseline 88) — 15 files, +43 tests
npx tsc --noEmit0 errors
npx eslint . --max-warnings=-1exit 0, 36 warnings (baseline)
npm run buildexit 0, compiled successfully

One thing worth flagging because it nearly shipped broken: fixing the as any removed the only no-explicit-any in Landing.tsx, which left a stale entry in eslint-suppressions.json. ESLint exits 2 on a stale suppression even with zero errors — main exits 0, this branch did not — so the lint gate would have failed CI. Pruned in its own commit.

For the reviewer

  1. Two files outside the intended scope got a one-line fixture addition each (src/lib/data.test.ts, src/components/DocumentUploadModal.test.tsx), forced by making term required on EnrolledCourse.
  2. Landing reads ?semester= from window.location.search in a useState initializer rather than useSearchParams(), which would require a Suspense boundary in the route shell this component doesn't own. It never reaches the rendered tree, so there's no hydration mismatch — but adding the boundary instead is a reasonable alternative.
  3. The client's current-term rule is a hand-mirror of the Python one. Test fixtures are migration 0019's four terms verbatim so drift surfaces, but there is no automated cross-language check.
  4. The knowledge graph still renders nodes from all terms — only the course lists and legends are term-scoped. Filtering graph data by term is a separate product call.

🤖 Generated with Claude Code

Jose-Gael-Cruz-Lopezand others added 25 commits July 20, 2026 23:08
/api/graph/{user_id}/courses has always returned the offering's term
label; the client type never declared it, so every consumer had to cast
through any to reach it.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
termRankFromLabel mirrors the sort_key formula from migration 0019 so a
label-only fallback orders identically to the server.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Mirrors services/academics.py::current_term — today within
[start_date, end_date], else the highest sort_key — so client and server
never disagree about which semester is current.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ack (#140)
Fixtures are the four terms seeded by migration 0019 verbatim, so a drift
between this rule and the backend's shows up here.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Ordering keys on sort_key when the semesters payload is available and
degrades to the label-derived rank otherwise. Courses with no term go to
an 'Other' bucket rather than being dropped.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…#140)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Only courses that rank strictly below the current term are archived.
Undatable courses — and every course when /api/semesters gives us
nothing — stay in the default list.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ck (#140)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…140)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
/api/graph/{user_id}/courses emits `term`; the landing read
`(c as any).semester`, which is always undefined. `distinct` was
therefore always empty and every signed-in user silently fell through to
the hardcoded SAMPLE_SEMESTERS demo chips.
The sample chips are now the logged-out preview only — a signed-in user
with no terms gets their own empty state instead of another student's
fake grades.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…140)
Chips now sort by sort_key from /api/semesters and default to the
date-derived current term instead of whichever term the courses payload
happened to list first. A failed semesters fetch degrades to the
label-derived order.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Gives the dashboard archive somewhere to land: selecting an archived
class opens that semester's gradebook rather than the current one.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…140)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…140)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…#140)
Same markup, lifted so the current-term list, the archive and the graph
overlay can all render a course line without a third copy.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ve (#140)
Current-term courses show by default; earlier terms collapse behind an
Archive toggle, grouped by label most recent first. Also covers the
mobile 'My Courses' tab, which renders the same panel.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The floating course key now lists only current-term courses and offers
past terms as a compact Archive that deep-links into each semester's
gradebook.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Headings only appear once a student has courses in more than one term.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Keeps the top-nav legend consistent with the courses key overlay, which
already lists only the current semester.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…tion (#140)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…tech (#140)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Reading `term` instead of `(c as any).semester` removed the only
no-explicit-any in Landing.tsx, so its suppression entry no longer matches
anything. eslint exits 2 on a stale suppression even with zero errors, which
fails the CI lint gate — `main` exits 0, this branch did not.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@coderabbitai

coderabbitaiBot commented Jul 21, 2026

Copy link
Copy Markdown

Warning

Review limit reached

@AndresL230, you've reached your PR review limit, so we couldn't start this review.

Next review available in:54 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Free

Run ID: 39694af7-3514-4235-b33b-03e1297e832a

📥 Commits

Reviewing files that changed from the base of the PR and between 4227689 and c1d6f9b.

📒 Files selected for processing (11)
  • frontend/eslint-suppressions.json
  • frontend/src/components/DocumentUploadModal.test.tsx
  • frontend/src/components/ManageCoursesModal.tsx
  • frontend/src/components/screens/Dashboard.test.tsx
  • frontend/src/components/screens/Dashboard.tsx
  • frontend/src/components/screens/Gradebook/Landing.test.tsx
  • frontend/src/components/screens/Gradebook/Landing.tsx
  • frontend/src/lib/api.ts
  • frontend/src/lib/data.test.ts
  • frontend/src/lib/semesters.test.ts
  • frontend/src/lib/semesters.ts

Note

🎁 Summarized by CodeRabbit Free

Your organization is on the Free plan. CodeRabbit will generate a high-level summary and a walkthrough for each pull request. For a comprehensive line-by-line review, please upgrade your subscription to CodeRabbit Pro by visiting https://app.coderabbit.ai/login.

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

@cloudflare-workers-and-pages

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

Copy link
Copy Markdown

Deploying with Cloudflare Workers Cloudflare Workers

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

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

Branch Preview URL
Jul 22 2026, 04:25 AM

@Jose-Gael-Cruz-Lopez

Copy link
Copy Markdown
MemberAuthor

@coderabbitai review

@coderabbitai

coderabbitaiBot commented Jul 21, 2026

Copy link
Copy Markdown
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

Empty commit to re-run CI after the frontend-staging Workers build-command config fix (npm run cf:build restored; the broken wrangler deploy --env staging skipped the OpenNext build).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@Jose-Gael-Cruz-Lopez
Jose-Gael-Cruz-Lopez merged commit 452c10a into mainJul 24, 2026
6 checks passed
@AndresL230
AndresL230 deleted the feat/140-semester-grouping-archive branch August 2, 2026 18:30
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[P2] Semesters: dashboard/course-list grouping + Archive for past terms

2 participants

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

feat(dashboard): group courses by semester with an Archive for past terms (#140) - #368

Merged
Jose-Gael-Cruz-Lopez merged 26 commits into
mainfrom
feat/140-semester-grouping-archive
Jul 24, 2026
Merged

feat(dashboard): group courses by semester with an Archive for past terms (#140)#368
Jose-Gael-Cruz-Lopez merged 26 commits into
mainfrom
feat/140-semester-grouping-archive

Conversation

@Jose-Gael-Cruz-Lopez

Copy link
Copy Markdown
Member

Closes#140.

Groups enrolled classes by semester, defaults to the current term, and puts past terms behind an Archive.

The dependency was already satisfied

#140 is filed as "depends on the backend API issue", but that's stale — nothing was blocking:

  • GET /api/semesters already exists and serves {id, term, year, label, start_date, end_date, sort_key}.
  • GET /api/graph/{user_id}/courses already returns a term label per course; only the frontend EnrolledCourse interface hadn't declared it.

So this is frontend-only.

It also fixes a live bug

screens/Gradebook/Landing.tsx read (c as any).semester off the courses payload — but the API sends term. The lookup therefore always came back empty and fell through to the hardcoded demo constant:

constSAMPLE_SEMESTERS=["Spring 2026","Fall 2025"];

Every signed-in user was being shown those two fake semester chips instead of their own. The as any is exactly what let the field-name mismatch through the type checker. SAMPLE_SEMESTERS is now strictly the logged-out preview; a signed-in user with no terms gets their own empty state (a termsReady gate keeps that from hanging on the skeleton). Covered by tests.

New src/lib/semesters.ts — one tested source of truth

  • currentTerm(semesters, today) mirrors services/academics.py::current_termexactly: the highest-sort_key term whose [start_date, end_date] contains today, falling back to the highest sort_key overall when today lands in a gap between terms. today is injectable. Dates compare as YYYY-MM-DD strings (lexicographic == chronological), and a Date is read in local time — toISOString() would shift the day either side of midnight.
  • termRankFromLabel("Fall 2025") -> 20253, mirroring migration 0019's year * 10 + ordinal. Only consulted when no real sort_key is available; sort_key always wins.
  • groupCoursesByTerm, courseTermLabels, partitionCurrentAndArchive.

Degradation — the rule is "never hide a course"

If /api/semesters fails or returns nothing, everything collapses to one flat ungrouped list with no Archive control and nothing hidden. Courses with an empty or unparseable term are never dropped and never archived. Future-term courses are never archived. Only a course ranking strictly below the current term is archived — showing a course in the wrong bucket is recoverable, hiding it isn't.

UI

The partition is applied to the "My courses" panel (which is also the mobile courses tab), the graph legend chips, the CoursesKey overlay, and ManageCoursesModal's list. CourseProgressRow was extracted rather than copying that markup a third time. Archived rows route to /gradebook?semester=<label>, which the gradebook landing reads and preselects — so selecting an archived class lands in that semester's gradebook, per the acceptance criteria.

The Calendar is untouched, per the explicit product decision in the issue.

Verification

GateResult
npm test131 passing (baseline 88) — 15 files, +43 tests
npx tsc --noEmit0 errors
npx eslint . --max-warnings=-1exit 0, 36 warnings (baseline)
npm run buildexit 0, compiled successfully

One thing worth flagging because it nearly shipped broken: fixing the as any removed the only no-explicit-any in Landing.tsx, which left a stale entry in eslint-suppressions.json. ESLint exits 2 on a stale suppression even with zero errors — main exits 0, this branch did not — so the lint gate would have failed CI. Pruned in its own commit.

For the reviewer

  1. Two files outside the intended scope got a one-line fixture addition each (src/lib/data.test.ts, src/components/DocumentUploadModal.test.tsx), forced by making term required on EnrolledCourse.
  2. Landing reads ?semester= from window.location.search in a useState initializer rather than useSearchParams(), which would require a Suspense boundary in the route shell this component doesn't own. It never reaches the rendered tree, so there's no hydration mismatch — but adding the boundary instead is a reasonable alternative.
  3. The client's current-term rule is a hand-mirror of the Python one. Test fixtures are migration 0019's four terms verbatim so drift surfaces, but there is no automated cross-language check.
  4. The knowledge graph still renders nodes from all terms — only the course lists and legends are term-scoped. Filtering graph data by term is a separate product call.

🤖 Generated with Claude Code

Jose-Gael-Cruz-Lopezand others added 25 commits July 20, 2026 23:08
/api/graph/{user_id}/courses has always returned the offering's term
label; the client type never declared it, so every consumer had to cast
through any to reach it.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
termRankFromLabel mirrors the sort_key formula from migration 0019 so a
label-only fallback orders identically to the server.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Mirrors services/academics.py::current_term — today within
[start_date, end_date], else the highest sort_key — so client and server
never disagree about which semester is current.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ack (#140)
Fixtures are the four terms seeded by migration 0019 verbatim, so a drift
between this rule and the backend's shows up here.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Ordering keys on sort_key when the semesters payload is available and
degrades to the label-derived rank otherwise. Courses with no term go to
an 'Other' bucket rather than being dropped.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…#140)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Only courses that rank strictly below the current term are archived.
Undatable courses — and every course when /api/semesters gives us
nothing — stay in the default list.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ck (#140)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…140)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
/api/graph/{user_id}/courses emits `term`; the landing read
`(c as any).semester`, which is always undefined. `distinct` was
therefore always empty and every signed-in user silently fell through to
the hardcoded SAMPLE_SEMESTERS demo chips.
The sample chips are now the logged-out preview only — a signed-in user
with no terms gets their own empty state instead of another student's
fake grades.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…140)
Chips now sort by sort_key from /api/semesters and default to the
date-derived current term instead of whichever term the courses payload
happened to list first. A failed semesters fetch degrades to the
label-derived order.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Gives the dashboard archive somewhere to land: selecting an archived
class opens that semester's gradebook rather than the current one.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…140)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…140)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…#140)
Same markup, lifted so the current-term list, the archive and the graph
overlay can all render a course line without a third copy.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ve (#140)
Current-term courses show by default; earlier terms collapse behind an
Archive toggle, grouped by label most recent first. Also covers the
mobile 'My Courses' tab, which renders the same panel.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The floating course key now lists only current-term courses and offers
past terms as a compact Archive that deep-links into each semester's
gradebook.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Headings only appear once a student has courses in more than one term.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Keeps the top-nav legend consistent with the courses key overlay, which
already lists only the current semester.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…tion (#140)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…tech (#140)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Reading `term` instead of `(c as any).semester` removed the only
no-explicit-any in Landing.tsx, so its suppression entry no longer matches
anything. eslint exits 2 on a stale suppression even with zero errors, which
fails the CI lint gate — `main` exits 0, this branch did not.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@coderabbitai

coderabbitaiBot commented Jul 21, 2026

Copy link
Copy Markdown

Warning

Review limit reached

@AndresL230, you've reached your PR review limit, so we couldn't start this review.

Next review available in:54 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Free

Run ID: 39694af7-3514-4235-b33b-03e1297e832a

📥 Commits

Reviewing files that changed from the base of the PR and between 4227689 and c1d6f9b.

📒 Files selected for processing (11)
  • frontend/eslint-suppressions.json
  • frontend/src/components/DocumentUploadModal.test.tsx
  • frontend/src/components/ManageCoursesModal.tsx
  • frontend/src/components/screens/Dashboard.test.tsx
  • frontend/src/components/screens/Dashboard.tsx
  • frontend/src/components/screens/Gradebook/Landing.test.tsx
  • frontend/src/components/screens/Gradebook/Landing.tsx
  • frontend/src/lib/api.ts
  • frontend/src/lib/data.test.ts
  • frontend/src/lib/semesters.test.ts
  • frontend/src/lib/semesters.ts

Note

🎁 Summarized by CodeRabbit Free

Your organization is on the Free plan. CodeRabbit will generate a high-level summary and a walkthrough for each pull request. For a comprehensive line-by-line review, please upgrade your subscription to CodeRabbit Pro by visiting https://app.coderabbit.ai/login.

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

@cloudflare-workers-and-pages

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

Copy link
Copy Markdown

Deploying with Cloudflare Workers Cloudflare Workers

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

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

Branch Preview URL
Jul 22 2026, 04:25 AM

@Jose-Gael-Cruz-Lopez

Copy link
Copy Markdown
MemberAuthor

@coderabbitai review

@coderabbitai

coderabbitaiBot commented Jul 21, 2026

Copy link
Copy Markdown
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

Empty commit to re-run CI after the frontend-staging Workers build-command config fix (npm run cf:build restored; the broken wrangler deploy --env staging skipped the OpenNext build).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@Jose-Gael-Cruz-Lopez
Jose-Gael-Cruz-Lopez merged commit 452c10a into mainJul 24, 2026
6 checks passed
@AndresL230
AndresL230 deleted the feat/140-semester-grouping-archive branch August 2, 2026 18:30
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[P2] Semesters: dashboard/course-list grouping + Archive for past terms

2 participants

@Jose-Gael-Cruz-Lopez@AndresL230