Skip to content

Commit

Permalink
Zot4plan import button (#555)
Browse files Browse the repository at this point in the history
* feat: initial import button and modal

* feat: import route placeholder and dispatch

* feat: perform import using zot4plan api

* fix: roadmap multiplan selector index

* feat: user selects year for imported roadmap

* feat: trim unused zot4plan years

* feat: improve import warning styles

* feat: imported planner client side checks

* feat: better import modal wording

* refactor: variable for warning red color

* refactor: filtering undefined imported courses

* refactor: throw error in import route

* fix: start year for fall quarter

* refactor: organize zot4plan conversion code

* refactor: further organize conversion code

* feat: numbering for duplicate plan names

* doc: clarify month index for fall quarter cutoff

* fix: prevent submitting form in modal
  • Loading branch information
CadenLee2 authored Jan 28, 2025
1 parent c2f40ef commit ae97888
Show file tree
Hide file tree
Showing 10 changed files with 366 additions and 21 deletions.
2 changes: 2 additions & 0 deletions api/src/controllers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { savedCoursesRouter } from './savedCourses';
import scheduleRouter from './schedule';
import usersRouter from './users';
import searchRouter from './search';
import zot4PlanImportRouter from './zot4planimport';

export const appRouter = router({
courses: coursesRouter,
Expand All @@ -19,6 +20,7 @@ export const appRouter = router({
search: searchRouter,
schedule: scheduleRouter,
users: usersRouter,
zot4PlanImportRouter: zot4PlanImportRouter,
});

// Export only the type of a router!
Expand Down
174 changes: 174 additions & 0 deletions api/src/controllers/zot4planimport.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
/**
@module Zot4PlanImportRoute
*/

import { z } from 'zod';
import { publicProcedure, router } from '../helpers/trpc';
import { TRPCError } from '@trpc/server';
import { SavedRoadmap, SavedPlannerData, SavedPlannerQuarterData, QuarterName } from '@peterportal/types';

type Zot4PlanYears = string[][][];

type Zot4PlanSchedule = {
years: Zot4PlanYears;
selectedPrograms: {
value: number;
label: string;
is_major: boolean;
}[][];
addedCourses: [];
courses: [];
apExam: {
id: number;
name: string;
score: number;
courses: string[];
GE: [];
units: number;
}[];
};

/**
* Get a JSON schedule from Zot4Plan by name
* Throw an error if it does not exist
*/
const getFromZot4Plan = async (scheduleName: string) => {
let res = {};
await fetch('https://api.zot4plan.com/api/loadSchedule/' + scheduleName, {
method: 'PUT',
})
.then((response) => {
if (!response.ok) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Schedule name could not be obtained from Zot4Plan',
});
}
return response.json();
})
.then((json) => {
res = json;
});
return res as Zot4PlanSchedule;
};

/**
* Convert a Zot4Plan course name into a PeterPortal course ID
*/
const convertIntoCourseID = (zot4PlanCourse: string): string => {
// PeterPortal course IDs are the same as Zot4Plan course IDs except all spaces are removed
return zot4PlanCourse.replace(/\s/g, '');
};

/**
* Trim the empty years off the end of a saved roadmap planner
* (Other than the first year)
*/
const trimEmptyYears = (planner: SavedPlannerData) => {
// Empty years in the middle aren't trimmed because that makes it hard to add years there
while (planner.content.length > 1) {
let yearHasCourses = false;
for (const quarter of planner.content[planner.content.length - 1].quarters) {
if (quarter.courses.length != 0) {
yearHasCourses = true;
}
}
if (!yearHasCourses) {
// The year does not have courses, so trim it
planner.content.pop();
} else {
// The year does have courses, so we are done
break;
}
}
};

/**
* Determine a student's start year based on their current year in school
* (ex. a first-year's current year is "1")
*/
const getStartYear = (studentYear: string): number => {
let startYear = new Date().getFullYear();
startYear -= parseInt(studentYear);
// First-years in Fall start this year, not the previous year
// Month index 7 is August, when Fall quarter is approaching
if (new Date().getMonth() >= 7) startYear += 1;
return startYear;
};

/**
* Convert the years of a Zot4Plan schedule into the saved roadmap planner format
*/
const convertIntoSavedPlanner = (
originalScheduleYears: Zot4PlanYears,
scheduleName: string,
startYear: number,
): SavedPlannerData => {
const converted: SavedPlannerData = {
name: scheduleName,
content: [],
};

// Add courses
for (let i = 0; i < originalScheduleYears.length; i++) {
const year = originalScheduleYears[i];
const quartersList: SavedPlannerQuarterData[] = [];
for (let j = 0; j < year.length; j++) {
const quarter = year[j];
const courses: string[] = [];
for (let k = 0; k < quarter.length; k++) {
courses.push(convertIntoCourseID(quarter[k]));
}
if (j >= 3 && courses.length == 0) {
// Do not include the summer quarter if it has no courses (it is irrelevant)
continue;
}
quartersList.push({
name: ['Fall', 'Winter', 'Spring', 'Summer1', 'Summer2', 'Summer10wk'][Math.min(j, 5)] as QuarterName,
courses: courses,
});
}
converted.content.push({
startYear: startYear + i,
name: 'Year ' + (i + 1),
quarters: quartersList,
});
}
// Trim trailing years
trimEmptyYears(converted);

return converted;
};

/**
* Convert a Zot4Plan schedule into the saved roadmap format
*/
const convertIntoSavedRoadmap = (
originalSchedule: Zot4PlanSchedule,
scheduleName: string,
startYear: number,
): SavedRoadmap => {
// Convert the individual components
const convertedPlanner = convertIntoSavedPlanner(originalSchedule.years, scheduleName, startYear);
const res: SavedRoadmap = {
planners: [convertedPlanner],
transfers: [],
};
return res;
};

const zot4PlanImportRouter = router({
/**
* Get a roadmap formatted for PeterPortal based on a Zot4Plan schedule by name
* and labeled with years based on the current year and the student's year
*/
getScheduleFormatted: publicProcedure
.input(z.object({ scheduleName: z.string(), studentYear: z.string() }))
.query(async ({ input }) => {
const originalScheduleRaw = await getFromZot4Plan(input.scheduleName);
const res = convertIntoSavedRoadmap(originalScheduleRaw, input.scheduleName, getStartYear(input.studentYear));
return res;
}),
});

export default zot4PlanImportRouter;
Binary file added site/src/asset/zot4plan-import-help.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
11 changes: 1 addition & 10 deletions site/src/component/CoursePopover/CoursePopover.scss
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@

.popover-detail-warning {
font-size: 14px;
color: #ce0000;
color: var(--warning-red);
gap: 5px;

.popover-detail-warning-icon {
Expand All @@ -51,12 +51,3 @@
color: var(--petr-gray);
}
}

[data-theme='dark'] {
.popover-detail-warning {
color: red;
}
.popover-detail-italics {
color: var(--petr-gray);
}
}
16 changes: 16 additions & 0 deletions site/src/helpers/planner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,22 @@ export function normalizeQuarterName(name: string): QuarterName {
return lookup[name];
}

export const makeUniquePlanName = (plannerName: string, allPlans: RoadmapPlan[]): string => {
let newName = plannerName;
while (allPlans.find((p) => p.name === newName)) {
// The regex matches an integer number at the end
const counter = newName.match(/\d+$/);
if (counter != null) {
const numberValue = newName.substring(counter.index!);
newName = newName.substring(0, counter.index) + (parseInt(numberValue) + 1);
} else {
// No number exists at the end, so default with 2
newName += ' 2';
}
}
return newName;
};

// remove all unecessary data to store into the database
export const collapsePlanner = (planner: PlannerData): SavedPlannerYearData[] => {
const savedPlanner: SavedPlannerYearData[] = [];
Expand Down
20 changes: 20 additions & 0 deletions site/src/pages/RoadmapPage/ImportZot4PlanPopup.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
.import-schedule-btn.btn-light {
background-color: #e3e5eb;
}

.import-schedule-icon {
scale: 1.2;
margin-right: 4px;
}

.import-schedule-warning {
font-size: 14px;
color: var(--warning-red);
gap: 5px;

.import-schedule-warning-icon {
margin-bottom: 4px;
margin-right: 4px;
font-size: 16px;
}
}
Loading

0 comments on commit ae97888

Please sign in to comment.