Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Zot4plan import button #555

Merged
merged 22 commits into from
Jan 28, 2025
Merged
Show file tree
Hide file tree
Changes from 18 commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
08168b6
feat: initial import button and modal
CadenLee2 Jan 12, 2025
33facb2
feat: import route placeholder and dispatch
CadenLee2 Jan 15, 2025
d0e8ffc
Merge branch 'main' of https://github.com/icssc/peterportal-client in…
CadenLee2 Jan 15, 2025
92e778d
feat: perform import using zot4plan api
CadenLee2 Jan 17, 2025
9ce8f5a
fix: roadmap multiplan selector index
CadenLee2 Jan 17, 2025
df7916d
feat: user selects year for imported roadmap
CadenLee2 Jan 21, 2025
a0abfea
feat: trim unused zot4plan years
CadenLee2 Jan 21, 2025
4e30b43
Merge branch 'main' of https://github.com/icssc/peterportal-client in…
CadenLee2 Jan 23, 2025
f9ab9e3
feat: improve import warning styles
CadenLee2 Jan 23, 2025
125abdd
feat: imported planner client side checks
CadenLee2 Jan 24, 2025
3aa543c
Merge branch 'main' of https://github.com/icssc/peterportal-client in…
CadenLee2 Jan 27, 2025
ab08fa1
feat: better import modal wording
CadenLee2 Jan 27, 2025
f9587be
refactor: variable for warning red color
CadenLee2 Jan 27, 2025
a69d2f6
refactor: filtering undefined imported courses
CadenLee2 Jan 27, 2025
4de7996
refactor: throw error in import route
CadenLee2 Jan 27, 2025
5c8e743
fix: start year for fall quarter
CadenLee2 Jan 27, 2025
bdde1f1
refactor: organize zot4plan conversion code
CadenLee2 Jan 27, 2025
814f709
refactor: further organize conversion code
CadenLee2 Jan 27, 2025
ae853a2
feat: numbering for duplicate plan names
CadenLee2 Jan 28, 2025
b577087
Merge branch 'main' of https://github.com/icssc/peterportal-client in…
CadenLee2 Jan 28, 2025
5579e40
doc: clarify month index for fall quarter cutoff
CadenLee2 Jan 28, 2025
d3e90e6
fix: prevent submitting form in modal
CadenLee2 Jan 28, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
173 changes: 173 additions & 0 deletions api/src/controllers/zot4planimport.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
/**
@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
if (new Date().getMonth() >= 7) startYear += 1;
CadenLee2 marked this conversation as resolved.
Show resolved Hide resolved
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);
}
}
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;
}
}
147 changes: 147 additions & 0 deletions site/src/pages/RoadmapPage/ImportZot4PlanPopup.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
import { FC, useContext, useState } from 'react';
import './ImportZot4PlanPopup.scss';
import { CloudArrowDown, ExclamationTriangle } from 'react-bootstrap-icons';
import { Button, Form, Modal } from 'react-bootstrap';
import { addRoadmapPlan, setPlanIndex, selectAllPlans } from '../../store/slices/roadmapSlice';
import { useAppDispatch, useAppSelector } from '../../store/hooks';
import ThemeContext from '../../style/theme-context';
import trpc from '../../trpc.ts';
import { expandAllPlanners } from '../../helpers/planner';
import spawnToast from '../../helpers/toastify';
import helpImage from '../../asset/zot4plan-import-help.png';

const ImportZot4PlanPopup: FC = () => {
const dispatch = useAppDispatch();
const { darkMode } = useContext(ThemeContext);
const [showModal, setShowModal] = useState(false);
const [scheduleName, setScheduleName] = useState('');
const [studentYear, setStudentYear] = useState('1');
const [busy, setBusy] = useState(false);
const allPlanData = useAppSelector(selectAllPlans);

const obtainImportedRoadmap = async (schedName: string, currYear: string) => {
// Get the result
try {
const result = await trpc.zot4PlanImportRouter.getScheduleFormatted.query({
scheduleName: schedName,
studentYear: currYear,
});
// Expand the result
const expandedPlanners = await expandAllPlanners(result.planners);
// Check for validity: length and invalid course names
if (expandedPlanners.length < 1) {
spawnToast('The schedule "' + schedName + '" could not be imported', true);
return;
}
// Unknown (undefined) course names will crash PeterPortal if loaded, so remove them
let problemCount = 0;
for (const yearPlan of expandedPlanners[0].content.yearPlans) {
for (const quarter of yearPlan.quarters) {
const newCourses = quarter.courses.filter((course) => course != undefined);
problemCount += quarter.courses.length - newCourses.length;
quarter.courses = newCourses;
}
}
if (problemCount > 0) {
spawnToast('Partially imported "' + schedName + '" (removed ' + problemCount + ' unknown course(s)', true);
}
// Check for validity: the name should be unique among current planners
const takenNames = new Set<string>();
for (const planner of allPlanData) {
takenNames.add(planner.name);
}
while (takenNames.has(expandedPlanners[0].name)) {
// Users can change their planner name easily, so use a simple naming scheme
expandedPlanners[0].name += '+';
}
// Add the expanded result as a new planner to the roadmap
const currentPlanDataLength = allPlanData.length;
dispatch(addRoadmapPlan(expandedPlanners[0]));
dispatch(setPlanIndex(currentPlanDataLength));
} catch (err) {
// Notify the user
spawnToast('The schedule "' + schedName + '" could not be retrieved', true);
return;
}
};

const handleImport = async () => {
setBusy(true);
try {
// Use the backend route to try to obtain the formatted schedule
await obtainImportedRoadmap(scheduleName, studentYear);
// Success; hide the modal
setShowModal(false);
} finally {
setBusy(false);
}
};

return (
<>
<Modal show={showModal} onHide={() => setShowModal(false)} centered className="ppc-modal transcript-form">
<Modal.Header closeButton>
<h2>Import Schedule from Zot4Plan</h2>
</Modal.Header>
<Modal.Body>
<Form className="ppc-modal-form">
<Form.Group>
<p>
If you use{' '}
<a target="_blank" href="https://zot4plan.com/" rel="noreferrer">
Zot4Plan
</a>
, you can add all your classes from that schedule to a new roadmap in PeterPortal. Your schedule in
Zot4Plan and your current roadmaps will not be modified.
</p>
<p>Please enter the exact name that you use to save and load your Zot4Plan schedule, as shown here:</p>
<img
className="w-100"
src={helpImage}
alt="Screenshot of Zot4Plan's save feature where the schedule name is typically used"
/>
</Form.Group>
<Form.Group controlId="ScheduleName">
<Form.Label className="ppc-modal-form-label">Schedule Name</Form.Label>
<Form.Control
type="text"
placeholder="Exact Zot4Plan schedule name"
onChange={(e) => setScheduleName(e.target.value)}
/>
{scheduleName.length > 0 && scheduleName.length < 8 && (
<span className="import-schedule-warning">
<ExclamationTriangle className="import-schedule-warning-icon" />
No Zot4Plan schedule name contains less than 8 characters
</span>
)}
</Form.Group>
<Form.Group controlId="CurrentYear">
<Form.Label className="ppc-modal-form-label">I am currently a...</Form.Label>
<Form.Control as="select" onChange={(ev) => setStudentYear(ev.target.value)} value={studentYear}>
<option value="1" selected>
1st year
</option>
<option value="2">2nd year</option>
<option value="3">3rd year</option>
<option value="4">4th year</option>
</Form.Control>
</Form.Group>
</Form>
<Button variant="primary" disabled={busy || scheduleName.length < 8} onClick={handleImport}>
{busy ? 'Importing...' : 'Import'}
</Button>
</Modal.Body>
</Modal>
<Button
variant={darkMode ? 'dark' : 'light'}
className="ppc-btn import-schedule-btn"
onClick={() => setShowModal(true)}
>
<CloudArrowDown className="import-schedule-icon" />
<div>Import Zot4Plan Schedule</div>
</Button>
</>
);
};

export default ImportZot4PlanPopup;
2 changes: 2 additions & 0 deletions site/src/pages/RoadmapPage/Planner.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { useFirstRender } from '../../hooks/firstRenderer';
import { SavedRoadmap } from '@peterportal/types';
import { convertLegacyLocalRoadmap, defaultYear, expandAllPlanners } from '../../helpers/planner';
import ImportTranscriptPopup from './ImportTranscriptPopup';
import ImportZot4PlanPopup from './ImportZot4PlanPopup';
import { collapseAllPlanners, loadRoadmap, validatePlanner } from '../../helpers/planner';
import { Button, Modal } from 'react-bootstrap';
import trpc from '../../trpc';
Expand Down Expand Up @@ -187,6 +188,7 @@ const Planner: FC = () => {
}
/>
<ImportTranscriptPopup />
<ImportZot4PlanPopup />
</div>
);
};
Expand Down
Loading
Loading