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 * Single schools / tuition / etc. routes
**************************************************/ **************************************************/
app.get('/api/schools', (req, res) => { app.get('/api/schools', (req, res) => {
const { cipCode, state } = req.query; // 1) Read `cipCodes` from query (comma-separated string)
console.log('Query Params:', { cipCode }); const { cipCodes } = req.query;
if (!cipCode || !state) {
return res.status(400).json({ error: 'CIP Code is required' }); if (!cipCodes ) {
return res.status(400).json({ error: 'cipCodes (comma-separated) and state are required.' });
} }
try { 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 = []; let schoolsData = [];
try { try {
const rawData = fs.readFileSync(institutionFilePath, 'utf8'); const rawData = fs.readFileSync(institutionFilePath, 'utf8');
@ -406,41 +414,82 @@ app.get('/api/schools', (req, res) => {
console.error('Error parsing institution data:', err.message); console.error('Error parsing institution data:', err.message);
return res.status(500).json({ error: 'Failed to load schools data.' }); 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 filtered = schoolsData.filter((s) => {
const scip = s['CIPCODE']?.toString().replace('.', '').slice(0, 4); 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) { } catch (err) {
console.error('Error reading Institution data:', err.message); console.error('Error reading Institution data:', err.message);
res.status(500).json({ error: 'Failed to load schools data.' }); res.status(500).json({ error: 'Failed to load schools data.' });
} }
}); });
// tuition // tuition
app.get('/api/tuition', (req, res) => { app.get('/api/tuition', (req, res) => {
const { cipCode, state } = req.query; const { cipCodes, state } = req.query;
console.log(`Received CIP: ${cipCode}, State: ${state}`); if (!cipCodes || !state) {
if (!cipCode || !state) { return res.status(400).json({ error: 'cipCodes and state are required.' });
return res.status(400).json({ error: 'CIP Code and State are required.' });
} }
try { try {
const raw = fs.readFileSync(institutionFilePath, 'utf8'); const raw = fs.readFileSync(institutionFilePath, 'utf8');
const schoolsData = JSON.parse(raw); 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 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(); 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) { } catch (err) {
console.error('Error reading tuition data:', err.message); console.error('Error reading tuition data:', err.message);
res.status(500).json({ error: 'Failed to load tuition data.' }); res.status(500).json({ error: 'Failed to load tuition data.' });
} }
}); });
/************************************************** /**************************************************
* SINGLE route for projections from economicproj.json * SINGLE route for projections from economicproj.json
**************************************************/ **************************************************/

View File

@ -8,42 +8,43 @@ const CareerSearch = ({ onCareerSelected }) => {
useEffect(() => { useEffect(() => {
const fetchCareerData = async () => { const fetchCareerData = async () => {
try { try {
const response = await fetch('/career_clusters.json'); const response = await fetch('/careers_with_ratings.json');
const data = await response.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 uniqueByTitle = new Map();
const clusters = Object.keys(data); // data is presumably an array like:
for (let i = 0; i < clusters.length; i++) { // [
const clusterKey = clusters[i]; // { soc_code: "15-1241.00", title: "Computer Network Architects", cip_codes: [...], ... },
const subdivisions = Object.keys(data[clusterKey]); // { soc_code: "15-1299.07", title: "Blockchain Engineers", cip_codes: [...], ... },
// ...
for (let j = 0; j < subdivisions.length; j++) { // ]
const subKey = subdivisions[j]; for (const c of data) {
const careersList = data[clusterKey][subKey] || []; // Make sure we have a valid title, soc_code, and cip_codes
if (c.title && c.soc_code && c.cip_codes) {
for (let k = 0; k < careersList.length; k++) { // Only store the first unique title found
const c = careersList[k];
if (c.title && c.soc_code && c.cip_code !== undefined) {
if (!uniqueByTitle.has(c.title)) { if (!uniqueByTitle.has(c.title)) {
uniqueByTitle.set(c.title, { uniqueByTitle.set(c.title, {
title: c.title, title: c.title,
soc_code: c.soc_code, soc_code: c.soc_code,
cip_code: c.cip_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()]; const dedupedArr = [...uniqueByTitle.values()];
setCareerObjects(dedupedArr); setCareerObjects(dedupedArr);
} catch (error) { } catch (error) {
console.error('Error loading or parsing career_clusters.json:', error); console.error('Error loading or parsing careers_with_ratings.json:', error);
} }
}; };
fetchCareerData(); fetchCareerData();
}, []); }, []);

View File

@ -1,75 +1,93 @@
import React, { useEffect, useMemo, useState } from 'react'; import React, { useEffect, useMemo, useState } from 'react';
import { useLocation } from 'react-router-dom'; import { useLocation } from 'react-router-dom';
// Your existing search component
import CareerSearch from './CareerSearch.js'; import CareerSearch from './CareerSearch.js';
// The existing utility calls
import { fetchSchools, clientGeocodeZip, haversineDistance } from '../utils/apiUtils.js'; 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() { function EducationalProgramsPage() {
// 1) Get CIP code from React Router location.state (if available) // 1) Read an array of CIP codes from route state (if provided),
// If no CIP code in route state, default to an empty string // or default to an empty array.
const location = useLocation(); 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 [userState, setUserState] = useState(location.state?.userState || '');
const [userZip, setUserZip] = useState(location.state?.userZip || ''); const [userZip, setUserZip] = useState(location.state?.userZip || '');
// ============ Data + UI state ============ // For UI
const [schools, setSchools] = useState([]); const [schools, setSchools] = useState([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState(null); const [error, setError] = useState(null);
// Filter states // Filters
const [sortBy, setSortBy] = useState('tuition'); // 'tuition' or 'distance' const [sortBy, setSortBy] = useState('tuition'); // or 'distance'
const [maxTuition, setMaxTuition] = useState(99999); const [maxTuition, setMaxTuition] = useState(20000);
const [maxDistance, setMaxDistance] = useState(99999); const [maxDistance, setMaxDistance] = useState(100);
// Optional “in-state only” toggle
const [inStateOnly, setInStateOnly] = useState(false); 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) => { const handleCareerSelected = (foundObj) => {
// foundObj = { title, soc_code, cip_code } from CareerSearch setCareerTitle(foundObj.title || '');
if (foundObj?.cip_code) { let rawCips = [];
setCipCode(foundObj.cip_code); 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(() => { useEffect(() => {
// If no CIP code is set yet, do nothing. async function loadUserProfile() {
if (!cipCode) return; 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 (!cipCodes.length) return; // no CIP codes => show CareerSearch fallback
const fetchData = async () => { const fetchData = async () => {
setLoading(true); setLoading(true);
setError(null); setError(null);
try { try {
// 1) Fetch schools by CIP code (and userState if your API still uses it) // 1) Call fetchSchools with an array of CIP codes
const fetchedSchools = await fetchSchools(cipCode, userState); // 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 userLat = null;
let userLng = null; let userLng = null;
if (userZip) { 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 schoolsWithDistance = fetchedSchools.map((sch) => {
const lat2 = sch.LATITUDE ? parseFloat(sch.LATITUDE) : null; const lat2 = sch.LATITUDE ? parseFloat(sch.LATITUDE) : null;
const lon2 = sch.LONGITUD ? parseFloat(sch.LONGITUD) : null; const lon2 = sch.LONGITUD ? parseFloat(sch.LONGITUD) : null;
@ -90,14 +108,13 @@ function EducationalProgramsPage() {
if (userLat && userLng && lat2 && lon2) { if (userLat && userLng && lat2 && lon2) {
const distMiles = haversineDistance(userLat, userLng, lat2, lon2); const distMiles = haversineDistance(userLat, userLng, lat2, lon2);
return { ...sch, distance: distMiles.toFixed(1) }; return { ...sch, distance: distMiles.toFixed(1) };
} else {
return { ...sch, distance: null };
} }
return { ...sch, distance: null };
}); });
setSchools(schoolsWithDistance); setSchools(schoolsWithDistance);
} catch (err) { } catch (err) {
console.error('Error fetching/processing schools:', err); console.error('[EducationalProgramsPage] error:', err);
setError('Failed to load schools.'); setError('Failed to load schools.');
} finally { } finally {
setLoading(false); setLoading(false);
@ -105,59 +122,63 @@ function EducationalProgramsPage() {
}; };
fetchData(); fetchData();
}, [cipCode, userState, userZip]); }, [cipCodes, userState, userZip]);
// ============ Filter + Sort ============ // ============== Filter & Sort in useMemo ==============
const filteredAndSortedSchools = useMemo(() => { const filteredAndSortedSchools = useMemo(() => {
if (!schools) return []; if (!schools) return [];
let result = [...schools]; let result = [...schools];
// 1) (Optional) In-state only // 1) In-state
if (inStateOnly && userState) { 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) => { result = result.filter((sch) => {
const inStateCost = sch['In_state cost'] const cost = sch['In_state cost']
? parseFloat(sch['In_state cost']) ? parseFloat(sch['In_state cost'])
: 999999; : 999999;
return inStateCost <= maxTuition; return cost <= maxTuition;
}); });
// 3) Filter by max distance // 3) Max distance
result = result.filter((sch) => { result = result.filter((sch) => {
if (sch.distance === null) { if (sch.distance === null) return true; // keep unknown
// If distance is unknown, decide if you want to include or exclude it
return true; // lets include unknown
}
return parseFloat(sch.distance) <= maxDistance; return parseFloat(sch.distance) <= maxDistance;
}); });
// 4) Sort // 4) Sort
if (sortBy === 'distance') { if (sortBy === 'distance') {
result.sort((a, b) => { result.sort((a, b) => {
const distA = a.distance !== null ? parseFloat(a.distance) : Infinity; const distA = a.distance ? parseFloat(a.distance) : Infinity;
const distB = b.distance !== null ? parseFloat(b.distance) : Infinity; const distB = b.distance ? parseFloat(b.distance) : Infinity;
return distA - distB; return distA - distB;
}); });
} else { } else {
// sort by tuition // Sort by in-state tuition
result.sort((a, b) => { result.sort((a, b) => {
const tA = a['In_state cost'] ? parseFloat(a['In_state cost']) : Infinity; const tA = a['In_state cost']
const tB = b['In_state cost'] ? parseFloat(b['In_state cost']) : Infinity; ? parseFloat(a['In_state cost'])
: Infinity;
const tB = b['In_state cost']
? parseFloat(b['In_state cost'])
: Infinity;
return tA - tB; return tA - tB;
}); });
} }
return result; return result;
}, [schools, sortBy, maxTuition, maxDistance, inStateOnly, userState]); }, [schools, inStateOnly, userState, maxTuition, maxDistance, sortBy]);
// ============ Render UI ============ // ============== Render ==============
// Show the fallback (CareerSearch) if we have no CIP codes
// 1) If we have NO CIP code yet, show the fallback “CareerSearch” if (!cipCodes.length) {
if (!cipCode) {
return ( return (
<div className="p-4"> <div className="p-4">
<h2 className="text-2xl font-bold mb-4">Educational Programs</h2> <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) { if (loading) {
return <div className="p-4">Loading schools...</div>; return <div className="p-4">Loading schools...</div>;
} }
@ -187,9 +208,9 @@ function EducationalProgramsPage() {
return ( return (
<div className="p-4"> <div className="p-4">
<h2 className="text-2xl font-bold mb-4"> <h2>Schools for: {careerTitle || 'Unknown Career'}</h2>
Schools Offering Programs for CIP: {cipCode}
</h2>
{/* Filter Bar */} {/* Filter Bar */}
<div className="mb-4 flex flex-wrap items-center space-x-4"> <div className="mb-4 flex flex-wrap items-center space-x-4">
@ -228,7 +249,6 @@ function EducationalProgramsPage() {
/> />
</label> </label>
{/* Optional: In-State Only Toggle */}
{userState && ( {userState && (
<label className="inline-flex items-center space-x-2 text-sm text-gray-600"> <label className="inline-flex items-center space-x-2 text-sm text-gray-600">
<input <input

View File

@ -47,19 +47,26 @@ export function haversineDistance(lat1, lon1, lat2, lon2) {
return R * c; return R * c;
} }
// Fetch schools export async function fetchSchools(cipCodes) {
export const fetchSchools = async (cipCode) => {
try { try {
const apiUrl = process.env.REACT_APP_API_URL || ''; 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`, { const response = await axios.get(`${apiUrl}/schools`, {
params: { params: {
cipCode cipCodes: cipParam,
}, },
}); });
return response.data;
return response.data; // Return filtered data
} catch (error) { } catch (error) {
console.error('Error fetching schools:', error); console.error('Error fetching schools:', error);
return []; return [];
} }
}; }