diff --git a/client/src/Visualizations/KitchenOutcomeViz.tsx b/client/src/Visualizations/KitchenOutcomeViz.tsx index 5899a2a2..0c3ba4d0 100644 --- a/client/src/Visualizations/KitchenOutcomeViz.tsx +++ b/client/src/Visualizations/KitchenOutcomeViz.tsx @@ -27,7 +27,7 @@ interface SurveyData { organizationName: string; responderName: string; responderTitle: string; - hungerReliefMealsServed?: number; + hungerReliefsMealsServed?: number; typeOfMealsServed?: | 'Childcare Meals' | 'School Meals' @@ -130,6 +130,9 @@ function KitchenOutcomesVisualization() { }; const [surveyData, setSurveyData] = useState(null); + const [networkAverages, setNetworkAverages] = useState<{ + [key: string]: number | null; + }>({}); type OrgVal = { name: string; id: string; @@ -291,6 +294,92 @@ function KitchenOutcomesVisualization() { }, }); + const [avgChartData, setAvgChartData] = useState<{ + ageData: { + labels: string[]; + datasets: { + label: string; + data: number[]; + backgroundColor: string[]; + hoverBackgroundColor: string[]; + }[]; + }; + raceData: { + labels: string[]; + datasets: { + label: string; + data: number[]; + backgroundColor: string[]; + hoverBackgroundColor: string[]; + }[]; + }; + }>({ + ageData: { + labels: ['Infants', 'Children', 'Adults', 'Seniors', 'Unknown'], + datasets: [ + { + label: 'Age Distribution', + data: [], + backgroundColor: [ + '#7C9CB4', // Muted blue + '#86A873', // Muted green + '#B47C8E', // Muted rose + '#8E8EA6', // Muted purple + '#A8A8A8', // Muted gray + ], + hoverBackgroundColor: [ + '#6B8BA3', // Darker blue + '#759662', // Darker green + '#A36B7D', // Darker rose + '#7D7D95', // Darker purple + '#979797', // Darker gray + ], + }, + ], + }, + raceData: { + labels: [ + 'American Indian', + 'Asian', + 'Black', + 'Latinx', + 'Native Hawaiian', + 'Multi Racial', + 'White', + 'Other', + 'Unknown', + ], + datasets: [ + { + label: 'Race Distribution', + data: [], + backgroundColor: [ + '#D95D39', // Muted rust + '#45B7D1', // Muted turquoise + '#2D4654', // Dark slate + '#98B06F', // Muted olive + '#6B4E71', // Muted purple + '#D98E32', // Muted orange + '#9EA7AA', // Light gray + '#B55A30', // Terracotta + '#626D71', // Dark gray + ], + hoverBackgroundColor: [ + '#C54D29', // Darker rust + '#35A7C1', // Darker turquoise + '#1D3644', // Darker slate + '#88A05F', // Darker olive + '#5B3E61', // Darker purple + '#C97E22', // Darker orange + '#8E979A', // Darker light gray + '#A54A20', // Darker terracotta + '#525D61', // Darker gray + ], + }, + ], + }, + }); + useEffect(() => { // Update chartData when surveyData changes if (surveyData) { @@ -380,6 +469,95 @@ function KitchenOutcomesVisualization() { } }, [surveyData]); + useEffect(() => { + // Update avgChartData when networkAverages changes + if (networkAverages) { + const ageData = { + labels: ['Infants', 'Children', 'Adults', 'Seniors', 'Unknown'], + datasets: [ + { + label: 'Age Distribution', + data: [ + networkAverages?.mealsInfants ?? 0, + networkAverages?.mealsChildren ?? 0, + networkAverages?.mealsAdults ?? 0, + networkAverages?.mealsSeniors ?? 0, + networkAverages?.mealsAgeUnknown ?? 0, + ], + backgroundColor: [ + '#7C9CB4', // Muted blue + '#86A873', // Muted green + '#B47C8E', // Muted rose + '#8E8EA6', // Muted purple + '#A8A8A8', // Muted gray + ], + hoverBackgroundColor: [ + '#6B8BA3', // Darker blue + '#759662', // Darker green + '#A36B7D', // Darker rose + '#7D7D95', // Darker purple + '#979797', // Darker gray + ], + }, + ], + }; + + const raceData = { + labels: [ + 'American Indian', + 'Asian', + 'Black', + 'Latinx', + 'Native Hawaiian', + 'Multi Racial', + 'White', + 'Other', + 'Unknown', + ], + datasets: [ + { + label: 'Race Distribution', + data: [ + networkAverages?.mealsAmericanIndian ?? 0, + networkAverages?.mealsAsian ?? 0, + networkAverages?.mealsBlack ?? 0, + networkAverages?.mealsLatinx ?? 0, + networkAverages?.mealsNativeHawaiian ?? 0, + networkAverages?.mealsMultiRacial ?? 0, + networkAverages?.mealsWhite ?? 0, + networkAverages?.mealsOtherRace ?? 0, + networkAverages?.mealsRaceUnknown ?? 0, + ], + backgroundColor: [ + '#D95D39', // Muted rust + '#45B7D1', // Muted turquoise + '#2D4654', // Dark slate + '#98B06F', // Muted olive + '#6B4E71', // Muted purple + '#D98E32', // Muted orange + '#9EA7AA', // Light gray + '#B55A30', // Terracotta + '#626D71', // Dark gray + ], + hoverBackgroundColor: [ + '#C54D29', // Darker rust + '#35A7C1', // Darker turquoise + '#1D3644', // Darker slate + '#88A05F', // Darker olive + '#5B3E61', // Darker purple + '#C97E22', // Darker orange + '#8E979A', // Darker light gray + '#A54A20', // Darker terracotta + '#525D61', // Darker gray + ], + }, + ], + }; + + setAvgChartData({ ageData, raceData }); + } + }, [networkAverages]); + const options = { responsive: true, maintainAspectRatio: true, @@ -551,6 +729,63 @@ function KitchenOutcomesVisualization() { }, }, }; + + const fetchAllNetworkAverages = async (selectedYear: number) => { + console.log('Fetching network averages for year:', selectedYear); + + const fields = [ + // basic stats + 'costPerMeal', + 'foodCostPercentage', + 'mealReimbursement', + 'hungerReliefsMealsServed', + ]; + + const averages: { [key: string]: number | null } = {}; + + await Promise.all( + fields.map(async (field) => { + try { + console.log( + `trying to get network avg route with ${field} ${selectedYear}`, + ); + const response = await getData( + `kitchen_outcomes/network-average/${field}/${selectedYear}`, + ); + averages[field] = response.data.average; + } catch (error) { + console.error(`Error fetching network average for ${field}:`, error); + averages[field] = null; + } + }), + ); + + try { + console.log('trying to call route with year: ', selectedYear); + const response2 = await getData( + `kitchen_outcomes/distri/${selectedYear}`, + ); + console.log('response data: ', response2.data); + const ageRaceData = response2.data; + + Object.keys(ageRaceData.ageDistribution).forEach((field) => { + averages[field] = ageRaceData.ageDistribution[field]; + }); + } catch (error) { + console.error('Error fetching age-race distributions:', error); + } + + console.log('Fetched network averages:', averages); + setNetworkAverages(averages); + return averages; + }; + + useEffect(() => { + if (year) { + fetchAllNetworkAverages(Number(year)); + } + }, [year]); + return ( @@ -788,7 +1023,112 @@ function KitchenOutcomesVisualization() { }, { label: 'Hunger Relief Meals Served', - value: surveyData?.hungerReliefMealsServed, + value: surveyData?.hungerReliefsMealsServed, + }, + ].map((item) => ( + + + + {item.label} + + + {item.prefix || ''} + {item.value?.toLocaleString() || 'N/A'} + {item.suffix || ''} + + + + ))} + + + + + + Network Averages + + + {/* Age Distribution Chart */} + + + + Network Average Age Distribution + + + + + + + + {/* Race Distribution Chart */} + + + + Network Average Race Distribution + + + + + + + + {/* Important Figures */} + + + + Network Average Important Figures + + + {[ + { + label: 'Network Avg. Cost per Meal', + value: networkAverages?.costPerMeal, + prefix: '$', + }, + { + label: 'Network Avg. Food Cost Percentage', + value: networkAverages?.foodCostPercentage, + suffix: '%', + }, + { + label: 'Network Avg. Meal Reimbursement', + value: networkAverages?.mealReimbursement, + prefix: '$', + }, + { + label: 'Network Avg. Hunger Relief Meals Served', + value: networkAverages?.hungerReliefsMealsServed, }, ].map((item) => ( diff --git a/server/src/controllers/kitchen.outcomes.controller.ts b/server/src/controllers/kitchen.outcomes.controller.ts index 5cef69e9..46a0993b 100644 --- a/server/src/controllers/kitchen.outcomes.controller.ts +++ b/server/src/controllers/kitchen.outcomes.controller.ts @@ -11,8 +11,84 @@ import { getAllKitchenOutcomesByOrg, deleteKitchenOutcomeById, addKitchenOutcomes, + getNetworkAverage, + calculateAgeAndRaceDistributions, } from '../services/kitchen.outcomes.service.ts'; +const distriController = async ( + req: express.Request, + res: express.Response, + next: express.NextFunction, +) => { + const { year } = req.params; + + if (!year) { + next(ApiError.missingFields(['year'])); + return; + } + + try { + const yearNum = parseInt(year, 10); + if (isNaN(yearNum)) { + next(ApiError.badRequest('Invalid year format')); + return; + } + + console.log( + 'calculating age and race distribution controller for year:', + year, + ); + + const ageDistribution = await calculateAgeAndRaceDistributions(yearNum); + + res.status(StatusCode.OK).json({ + year: yearNum, + ageDistribution, + }); + } catch (error) { + next( + ApiError.internal( + `Unable to calculate age distribution for year ${year}`, + ), + ); + } +}; + +export { distriController }; + +const getNetworkAverageController = async ( + req: express.Request, + res: express.Response, + next: express.NextFunction, +) => { + const { field, year } = req.params; + + if (!field || !year) { + next(ApiError.missingFields(['field', 'year'])); + return; + } + + try { + const yearNum = parseInt(year, 10); + if (isNaN(yearNum)) { + next(ApiError.badRequest('Invalid year format')); + return; + } + + const average = await getNetworkAverage(field, yearNum); + + res.status(StatusCode.OK).json({ + field, + year: yearNum, + average: average ?? null, + }); + } catch (error) { + next(ApiError.internal(`Unable to calculate network average for ${field}`)); + } +}; + +export { getNetworkAverageController }; + const getOneKitchenOutcomesController = async ( req: express.Request, res: express.Response, diff --git a/server/src/routes/kitchen.outcomes.route.ts b/server/src/routes/kitchen.outcomes.route.ts index ee85b50e..e0816084 100644 --- a/server/src/routes/kitchen.outcomes.route.ts +++ b/server/src/routes/kitchen.outcomes.route.ts @@ -10,10 +10,15 @@ import { getKitchenOutcomesByOrg, deleteKitchenOutcomeByIdController, addKitchenOutcomesController, + getNetworkAverageController, + distriController, } from '../controllers/kitchen.outcomes.controller.ts'; const router = express.Router(); +router.get('/distri/:year', isAuthenticated, distriController); + + // router.get('/:year/:orgName', isAuthenticated, getOneKitchenOutcomesController); router.get('/:year/:orgId', isAuthenticated, getOneKitchenOutcomesController); // no authentication for now @@ -33,4 +38,11 @@ router.get('/get/all/:orgId', isAuthenticated, getKitchenOutcomesByOrg); router.delete('/delete/:id', isAdmin, deleteKitchenOutcomeByIdController); router.post('/add/', isAuthenticated, addKitchenOutcomesController); + +router.get( + '/network-average/:field/:year', + isAuthenticated, + getNetworkAverageController, +); + export default router; diff --git a/server/src/services/kitchen.outcomes.service.ts b/server/src/services/kitchen.outcomes.service.ts index f2a45141..4ddd9209 100644 --- a/server/src/services/kitchen.outcomes.service.ts +++ b/server/src/services/kitchen.outcomes.service.ts @@ -4,6 +4,159 @@ import { KitchenOutcomes, } from '../models/kitchen.outcomes.model.ts'; +const calculateAgeAndRaceDistributions = async (year: number) => { + try { + console.log( + 'calculating age and race distribution service for year:', + year, + ); + const startDate = new Date(Date.UTC(year, 0, 1)); + const endDate = new Date(Date.UTC(year + 1, 0, 1)); + + const outcomes = await KitchenOutcomes.find({ + year: { $gte: startDate, $lt: endDate }, + }); + + let totalMealsAdults = 0; + let totalMealsInfants = 0; + let totalMealsChildren = 0; + let totalMealsSeniors = 0; + let totalMealsAgeUnknown = 0; + let totalMealsAmericanIndian = 0; + let totalMealsAsian = 0; + let totalMealsBlack = 0; + let totalMealsLatinx = 0; + let totalMealsNativeHawaiian = 0; + let totalMealsMultiRacial = 0; + let totalMealsWhite = 0; + let totalMealsOtherRace = 0; + let totalMealsRaceUnknown = 0; + + outcomes.forEach((outcome) => { + if ( + !Number.isNaN(outcome.hungerReliefsMealsServed) && + !Number.isNaN(outcome.mealsAdults) && + !Number.isNaN(outcome.mealsInfants) && + !Number.isNaN(outcome.mealsChildren) && + !Number.isNaN(outcome.mealsSeniors) && + !Number.isNaN(outcome.mealsAgeUnknown) + ) { + const mealsServed = outcome.hungerReliefsMealsServed || 0; + totalMealsAdults += (outcome.mealsAdults || 0) * mealsServed; + totalMealsInfants += (outcome.mealsInfants || 0) * mealsServed; + totalMealsChildren += (outcome.mealsChildren || 0) * mealsServed; + totalMealsSeniors += (outcome.mealsSeniors || 0) * mealsServed; + totalMealsAgeUnknown += (outcome.mealsAgeUnknown || 0) * mealsServed; + } + if ( + !Number.isNaN(outcome.hungerReliefsMealsServed) && + !Number.isNaN(outcome.mealsAmericanIndian) && + !Number.isNaN(outcome.mealsAsian) && + !Number.isNaN(outcome.mealsBlack) && + !Number.isNaN(outcome.mealsLatinx) && + !Number.isNaN(outcome.mealsNativeHawaiian) && + !Number.isNaN(outcome.mealsMultiRacial) && + !Number.isNaN(outcome.mealsWhite) && + !Number.isNaN(outcome.mealsOtherRace) && + !Number.isNaN(outcome.mealsRaceUnknown) + ) { + const mealsServed = outcome.hungerReliefsMealsServed || 0; + totalMealsAmericanIndian += + (outcome.mealsAmericanIndian || 0) * mealsServed; + totalMealsAsian += (outcome.mealsAsian || 0) * mealsServed; + totalMealsBlack += (outcome.mealsBlack || 0) * mealsServed; + totalMealsLatinx += (outcome.mealsLatinx || 0) * mealsServed; + totalMealsNativeHawaiian += + (outcome.mealsNativeHawaiian || 0) * mealsServed; + totalMealsMultiRacial += (outcome.mealsMultiRacial || 0) * mealsServed; + totalMealsWhite += (outcome.mealsWhite || 0) * mealsServed; + totalMealsOtherRace += (outcome.mealsOtherRace || 0) * mealsServed; + totalMealsRaceUnknown += (outcome.mealsRaceUnknown || 0) * mealsServed; + } + }); + + const totalAgeMeals = + totalMealsAdults + + totalMealsInfants + + totalMealsChildren + + totalMealsSeniors + + totalMealsAgeUnknown; + + const totalRaceMeals = + totalMealsAmericanIndian + + totalMealsAsian + + totalMealsBlack + + totalMealsLatinx + + totalMealsNativeHawaiian + + totalMealsMultiRacial + + totalMealsWhite + + totalMealsOtherRace + + totalMealsRaceUnknown; + + const ageAndRaceDistributions = { + mealsAdults: (totalMealsAdults / totalAgeMeals) * 100, + mealsInfants: (totalMealsInfants / totalAgeMeals) * 100, + mealsChildren: (totalMealsChildren / totalAgeMeals) * 100, + mealsSeniors: (totalMealsSeniors / totalAgeMeals) * 100, + mealsAgeUnknown: (totalMealsAgeUnknown / totalAgeMeals) * 100, + mealsAmericanIndian: (totalMealsAmericanIndian / totalRaceMeals) * 100, + mealsAsian: (totalMealsAsian / totalRaceMeals) * 100, + mealsBlack: (totalMealsBlack / totalRaceMeals) * 100, + mealsLatinx: (totalMealsLatinx / totalRaceMeals) * 100, + mealsNativeHawaiian: (totalMealsNativeHawaiian / totalRaceMeals) * 100, + mealsMultiRacial: (totalMealsMultiRacial / totalRaceMeals) * 100, + mealsWhite: (totalMealsWhite / totalRaceMeals) * 100, + mealsOtherRace: (totalMealsOtherRace / totalRaceMeals) * 100, + mealsRaceUnknown: (totalMealsRaceUnknown / totalRaceMeals) * 100, + }; + + console.log('AGE RACE DISTRIBUTIONS:', ageAndRaceDistributions); + + return ageAndRaceDistributions; + } catch (error) { + console.error('Error calculating age and race distributions:', error); + throw new Error('Unable to calculate age and race distributions'); + } +}; + +export { calculateAgeAndRaceDistributions }; + +const getNetworkAverage = async ( + field: string, + year: number, +): Promise => { + const startDate = new Date(Date.UTC(year, 0, 1)); + const endDate = new Date(Date.UTC(year + 1, 0, 1)); + + try { + console.log('Service - Getting network average for:', { + field, + year, + startDate, + endDate, + }); + const result = await KitchenOutcomes.aggregate([ + { + $match: { + year: { $gte: startDate, $lt: endDate }, + [field]: { $exists: true, $ne: NaN }, + }, + }, + { + $group: { + _id: null, + average: { $avg: `$${field}` }, + }, + }, + ]); + console.log('Service - Network average result:', result); + return result.length > 0 ? result[0].average : null; + } catch (error) { + console.error(`Error calculating network average for ${field}:`, error); + throw new Error(`Unable to calculate network average for ${field}`); + } +}; + const getOneKitchenOutcomes = async (year: Date, orgId: string) => { console.log('Year', year.getFullYear()); const startDate = new Date(Date.UTC(year.getFullYear(), 0, 1)); @@ -137,4 +290,5 @@ export { getAllKitchenOutcomesByOrg, deleteKitchenOutcomeById, addKitchenOutcomes, + getNetworkAverage, };