From 08d1cf952cc6ccae81ec89bb4ea4491267e9bd1a Mon Sep 17 00:00:00 2001 From: Diego Temkin <65834932+dtemkin1@users.noreply.github.com> Date: Fri, 10 Jan 2025 18:04:19 -0500 Subject: [PATCH] implement loading subjects (todo: consider just removing cookies dialog and adding privacy policy?) --- package.json | 6 +- pnpm-lock.yaml | 22 +++++ src/context/component.tsx | 169 +++++++++++++++++++++++++++++++++-- src/context/create.ts | 2 +- src/context/types.ts | 26 +++--- src/routes/road/[[road]].tsx | 68 ++++++++++++-- 6 files changed, 264 insertions(+), 29 deletions(-) diff --git a/package.json b/package.json index 3e0b09e..4d7bf67 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "@solidjs/meta": "^0.29.4", "@solidjs/router": "^0.15.2", "@solidjs/start": "^1.0.11", + "localforage": "^1.10.0", "lucide-solid": "^0.469.0", "solid-js": "^1.9.4", "ua-parser-js": "^2.0.0", @@ -64,5 +65,8 @@ "url": "git+/~https://github.com/sipb/courseroad3.git" }, "license": "MIT", - "keywords": ["mit", "courseroad"] + "keywords": [ + "mit", + "courseroad" + ] } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 407bedc..1bfff6f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -38,6 +38,9 @@ importers: '@solidjs/start': specifier: ^1.0.11 version: 1.0.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.4)(vinxi@0.4.3(@types/node@22.10.5)(db0@0.2.1)(ioredis@5.4.2)(lightningcss@1.25.1)(terser@5.37.0)(typescript@5.7.3))(vite@5.4.11(@types/node@22.10.5)(lightningcss@1.25.1)(terser@5.37.0)) + localforage: + specifier: ^1.10.0 + version: 1.10.0 lucide-solid: specifier: ^0.469.0 version: 0.469.0(solid-js@1.9.4) @@ -2802,6 +2805,9 @@ packages: resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} engines: {node: '>= 4'} + immediate@3.0.6: + resolution: {integrity: sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==} + import-fresh@3.3.0: resolution: {integrity: sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==} engines: {node: '>=6'} @@ -3007,6 +3013,9 @@ packages: resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} engines: {node: '>= 0.8.0'} + lie@3.1.1: + resolution: {integrity: sha512-RiNhHysUjhrDQntfYSfY4MU24coXXdEOgw9WGcKHNeEwffDYbF//u87M1EWaMGzuFoSbqW0C9C6lEEhDOAswfw==} + lightningcss-darwin-arm64@1.25.1: resolution: {integrity: sha512-G4Dcvv85bs5NLENcu/s1f7ehzE3D5ThnlWSDwE190tWXRQCQaqwcuHe+MGSVI/slm0XrxnaayXY+cNl3cSricw==} engines: {node: '>= 12.0.0'} @@ -3076,6 +3085,9 @@ packages: resolution: {integrity: sha512-9rrA30MRRP3gBD3HTGnC6cDFpaE1kVDWxWgqWJUN0RvDNAo+Nz/9GxB+nHOH0ifbVFy0hSA1V6vFDvnx54lTEQ==} engines: {node: '>=14'} + localforage@1.10.0: + resolution: {integrity: sha512-14/H1aX7hzBBmmh7sGPd+AOMkkIrHM3Z1PAyGgZigA1H1p5O5ANnMyWzvpAETtG68/dC4pC0ncy3+PPGzXZHPg==} + locate-path@6.0.0: resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} engines: {node: '>=10'} @@ -7826,6 +7838,8 @@ snapshots: ignore@5.3.2: {} + immediate@3.0.6: {} + import-fresh@3.3.0: dependencies: parent-module: 1.0.1 @@ -8011,6 +8025,10 @@ snapshots: prelude-ls: 1.2.1 type-check: 0.4.0 + lie@3.1.1: + dependencies: + immediate: 3.0.6 + lightningcss-darwin-arm64@1.25.1: optional: true @@ -8080,6 +8098,10 @@ snapshots: mlly: 1.7.3 pkg-types: 1.3.0 + localforage@1.10.0: + dependencies: + lie: 3.1.1 + locate-path@6.0.0: dependencies: p-locate: 5.0.0 diff --git a/src/context/component.tsx b/src/context/component.tsx index 4aa27d6..14972d3 100644 --- a/src/context/component.tsx +++ b/src/context/component.tsx @@ -1,6 +1,9 @@ import { makePersisted } from "@solid-primitives/storage"; import { type ParentComponent, createResource } from "solid-js"; import { createStore, produce, reconcile } from "solid-js/store"; +import { isServer } from "solid-js/web"; + +import localforage from "localforage"; import { CourseDataContext, @@ -14,6 +17,7 @@ const CourseDataProvider: ParentComponent = (props) => { createStore(structuredClone(defaultState)), { name: "courseRoadStore", + storage: !isServer ? localforage : undefined, }, ); @@ -179,8 +183,130 @@ const CourseDataProvider: ParentComponent = (props) => { }), ); }, - parseGenericCourses: () => {}, - parseGenericIndex: () => {}, + parseGenericCourses: () => { + setStore( + "genericCourses", + produce((genericCourses) => { + // clears array + genericCourses.length = 0; + + const girAttributes = { + PHY1: ["Physics 1 GIR", "p1"], + PHY2: ["Physics 2 GIR", "p2"], + CHEM: ["Chemistry GIR", "c"], + BIOL: ["Biology GIR", "b"], + CAL1: ["Calculus I GIR", "m1"], + CAL2: ["Calculus II GIR", "m2"], + LAB: ["Lab GIR", "l1"], + REST: ["REST GIR", "r"], + } as const; + + const hassAttributes = { + "HASS-A": ["HASS Arts", "ha"], + "HASS-S": ["HASS Social Sciences", "hs"], + "HASS-H": ["HASS Humanities", "hh"], + "HASS-E": ["HASS Elective", "ht"], + } as const; + + const ciAttributes = { + "CI-H": ["Communication Intensive", "hc"], + "CI-HW": ["Communication Intensive with Writing", "hw"], + } as const; + + const baseGeneric = { + description: + "Use this generic subject to indicate that you are fulfilling a requirement, but do not yet have a specific subject selected.", + total_units: 12, + } as const; + + const baseurl = + "http://student.mit.edu/catalog/search.cgi?search=&style=verbatim&when=*&termleng=4&days_offered=*&start_time=*&duration=*&total_units=*" as const; + + for (const gir in girAttributes) { + const offeredGir = actions.getMatchingAttributes( + gir, + undefined, + undefined, + ); + + genericCourses.push({ + ...baseGeneric, + ...offeredGir, + + gir_attribute: gir as keyof typeof girAttributes, + title: `Generic ${girAttributes[gir as keyof typeof girAttributes][0]}`, + subject_id: gir, + url: `${baseurl}&cred=${girAttributes[gir as keyof typeof girAttributes][1]}&commun_int=*`, + }); + } + + for (const hass in hassAttributes) { + const offeredHass = actions.getMatchingAttributes( + undefined, + hass, + undefined, + ); + + genericCourses.push({ + ...baseGeneric, + ...offeredHass, + hass_attribute: hass as keyof typeof hassAttributes, + title: `Generic ${hass}`, + subject_id: hass, + url: `${baseurl}&cred=${hassAttributes[hass as keyof typeof hassAttributes][1]}&commun_int=*`, + }); + + const offeredHassCI = actions.getMatchingAttributes( + undefined, + hass, + "CI-H", + ); + + genericCourses.push({ + ...baseGeneric, + ...offeredHassCI, + hass_attribute: hass as keyof typeof hassAttributes, + communication_requirement: "CI-H", + title: `Generic CI-H ${hass}`, + subject_id: `CI-H ${hass}`, + url: `${baseurl}&cred=${hassAttributes[hass as keyof typeof hassAttributes][1]}&commun_int=${ciAttributes["CI-H"][1]}`, + }); + } + + for (const ci in ciAttributes) { + const offeredCI = actions.getMatchingAttributes( + undefined, + undefined, + ci, + ); + + genericCourses.push({ + ...baseGeneric, + ...offeredCI, + communication_requirement: ci as keyof typeof ciAttributes, + title: `Generic ${ci}`, + hass_attribute: "HASS", + subject_id: ci as keyof typeof ciAttributes, + url: `${baseurl}&cred=*&commun_int=${ciAttributes[ci as keyof typeof ciAttributes][1]}`, + }); + } + }), + ); + }, + parseGenericIndex: () => { + setStore( + "genericIndex", + reconcile( + store.genericCourses.reduce( + (obj, item, index) => { + obj[item.subject_id] = index; + return obj; + }, + {} as Record, + ), + ), + ); + }, parseSubjectsIndex: () => {}, popClassStack: () => {}, pushClassStack: (id) => {}, @@ -209,7 +335,9 @@ const CourseDataProvider: ParentComponent = (props) => { setActiveRoad: (activeRoad) => { setStore("activeRoad", activeRoad); }, - setFullSubjectsInfoLoaded: (isFull) => {}, + setFullSubjectsInfoLoaded: (isFull) => { + setStore("fullSubjectsInfoLoaded", isFull); + }, setLoggedIn: (newLoggedIn) => { setStore("loggedIn", newLoggedIn); }, @@ -243,7 +371,9 @@ const CourseDataProvider: ParentComponent = (props) => { }), ); }, - setSubjectsInfo: (data) => {}, + setSubjectsInfo: (data) => { + setStore("subjectsInfo", reconcile(data)); + }, setCurrentSemester: (sem) => { setStore("currentSemester", Math.max(1, sem)); }, @@ -255,11 +385,34 @@ const CourseDataProvider: ParentComponent = (props) => { resetFulfillmentNeeded: () => { setStore("fulfillmentNeeded", "all"); }, - setLoadSubjectsPromise: (promise) => {}, - setSubjectsLoaded: () => {}, + setLoadSubjectsPromise: (promise) => { + setStore("loadSubjectsPromise", promise); + }, + setSubjectsLoaded: () => { + setStore("subjectsLoaded", true); + }, queueRoadMigration: (roadID) => {}, - clearMigrationQueue: () => {}, - loadSubjects: async () => {}, + clearMigrationQueue: () => { + setStore("roadsToMigrate", reconcile([])); + }, + loadAllSubjects: async () => { + const promise = fetch( + `${import.meta.env.VITE_FIREROAD_URL}/courses/all?full=true`, + ).then((response) => response.json() as Promise); + + actions.setLoadSubjectsPromise(promise); + const response = await promise; + actions.setSubjectsLoaded(); + actions.setSubjectsInfo(response); + actions.setFullSubjectsInfoLoaded(true); + actions.parseGenericCourses(); + actions.parseGenericIndex(); + actions.parseSubjectsIndex(); + for (const roadID of store.roadsToMigrate) { + actions.migrateOldSubjects(roadID); + } + actions.clearMigrationQueue(); + }, addAtPlaceholder: (index) => {}, waitLoadSubjects: async () => {}, waitAndMigrateOldSubjects: (roadID) => {}, diff --git a/src/context/create.ts b/src/context/create.ts index c2e94f9..14fba4e 100644 --- a/src/context/create.ts +++ b/src/context/create.ts @@ -122,7 +122,7 @@ export const defaultActions = { setSubjectsLoaded: () => {}, queueRoadMigration: (roadID: string) => {}, clearMigrationQueue: () => {}, - loadSubjects: async () => {}, + loadAllSubjects: async () => {}, addAtPlaceholder: (index: number) => {}, waitLoadSubjects: async () => {}, waitAndMigrateOldSubjects: (roadID: string) => {}, diff --git a/src/context/types.ts b/src/context/types.ts index acaf811..5d17fd0 100644 --- a/src/context/types.ts +++ b/src/context/types.ts @@ -6,7 +6,7 @@ export interface Subject { offered_IAP: boolean; offered_spring: boolean; offered_summer: boolean; - public: boolean; + public?: boolean; semester?: number; // specifically for when in a road... units?: number; // is manually set in some places index?: number; // is also manually set idk why @@ -21,7 +21,7 @@ export interface Subject { not_offered_year?: string; instructors?: string[]; communication_requirement?: "CI-H" | "CI-HW"; - hass_attribute?: "HASS-A" | "HASS-E" | "HASS-H" | "HASS-S"; + hass_attribute?: "HASS-A" | "HASS-E" | "HASS-H" | "HASS-S" | "HASS"; gir_attribute?: | "BIOL" | "CAL1" @@ -29,24 +29,24 @@ export interface Subject { | "CHEM" | "LAB" | "LAB2" - | "GIR:PHY1" - | "GIR:PHY2" - | "GIR:REST"; + | "PHY1" + | "PHY2" + | "REST"; children?: Subject["subject_id"][]; parent?: Subject["subject_id"]; prereqs?: string; virtual_status?: "In-Person" | "Virtual"; - old_id: string; + old_id?: string; } export interface SubjectFull extends Subject { - lecture_units: number; - lab_units: number; - design_units: number; - preparation_units: number; - is_variable_units: boolean; - is_half_class: boolean; - has_final: boolean; + lecture_units?: number; + lab_units?: number; + design_units?: number; + preparation_units?: number; + is_variable_units?: boolean; + is_half_class?: boolean; + has_final?: boolean; description?: string; prerequisites?: string; corequisites?: string; diff --git a/src/routes/road/[[road]].tsx b/src/routes/road/[[road]].tsx index 7fc6038..1ea1b61 100644 --- a/src/routes/road/[[road]].tsx +++ b/src/routes/road/[[road]].tsx @@ -12,10 +12,11 @@ import { } from "solid-js"; import { createStore } from "solid-js/store"; -import { useCourseDataContext } from "~/context/create"; +import { defaultState, useCourseDataContext } from "~/context/create"; import type { CourseRequirements, CourseRequirementsWithKey, + Reqs, SimplifiedSelectedSubjects, } from "~/context/types"; @@ -33,6 +34,7 @@ import SidebarDrawer from "~/components/layout/SidebarDrawer"; import { recipe as layoutRecipe } from "~/components/layout/layout.recipe"; import { Input } from "~/components/ui/input"; import { Tabs } from "~/components/ui/tabs"; +import { flatten } from "~/lib/browserSupport"; const styles = layoutRecipe(); @@ -48,12 +50,14 @@ export default function RoadPage() { watchRoadChanges, setRetrieved, getRoadKeys, + resetState, + loadAllSubjects, }, ] = useCourseDataContext(); const cookiesString = getCookiesString(); const navigate = useNavigate(); - const [reqTrees, setReqStrees] = createSignal({}); + const [reqTrees, setReqTrees] = createStore({} as Record); const [dragSemesterNum, setDragSemesterNum] = createSignal(-1); const [justLoaded, setJustLoaded] = createSignal(true); const [conflictDialog, setConflictDialog] = createSignal(false); @@ -61,6 +65,7 @@ export default function RoadPage() { const [searchInput, setSearchInput] = createSignal(""); const [dismissedCookies, setDismissedCookies] = createSignal(false); const [searchOpen, setSearchOpen] = createSignal(false); + const [isUpdatingFulfillment, setIsUpdatingFulfillment] = createSignal(false); let authComponentRef: AuthRef | undefined; @@ -111,8 +116,7 @@ export default function RoadPage() { setRetrieved(newRoad); }); } else if (newRoad !== "") { - // TODO: IMPLEMENT - // updateFulfillment(store.fulfillmentNeeded); + updateFulfillment(store.fulfillmentNeeded); } // If just loaded, store isn't loaded yet // and so we can't overwrite the router just yet @@ -130,8 +134,7 @@ export default function RoadPage() { allowCookies(); } if (activeRoad() !== "") { - // TODO: IMPLEMENT - // updateFulfillment(store.fulfillmentNeeded); + updateFulfillment(store.fulfillmentNeeded); } resetFulfillmentNeeded(); @@ -143,8 +146,61 @@ export default function RoadPage() { }), ); + const updateFulfillment = (fulfillmentNeeded: string) => { + if (!isUpdatingFulfillment() && fulfillmentNeeded !== "none") { + setIsUpdatingFulfillment(true); + + const fulfillments = + fulfillmentNeeded === "all" + ? roads()[activeRoad()].contents.coursesOfStudy + : [fulfillmentNeeded]; + + for (const req of fulfillments) { + const alteredRoadContents = Object.assign( + {}, + roads()[activeRoad()].contents, + ); + + alteredRoadContents.selectedSubjects = flatten( + alteredRoadContents.selectedSubjects, + ); + + fetch( + `${import.meta.env.VITE_FIREROAD_URL}/requirements/progress/${req}/`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(alteredRoadContents), + }, + ).then((response) => + response.json().then((data) => { + setReqTrees(req, data); + }), + ); + } + + setIsUpdatingFulfillment(false); + } + }; + onMount(() => { + if (defaultState.versionNumber !== store.versionNumber) { + resetState(); + } + setActiveRoadParam(); + + updateFulfillment("all"); + + // TODO: consider making this a resource instead of loaded on mount + loadAllSubjects().then(() => { + console.log("Subjects were loaded successfully!"); + }); + // .catch((e) => { + // console.log(`There was an error loading subjects: \n${e}`); + // }); }); const addRoad = (