From 77cd3b6845df44bbd9da63411fd762b3e84131cd Mon Sep 17 00:00:00 2001 From: Josh Date: Fri, 16 May 2025 15:51:01 +0000 Subject: [PATCH] Fetch schools in EducationalProgramsPage.js --- backend/server2.js | 81 +++++++++-- src/components/CareerSearch.js | 47 +++--- src/components/EducationalProgramsPage.js | 170 ++++++++++++---------- src/utils/apiUtils.js | 19 ++- 4 files changed, 197 insertions(+), 120 deletions(-) diff --git a/backend/server2.js b/backend/server2.js index 4734679..de30760 100755 --- a/backend/server2.js +++ b/backend/server2.js @@ -391,13 +391,21 @@ app.get('/api/cip/:socCode', (req, res) => { * Single schools / tuition / etc. routes **************************************************/ app.get('/api/schools', (req, res) => { - const { cipCode, state } = req.query; - console.log('Query Params:', { cipCode }); - if (!cipCode || !state) { - return res.status(400).json({ error: 'CIP Code is required' }); + // 1) Read `cipCodes` from query (comma-separated string) + const { cipCodes } = req.query; + + if (!cipCodes ) { + return res.status(400).json({ error: 'cipCodes (comma-separated) and state are required.' }); } + try { - const matchedCIP = cipCode.replace('.', '').slice(0, 4); + // 2) Convert `cipCodes` to array => e.g. "1101,1103,1104" => ["1101","1103","1104"] + const cipArray = cipCodes.split(',').map((c) => c.trim()).filter(Boolean); + if (cipArray.length === 0) { + return res.status(400).json({ error: 'No valid CIP codes were provided.' }); + } + + // 3) Load your raw schools data let schoolsData = []; try { const rawData = fs.readFileSync(institutionFilePath, 'utf8'); @@ -406,41 +414,82 @@ app.get('/api/schools', (req, res) => { console.error('Error parsing institution data:', err.message); return res.status(500).json({ error: 'Failed to load schools data.' }); } + + // 4) Filter any school whose CIP code matches ANY of the CIP codes in the array + // Convert the school's CIP code the same way you do in your old logic (remove dot, slice, etc.) const filtered = schoolsData.filter((s) => { const scip = s['CIPCODE']?.toString().replace('.', '').slice(0, 4); - return scip.startsWith(matchedCIP); + return cipArray.some((cip) => scip.startsWith(cip)); }); - console.log('Filtered schools:', filtered.length); - res.json(filtered); + + // 5) (Optional) Deduplicate if you suspect overlaps among CIP codes. + // E.g. by a “UNITID” or unique property: + const uniqueMap = new Map(); + for (const school of filtered) { + const key = school.UNITID || school.INSTNM; // pick your unique field + if (!uniqueMap.has(key)) { + uniqueMap.set(key, school); + } + } + const deduped = Array.from(uniqueMap.values()); + + console.log('Unique schools found:', deduped.length); + res.json(deduped); } catch (err) { console.error('Error reading Institution data:', err.message); res.status(500).json({ error: 'Failed to load schools data.' }); } }); + // tuition app.get('/api/tuition', (req, res) => { - const { cipCode, state } = req.query; - console.log(`Received CIP: ${cipCode}, State: ${state}`); - if (!cipCode || !state) { - return res.status(400).json({ error: 'CIP Code and State are required.' }); + const { cipCodes, state } = req.query; + if (!cipCodes || !state) { + return res.status(400).json({ error: 'cipCodes and state are required.' }); } + try { const raw = fs.readFileSync(institutionFilePath, 'utf8'); const schoolsData = JSON.parse(raw); + + const cipArray = cipCodes.split(',').map((c) => c.trim()).filter(Boolean); + if (!cipArray.length) { + return res.status(400).json({ error: 'No valid CIP codes.' }); + } + + // Filter logic const filtered = schoolsData.filter((school) => { - const cval = school['CIPCODE']?.toString().replace(/[^0-9]/g, ''); + const cval = school['CIPCODE']?.toString().replace(/\./g, '').slice(0, 4); const sVal = school['State']?.toUpperCase().trim(); - return cval.startsWith(cipCode) && sVal === state.toUpperCase().trim(); + + // Check if cval starts with ANY CIP in cipArray + const matchesCip = cipArray.some((cip) => cval.startsWith(cip)); + const matchesState = sVal === state.toUpperCase().trim(); + + return matchesCip && matchesState; }); - console.log('Filtered Tuition Data Count:', filtered.length); - res.json(filtered); + + // Optionally deduplicate by UNITID + const uniqueMap = new Map(); + for (const school of filtered) { + const key = school.UNITID || school.INSTNM; // or something else unique + if (!uniqueMap.has(key)) { + uniqueMap.set(key, school); + } + } + + const deduped = Array.from(uniqueMap.values()); + console.log('Filtered Tuition Data Count:', deduped.length); + + res.json(deduped); } catch (err) { console.error('Error reading tuition data:', err.message); res.status(500).json({ error: 'Failed to load tuition data.' }); } }); + /************************************************** * SINGLE route for projections from economicproj.json **************************************************/ diff --git a/src/components/CareerSearch.js b/src/components/CareerSearch.js index 888f77e..9e95b28 100644 --- a/src/components/CareerSearch.js +++ b/src/components/CareerSearch.js @@ -8,42 +8,43 @@ const CareerSearch = ({ onCareerSelected }) => { useEffect(() => { const fetchCareerData = async () => { try { - const response = await fetch('/career_clusters.json'); + const response = await fetch('/careers_with_ratings.json'); const data = await response.json(); - // Create a Map keyed by title, storing one object per unique title + // Create a Map keyed by career title, so we only keep one object per unique title const uniqueByTitle = new Map(); - const clusters = Object.keys(data); - for (let i = 0; i < clusters.length; i++) { - const clusterKey = clusters[i]; - const subdivisions = Object.keys(data[clusterKey]); - - for (let j = 0; j < subdivisions.length; j++) { - const subKey = subdivisions[j]; - const careersList = data[clusterKey][subKey] || []; - - for (let k = 0; k < careersList.length; k++) { - const c = careersList[k]; - if (c.title && c.soc_code && c.cip_code !== undefined) { - if (!uniqueByTitle.has(c.title)) { - uniqueByTitle.set(c.title, { - title: c.title, - soc_code: c.soc_code, - cip_code: c.cip_code - }); - } - } + // data is presumably an array like: + // [ + // { soc_code: "15-1241.00", title: "Computer Network Architects", cip_codes: [...], ... }, + // { soc_code: "15-1299.07", title: "Blockchain Engineers", cip_codes: [...], ... }, + // ... + // ] + for (const c of data) { + // Make sure we have a valid title, soc_code, and cip_codes + if (c.title && c.soc_code && c.cip_codes) { + // Only store the first unique title found + if (!uniqueByTitle.has(c.title)) { + uniqueByTitle.set(c.title, { + title: c.title, + soc_code: c.soc_code, + // NOTE: We store the array of CIPs in `cip_code`. + cip_code: c.cip_codes, + limited_data: c.limited_data, + ratings: c.ratings, + }); } } } + // Convert the map into an array const dedupedArr = [...uniqueByTitle.values()]; setCareerObjects(dedupedArr); } catch (error) { - console.error('Error loading or parsing career_clusters.json:', error); + console.error('Error loading or parsing careers_with_ratings.json:', error); } }; + fetchCareerData(); }, []); diff --git a/src/components/EducationalProgramsPage.js b/src/components/EducationalProgramsPage.js index 3b38d97..e653fd5 100644 --- a/src/components/EducationalProgramsPage.js +++ b/src/components/EducationalProgramsPage.js @@ -1,75 +1,93 @@ import React, { useEffect, useMemo, useState } from 'react'; import { useLocation } from 'react-router-dom'; - -// Your existing search component import CareerSearch from './CareerSearch.js'; - -// The existing utility calls import { fetchSchools, clientGeocodeZip, haversineDistance } from '../utils/apiUtils.js'; -// A simple Button component (if you don’t already import from elsewhere) -function Button({ onClick, children, ...props }) { - return ( - - ); -} - -/** - * EducationalProgramsPage - * - If we have a CIP code (from location.state or otherwise), we fetch + display schools. - * - If no CIP code is provided, user sees a CareerSearch to pick a career => sets CIP code. - * - Then the user can filter & sort (tuition, distance, optional in-state only). - */ function EducationalProgramsPage() { - // 1) Get CIP code from React Router location.state (if available) - // If no CIP code in route state, default to an empty string + // 1) Read an array of CIP codes from route state (if provided), + // or default to an empty array. const location = useLocation(); - const [cipCode, setCipCode] = useState(location.state?.cipCode || ''); + const [cipCodes, setCipCodes] = useState(location.state?.cipCodes || []); - // Optionally, you can also read userState / userZip from location.state or from user’s profile + // userState / userZip from route state or user profile const [userState, setUserState] = useState(location.state?.userState || ''); const [userZip, setUserZip] = useState(location.state?.userZip || ''); - // ============ Data + UI state ============ + // For UI const [schools, setSchools] = useState([]); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); - // Filter states - const [sortBy, setSortBy] = useState('tuition'); // 'tuition' or 'distance' - const [maxTuition, setMaxTuition] = useState(99999); - const [maxDistance, setMaxDistance] = useState(99999); - // Optional “in-state only” toggle + // Filters + const [sortBy, setSortBy] = useState('tuition'); // or 'distance' + const [maxTuition, setMaxTuition] = useState(20000); + const [maxDistance, setMaxDistance] = useState(100); const [inStateOnly, setInStateOnly] = useState(false); + const [careerTitle, setCareerTitle] = useState(location.state?.careerTitle || ''); - // ============ Handle Career Search -> CIP code ============ + // ============== If user picks a career from CareerSearch ============== const handleCareerSelected = (foundObj) => { - // foundObj = { title, soc_code, cip_code } from CareerSearch - if (foundObj?.cip_code) { - setCipCode(foundObj.cip_code); + setCareerTitle(foundObj.title || ''); + let rawCips = []; + if (Array.isArray(foundObj.cip_code)) { + // e.g. [11.0101, 11.0301, 11.0802, ...] + rawCips = foundObj.cip_code; + } else { + // single CIP code scenario + rawCips = [foundObj.cip_code]; } + + // Clean each CIP code (remove the dot, slice to 4 digits) + // e.g. "11.0101" => "1101" + const cleanedCips = rawCips.map((code) => { + const codeStr = code.toString(); + return codeStr.replace('.', '').slice(0,4); + }); + + // Now store them in state + setCipCodes(cleanedCips); }; - // ============ Fetch + Compute Distance once we have a CIP code ============ + // pseudo-code snippet +useEffect(() => { + async function loadUserProfile() { + try { + const token = localStorage.getItem('token'); + if (!token) { + console.warn('No token found, cannot load user-profile.'); + return; + } + const res = await fetch('/api/user-profile', { + headers: { Authorization: `Bearer ${token}` }, + }); + if (!res.ok) { + throw new Error('Failed to fetch user profile'); + } + const data = await res.json(); + // data.zipcode => "30102" + setUserZip(data.zipcode || ''); + setUserState(data.state || ''); + } catch (err) { + console.error('Error loading user profile:', err); + } + } + loadUserProfile(); +}, []); + + // ============== Fetch schools once we have CIP codes ============== useEffect(() => { - // If no CIP code is set yet, do nothing. - if (!cipCode) return; + if (!cipCodes.length) return; // no CIP codes => show CareerSearch fallback const fetchData = async () => { setLoading(true); setError(null); try { - // 1) Fetch schools by CIP code (and userState if your API still uses it) - const fetchedSchools = await fetchSchools(cipCode, userState); + // 1) Call fetchSchools with an array of CIP codes + // so we can do the "comma-separated" request in the utility. + const fetchedSchools = await fetchSchools(cipCodes); - // 2) Optionally geocode user ZIP to compute distances + // 2) Optionally geocode user ZIP for distance let userLat = null; let userLng = null; if (userZip) { @@ -82,7 +100,7 @@ function EducationalProgramsPage() { } } - // 3) Compute distance for each school (if lat/lng is available) + // 3) Compute distance const schoolsWithDistance = fetchedSchools.map((sch) => { const lat2 = sch.LATITUDE ? parseFloat(sch.LATITUDE) : null; const lon2 = sch.LONGITUD ? parseFloat(sch.LONGITUD) : null; @@ -90,14 +108,13 @@ function EducationalProgramsPage() { if (userLat && userLng && lat2 && lon2) { const distMiles = haversineDistance(userLat, userLng, lat2, lon2); return { ...sch, distance: distMiles.toFixed(1) }; - } else { - return { ...sch, distance: null }; } + return { ...sch, distance: null }; }); setSchools(schoolsWithDistance); } catch (err) { - console.error('Error fetching/processing schools:', err); + console.error('[EducationalProgramsPage] error:', err); setError('Failed to load schools.'); } finally { setLoading(false); @@ -105,59 +122,63 @@ function EducationalProgramsPage() { }; fetchData(); - }, [cipCode, userState, userZip]); + }, [cipCodes, userState, userZip]); - // ============ Filter + Sort ============ + // ============== Filter & Sort in useMemo ============== const filteredAndSortedSchools = useMemo(() => { if (!schools) return []; let result = [...schools]; - // 1) (Optional) In-state only + // 1) In-state if (inStateOnly && userState) { - result = result.filter((sch) => sch.STABBR === userState); + const userAbbr = userState.trim().toUpperCase(); + result = result.filter((sch) => { + const schoolAbbr = sch.State ? sch.State.trim().toUpperCase() : ''; + return schoolAbbr === userAbbr; + }); } - // 2) Filter by max tuition - // We’ll use “In_state cost” if your data references that, or you can adapt. + + // 2) Max tuition result = result.filter((sch) => { - const inStateCost = sch['In_state cost'] + const cost = sch['In_state cost'] ? parseFloat(sch['In_state cost']) : 999999; - return inStateCost <= maxTuition; + return cost <= maxTuition; }); - // 3) Filter by max distance + // 3) Max distance result = result.filter((sch) => { - if (sch.distance === null) { - // If distance is unknown, decide if you want to include or exclude it - return true; // let’s include unknown - } + if (sch.distance === null) return true; // keep unknown return parseFloat(sch.distance) <= maxDistance; }); // 4) Sort if (sortBy === 'distance') { result.sort((a, b) => { - const distA = a.distance !== null ? parseFloat(a.distance) : Infinity; - const distB = b.distance !== null ? parseFloat(b.distance) : Infinity; + const distA = a.distance ? parseFloat(a.distance) : Infinity; + const distB = b.distance ? parseFloat(b.distance) : Infinity; return distA - distB; }); } else { - // sort by tuition + // Sort by in-state tuition result.sort((a, b) => { - const tA = a['In_state cost'] ? parseFloat(a['In_state cost']) : Infinity; - const tB = b['In_state cost'] ? parseFloat(b['In_state cost']) : Infinity; + const tA = a['In_state cost'] + ? parseFloat(a['In_state cost']) + : Infinity; + const tB = b['In_state cost'] + ? parseFloat(b['In_state cost']) + : Infinity; return tA - tB; }); } return result; - }, [schools, sortBy, maxTuition, maxDistance, inStateOnly, userState]); + }, [schools, inStateOnly, userState, maxTuition, maxDistance, sortBy]); - // ============ Render UI ============ - - // 1) If we have NO CIP code yet, show the fallback “CareerSearch” - if (!cipCode) { + // ============== Render ============== + // Show the fallback (CareerSearch) if we have no CIP codes + if (!cipCodes.length) { return (

Educational Programs

@@ -172,7 +193,7 @@ function EducationalProgramsPage() { ); } - // 2) If we DO have a CIP code, show the filterable school list + // If CIP codes exist but we’re loading if (loading) { return
Loading schools...
; } @@ -187,9 +208,9 @@ function EducationalProgramsPage() { return (
-

- Schools Offering Programs for CIP: {cipCode} -

+

Schools for: {careerTitle || 'Unknown Career'}

+ + {/* Filter Bar */}
@@ -228,7 +249,6 @@ function EducationalProgramsPage() { /> - {/* Optional: In-State Only Toggle */} {userState && (