Fetch schools in EducationalProgramsPage.js

This commit is contained in:
Josh 2025-05-16 15:51:01 +00:00
parent 146061a9b9
commit 77cd3b6845
4 changed files with 197 additions and 120 deletions

View File

@ -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
**************************************************/

View File

@ -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();
}, []);

View File

@ -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 dont already import from elsewhere)
function Button({ onClick, children, ...props }) {
return (
<button
className="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-500"
onClick={onClick}
{...props}
>
{children}
</button>
);
}
/**
* 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 users 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
// Well 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; // lets 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 (
<div className="p-4">
<h2 className="text-2xl font-bold mb-4">Educational Programs</h2>
@ -172,7 +193,7 @@ function EducationalProgramsPage() {
);
}
// 2) If we DO have a CIP code, show the filterable school list
// If CIP codes exist but were loading
if (loading) {
return <div className="p-4">Loading schools...</div>;
}
@ -187,9 +208,9 @@ function EducationalProgramsPage() {
return (
<div className="p-4">
<h2 className="text-2xl font-bold mb-4">
Schools Offering Programs for CIP: {cipCode}
</h2>
<h2>Schools for: {careerTitle || 'Unknown Career'}</h2>
{/* Filter Bar */}
<div className="mb-4 flex flex-wrap items-center space-x-4">
@ -228,7 +249,6 @@ function EducationalProgramsPage() {
/>
</label>
{/* Optional: In-State Only Toggle */}
{userState && (
<label className="inline-flex items-center space-x-2 text-sm text-gray-600">
<input

View File

@ -47,19 +47,26 @@ export function haversineDistance(lat1, lon1, lat2, lon2) {
return R * c;
}
// Fetch schools
export const fetchSchools = async (cipCode) => {
export async function fetchSchools(cipCodes) {
try {
const apiUrl = process.env.REACT_APP_API_URL || '';
// 1) If `cipCodes` is a single string => wrap in array
let codesArray = Array.isArray(cipCodes) ? cipCodes : [cipCodes];
// 2) Turn that array into a comma-separated string
// e.g. ["1101","1409"] => "1101,1409"
const cipParam = codesArray.join(',');
// 3) Call your endpoint with `?cipCodes=1101,1409&state=NY`
const response = await axios.get(`${apiUrl}/schools`, {
params: {
cipCode
cipCodes: cipParam,
},
});
return response.data; // Return filtered data
return response.data;
} catch (error) {
console.error('Error fetching schools:', error);
return [];
}
};
}