Repository: icssc/peterportal-client
Pull request #663: feat: auto-rename ge requirements
Author: login: fiveminus1
Created: 2025-04-27T03:42:43Z | Merged: 2025-04-29T16:53:59Z

## Description

Automatically renames top-level "Select 1 of the following" labels by extracting a common prefix from its children requirements. 

Some of the words in the children labels may be common prefixes, but abbreviated. For example, "VIII. International/Global Issues" should have a common prefix in "VIII. Int'l/Global Issues" and "VIII. International/Global Issues," but "Int'l" should be expanded to "International." To account for this, a regex is used, and such abbreviations are recorded in `src/helpers/substitutions/labelSubstitutions`.  See #642.


## Screenshots
- Before 
![image](https://github.com/user-attachments/assets/8f1ec131-ceb6-49b0-b605-cecf38972fe0)
![image](https://github.com/user-attachments/assets/a3421a3a-6261-42e8-99a3-f69036d2097e)


- After
![image](https://github.com/user-attachments/assets/a310c924-0e99-46f7-8c68-e7d1905bdb71)
![image](https://github.com/user-attachments/assets/55700d42-4148-401c-a514-e13e58a43bd2)



## Test Plan

- Compare new `getCoursesForGE()` return array with original response from 
`trpc.programs.getRequiredCoursesUgrad.query({ id: 'GE' });` to confirm the replaced labels

## Issues

Closes #642


Linked issue #642: Auto-rename GE requirements more intelligently
Instead of "Select 1 of the following" being the name of the GE Requirement, we should look for a common prefix string (excluding parenthesized text) and use that as the title of the requirement.

Some examples below, though this may require hard-coding certain replacements to change abbreviations to full words

For upper-div writing, we should extract the common prefix of "I. Upper-Division Writing"
<img width="344" alt="Image" src="https://github.com/user-attachments/assets/99f8d316-8380-4052-9ed0-ed7368bc261c" />

For Foreign Language Requirements, we'd extract "VI. Language"
<img width="512" alt="Image" src="https://github.com/user-attachments/assets/b62e14dd-c570-4ecd-b11e-616deb3df483" />

For International/Global Issues, we'd extract "VIII. International/Global Issues"
<img width="552" alt="Image" src="https://github.com/user-attachments/assets/84a4cdd7-5ab0-4cc7-a6ca-b69ace62bfe3" />
Linked issue comments: user: login: fiveminus1
created_at: 2025-04-22T05:38:57Z
body: Greetings @Awesome-E! Can I try this one as well?
author_association: CONTRIBUTOR
user: login: Awesome-E
created_at: 2025-04-22T05:40:52Z
body: yes sir
author_association: MEMBER

Diff:
diff --git a/site/src/helpers/substitutions.ts b/site/src/helpers/substitutions.ts
new file mode 100644
index 000000000..3c34e3c20
--- /dev/null
+++ b/site/src/helpers/substitutions.ts
@@ -0,0 +1,49 @@
+import { ProgramRequirement } from '@peterportal/types';
+
+export const labelSubstitutions: Record<string, string> = {
+  Div: 'Division',
+  "Int'l": 'International',
+  US: 'United States',
+};
+
+export function formatLabel(label: string): string {
+  let expandedLabel = label.replace(/\s\(.*/g, ''); // remove all text after parentheses
+
+  /* expand any abbreviations */
+  for (const [abbrev, substitution] of Object.entries(labelSubstitutions)) {
+    const regex = new RegExp(`\\b${abbrev}\\b`, 'gi');
+    expandedLabel = expandedLabel.replace(regex, substitution);
+  }
+
+  return expandedLabel;
+}
+
+export function findCommonLabelPrefix(labels: string[]): string {
+  if (labels.length === 0) return '';
+
+  const expandedLabels = labels.map(formatLabel);
+  expandedLabels.sort();
+
+  const first = expandedLabels[0].split(' ');
+  const last = expandedLabels.at(-1)!.split(' ');
+
+  let i = 0;
+  while (i < first.length && first[i] === last[i]) {
+    i++;
+  }
+  return first.slice(0, i).join(' ');
+}
+
+export function normalizeTitleLabels(fetchedRequirements: ProgramRequirement[]): void {
+  fetchedRequirements.forEach((requirement) => {
+    /* if a top level "Select 1 of the following" group of requirements is seen, attempt to replace the label */
+    if (requirement.requirementType !== 'Group' || requirement.label !== 'Select 1 of the following') return;
+
+    const nestedLabels = requirement.requirements.map((req) => req.label);
+    const commonLabelPrefix = findCommonLabelPrefix(nestedLabels);
+
+    if (commonLabelPrefix) {
+      requirement.label = commonLabelPrefix; // if a common label prefix is found, use it as the top level label
+    }
+  });
+}
diff --git a/site/src/pages/RoadmapPage/sidebar/GERequiredCourseList.tsx b/site/src/pages/RoadmapPage/sidebar/GERequiredCourseList.tsx
index 195e21da5..6d2f9bcf5 100644
--- a/site/src/pages/RoadmapPage/sidebar/GERequiredCourseList.tsx
+++ b/site/src/pages/RoadmapPage/sidebar/GERequiredCourseList.tsx
@@ -4,9 +4,13 @@ import trpc from '../../../trpc';
 import RequirementsLoadingIcon from './RequirementsLoadingIcon';
 import { setGERequirements } from '../../../store/slices/courseRequirementsSlice';
 import { useAppDispatch, useAppSelector } from '../../../store/hooks';
+import { normalizeTitleLabels } from '../../../helpers/substitutions';
 
-function getCoursesForGE() {
-  return trpc.programs.getRequiredCoursesUgrad.query({ id: 'GE' });
+async function getCoursesForGE() {
+  const fetchedRequirements = await trpc.programs.getRequiredCoursesUgrad.query({ id: 'GE' });
+  normalizeTitleLabels(fetchedRequirements);
+
+  return fetchedRequirements;
 }
 
 const GERequiredCourseList: FC = () => {


Reviews:
user: login: Awesome-E
body: hi!
state: CHANGES_REQUESTED
author_association: MEMBER
submitted_at: 2025-04-27T04:20:38Z
user: login: Awesome-E
body: Pretty much ready, just a few small changes if you want to make those
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-27T16:36:50Z
user: login: Awesome-E
body: LGTM but might request a second review
state: APPROVED
author_association: MEMBER
submitted_at: 2025-04-28T17:53:41Z
user: login: ellisl10
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-04-28T22:14:21Z

Review comments:
path: package-lock.json
user: login: Awesome-E
body: Looks like you added this by accident? Probably happened because you did an `npm install` instead of `pnpm install` in the root
created_at: 2025-04-27T04:04:21Z
author_association: MEMBER
diff_hunk: @@ -4,9 +4,25 @@ import trpc from '../../../trpc';
 import RequirementsLoadingIcon from './RequirementsLoadingIcon';
 import { setGERequirements } from '../../../store/slices/courseRequirementsSlice';
 import { useAppDispatch, useAppSelector } from '../../../store/hooks';
+import { findCommonLabelPrefix } from '../../../helpers/substitutions';
 
-function getCoursesForGE() {
-  return trpc.programs.getRequiredCoursesUgrad.query({ id: 'GE' });
+async function getCoursesForGE() {
+  const fetchedCourses = await trpc.programs.getRequiredCoursesUgrad.query({ id: 'GE' });
+
+  /* if a top level "Select 1 of the following" group of requirements is seen, attempt to replace the label */
+  fetchedCourses.forEach((geRequirement) => {
+    if (geRequirement.requirementType === 'Group' && geRequirement.label === 'Select 1 of the following') {
path: site/src/pages/RoadmapPage/sidebar/GERequiredCourseList.tsx
user: login: Awesome-E
body: You can reduce a level of curly braces using a guard clause

```ts
if (req.requirementType !== 'Group' || req.label !== 'Select 1 of the following') return;
```
created_at: 2025-04-27T04:06:42Z
author_association: MEMBER
diff_hunk: @@ -4,9 +4,25 @@ import trpc from '../../../trpc';
 import RequirementsLoadingIcon from './RequirementsLoadingIcon';
 import { setGERequirements } from '../../../store/slices/courseRequirementsSlice';
 import { useAppDispatch, useAppSelector } from '../../../store/hooks';
+import { findCommonLabelPrefix } from '../../../helpers/substitutions';
 
-function getCoursesForGE() {
-  return trpc.programs.getRequiredCoursesUgrad.query({ id: 'GE' });
+async function getCoursesForGE() {
+  const fetchedCourses = await trpc.programs.getRequiredCoursesUgrad.query({ id: 'GE' });
+
+  /* if a top level "Select 1 of the following" group of requirements is seen, attempt to replace the label */
+  fetchedCourses.forEach((geRequirement) => {
+    if (geRequirement.requirementType === 'Group' && geRequirement.label === 'Select 1 of the following') {
+      const requirementSubLabels = geRequirement.requirements.map((req) => req.label);
path: site/src/pages/RoadmapPage/sidebar/GERequiredCourseList.tsx
user: login: Awesome-E
body: nitpick on naming – can be something more like `nestedLabels` since 'sub' can be ambiguous and 'requirement' is redundant
created_at: 2025-04-27T04:07:52Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,32 @@
+export const labelSubstitutions: Record<string, string> = {
+  Div: 'Division',
+  "Int'l": 'International',
+  US: 'United States',
+};
+
+export function expandAbbreviation(label: string): string {
+  let expandedLabel = label;
+
+  for (const [abbrev, substitution] of Object.entries(labelSubstitutions)) {
+    const regex = new RegExp(`\\b${abbrev}\\b`, 'gi');
+    expandedLabel = expandedLabel.replace(regex, substitution);
+  }
+
+  return expandedLabel;
+}
+
+export function findCommonLabelPrefix(labels: string[]): string {
+  if (labels.length === 0) return '';
+
+  const expandedLabels = labels.map(expandAbbreviation); // expand any words in labels that may be abbreviated
path: site/src/helpers/substitutions.ts
user: login: Awesome-E
body: comment is unnecessary
```suggestion
  const expandedLabels = labels.map(expandAbbreviation);
```
created_at: 2025-04-27T04:09:06Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,32 @@
+export const labelSubstitutions: Record<string, string> = {
+  Div: 'Division',
+  "Int'l": 'International',
+  US: 'United States',
+};
+
+export function expandAbbreviation(label: string): string {
+  let expandedLabel = label;
+
+  for (const [abbrev, substitution] of Object.entries(labelSubstitutions)) {
+    const regex = new RegExp(`\\b${abbrev}\\b`, 'gi');
+    expandedLabel = expandedLabel.replace(regex, substitution);
+  }
+
+  return expandedLabel;
+}
+
+export function findCommonLabelPrefix(labels: string[]): string {
+  if (labels.length === 0) return '';
+
+  const expandedLabels = labels.map(expandAbbreviation); // expand any words in labels that may be abbreviated
+
+  expandedLabels.sort();
+  const first = expandedLabels[0].split(' ');
+  const last = expandedLabels[expandedLabels.length - 1].split(' ').filter((word) => !word.startsWith('(')); // ignore any words within parentheses
path: site/src/helpers/substitutions.ts
user: login: Awesome-E
body: I guess this is one way of terminating match since "I. Upper-Division Writing (min grade C-) (subject)" will be different from "I. Upper-Division Writing grade C-) (subject 2)", but I think it's probably more understandable if we just remove all text after the first open paren (`.replace(/\s\(.*/g, '')`) before filtering like this.

Also you can get last item with `arr.at(-1)` instead of `arr[arr.length - 1]`
created_at: 2025-04-27T04:17:09Z
author_association: MEMBER
diff_hunk: @@ -4,9 +4,25 @@ import trpc from '../../../trpc';
 import RequirementsLoadingIcon from './RequirementsLoadingIcon';
 import { setGERequirements } from '../../../store/slices/courseRequirementsSlice';
 import { useAppDispatch, useAppSelector } from '../../../store/hooks';
+import { findCommonLabelPrefix } from '../../../helpers/substitutions';
 
-function getCoursesForGE() {
-  return trpc.programs.getRequiredCoursesUgrad.query({ id: 'GE' });
+async function getCoursesForGE() {
+  const fetchedCourses = await trpc.programs.getRequiredCoursesUgrad.query({ id: 'GE' });
path: site/src/pages/RoadmapPage/sidebar/GERequiredCourseList.tsx
user: login: Awesome-E
body: variable name is inaccurate, as this is a requirements list.

Additionally, there is already a function to normalize responses, so the logic written here might be better suited to go alongside other logic that does similar things. Even if you do only apply it to GEs, it still might be worth defining a separate function to normalize its data there
created_at: 2025-04-27T04:20:19Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,52 @@
+import { ProgramRequirement } from '@peterportal/types';
+
+export const labelSubstitutions: Record<string, string> = {
+  Div: 'Division',
+  "Int'l": 'International',
+  US: 'United States',
+};
+
+export function expandAbbreviation(label: string): string {
+  let expandedLabel = label;
+
+  for (const [abbrev, substitution] of Object.entries(labelSubstitutions)) {
+    const regex = new RegExp(`\\b${abbrev}\\b`, 'gi');
+    expandedLabel = expandedLabel.replace(regex, substitution);
+  }
+
+  return expandedLabel;
+}
+
+export function findCommonLabelPrefix(labels: string[]): string {
+  if (labels.length === 0) return '';
+
+  const expandedLabels = labels.map(expandAbbreviation);
+
+  expandedLabels.sort();
+  /* remove all text after parentheses */
path: site/src/helpers/substitutions.ts
user: login: Awesome-E
body: I would do the replace in the `expandAbbreviation` function and potentially rename that function to "formatLabel"
created_at: 2025-04-27T16:35:21Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,52 @@
+import { ProgramRequirement } from '@peterportal/types';
+
+export const labelSubstitutions: Record<string, string> = {
+  Div: 'Division',
+  "Int'l": 'International',
+  US: 'United States',
+};
+
+export function expandAbbreviation(label: string): string {
+  let expandedLabel = label;
+
+  for (const [abbrev, substitution] of Object.entries(labelSubstitutions)) {
+    const regex = new RegExp(`\\b${abbrev}\\b`, 'gi');
+    expandedLabel = expandedLabel.replace(regex, substitution);
+  }
+
+  return expandedLabel;
+}
+
+export function findCommonLabelPrefix(labels: string[]): string {
+  if (labels.length === 0) return '';
+
+  const expandedLabels = labels.map(expandAbbreviation);
+
+  expandedLabels.sort();
+  /* remove all text after parentheses */
+  const first = expandedLabels[0].replace(/\s\(.*/g, '').split(' ');
+  const last = expandedLabels
+    .at(-1)!
+    .replace(/\s\(.*/g, '')
+    .split(' ');
+
+  let i = 0;
+  while (i < first.length && first[i] === last[i]) {
+    i++;
+  }
+  return first.slice(0, i).join(' ');
+}
+
+export function normalizeGERequirements(fetchedRequirements: ProgramRequirement[]): void {
path: site/src/helpers/substitutions.ts
user: login: Awesome-E
body: on the topic of naming functions, "inferLabelTitle" or something similar might be more descriptive, and may later be useful for non-GE requirements as well.
created_at: 2025-04-27T16:36:37Z
author_association: MEMBER

PR comments:
user: login: laggycomputer
created_at: 2025-04-27T03:43:51Z
body: thamk you ehrb very cool
author_association: MEMBER
user: login: fiveminus1
created_at: 2025-04-27T03:50:18Z
body: laggy comptuer 


author_association: CONTRIBUTOR