From f0b337d765fa514515d7b064b11f8288bdfc57c8 Mon Sep 17 00:00:00 2001 From: Anthony Volk Date: Tue, 8 Apr 2025 22:43:37 -0700 Subject: [PATCH 01/69] feat: Add validaiton objects for society-wide impacts and parse SequentialResult items into SocietyWideImpact items --- src/api/aggregatePolicyImpacts.js | 168 ++++++++++++++++++++++++++++++ 1 file changed, 168 insertions(+) create mode 100644 src/api/aggregatePolicyImpacts.js diff --git a/src/api/aggregatePolicyImpacts.js b/src/api/aggregatePolicyImpacts.js new file mode 100644 index 000000000..fe27e3723 --- /dev/null +++ b/src/api/aggregatePolicyImpacts.js @@ -0,0 +1,168 @@ +import { SequentialResult } from "./makeSequentialRequests"; +import * as yup from "yup"; + +export const BaselineReformComparison = yup.object({ + baseline: yup.number(), + reform: yup.number(), +}); + +export const DecileComparison = yup.object({ + "-1": yup.number().notRequired().default(undefined), + 1: yup.number().required(), + 2: yup.number().required(), + 3: yup.number().required(), + 4: yup.number().required(), + 5: yup.number().required(), + 6: yup.number().required(), + 7: yup.number().required(), + 8: yup.number().required(), + 9: yup.number().required(), + 10: yup.number().required(), +}); + +export const PovertyByAgeBreakdown = yup.object({ + adult: BaselineReformComparison, + all: BaselineReformComparison, + child: BaselineReformComparison, + senior: BaselineReformComparison, +}); + +export const PovertyByGenderBreakdown = yup.object({ + male: BaselineReformComparison, + female: BaselineReformComparison, +}); + +export const PovertyByRaceBreakdown = yup.object({ + black: BaselineReformComparison, + hispanic: BaselineReformComparison, + white: BaselineReformComparison, + other: BaselineReformComparison, +}); + +export const WinnersLosersBreakdown = yup.object({ + "Gain less than 5%": yup.number(), + "Gain more than 5%": yup.number(), + "Lose less than 5%": yup.number(), + "Lose more than 5%": yup.number(), + "No change": yup.number(), +}); + +export const SocietyWideImpact = yup.object({ + budget: yup.object({ + baseline_net_income: yup.number(), + benefit_spending_impact: yup.number(), + budgetary_impact: yup.number(), + households: yup.number(), + state_tax_revenue_impact: yup.number(), + tax_revenue_impact: yup.number(), + }), + constituency_impact: yup.object().nullable(), + decile: yup.object({ + average: DecileComparison, + relative: DecileComparison, + }), + detailed_budget: yup.object().notRequired(), + inequality: yup.object({ + gini: BaselineReformComparison, + top_10_pct_share: BaselineReformComparison, + top_1_pct_share: BaselineReformComparison, + }), + intra_decile: yup.object({ + all: WinnersLosersBreakdown, + deciles: WinnersLosersBreakdown, + }), + intra_wealth_decile: yup.object().notRequired(), + labor_supply_response: yup.object({ + decile: yup.object({ + average: yup.object({ + income: DecileComparison, + substitution: DecileComparison, + }), + relative: yup.object({ + income: DecileComparison, + substitution: DecileComparison, + }), + }), + hours: { + baseline: yup.number().default(0), + change: yup.number().default(0), + income_effect: yup.number().default(0), + reform: yup.number().default(0), + substitution_effect: yup.number().default(0), + }, + income_lsr: yup.number().default(0), + relative_lsr: { + income: yup.number().default(0), + substitution: yup.number().default(0), + }, + revenue_change: yup.number().default(0), + substitution_lsr: yup.number().default(0), + total_change: yup.number().default(0), + }), + poverty: yup.object({ + deep_poverty: PovertyByAgeBreakdown, + poverty: PovertyByAgeBreakdown, + }), + poverty_by_gender: yup.object({ + deep_poverty: PovertyByGenderBreakdown, + poverty: PovertyByGenderBreakdown, + }), + poverty_by_race: yup + .object({ + poverty: PovertyByRaceBreakdown, + }) + .notRequired() + .default(null), + wealth_decile: yup.object().notRequired(), +}); + +/** + * Aggregate a series of policy impact objects (this is a set + * type, but unfortunately not documented, only implicit) + * @param {Array} impacts An array of policy impact objects + * @returns {Object} An object with the following properties: + */ +export function aggregatePolicyImpacts() { + return; +} + +/** + * Taking an array of SequentialResult items assumed to be + * successful society-wide impact calculations, validate that + * 1. each item is a succesful response and 2. that each response + * is a valid SocietyWideImpact object, then return all in an array + * @param {Array} sequentialResults An array of SequentialResult objects + * @returns {Array} An array of SocietyWideImpact objects + * @throws {Error} If any of the SequentialResult objects are error responses or + * if any of the responses are not valid SocietyWideImpact objects + */ +export async function parseSocietyWideResultsFromSequentialRequests( + sequentialResults, +) { + let impacts = []; + + try { + for (let i = 0; i < sequentialResults.length; i++) { + const result = sequentialResults[i]; + + if (result.status !== "success") { + throw new Error(`SequentialResult ${i} is not a success`); + } + + const response = result.response; + + const responseJson = await response.json(); + const resultData = responseJson.result; + + // Validate the response + const validImpact = SocietyWideImpact.cast(resultData); + + // Push the valid impact to the array + impacts.push(validImpact); + } + } catch (error) { + console.error("Error parsing sequential results:", error); + throw error; + } + return impacts; +} From 202dad92eb8354a88b59a183b34843ded99aebe7 Mon Sep 17 00:00:00 2001 From: Anthony Volk Date: Wed, 9 Apr 2025 09:57:06 -0700 Subject: [PATCH 02/69] chore: Refactor SocietyWideImpact into new file --- src/api/aggregatePolicyImpacts.js | 126 ++---------------------------- src/api/societyWideImpact.js | 116 +++++++++++++++++++++++++++ 2 files changed, 121 insertions(+), 121 deletions(-) create mode 100644 src/api/societyWideImpact.js diff --git a/src/api/aggregatePolicyImpacts.js b/src/api/aggregatePolicyImpacts.js index fe27e3723..1e895f7c1 100644 --- a/src/api/aggregatePolicyImpacts.js +++ b/src/api/aggregatePolicyImpacts.js @@ -1,128 +1,12 @@ import { SequentialResult } from "./makeSequentialRequests"; -import * as yup from "yup"; - -export const BaselineReformComparison = yup.object({ - baseline: yup.number(), - reform: yup.number(), -}); - -export const DecileComparison = yup.object({ - "-1": yup.number().notRequired().default(undefined), - 1: yup.number().required(), - 2: yup.number().required(), - 3: yup.number().required(), - 4: yup.number().required(), - 5: yup.number().required(), - 6: yup.number().required(), - 7: yup.number().required(), - 8: yup.number().required(), - 9: yup.number().required(), - 10: yup.number().required(), -}); - -export const PovertyByAgeBreakdown = yup.object({ - adult: BaselineReformComparison, - all: BaselineReformComparison, - child: BaselineReformComparison, - senior: BaselineReformComparison, -}); - -export const PovertyByGenderBreakdown = yup.object({ - male: BaselineReformComparison, - female: BaselineReformComparison, -}); - -export const PovertyByRaceBreakdown = yup.object({ - black: BaselineReformComparison, - hispanic: BaselineReformComparison, - white: BaselineReformComparison, - other: BaselineReformComparison, -}); - -export const WinnersLosersBreakdown = yup.object({ - "Gain less than 5%": yup.number(), - "Gain more than 5%": yup.number(), - "Lose less than 5%": yup.number(), - "Lose more than 5%": yup.number(), - "No change": yup.number(), -}); - -export const SocietyWideImpact = yup.object({ - budget: yup.object({ - baseline_net_income: yup.number(), - benefit_spending_impact: yup.number(), - budgetary_impact: yup.number(), - households: yup.number(), - state_tax_revenue_impact: yup.number(), - tax_revenue_impact: yup.number(), - }), - constituency_impact: yup.object().nullable(), - decile: yup.object({ - average: DecileComparison, - relative: DecileComparison, - }), - detailed_budget: yup.object().notRequired(), - inequality: yup.object({ - gini: BaselineReformComparison, - top_10_pct_share: BaselineReformComparison, - top_1_pct_share: BaselineReformComparison, - }), - intra_decile: yup.object({ - all: WinnersLosersBreakdown, - deciles: WinnersLosersBreakdown, - }), - intra_wealth_decile: yup.object().notRequired(), - labor_supply_response: yup.object({ - decile: yup.object({ - average: yup.object({ - income: DecileComparison, - substitution: DecileComparison, - }), - relative: yup.object({ - income: DecileComparison, - substitution: DecileComparison, - }), - }), - hours: { - baseline: yup.number().default(0), - change: yup.number().default(0), - income_effect: yup.number().default(0), - reform: yup.number().default(0), - substitution_effect: yup.number().default(0), - }, - income_lsr: yup.number().default(0), - relative_lsr: { - income: yup.number().default(0), - substitution: yup.number().default(0), - }, - revenue_change: yup.number().default(0), - substitution_lsr: yup.number().default(0), - total_change: yup.number().default(0), - }), - poverty: yup.object({ - deep_poverty: PovertyByAgeBreakdown, - poverty: PovertyByAgeBreakdown, - }), - poverty_by_gender: yup.object({ - deep_poverty: PovertyByGenderBreakdown, - poverty: PovertyByGenderBreakdown, - }), - poverty_by_race: yup - .object({ - poverty: PovertyByRaceBreakdown, - }) - .notRequired() - .default(null), - wealth_decile: yup.object().notRequired(), -}); +import { SocietyWideImpact } from "./societyWideImpact"; /** - * Aggregate a series of policy impact objects (this is a set - * type, but unfortunately not documented, only implicit) - * @param {Array} impacts An array of policy impact objects - * @returns {Object} An object with the following properties: + * Aggregate a series of SocietyWideImpact items and return the aggregated result + * @param {Array} impacts An array of policy impact objects + * @returns {SocietyWideImpact} An object with the following properties: */ -export function aggregatePolicyImpacts() { +export function aggregatePolicyImpacts(impacts) { return; } diff --git a/src/api/societyWideImpact.js b/src/api/societyWideImpact.js new file mode 100644 index 000000000..1f7b0b041 --- /dev/null +++ b/src/api/societyWideImpact.js @@ -0,0 +1,116 @@ +import * as yup from "yup"; + +export const BaselineReformComparison = yup.object({ + baseline: yup.number(), + reform: yup.number(), +}); + +export const DecileComparison = yup.object({ + "-1": yup.number().notRequired().default(undefined), + 1: yup.number().required(), + 2: yup.number().required(), + 3: yup.number().required(), + 4: yup.number().required(), + 5: yup.number().required(), + 6: yup.number().required(), + 7: yup.number().required(), + 8: yup.number().required(), + 9: yup.number().required(), + 10: yup.number().required(), +}); + +export const PovertyByAgeBreakdown = yup.object({ + adult: BaselineReformComparison, + all: BaselineReformComparison, + child: BaselineReformComparison, + senior: BaselineReformComparison, +}); + +export const PovertyByGenderBreakdown = yup.object({ + male: BaselineReformComparison, + female: BaselineReformComparison, +}); + +export const PovertyByRaceBreakdown = yup.object({ + black: BaselineReformComparison, + hispanic: BaselineReformComparison, + white: BaselineReformComparison, + other: BaselineReformComparison, +}); + +export const WinnersLosersBreakdown = yup.object({ + "Gain less than 5%": yup.number(), + "Gain more than 5%": yup.number(), + "Lose less than 5%": yup.number(), + "Lose more than 5%": yup.number(), + "No change": yup.number(), +}); + +export const SocietyWideImpact = yup.object({ + budget: yup.object({ + baseline_net_income: yup.number(), + benefit_spending_impact: yup.number(), + budgetary_impact: yup.number(), + households: yup.number(), + state_tax_revenue_impact: yup.number(), + tax_revenue_impact: yup.number(), + }), + constituency_impact: yup.object().nullable(), + decile: yup.object({ + average: DecileComparison, + relative: DecileComparison, + }), + detailed_budget: yup.object().notRequired(), + inequality: yup.object({ + gini: BaselineReformComparison, + top_10_pct_share: BaselineReformComparison, + top_1_pct_share: BaselineReformComparison, + }), + intra_decile: yup.object({ + all: WinnersLosersBreakdown, + deciles: WinnersLosersBreakdown, + }), + intra_wealth_decile: yup.object().notRequired(), + labor_supply_response: yup.object({ + decile: yup.object({ + average: yup.object({ + income: DecileComparison, + substitution: DecileComparison, + }), + relative: yup.object({ + income: DecileComparison, + substitution: DecileComparison, + }), + }), + hours: { + baseline: yup.number().default(0), + change: yup.number().default(0), + income_effect: yup.number().default(0), + reform: yup.number().default(0), + substitution_effect: yup.number().default(0), + }, + income_lsr: yup.number().default(0), + relative_lsr: { + income: yup.number().default(0), + substitution: yup.number().default(0), + }, + revenue_change: yup.number().default(0), + substitution_lsr: yup.number().default(0), + total_change: yup.number().default(0), + }), + poverty: yup.object({ + deep_poverty: PovertyByAgeBreakdown, + poverty: PovertyByAgeBreakdown, + }), + poverty_by_gender: yup.object({ + deep_poverty: PovertyByGenderBreakdown, + poverty: PovertyByGenderBreakdown, + }), + poverty_by_race: yup + .object({ + poverty: PovertyByRaceBreakdown, + }) + .notRequired() + .default(null), + wealth_decile: yup.object().notRequired(), +}); From 9f0271bc33fe9f69b3dc96a91d08f8b73fcb6793 Mon Sep 17 00:00:00 2001 From: Anthony Volk Date: Wed, 9 Apr 2025 10:01:53 -0700 Subject: [PATCH 03/69] feat: Validate impacts --- src/api/aggregatePolicyImpacts.js | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/src/api/aggregatePolicyImpacts.js b/src/api/aggregatePolicyImpacts.js index 1e895f7c1..a3b47d091 100644 --- a/src/api/aggregatePolicyImpacts.js +++ b/src/api/aggregatePolicyImpacts.js @@ -6,7 +6,18 @@ import { SocietyWideImpact } from "./societyWideImpact"; * @param {Array} impacts An array of policy impact objects * @returns {SocietyWideImpact} An object with the following properties: */ -export function aggregatePolicyImpacts(impacts) { +export async function aggregateSocietyWideImpacts(impacts) { + + try { + for (const impact of impacts) { + // Validate the impact object + await SocietyWideImpact.validate(impact); + } + } catch (error) { + console.error("Error validating impacts:", error); + throw error; + } + return; } From c341c34c31fd3b3ca7f802c8716a7f0691a3e89a Mon Sep 17 00:00:00 2001 From: Anthony Volk Date: Wed, 9 Apr 2025 10:18:05 -0700 Subject: [PATCH 04/69] feat: Add aggregation for budgetary impacts --- src/api/aggregatePolicyImpacts.js | 59 +++++++++++++++++++++++++++++-- src/api/aggregationFunctions.js | 12 +++++++ 2 files changed, 69 insertions(+), 2 deletions(-) create mode 100644 src/api/aggregationFunctions.js diff --git a/src/api/aggregatePolicyImpacts.js b/src/api/aggregatePolicyImpacts.js index a3b47d091..4447658e8 100644 --- a/src/api/aggregatePolicyImpacts.js +++ b/src/api/aggregatePolicyImpacts.js @@ -1,5 +1,6 @@ import { SequentialResult } from "./makeSequentialRequests"; import { SocietyWideImpact } from "./societyWideImpact"; +import { aggregators } from "./aggregationFunctions"; /** * Aggregate a series of SocietyWideImpact items and return the aggregated result @@ -7,7 +8,6 @@ import { SocietyWideImpact } from "./societyWideImpact"; * @returns {SocietyWideImpact} An object with the following properties: */ export async function aggregateSocietyWideImpacts(impacts) { - try { for (const impact of impacts) { // Validate the impact object @@ -18,7 +18,62 @@ export async function aggregateSocietyWideImpacts(impacts) { throw error; } - return; + const unvalidatedReturn = { + budget: aggregateBudgetData(impacts.map((impact) => impact.budget)), + /* + constituency_impact: null, // Placeholder for constituency impact aggregation + decile: aggregateDecileData(impacts.map(impact => impact.decile)), + detailed_budget: null, // Placeholder for detailed budget aggregation + inequality: aggregateInequalityData(impacts.map(impact => impact.inequality)), + intra_decile: aggregateIntraDecileData(impacts.map(impact => impact.intra_decile)), + intra_wealth_decile: null, // Placeholder for intra wealth decile aggregation + labor_supply_response: aggregateLaborSupplyResponseData(impacts.map(impact => impact.labor_supply_response)), + poverty: aggregatePovertyData(impacts.map(impact => impact.poverty)), + poverty_by_gender: aggregatePovertyByGenderData(impacts.map(impact => impact.poverty_by_gender)), + poverty_by_race: aggregatePovertyByRaceData(impacts.map(impact => impact.poverty_by_race)), + wealth_decile: null, // Placeholder for wealth decile aggregation + */ + }; + + return SocietyWideImpact.cast(unvalidatedReturn); +} + +/** + * Aggregates numeric values using either sum or average based on specified strategy + * @param {Array} values - Array of numbers to aggregate + * @param {'sum' | 'mean'} strategy - Aggregation strategy, either 'sum' or 'mean'; + * add a custom strategy by modifying the aggregators object in aggregationFunctions.js + * and updating JSDoc literal comment + * @returns {number} The aggregated value + */ +export function aggregateValues(values, strategy = "sum") { + const validValues = values.filter((val) => val !== undefined && val !== null); + + if (validValues.length === 0) { + console.error("Error aggregating values within array:", values); + return null; + } + + return aggregators[strategy](validValues); +} + +export function aggregateBudgetData(budgets) { + return { + baseline_net_income: aggregateValues( + budgets.map((b) => b?.baseline_net_income), + ), + benefit_spending_impact: aggregateValues( + budgets.map((b) => b?.benefit_spending_impact), + ), + budgetary_impact: aggregateValues(budgets.map((b) => b?.budgetary_impact)), + households: aggregateValues(budgets.map((b) => b?.households)), + state_tax_revenue_impact: aggregateValues( + budgets.map((b) => b?.state_tax_revenue_impact), + ), + tax_revenue_impact: aggregateValues( + budgets.map((b) => b?.tax_revenue_impact), + ), + }; } /** diff --git a/src/api/aggregationFunctions.js b/src/api/aggregationFunctions.js new file mode 100644 index 000000000..313a36ccb --- /dev/null +++ b/src/api/aggregationFunctions.js @@ -0,0 +1,12 @@ +export function sumArray(arr) { + return arr.reduce((acc, val) => acc + val, 0); +} + +export function findMeanOfArray(arr) { + return arr.reduce((acc, val) => acc + val, 0) / arr.length; +} + +export const aggregators = { + sum: sumArray, + mean: findMeanOfArray, +}; From eee2f9ed968cd0e9352bcffc4bb8d67f8ce92b1c Mon Sep 17 00:00:00 2001 From: Anthony Volk Date: Wed, 9 Apr 2025 11:11:05 -0700 Subject: [PATCH 05/69] feat: Aggregate budgetary impacts --- src/api/aggregatePolicyImpacts.js | 13 ++++++++----- src/api/societyWideImpact.js | 2 ++ 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/src/api/aggregatePolicyImpacts.js b/src/api/aggregatePolicyImpacts.js index 4447658e8..40ec1cfcf 100644 --- a/src/api/aggregatePolicyImpacts.js +++ b/src/api/aggregatePolicyImpacts.js @@ -1,6 +1,7 @@ import { SequentialResult } from "./makeSequentialRequests"; import { SocietyWideImpact } from "./societyWideImpact"; import { aggregators } from "./aggregationFunctions"; +import { testObjects } from "./testObjects"; /** * Aggregate a series of SocietyWideImpact items and return the aggregated result @@ -8,14 +9,16 @@ import { aggregators } from "./aggregationFunctions"; * @returns {SocietyWideImpact} An object with the following properties: */ export async function aggregateSocietyWideImpacts(impacts) { + try { for (const impact of impacts) { - // Validate the impact object - await SocietyWideImpact.validate(impact); + console.log("Impact inside validator:", impact); + await SocietyWideImpact.validate(impact, {abortEarly: false}); + console.log("Impact validated:", impact); } - } catch (error) { - console.error("Error validating impacts:", error); - throw error; + } catch (err) { + console.error("Error validating impacts:", err); + throw err; } const unvalidatedReturn = { diff --git a/src/api/societyWideImpact.js b/src/api/societyWideImpact.js index 1f7b0b041..dcd176e1f 100644 --- a/src/api/societyWideImpact.js +++ b/src/api/societyWideImpact.js @@ -55,6 +55,7 @@ export const SocietyWideImpact = yup.object({ state_tax_revenue_impact: yup.number(), tax_revenue_impact: yup.number(), }), + /* constituency_impact: yup.object().nullable(), decile: yup.object({ average: DecileComparison, @@ -113,4 +114,5 @@ export const SocietyWideImpact = yup.object({ .notRequired() .default(null), wealth_decile: yup.object().notRequired(), + */ }); From 75af92b964fb5ed4e0e8c292830de95b11ff60b2 Mon Sep 17 00:00:00 2001 From: Anthony Volk Date: Wed, 9 Apr 2025 11:12:19 -0700 Subject: [PATCH 06/69] fix: Remove testing objects --- src/api/aggregatePolicyImpacts.js | 1 - 1 file changed, 1 deletion(-) diff --git a/src/api/aggregatePolicyImpacts.js b/src/api/aggregatePolicyImpacts.js index 40ec1cfcf..469c52649 100644 --- a/src/api/aggregatePolicyImpacts.js +++ b/src/api/aggregatePolicyImpacts.js @@ -1,7 +1,6 @@ import { SequentialResult } from "./makeSequentialRequests"; import { SocietyWideImpact } from "./societyWideImpact"; import { aggregators } from "./aggregationFunctions"; -import { testObjects } from "./testObjects"; /** * Aggregate a series of SocietyWideImpact items and return the aggregated result From 3e5f753fa6215b2045164441b884469f81cf63fd Mon Sep 17 00:00:00 2001 From: Anthony Volk Date: Wed, 9 Apr 2025 11:17:25 -0700 Subject: [PATCH 07/69] feat: Add decile data aggregation --- src/api/aggregatePolicyImpacts.js | 32 +++++++++++++++++++++++++++++-- 1 file changed, 30 insertions(+), 2 deletions(-) diff --git a/src/api/aggregatePolicyImpacts.js b/src/api/aggregatePolicyImpacts.js index 469c52649..652c4628c 100644 --- a/src/api/aggregatePolicyImpacts.js +++ b/src/api/aggregatePolicyImpacts.js @@ -22,9 +22,9 @@ export async function aggregateSocietyWideImpacts(impacts) { const unvalidatedReturn = { budget: aggregateBudgetData(impacts.map((impact) => impact.budget)), - /* - constituency_impact: null, // Placeholder for constituency impact aggregation + // constituency_impact: null, // Placeholder for constituency impact aggregation decile: aggregateDecileData(impacts.map(impact => impact.decile)), + /* detailed_budget: null, // Placeholder for detailed budget aggregation inequality: aggregateInequalityData(impacts.map(impact => impact.inequality)), intra_decile: aggregateIntraDecileData(impacts.map(impact => impact.intra_decile)), @@ -78,6 +78,34 @@ export function aggregateBudgetData(budgets) { }; } +function aggregateDecileComparison(decileData, strategy = "sum") { + const deciles = [-1, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10]; + const result = {}; + + deciles.forEach(decile => { + // Convert to string for property access + const decileKey = String(decile); + const values = decileData.map(d => d?.[decileKey]).filter(v => v !== undefined); + + if (values.length > 0) { + result[decileKey] = aggregateValues(values, strategy); + } + }); + + return result; +} + +function aggregateDecileData(deciles) { + return { + average: { + ...aggregateDecileComparison(deciles.map(d => d?.average)), + }, + relative: { + ...aggregateDecileComparison(deciles.map(d => d?.relative), "mean"), + }, + }; +} + /** * Taking an array of SequentialResult items assumed to be * successful society-wide impact calculations, validate that From 9d4fa121fbc14c85bca66ccee28f0749ae64a0d2 Mon Sep 17 00:00:00 2001 From: Anthony Volk Date: Wed, 9 Apr 2025 11:22:30 -0700 Subject: [PATCH 08/69] feat: Add inequality aggregation --- src/api/aggregatePolicyImpacts.js | 79 ++++++++++++++++++++----------- src/api/societyWideImpact.js | 6 +-- 2 files changed, 54 insertions(+), 31 deletions(-) diff --git a/src/api/aggregatePolicyImpacts.js b/src/api/aggregatePolicyImpacts.js index 652c4628c..b21159d7d 100644 --- a/src/api/aggregatePolicyImpacts.js +++ b/src/api/aggregatePolicyImpacts.js @@ -24,9 +24,9 @@ export async function aggregateSocietyWideImpacts(impacts) { budget: aggregateBudgetData(impacts.map((impact) => impact.budget)), // constituency_impact: null, // Placeholder for constituency impact aggregation decile: aggregateDecileData(impacts.map(impact => impact.decile)), - /* - detailed_budget: null, // Placeholder for detailed budget aggregation + // detailed_budget: null, // Placeholder for detailed budget aggregation inequality: aggregateInequalityData(impacts.map(impact => impact.inequality)), + /* intra_decile: aggregateIntraDecileData(impacts.map(impact => impact.intra_decile)), intra_wealth_decile: null, // Placeholder for intra wealth decile aggregation labor_supply_response: aggregateLaborSupplyResponseData(impacts.map(impact => impact.labor_supply_response)), @@ -40,24 +40,7 @@ export async function aggregateSocietyWideImpacts(impacts) { return SocietyWideImpact.cast(unvalidatedReturn); } -/** - * Aggregates numeric values using either sum or average based on specified strategy - * @param {Array} values - Array of numbers to aggregate - * @param {'sum' | 'mean'} strategy - Aggregation strategy, either 'sum' or 'mean'; - * add a custom strategy by modifying the aggregators object in aggregationFunctions.js - * and updating JSDoc literal comment - * @returns {number} The aggregated value - */ -export function aggregateValues(values, strategy = "sum") { - const validValues = values.filter((val) => val !== undefined && val !== null); - - if (validValues.length === 0) { - console.error("Error aggregating values within array:", values); - return null; - } - return aggregators[strategy](validValues); -} export function aggregateBudgetData(budgets) { return { @@ -78,6 +61,34 @@ export function aggregateBudgetData(budgets) { }; } + +function aggregateDecileData(deciles) { + return { + average: { + ...aggregateDecileComparison(deciles.map(d => d?.average)), + }, + relative: { + ...aggregateDecileComparison(deciles.map(d => d?.relative), "mean"), + }, + }; +} + +function aggregateInequalityData(inequalityData) { + // Average approach for inequality metrics + return { + gini: aggregateBaselineReformComparison(inequalityData.map(i => i?.gini), 'mean', 'mean'), + top_10_pct_share: aggregateBaselineReformComparison(inequalityData.map(i => i?.top_10_pct_share), 'mean', 'mean'), + top_1_pct_share: aggregateBaselineReformComparison(inequalityData.map(i => i?.top_1_pct_share), 'mean', 'mean'), + }; +} + +function aggregateBaselineReformComparison(comparisons, baselineStrategy = 'sum', reformStrategy = 'sum') { + return { + baseline: aggregateValues(comparisons.map(c => c?.baseline), baselineStrategy), + reform: aggregateValues(comparisons.map(c => c?.reform), reformStrategy), + }; +} + function aggregateDecileComparison(decileData, strategy = "sum") { const deciles = [-1, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10]; const result = {}; @@ -95,15 +106,27 @@ function aggregateDecileComparison(decileData, strategy = "sum") { return result; } -function aggregateDecileData(deciles) { - return { - average: { - ...aggregateDecileComparison(deciles.map(d => d?.average)), - }, - relative: { - ...aggregateDecileComparison(deciles.map(d => d?.relative), "mean"), - }, - }; +/** + * Aggregates numeric values using either sum or average based on specified strategy + * @param {Array} values - Array of numbers to aggregate + * @param {'sum' | 'mean'} strategy - Aggregation strategy, either 'sum' or 'mean'; + * add a custom strategy by modifying the aggregators object in aggregationFunctions.js + * and updating JSDoc literal comment + * @returns {number} The aggregated value + */ +export function aggregateValues(values, strategy = "sum") { + if (!Object.keys(aggregators).includes(strategy)) { + throw new Error(`Invalid aggregation strategy: ${strategy}`); + } + + const validValues = values.filter((val) => val !== undefined && val !== null); + + if (validValues.length === 0) { + console.error("Error aggregating values within array:", values); + return null; + } + + return aggregators[strategy](validValues); } /** diff --git a/src/api/societyWideImpact.js b/src/api/societyWideImpact.js index dcd176e1f..ba1576db1 100644 --- a/src/api/societyWideImpact.js +++ b/src/api/societyWideImpact.js @@ -55,18 +55,18 @@ export const SocietyWideImpact = yup.object({ state_tax_revenue_impact: yup.number(), tax_revenue_impact: yup.number(), }), - /* - constituency_impact: yup.object().nullable(), + // constituency_impact: yup.object().nullable(), decile: yup.object({ average: DecileComparison, relative: DecileComparison, }), - detailed_budget: yup.object().notRequired(), + // detailed_budget: yup.object().notRequired(), inequality: yup.object({ gini: BaselineReformComparison, top_10_pct_share: BaselineReformComparison, top_1_pct_share: BaselineReformComparison, }), + /* intra_decile: yup.object({ all: WinnersLosersBreakdown, deciles: WinnersLosersBreakdown, From 18e1a8f6f9b77d997905c54dc75b2478236fe004 Mon Sep 17 00:00:00 2001 From: Anthony Volk Date: Wed, 9 Apr 2025 11:46:08 -0700 Subject: [PATCH 09/69] feat: Add standard poverty data aggregation --- src/api/aggregatePolicyImpacts.js | 18 ++++++++++++++++++ src/api/societyWideImpact.js | 2 ++ 2 files changed, 20 insertions(+) diff --git a/src/api/aggregatePolicyImpacts.js b/src/api/aggregatePolicyImpacts.js index b21159d7d..6585dbc8a 100644 --- a/src/api/aggregatePolicyImpacts.js +++ b/src/api/aggregatePolicyImpacts.js @@ -30,7 +30,9 @@ export async function aggregateSocietyWideImpacts(impacts) { intra_decile: aggregateIntraDecileData(impacts.map(impact => impact.intra_decile)), intra_wealth_decile: null, // Placeholder for intra wealth decile aggregation labor_supply_response: aggregateLaborSupplyResponseData(impacts.map(impact => impact.labor_supply_response)), + */ poverty: aggregatePovertyData(impacts.map(impact => impact.poverty)), + /* poverty_by_gender: aggregatePovertyByGenderData(impacts.map(impact => impact.poverty_by_gender)), poverty_by_race: aggregatePovertyByRaceData(impacts.map(impact => impact.poverty_by_race)), wealth_decile: null, // Placeholder for wealth decile aggregation @@ -106,6 +108,22 @@ function aggregateDecileComparison(decileData, strategy = "sum") { return result; } +function aggregatePovertyData(povertyData) { + return { + deep_poverty: aggregatePovertyByAgeBreakdown(povertyData.map(p => p?.deep_poverty)), + poverty: aggregatePovertyByAgeBreakdown(povertyData.map(p => p?.poverty)), + }; +} + +function aggregatePovertyByAgeBreakdown(ageBreakdowns) { + return { + adult: aggregateBaselineReformComparison(ageBreakdowns.map(b => b?.adult), 'mean', 'mean'), + all: aggregateBaselineReformComparison(ageBreakdowns.map(b => b?.all), 'mean', 'mean'), + child: aggregateBaselineReformComparison(ageBreakdowns.map(b => b?.child), 'mean', 'mean'), + senior: aggregateBaselineReformComparison(ageBreakdowns.map(b => b?.senior), 'mean', 'mean'), + }; +} + /** * Aggregates numeric values using either sum or average based on specified strategy * @param {Array} values - Array of numbers to aggregate diff --git a/src/api/societyWideImpact.js b/src/api/societyWideImpact.js index ba1576db1..37d451fd2 100644 --- a/src/api/societyWideImpact.js +++ b/src/api/societyWideImpact.js @@ -99,10 +99,12 @@ export const SocietyWideImpact = yup.object({ substitution_lsr: yup.number().default(0), total_change: yup.number().default(0), }), + */ poverty: yup.object({ deep_poverty: PovertyByAgeBreakdown, poverty: PovertyByAgeBreakdown, }), + /* poverty_by_gender: yup.object({ deep_poverty: PovertyByGenderBreakdown, poverty: PovertyByGenderBreakdown, From 052ef0c316388e73654bf40f20df0f233db1925e Mon Sep 17 00:00:00 2001 From: Anthony Volk Date: Wed, 9 Apr 2025 11:48:01 -0700 Subject: [PATCH 10/69] feat: Aggregate poverty by gender --- src/api/aggregatePolicyImpacts.js | 16 +++++++++++++++- src/api/societyWideImpact.js | 2 +- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/src/api/aggregatePolicyImpacts.js b/src/api/aggregatePolicyImpacts.js index 6585dbc8a..87ffaf45a 100644 --- a/src/api/aggregatePolicyImpacts.js +++ b/src/api/aggregatePolicyImpacts.js @@ -32,8 +32,8 @@ export async function aggregateSocietyWideImpacts(impacts) { labor_supply_response: aggregateLaborSupplyResponseData(impacts.map(impact => impact.labor_supply_response)), */ poverty: aggregatePovertyData(impacts.map(impact => impact.poverty)), - /* poverty_by_gender: aggregatePovertyByGenderData(impacts.map(impact => impact.poverty_by_gender)), + /* poverty_by_race: aggregatePovertyByRaceData(impacts.map(impact => impact.poverty_by_race)), wealth_decile: null, // Placeholder for wealth decile aggregation */ @@ -124,6 +124,20 @@ function aggregatePovertyByAgeBreakdown(ageBreakdowns) { }; } +function aggregatePovertyByGenderData(povertyByGenderData) { + return { + deep_poverty: aggregatePovertyByGenderBreakdown(povertyByGenderData.map(p => p?.deep_poverty)), + poverty: aggregatePovertyByGenderBreakdown(povertyByGenderData.map(p => p?.poverty)), + }; +} + +function aggregatePovertyByGenderBreakdown(genderBreakdowns) { + return { + male: aggregateBaselineReformComparison(genderBreakdowns.map(b => b?.male), 'mean', 'mean'), + female: aggregateBaselineReformComparison(genderBreakdowns.map(b => b?.female), 'mean', 'mean'), + }; +} + /** * Aggregates numeric values using either sum or average based on specified strategy * @param {Array} values - Array of numbers to aggregate diff --git a/src/api/societyWideImpact.js b/src/api/societyWideImpact.js index 37d451fd2..615add081 100644 --- a/src/api/societyWideImpact.js +++ b/src/api/societyWideImpact.js @@ -104,11 +104,11 @@ export const SocietyWideImpact = yup.object({ deep_poverty: PovertyByAgeBreakdown, poverty: PovertyByAgeBreakdown, }), - /* poverty_by_gender: yup.object({ deep_poverty: PovertyByGenderBreakdown, poverty: PovertyByGenderBreakdown, }), + /* poverty_by_race: yup .object({ poverty: PovertyByRaceBreakdown, From 78dcc410251872b1cdc99fac692dfd271b7af2b6 Mon Sep 17 00:00:00 2001 From: Anthony Volk Date: Wed, 9 Apr 2025 11:50:14 -0700 Subject: [PATCH 11/69] feat: Aggregate poverty by race --- src/api/aggregatePolicyImpacts.js | 19 ++++++++++++++++--- src/api/societyWideImpact.js | 4 +--- 2 files changed, 17 insertions(+), 6 deletions(-) diff --git a/src/api/aggregatePolicyImpacts.js b/src/api/aggregatePolicyImpacts.js index 87ffaf45a..229732761 100644 --- a/src/api/aggregatePolicyImpacts.js +++ b/src/api/aggregatePolicyImpacts.js @@ -33,10 +33,8 @@ export async function aggregateSocietyWideImpacts(impacts) { */ poverty: aggregatePovertyData(impacts.map(impact => impact.poverty)), poverty_by_gender: aggregatePovertyByGenderData(impacts.map(impact => impact.poverty_by_gender)), - /* poverty_by_race: aggregatePovertyByRaceData(impacts.map(impact => impact.poverty_by_race)), - wealth_decile: null, // Placeholder for wealth decile aggregation - */ + // wealth_decile: null, // Placeholder for wealth decile aggregation }; return SocietyWideImpact.cast(unvalidatedReturn); @@ -138,6 +136,21 @@ function aggregatePovertyByGenderBreakdown(genderBreakdowns) { }; } +function aggregatePovertyByRaceData(povertyByRaceData) { + return { + poverty: aggregatePovertyByRaceBreakdown(povertyByRaceData.map(p => p?.poverty)), + } +} + +function aggregatePovertyByRaceBreakdown(raceBreakdowns) { + return { + black: aggregateBaselineReformComparison(raceBreakdowns.map(b => b?.black), 'mean', 'mean'), + hispanic: aggregateBaselineReformComparison(raceBreakdowns.map(b => b?.hispanic), 'mean', 'mean'), + white: aggregateBaselineReformComparison(raceBreakdowns.map(b => b?.white), 'mean', 'mean'), + other: aggregateBaselineReformComparison(raceBreakdowns.map(b => b?.other), 'mean', 'mean'), + } +} + /** * Aggregates numeric values using either sum or average based on specified strategy * @param {Array} values - Array of numbers to aggregate diff --git a/src/api/societyWideImpact.js b/src/api/societyWideImpact.js index 615add081..60292ff68 100644 --- a/src/api/societyWideImpact.js +++ b/src/api/societyWideImpact.js @@ -108,13 +108,11 @@ export const SocietyWideImpact = yup.object({ deep_poverty: PovertyByGenderBreakdown, poverty: PovertyByGenderBreakdown, }), - /* poverty_by_race: yup .object({ poverty: PovertyByRaceBreakdown, }) .notRequired() .default(null), - wealth_decile: yup.object().notRequired(), - */ + // wealth_decile: yup.object().notRequired(), }); From 2df00183ad9082e989af5d08f31696289dd3ebb3 Mon Sep 17 00:00:00 2001 From: Anthony Volk Date: Wed, 9 Apr 2025 13:01:38 -0700 Subject: [PATCH 12/69] feat: Aggregate LSRs --- src/api/aggregatePolicyImpacts.js | 32 ++++++++++++++++++++++++++++++- src/api/societyWideImpact.js | 10 +++++----- 2 files changed, 36 insertions(+), 6 deletions(-) diff --git a/src/api/aggregatePolicyImpacts.js b/src/api/aggregatePolicyImpacts.js index 229732761..1ba070473 100644 --- a/src/api/aggregatePolicyImpacts.js +++ b/src/api/aggregatePolicyImpacts.js @@ -29,8 +29,8 @@ export async function aggregateSocietyWideImpacts(impacts) { /* intra_decile: aggregateIntraDecileData(impacts.map(impact => impact.intra_decile)), intra_wealth_decile: null, // Placeholder for intra wealth decile aggregation - labor_supply_response: aggregateLaborSupplyResponseData(impacts.map(impact => impact.labor_supply_response)), */ + labor_supply_response: aggregateLaborSupplyData(impacts.map(impact => impact.labor_supply_response)), poverty: aggregatePovertyData(impacts.map(impact => impact.poverty)), poverty_by_gender: aggregatePovertyByGenderData(impacts.map(impact => impact.poverty_by_gender)), poverty_by_race: aggregatePovertyByRaceData(impacts.map(impact => impact.poverty_by_race)), @@ -151,6 +151,36 @@ function aggregatePovertyByRaceBreakdown(raceBreakdowns) { } } +function aggregateLaborSupplyData(laborSupplyData) { + return { + hours: { + baseline: aggregateValues(laborSupplyData.map(l => l?.hours?.baseline)), + change: aggregateValues(laborSupplyData.map(l => l?.hours?.change)), + income_effect: aggregateValues(laborSupplyData.map(l => l?.hours?.income_effect)), + reform: aggregateValues(laborSupplyData.map(l => l?.hours?.reform)), + substitution_effect: aggregateValues(laborSupplyData.map(l => l?.hours?.substitution_effect)), + }, + income_lsr: aggregateValues(laborSupplyData.map(l => l?.income_lsr)), + substitution_lsr: aggregateValues(laborSupplyData.map(l => l?.substitution_lsr)), + total_change: aggregateValues(laborSupplyData.map(l => l?.total_change)), + revenue_change: aggregateValues(laborSupplyData.map(l => l?.revenue_change)), + decile: { + average: { + income: aggregateDecileComparison(laborSupplyData.map(l => l?.decile?.average?.income)), + substitution: aggregateDecileComparison(laborSupplyData.map(l => l?.decile?.average?.substitution)), + }, + relative: { + income: aggregateDecileComparison(laborSupplyData.map(l => l?.decile?.relative?.income), 'mean'), + substitution: aggregateDecileComparison(laborSupplyData.map(l => l?.decile?.relative?.substitution), 'mean'), + }, + }, + relative_lsr: { + income: aggregateValues(laborSupplyData.map(l => l?.relative_lsr?.income), 'mean'), + substitution: aggregateValues(laborSupplyData.map(l => l?.relative_lsr?.substitution), 'mean'), + }, + }; +} + /** * Aggregates numeric values using either sum or average based on specified strategy * @param {Array} values - Array of numbers to aggregate diff --git a/src/api/societyWideImpact.js b/src/api/societyWideImpact.js index 60292ff68..03e7135ce 100644 --- a/src/api/societyWideImpact.js +++ b/src/api/societyWideImpact.js @@ -72,6 +72,7 @@ export const SocietyWideImpact = yup.object({ deciles: WinnersLosersBreakdown, }), intra_wealth_decile: yup.object().notRequired(), + */ labor_supply_response: yup.object({ decile: yup.object({ average: yup.object({ @@ -83,23 +84,22 @@ export const SocietyWideImpact = yup.object({ substitution: DecileComparison, }), }), - hours: { + hours: yup.object({ baseline: yup.number().default(0), change: yup.number().default(0), income_effect: yup.number().default(0), reform: yup.number().default(0), substitution_effect: yup.number().default(0), - }, + }), income_lsr: yup.number().default(0), - relative_lsr: { + relative_lsr: yup.object({ income: yup.number().default(0), substitution: yup.number().default(0), - }, + }), revenue_change: yup.number().default(0), substitution_lsr: yup.number().default(0), total_change: yup.number().default(0), }), - */ poverty: yup.object({ deep_poverty: PovertyByAgeBreakdown, poverty: PovertyByAgeBreakdown, From 1b56a1e4029a607e4e0f3aef415c6e90db9ed55b Mon Sep 17 00:00:00 2001 From: Anthony Volk Date: Wed, 9 Apr 2025 14:28:27 -0700 Subject: [PATCH 13/69] feat: Implement intra-decile impacts --- src/api/aggregatePolicyImpacts.js | 83 +++++++++++++++++++++++++++++-- 1 file changed, 80 insertions(+), 3 deletions(-) diff --git a/src/api/aggregatePolicyImpacts.js b/src/api/aggregatePolicyImpacts.js index 1ba070473..20a4e5cb9 100644 --- a/src/api/aggregatePolicyImpacts.js +++ b/src/api/aggregatePolicyImpacts.js @@ -26,10 +26,8 @@ export async function aggregateSocietyWideImpacts(impacts) { decile: aggregateDecileData(impacts.map(impact => impact.decile)), // detailed_budget: null, // Placeholder for detailed budget aggregation inequality: aggregateInequalityData(impacts.map(impact => impact.inequality)), - /* intra_decile: aggregateIntraDecileData(impacts.map(impact => impact.intra_decile)), - intra_wealth_decile: null, // Placeholder for intra wealth decile aggregation - */ + // intra_wealth_decile: null, // Placeholder for intra wealth decile aggregation labor_supply_response: aggregateLaborSupplyData(impacts.map(impact => impact.labor_supply_response)), poverty: aggregatePovertyData(impacts.map(impact => impact.poverty)), poverty_by_gender: aggregatePovertyByGenderData(impacts.map(impact => impact.poverty_by_gender)), @@ -106,6 +104,85 @@ function aggregateDecileComparison(decileData, strategy = "sum") { return result; } +function aggregateWinnersLosersBreakdownSimple(breakdowns, strategy = "sum") { + const categories = [ + "Gain less than 5%", + "Gain more than 5%", + "Lose less than 5%", + "Lose more than 5%", + "No change" + ]; + + const result = {}; + + categories.forEach(category => { + // If breakdowns is an array, aggregate deciles + if (Array.isArray(breakdowns)) { + result[category] = aggregateDecileComparison(breakdowns.map(b => b?.[category]), strategy); + + return result; + } + + result[category] = aggregateValues( + breakdowns.map(b => b?.[category]), + strategy + ); + }); + + return result; +} + +function aggregateWinnersLosersBreakdownDeciles(breakdowns, strategy = "sum") { + const categories = [ + "Gain less than 5%", + "Gain more than 5%", + "Lose less than 5%", + "Lose more than 5%", + "No change" + ]; + + const result = {}; + + categories.forEach(category => { + // Initialize with an empty array if no valid data is found + if (!breakdowns.some(b => Array.isArray(b?.[category]))) { + result[category] = []; + return; + } + + // Get the first non-null array to determine length + const firstValidArray = breakdowns.find(b => Array.isArray(b?.[category]))?.[category]; + if (!firstValidArray) { + result[category] = []; + return; + } + + // Create an array of the same length with aggregated values at each position + result[category] = Array(firstValidArray.length).fill(0).map((_, index) => { + const valuesAtIndex = breakdowns + .filter(b => Array.isArray(b?.[category]) && b[category].length > index) + .map(b => b[category][index]); + + return aggregateValues(valuesAtIndex, strategy); + }); + }); + + return result; +} + +function aggregateIntraDecileData(intraDecileData) { + return { + all: aggregateWinnersLosersBreakdownSimple( + intraDecileData.map(d => d?.all), + "mean", + ), + deciles: aggregateWinnersLosersBreakdownDeciles( + intraDecileData.map(d => d?.deciles), + "mean", + ) + }; +} + function aggregatePovertyData(povertyData) { return { deep_poverty: aggregatePovertyByAgeBreakdown(povertyData.map(p => p?.deep_poverty)), From 9f64a45b5dacf971dd1e497d64a2ac47399b48e8 Mon Sep 17 00:00:00 2001 From: Anthony Volk Date: Wed, 9 Apr 2025 17:07:51 -0700 Subject: [PATCH 14/69] feat: Add constituency impacts --- src/api/aggregatePolicyImpacts.js | 79 ++++++++++++++++++++++++++++++- src/api/societyWideImpact.js | 28 ++++++++--- 2 files changed, 99 insertions(+), 8 deletions(-) diff --git a/src/api/aggregatePolicyImpacts.js b/src/api/aggregatePolicyImpacts.js index 20a4e5cb9..0c0831f14 100644 --- a/src/api/aggregatePolicyImpacts.js +++ b/src/api/aggregatePolicyImpacts.js @@ -17,12 +17,13 @@ export async function aggregateSocietyWideImpacts(impacts) { } } catch (err) { console.error("Error validating impacts:", err); + console.error(err?.errors); throw err; } const unvalidatedReturn = { budget: aggregateBudgetData(impacts.map((impact) => impact.budget)), - // constituency_impact: null, // Placeholder for constituency impact aggregation + constituency_impact: aggregateConstituencyData(impacts.map((impact) => impact.constituency_impact)), decile: aggregateDecileData(impacts.map(impact => impact.decile)), // detailed_budget: null, // Placeholder for detailed budget aggregation inequality: aggregateInequalityData(impacts.map(impact => impact.inequality)), @@ -214,6 +215,9 @@ function aggregatePovertyByGenderBreakdown(genderBreakdowns) { } function aggregatePovertyByRaceData(povertyByRaceData) { + if (!povertyByRaceData || !povertyByRaceData.length) { + return null; + } return { poverty: aggregatePovertyByRaceBreakdown(povertyByRaceData.map(p => p?.poverty)), } @@ -258,6 +262,79 @@ function aggregateLaborSupplyData(laborSupplyData) { }; } +function aggregateConstituencyImpactData(impacts) { + if (!impacts || !impacts.length) { + throw new Error('Cannot aggregate empty or undefined impacts'); + } + + return { + by_constituency: aggregateConstituencyData(impacts.by_constituency), + outcomes_by_region: { + england: aggregateWinnersLosersBreakdownSimple(impacts.map(i => i?.outcomes_by_region?.england), "mean"), + northern_ireland: aggregateWinnersLosersBreakdownSimple(impacts.map(i => i?.outcomes_by_region?.northern_ireland), "mean"), + scotland: aggregateWinnersLosersBreakdownSimple(impacts.map(i => i?.outcomes_by_region?.scotland), "mean"), + wales: aggregateWinnersLosersBreakdownSimple(impacts.map(i => i?.outcomes_by_region?.wales), "mean"), + uk: aggregateWinnersLosersBreakdownSimple(impacts.map(i => i?.outcomes_by_region?.uk), "mean"), + } + } +} + +function aggregateConstituencyData(impacts) { + if (!impacts || !impacts.length) { + throw new Error('Cannot aggregate empty or undefined impacts'); + } + + const validConstituencyImpacts = impacts + .map(impact => impact.constituency_impact) + .filter(impact => impact !== null && impact !== undefined); + + if (validConstituencyImpacts.length === 0) { + return null; + } + + const constituencyMap = new Map(); + + validConstituencyImpacts.forEach(constituencyImpact => { + Object.keys(constituencyImpact).forEach(constituencyName => { + const constituencyData = constituencyImpact[constituencyName]; + + if (!constituencyMap.has(constituencyName)) { + // Initialize constituency data if it doesn't exist yet + constituencyMap.set(constituencyName, { + // Using snake_case to reflect original data schema + average_household_income_changes: [], + relative_household_income_changes: [], + x: constituencyData.x, + y: constituencyData.y + }); + } + + const mapEntry = constituencyMap.get(constituencyName); + mapEntry.averageHouseholdIncomeChanges.push(constituencyData.average_household_income_change); + mapEntry.relativeHouseholdIncomeChanges.push(constituencyData.relative_household_income_change); + }); + }); + + const aggregatedConstituencies = {}; + + constituencyMap.forEach((data, constituencyName) => { + aggregatedConstituencies[constituencyName] = { + average_household_income_change: aggregateValues( + data.average_household_income_changes, + 'mean' + ), + relative_household_income_change: aggregateValues( + data.relative_household_income_changes, + 'mean' + ), + x: data.x, + y: data.y + }; + }); + + return aggregatedConstituencies; +} + /** * Aggregates numeric values using either sum or average based on specified strategy * @param {Array} values - Array of numbers to aggregate diff --git a/src/api/societyWideImpact.js b/src/api/societyWideImpact.js index 03e7135ce..552b15fb4 100644 --- a/src/api/societyWideImpact.js +++ b/src/api/societyWideImpact.js @@ -5,6 +5,11 @@ export const BaselineReformComparison = yup.object({ reform: yup.number(), }); +export const BaselineReformComparisonNullable = yup.object({ + baseline: yup.number().nullable(), + reform: yup.number().nullable(), +}) + export const DecileComparison = yup.object({ "-1": yup.number().notRequired().default(undefined), 1: yup.number().required(), @@ -32,10 +37,10 @@ export const PovertyByGenderBreakdown = yup.object({ }); export const PovertyByRaceBreakdown = yup.object({ - black: BaselineReformComparison, - hispanic: BaselineReformComparison, - white: BaselineReformComparison, - other: BaselineReformComparison, + black: BaselineReformComparisonNullable.nullable(), + hispanic: BaselineReformComparisonNullable.nullable(), + white: BaselineReformComparisonNullable.nullable(), + other: BaselineReformComparisonNullable.nullable(), }); export const WinnersLosersBreakdown = yup.object({ @@ -55,7 +60,16 @@ export const SocietyWideImpact = yup.object({ state_tax_revenue_impact: yup.number(), tax_revenue_impact: yup.number(), }), - // constituency_impact: yup.object().nullable(), + constituency_impact: yup.object({ + by_constituency: yup.object(), // This contains all 650 constituencies as keys and is impractical to profile + outcomes_by_region: yup.object({ + england: WinnersLosersBreakdown, + northern_ireland: WinnersLosersBreakdown, + scotland: WinnersLosersBreakdown, + wales: WinnersLosersBreakdown, + uk: WinnersLosersBreakdown, + }) + }).notRequired(), decile: yup.object({ average: DecileComparison, relative: DecileComparison, @@ -110,9 +124,9 @@ export const SocietyWideImpact = yup.object({ }), poverty_by_race: yup .object({ - poverty: PovertyByRaceBreakdown, + poverty: PovertyByRaceBreakdown.nullable(), }) + .nullable() .notRequired() - .default(null), // wealth_decile: yup.object().notRequired(), }); From 6daa5f1d9474814e1fda889d7f17bf90b54ab95f Mon Sep 17 00:00:00 2001 From: Anthony Volk Date: Wed, 9 Apr 2025 17:16:25 -0700 Subject: [PATCH 15/69] fix: Correct how winners and losers works --- src/api/aggregatePolicyImpacts.js | 6 ------ src/api/societyWideImpact.js | 14 ++++++++++---- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/src/api/aggregatePolicyImpacts.js b/src/api/aggregatePolicyImpacts.js index 0c0831f14..78f77772e 100644 --- a/src/api/aggregatePolicyImpacts.js +++ b/src/api/aggregatePolicyImpacts.js @@ -117,12 +117,6 @@ function aggregateWinnersLosersBreakdownSimple(breakdowns, strategy = "sum") { const result = {}; categories.forEach(category => { - // If breakdowns is an array, aggregate deciles - if (Array.isArray(breakdowns)) { - result[category] = aggregateDecileComparison(breakdowns.map(b => b?.[category]), strategy); - - return result; - } result[category] = aggregateValues( breakdowns.map(b => b?.[category]), diff --git a/src/api/societyWideImpact.js b/src/api/societyWideImpact.js index 552b15fb4..c26bb6df4 100644 --- a/src/api/societyWideImpact.js +++ b/src/api/societyWideImpact.js @@ -51,6 +51,14 @@ export const WinnersLosersBreakdown = yup.object({ "No change": yup.number(), }); +export const WinnersLosersDeciles = yup.object({ + "Gain less than 5%": yup.array().of(yup.number()), + "Gain more than 5%": yup.array().of(yup.number()), + "Lose less than 5%": yup.array().of(yup.number()), + "Lose more than 5%": yup.array().of(yup.number()), + "No change": yup.array().of(yup.number()), +}); + export const SocietyWideImpact = yup.object({ budget: yup.object({ baseline_net_income: yup.number(), @@ -80,13 +88,11 @@ export const SocietyWideImpact = yup.object({ top_10_pct_share: BaselineReformComparison, top_1_pct_share: BaselineReformComparison, }), - /* intra_decile: yup.object({ all: WinnersLosersBreakdown, - deciles: WinnersLosersBreakdown, + deciles: WinnersLosersDeciles, }), - intra_wealth_decile: yup.object().notRequired(), - */ + // intra_wealth_decile: yup.object().notRequired(), labor_supply_response: yup.object({ decile: yup.object({ average: yup.object({ From 199f5b8cdb8c14dcbe4929d21817ae8f31d20591 Mon Sep 17 00:00:00 2001 From: Anthony Volk Date: Wed, 9 Apr 2025 17:32:14 -0700 Subject: [PATCH 16/69] feat: Add detailed budget view --- src/api/aggregatePolicyImpacts.js | 44 ++++++++++++++++++++++++++++++- src/api/societyWideImpact.js | 22 +++++++++++++++- 2 files changed, 64 insertions(+), 2 deletions(-) diff --git a/src/api/aggregatePolicyImpacts.js b/src/api/aggregatePolicyImpacts.js index 78f77772e..acbf3276c 100644 --- a/src/api/aggregatePolicyImpacts.js +++ b/src/api/aggregatePolicyImpacts.js @@ -25,7 +25,7 @@ export async function aggregateSocietyWideImpacts(impacts) { budget: aggregateBudgetData(impacts.map((impact) => impact.budget)), constituency_impact: aggregateConstituencyData(impacts.map((impact) => impact.constituency_impact)), decile: aggregateDecileData(impacts.map(impact => impact.decile)), - // detailed_budget: null, // Placeholder for detailed budget aggregation + detailed_budget: aggregateDetailedBudgetData(impacts.map(impact => impact.detailed_budget)), inequality: aggregateInequalityData(impacts.map(impact => impact.inequality)), intra_decile: aggregateIntraDecileData(impacts.map(impact => impact.intra_decile)), // intra_wealth_decile: null, // Placeholder for intra wealth decile aggregation @@ -329,6 +329,48 @@ function aggregateConstituencyData(impacts) { return aggregatedConstituencies; } +function aggregateDetailedBudgetData(detailedBudgets) { + if (!detailedBudgets || !detailedBudgets.length) { + return null; + } + + const validBudgets = detailedBudgets.filter(budget => + budget !== null && budget !== undefined + ); + + if (validBudgets.length === 0) { + return {}; + } + + // Identify all unique budget line items across all impact objects + const allLineItems = new Set(); + validBudgets.forEach(budget => { + Object.keys(budget).forEach(lineItem => { + allLineItems.add(lineItem); + }); + }); + + const aggregatedBudget = {}; + + allLineItems.forEach(lineItem => { + // Extract values for this line item from all budgets + const lineItemData = validBudgets.map(budget => budget[lineItem] || null) + .filter(data => data !== null); + + if (lineItemData.length === 0) { + return; + } + + aggregatedBudget[lineItem] = { + baseline: aggregateValues(lineItemData.map(data => data.baseline)), + difference: aggregateValues(lineItemData.map(data => data.difference)), + reform: aggregateValues(lineItemData.map(data => data.reform)) + }; + }); + + return aggregatedBudget; +} + /** * Aggregates numeric values using either sum or average based on specified strategy * @param {Array} values - Array of numbers to aggregate diff --git a/src/api/societyWideImpact.js b/src/api/societyWideImpact.js index c26bb6df4..038313500 100644 --- a/src/api/societyWideImpact.js +++ b/src/api/societyWideImpact.js @@ -59,6 +59,26 @@ export const WinnersLosersDeciles = yup.object({ "No change": yup.array().of(yup.number()), }); +export const BaselineReformDifferenceNullable = yup.object({ + baseline: yup.number().nullable(), + reform: yup.number().nullable(), + difference: yup.number().nullable(), +}); + +export const UKDetailedPrograms = yup.object({ + child_benefit: BaselineReformDifferenceNullable.nullable(), + council_tax: BaselineReformDifferenceNullable.nullable(), + fuel_duty: BaselineReformDifferenceNullable.nullable(), + income_tax: BaselineReformDifferenceNullable.nullable(), + national_insurance: BaselineReformDifferenceNullable.nullable(), + ni_employer: BaselineReformDifferenceNullable.nullable(), + pension_credit: BaselineReformDifferenceNullable.nullable(), + state_pension: BaselineReformDifferenceNullable.nullable(), + tax_credits: BaselineReformDifferenceNullable.nullable(), + universal_credit: BaselineReformDifferenceNullable.nullable(), + vat: BaselineReformDifferenceNullable.nullable(), +}).nullable(); + export const SocietyWideImpact = yup.object({ budget: yup.object({ baseline_net_income: yup.number(), @@ -82,7 +102,7 @@ export const SocietyWideImpact = yup.object({ average: DecileComparison, relative: DecileComparison, }), - // detailed_budget: yup.object().notRequired(), + detailed_budget: UKDetailedPrograms.nullable(), inequality: yup.object({ gini: BaselineReformComparison, top_10_pct_share: BaselineReformComparison, From 258cfb0669ec266826f9c93ee458a600c1ad18aa Mon Sep 17 00:00:00 2001 From: Anthony Volk Date: Wed, 9 Apr 2025 17:36:52 -0700 Subject: [PATCH 17/69] feat: Add intra-wealth deciles --- src/api/aggregatePolicyImpacts.js | 6 +++++- src/api/societyWideImpact.js | 25 ++++++++++++++----------- 2 files changed, 19 insertions(+), 12 deletions(-) diff --git a/src/api/aggregatePolicyImpacts.js b/src/api/aggregatePolicyImpacts.js index acbf3276c..c7d07973c 100644 --- a/src/api/aggregatePolicyImpacts.js +++ b/src/api/aggregatePolicyImpacts.js @@ -28,7 +28,7 @@ export async function aggregateSocietyWideImpacts(impacts) { detailed_budget: aggregateDetailedBudgetData(impacts.map(impact => impact.detailed_budget)), inequality: aggregateInequalityData(impacts.map(impact => impact.inequality)), intra_decile: aggregateIntraDecileData(impacts.map(impact => impact.intra_decile)), - // intra_wealth_decile: null, // Placeholder for intra wealth decile aggregation + intra_wealth_decile: aggregateIntraDecileData(impacts.map(impact => impact.intra_wealth_decile)), labor_supply_response: aggregateLaborSupplyData(impacts.map(impact => impact.labor_supply_response)), poverty: aggregatePovertyData(impacts.map(impact => impact.poverty)), poverty_by_gender: aggregatePovertyByGenderData(impacts.map(impact => impact.poverty_by_gender)), @@ -166,6 +166,10 @@ function aggregateWinnersLosersBreakdownDeciles(breakdowns, strategy = "sum") { } function aggregateIntraDecileData(intraDecileData) { + if (!intraDecileData || !intraDecileData.length) { + return null; + } + return { all: aggregateWinnersLosersBreakdownSimple( intraDecileData.map(d => d?.all), diff --git a/src/api/societyWideImpact.js b/src/api/societyWideImpact.js index 038313500..5042039e2 100644 --- a/src/api/societyWideImpact.js +++ b/src/api/societyWideImpact.js @@ -44,19 +44,19 @@ export const PovertyByRaceBreakdown = yup.object({ }); export const WinnersLosersBreakdown = yup.object({ - "Gain less than 5%": yup.number(), - "Gain more than 5%": yup.number(), - "Lose less than 5%": yup.number(), - "Lose more than 5%": yup.number(), - "No change": yup.number(), + "Gain less than 5%": yup.number().nullable(), + "Gain more than 5%": yup.number().nullable(), + "Lose less than 5%": yup.number().nullable(), + "Lose more than 5%": yup.number().nullable(), + "No change": yup.number().nullable(), }); export const WinnersLosersDeciles = yup.object({ - "Gain less than 5%": yup.array().of(yup.number()), - "Gain more than 5%": yup.array().of(yup.number()), - "Lose less than 5%": yup.array().of(yup.number()), - "Lose more than 5%": yup.array().of(yup.number()), - "No change": yup.array().of(yup.number()), + "Gain less than 5%": yup.array().of(yup.number()).nullable(), + "Gain more than 5%": yup.array().of(yup.number()).nullable(), + "Lose less than 5%": yup.array().of(yup.number()).nullable(), + "Lose more than 5%": yup.array().of(yup.number()).nullable(), + "No change": yup.array().of(yup.number()).nullable(), }); export const BaselineReformDifferenceNullable = yup.object({ @@ -112,7 +112,10 @@ export const SocietyWideImpact = yup.object({ all: WinnersLosersBreakdown, deciles: WinnersLosersDeciles, }), - // intra_wealth_decile: yup.object().notRequired(), + intra_wealth_decile: yup.object({ + all: WinnersLosersBreakdown.nullable(), + deciles: WinnersLosersDeciles.nullable(), + }).notRequired().nullable(), labor_supply_response: yup.object({ decile: yup.object({ average: yup.object({ From 906db6c454fb1cf3e7b2ec89a9af6352e7fe5770 Mon Sep 17 00:00:00 2001 From: Anthony Volk Date: Wed, 9 Apr 2025 17:42:29 -0700 Subject: [PATCH 18/69] feat: Add wealth deciles --- src/api/aggregatePolicyImpacts.js | 8 +++++--- src/api/societyWideImpact.js | 21 +++++++++++++++++++-- 2 files changed, 24 insertions(+), 5 deletions(-) diff --git a/src/api/aggregatePolicyImpacts.js b/src/api/aggregatePolicyImpacts.js index c7d07973c..c26beebe6 100644 --- a/src/api/aggregatePolicyImpacts.js +++ b/src/api/aggregatePolicyImpacts.js @@ -33,7 +33,7 @@ export async function aggregateSocietyWideImpacts(impacts) { poverty: aggregatePovertyData(impacts.map(impact => impact.poverty)), poverty_by_gender: aggregatePovertyByGenderData(impacts.map(impact => impact.poverty_by_gender)), poverty_by_race: aggregatePovertyByRaceData(impacts.map(impact => impact.poverty_by_race)), - // wealth_decile: null, // Placeholder for wealth decile aggregation + wealth_decile: aggregateDecileData(impacts.map(impact => impact.wealth_decile)), }; return SocietyWideImpact.cast(unvalidatedReturn); @@ -62,6 +62,10 @@ export function aggregateBudgetData(budgets) { function aggregateDecileData(deciles) { + if (!deciles || !deciles.length) { + return null; + } + return { average: { ...aggregateDecileComparison(deciles.map(d => d?.average)), @@ -426,10 +430,8 @@ export async function parseSocietyWideResultsFromSequentialRequests( const responseJson = await response.json(); const resultData = responseJson.result; - // Validate the response const validImpact = SocietyWideImpact.cast(resultData); - // Push the valid impact to the array impacts.push(validImpact); } } catch (error) { diff --git a/src/api/societyWideImpact.js b/src/api/societyWideImpact.js index 5042039e2..f7b6832cf 100644 --- a/src/api/societyWideImpact.js +++ b/src/api/societyWideImpact.js @@ -24,6 +24,20 @@ export const DecileComparison = yup.object({ 10: yup.number().required(), }); +export const DecileComparisonNullable = yup.object({ + "-1": yup.number().nullable(), + 1: yup.number().nullable(), + 2: yup.number().nullable(), + 3: yup.number().nullable(), + 4: yup.number().nullable(), + 5: yup.number().nullable(), + 6: yup.number().nullable(), + 7: yup.number().nullable(), + 8: yup.number().nullable(), + 9: yup.number().nullable(), + 10: yup.number().nullable(), +}); + export const PovertyByAgeBreakdown = yup.object({ adult: BaselineReformComparison, all: BaselineReformComparison, @@ -156,6 +170,9 @@ export const SocietyWideImpact = yup.object({ poverty: PovertyByRaceBreakdown.nullable(), }) .nullable() - .notRequired() - // wealth_decile: yup.object().notRequired(), + .notRequired(), + wealth_decile: yup.object({ + average: DecileComparisonNullable.nullable(), + relative: DecileComparisonNullable.nullable(), + }).nullable().notRequired(), }); From 7116cc927295b68f2e37d7615bd2a34e739980af Mon Sep 17 00:00:00 2001 From: Anthony Volk Date: Wed, 9 Apr 2025 17:48:05 -0700 Subject: [PATCH 19/69] fix: Modify constituency setup --- src/api/aggregatePolicyImpacts.js | 4 ++-- src/api/societyWideImpact.js | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/api/aggregatePolicyImpacts.js b/src/api/aggregatePolicyImpacts.js index c26beebe6..b017ee037 100644 --- a/src/api/aggregatePolicyImpacts.js +++ b/src/api/aggregatePolicyImpacts.js @@ -23,7 +23,7 @@ export async function aggregateSocietyWideImpacts(impacts) { const unvalidatedReturn = { budget: aggregateBudgetData(impacts.map((impact) => impact.budget)), - constituency_impact: aggregateConstituencyData(impacts.map((impact) => impact.constituency_impact)), + constituency_impact: aggregateConstituencyImpactData(impacts.map((impact) => impact.constituency_impact)), decile: aggregateDecileData(impacts.map(impact => impact.decile)), detailed_budget: aggregateDetailedBudgetData(impacts.map(impact => impact.detailed_budget)), inequality: aggregateInequalityData(impacts.map(impact => impact.inequality)), @@ -283,7 +283,7 @@ function aggregateConstituencyImpactData(impacts) { function aggregateConstituencyData(impacts) { if (!impacts || !impacts.length) { - throw new Error('Cannot aggregate empty or undefined impacts'); + return null; } const validConstituencyImpacts = impacts diff --git a/src/api/societyWideImpact.js b/src/api/societyWideImpact.js index f7b6832cf..c3d8986f5 100644 --- a/src/api/societyWideImpact.js +++ b/src/api/societyWideImpact.js @@ -103,7 +103,7 @@ export const SocietyWideImpact = yup.object({ tax_revenue_impact: yup.number(), }), constituency_impact: yup.object({ - by_constituency: yup.object(), // This contains all 650 constituencies as keys and is impractical to profile + by_constituency: yup.object().nullable().notRequired(), // This contains all 650 constituencies as keys and is impractical to profile outcomes_by_region: yup.object({ england: WinnersLosersBreakdown, northern_ireland: WinnersLosersBreakdown, @@ -111,7 +111,7 @@ export const SocietyWideImpact = yup.object({ wales: WinnersLosersBreakdown, uk: WinnersLosersBreakdown, }) - }).notRequired(), + }).nullable().notRequired(), decile: yup.object({ average: DecileComparison, relative: DecileComparison, From 230b80f114acb3df0d81851e5c6a937d50b532d3 Mon Sep 17 00:00:00 2001 From: Anthony Volk Date: Thu, 10 Apr 2025 15:13:36 -0700 Subject: [PATCH 20/69] feat: Create separate schemas for US and UK --- src/api/societyWideImpact.js | 178 ----------------------------------- 1 file changed, 178 deletions(-) delete mode 100644 src/api/societyWideImpact.js diff --git a/src/api/societyWideImpact.js b/src/api/societyWideImpact.js deleted file mode 100644 index c3d8986f5..000000000 --- a/src/api/societyWideImpact.js +++ /dev/null @@ -1,178 +0,0 @@ -import * as yup from "yup"; - -export const BaselineReformComparison = yup.object({ - baseline: yup.number(), - reform: yup.number(), -}); - -export const BaselineReformComparisonNullable = yup.object({ - baseline: yup.number().nullable(), - reform: yup.number().nullable(), -}) - -export const DecileComparison = yup.object({ - "-1": yup.number().notRequired().default(undefined), - 1: yup.number().required(), - 2: yup.number().required(), - 3: yup.number().required(), - 4: yup.number().required(), - 5: yup.number().required(), - 6: yup.number().required(), - 7: yup.number().required(), - 8: yup.number().required(), - 9: yup.number().required(), - 10: yup.number().required(), -}); - -export const DecileComparisonNullable = yup.object({ - "-1": yup.number().nullable(), - 1: yup.number().nullable(), - 2: yup.number().nullable(), - 3: yup.number().nullable(), - 4: yup.number().nullable(), - 5: yup.number().nullable(), - 6: yup.number().nullable(), - 7: yup.number().nullable(), - 8: yup.number().nullable(), - 9: yup.number().nullable(), - 10: yup.number().nullable(), -}); - -export const PovertyByAgeBreakdown = yup.object({ - adult: BaselineReformComparison, - all: BaselineReformComparison, - child: BaselineReformComparison, - senior: BaselineReformComparison, -}); - -export const PovertyByGenderBreakdown = yup.object({ - male: BaselineReformComparison, - female: BaselineReformComparison, -}); - -export const PovertyByRaceBreakdown = yup.object({ - black: BaselineReformComparisonNullable.nullable(), - hispanic: BaselineReformComparisonNullable.nullable(), - white: BaselineReformComparisonNullable.nullable(), - other: BaselineReformComparisonNullable.nullable(), -}); - -export const WinnersLosersBreakdown = yup.object({ - "Gain less than 5%": yup.number().nullable(), - "Gain more than 5%": yup.number().nullable(), - "Lose less than 5%": yup.number().nullable(), - "Lose more than 5%": yup.number().nullable(), - "No change": yup.number().nullable(), -}); - -export const WinnersLosersDeciles = yup.object({ - "Gain less than 5%": yup.array().of(yup.number()).nullable(), - "Gain more than 5%": yup.array().of(yup.number()).nullable(), - "Lose less than 5%": yup.array().of(yup.number()).nullable(), - "Lose more than 5%": yup.array().of(yup.number()).nullable(), - "No change": yup.array().of(yup.number()).nullable(), -}); - -export const BaselineReformDifferenceNullable = yup.object({ - baseline: yup.number().nullable(), - reform: yup.number().nullable(), - difference: yup.number().nullable(), -}); - -export const UKDetailedPrograms = yup.object({ - child_benefit: BaselineReformDifferenceNullable.nullable(), - council_tax: BaselineReformDifferenceNullable.nullable(), - fuel_duty: BaselineReformDifferenceNullable.nullable(), - income_tax: BaselineReformDifferenceNullable.nullable(), - national_insurance: BaselineReformDifferenceNullable.nullable(), - ni_employer: BaselineReformDifferenceNullable.nullable(), - pension_credit: BaselineReformDifferenceNullable.nullable(), - state_pension: BaselineReformDifferenceNullable.nullable(), - tax_credits: BaselineReformDifferenceNullable.nullable(), - universal_credit: BaselineReformDifferenceNullable.nullable(), - vat: BaselineReformDifferenceNullable.nullable(), -}).nullable(); - -export const SocietyWideImpact = yup.object({ - budget: yup.object({ - baseline_net_income: yup.number(), - benefit_spending_impact: yup.number(), - budgetary_impact: yup.number(), - households: yup.number(), - state_tax_revenue_impact: yup.number(), - tax_revenue_impact: yup.number(), - }), - constituency_impact: yup.object({ - by_constituency: yup.object().nullable().notRequired(), // This contains all 650 constituencies as keys and is impractical to profile - outcomes_by_region: yup.object({ - england: WinnersLosersBreakdown, - northern_ireland: WinnersLosersBreakdown, - scotland: WinnersLosersBreakdown, - wales: WinnersLosersBreakdown, - uk: WinnersLosersBreakdown, - }) - }).nullable().notRequired(), - decile: yup.object({ - average: DecileComparison, - relative: DecileComparison, - }), - detailed_budget: UKDetailedPrograms.nullable(), - inequality: yup.object({ - gini: BaselineReformComparison, - top_10_pct_share: BaselineReformComparison, - top_1_pct_share: BaselineReformComparison, - }), - intra_decile: yup.object({ - all: WinnersLosersBreakdown, - deciles: WinnersLosersDeciles, - }), - intra_wealth_decile: yup.object({ - all: WinnersLosersBreakdown.nullable(), - deciles: WinnersLosersDeciles.nullable(), - }).notRequired().nullable(), - labor_supply_response: yup.object({ - decile: yup.object({ - average: yup.object({ - income: DecileComparison, - substitution: DecileComparison, - }), - relative: yup.object({ - income: DecileComparison, - substitution: DecileComparison, - }), - }), - hours: yup.object({ - baseline: yup.number().default(0), - change: yup.number().default(0), - income_effect: yup.number().default(0), - reform: yup.number().default(0), - substitution_effect: yup.number().default(0), - }), - income_lsr: yup.number().default(0), - relative_lsr: yup.object({ - income: yup.number().default(0), - substitution: yup.number().default(0), - }), - revenue_change: yup.number().default(0), - substitution_lsr: yup.number().default(0), - total_change: yup.number().default(0), - }), - poverty: yup.object({ - deep_poverty: PovertyByAgeBreakdown, - poverty: PovertyByAgeBreakdown, - }), - poverty_by_gender: yup.object({ - deep_poverty: PovertyByGenderBreakdown, - poverty: PovertyByGenderBreakdown, - }), - poverty_by_race: yup - .object({ - poverty: PovertyByRaceBreakdown.nullable(), - }) - .nullable() - .notRequired(), - wealth_decile: yup.object({ - average: DecileComparisonNullable.nullable(), - relative: DecileComparisonNullable.nullable(), - }).nullable().notRequired(), -}); From af156f15cb47576271016c02ff227bb029059566 Mon Sep 17 00:00:00 2001 From: Anthony Volk Date: Thu, 10 Apr 2025 15:21:53 -0700 Subject: [PATCH 21/69] fix: Remove aggregated impacts to split up PR --- src/api/aggregatePolicyImpacts.js | 442 ------------------------------ 1 file changed, 442 deletions(-) delete mode 100644 src/api/aggregatePolicyImpacts.js diff --git a/src/api/aggregatePolicyImpacts.js b/src/api/aggregatePolicyImpacts.js deleted file mode 100644 index b017ee037..000000000 --- a/src/api/aggregatePolicyImpacts.js +++ /dev/null @@ -1,442 +0,0 @@ -import { SequentialResult } from "./makeSequentialRequests"; -import { SocietyWideImpact } from "./societyWideImpact"; -import { aggregators } from "./aggregationFunctions"; - -/** - * Aggregate a series of SocietyWideImpact items and return the aggregated result - * @param {Array} impacts An array of policy impact objects - * @returns {SocietyWideImpact} An object with the following properties: - */ -export async function aggregateSocietyWideImpacts(impacts) { - - try { - for (const impact of impacts) { - console.log("Impact inside validator:", impact); - await SocietyWideImpact.validate(impact, {abortEarly: false}); - console.log("Impact validated:", impact); - } - } catch (err) { - console.error("Error validating impacts:", err); - console.error(err?.errors); - throw err; - } - - const unvalidatedReturn = { - budget: aggregateBudgetData(impacts.map((impact) => impact.budget)), - constituency_impact: aggregateConstituencyImpactData(impacts.map((impact) => impact.constituency_impact)), - decile: aggregateDecileData(impacts.map(impact => impact.decile)), - detailed_budget: aggregateDetailedBudgetData(impacts.map(impact => impact.detailed_budget)), - inequality: aggregateInequalityData(impacts.map(impact => impact.inequality)), - intra_decile: aggregateIntraDecileData(impacts.map(impact => impact.intra_decile)), - intra_wealth_decile: aggregateIntraDecileData(impacts.map(impact => impact.intra_wealth_decile)), - labor_supply_response: aggregateLaborSupplyData(impacts.map(impact => impact.labor_supply_response)), - poverty: aggregatePovertyData(impacts.map(impact => impact.poverty)), - poverty_by_gender: aggregatePovertyByGenderData(impacts.map(impact => impact.poverty_by_gender)), - poverty_by_race: aggregatePovertyByRaceData(impacts.map(impact => impact.poverty_by_race)), - wealth_decile: aggregateDecileData(impacts.map(impact => impact.wealth_decile)), - }; - - return SocietyWideImpact.cast(unvalidatedReturn); -} - - - -export function aggregateBudgetData(budgets) { - return { - baseline_net_income: aggregateValues( - budgets.map((b) => b?.baseline_net_income), - ), - benefit_spending_impact: aggregateValues( - budgets.map((b) => b?.benefit_spending_impact), - ), - budgetary_impact: aggregateValues(budgets.map((b) => b?.budgetary_impact)), - households: aggregateValues(budgets.map((b) => b?.households)), - state_tax_revenue_impact: aggregateValues( - budgets.map((b) => b?.state_tax_revenue_impact), - ), - tax_revenue_impact: aggregateValues( - budgets.map((b) => b?.tax_revenue_impact), - ), - }; -} - - -function aggregateDecileData(deciles) { - if (!deciles || !deciles.length) { - return null; - } - - return { - average: { - ...aggregateDecileComparison(deciles.map(d => d?.average)), - }, - relative: { - ...aggregateDecileComparison(deciles.map(d => d?.relative), "mean"), - }, - }; -} - -function aggregateInequalityData(inequalityData) { - // Average approach for inequality metrics - return { - gini: aggregateBaselineReformComparison(inequalityData.map(i => i?.gini), 'mean', 'mean'), - top_10_pct_share: aggregateBaselineReformComparison(inequalityData.map(i => i?.top_10_pct_share), 'mean', 'mean'), - top_1_pct_share: aggregateBaselineReformComparison(inequalityData.map(i => i?.top_1_pct_share), 'mean', 'mean'), - }; -} - -function aggregateBaselineReformComparison(comparisons, baselineStrategy = 'sum', reformStrategy = 'sum') { - return { - baseline: aggregateValues(comparisons.map(c => c?.baseline), baselineStrategy), - reform: aggregateValues(comparisons.map(c => c?.reform), reformStrategy), - }; -} - -function aggregateDecileComparison(decileData, strategy = "sum") { - const deciles = [-1, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10]; - const result = {}; - - deciles.forEach(decile => { - // Convert to string for property access - const decileKey = String(decile); - const values = decileData.map(d => d?.[decileKey]).filter(v => v !== undefined); - - if (values.length > 0) { - result[decileKey] = aggregateValues(values, strategy); - } - }); - - return result; -} - -function aggregateWinnersLosersBreakdownSimple(breakdowns, strategy = "sum") { - const categories = [ - "Gain less than 5%", - "Gain more than 5%", - "Lose less than 5%", - "Lose more than 5%", - "No change" - ]; - - const result = {}; - - categories.forEach(category => { - - result[category] = aggregateValues( - breakdowns.map(b => b?.[category]), - strategy - ); - }); - - return result; -} - -function aggregateWinnersLosersBreakdownDeciles(breakdowns, strategy = "sum") { - const categories = [ - "Gain less than 5%", - "Gain more than 5%", - "Lose less than 5%", - "Lose more than 5%", - "No change" - ]; - - const result = {}; - - categories.forEach(category => { - // Initialize with an empty array if no valid data is found - if (!breakdowns.some(b => Array.isArray(b?.[category]))) { - result[category] = []; - return; - } - - // Get the first non-null array to determine length - const firstValidArray = breakdowns.find(b => Array.isArray(b?.[category]))?.[category]; - if (!firstValidArray) { - result[category] = []; - return; - } - - // Create an array of the same length with aggregated values at each position - result[category] = Array(firstValidArray.length).fill(0).map((_, index) => { - const valuesAtIndex = breakdowns - .filter(b => Array.isArray(b?.[category]) && b[category].length > index) - .map(b => b[category][index]); - - return aggregateValues(valuesAtIndex, strategy); - }); - }); - - return result; -} - -function aggregateIntraDecileData(intraDecileData) { - if (!intraDecileData || !intraDecileData.length) { - return null; - } - - return { - all: aggregateWinnersLosersBreakdownSimple( - intraDecileData.map(d => d?.all), - "mean", - ), - deciles: aggregateWinnersLosersBreakdownDeciles( - intraDecileData.map(d => d?.deciles), - "mean", - ) - }; -} - -function aggregatePovertyData(povertyData) { - return { - deep_poverty: aggregatePovertyByAgeBreakdown(povertyData.map(p => p?.deep_poverty)), - poverty: aggregatePovertyByAgeBreakdown(povertyData.map(p => p?.poverty)), - }; -} - -function aggregatePovertyByAgeBreakdown(ageBreakdowns) { - return { - adult: aggregateBaselineReformComparison(ageBreakdowns.map(b => b?.adult), 'mean', 'mean'), - all: aggregateBaselineReformComparison(ageBreakdowns.map(b => b?.all), 'mean', 'mean'), - child: aggregateBaselineReformComparison(ageBreakdowns.map(b => b?.child), 'mean', 'mean'), - senior: aggregateBaselineReformComparison(ageBreakdowns.map(b => b?.senior), 'mean', 'mean'), - }; -} - -function aggregatePovertyByGenderData(povertyByGenderData) { - return { - deep_poverty: aggregatePovertyByGenderBreakdown(povertyByGenderData.map(p => p?.deep_poverty)), - poverty: aggregatePovertyByGenderBreakdown(povertyByGenderData.map(p => p?.poverty)), - }; -} - -function aggregatePovertyByGenderBreakdown(genderBreakdowns) { - return { - male: aggregateBaselineReformComparison(genderBreakdowns.map(b => b?.male), 'mean', 'mean'), - female: aggregateBaselineReformComparison(genderBreakdowns.map(b => b?.female), 'mean', 'mean'), - }; -} - -function aggregatePovertyByRaceData(povertyByRaceData) { - if (!povertyByRaceData || !povertyByRaceData.length) { - return null; - } - return { - poverty: aggregatePovertyByRaceBreakdown(povertyByRaceData.map(p => p?.poverty)), - } -} - -function aggregatePovertyByRaceBreakdown(raceBreakdowns) { - return { - black: aggregateBaselineReformComparison(raceBreakdowns.map(b => b?.black), 'mean', 'mean'), - hispanic: aggregateBaselineReformComparison(raceBreakdowns.map(b => b?.hispanic), 'mean', 'mean'), - white: aggregateBaselineReformComparison(raceBreakdowns.map(b => b?.white), 'mean', 'mean'), - other: aggregateBaselineReformComparison(raceBreakdowns.map(b => b?.other), 'mean', 'mean'), - } -} - -function aggregateLaborSupplyData(laborSupplyData) { - return { - hours: { - baseline: aggregateValues(laborSupplyData.map(l => l?.hours?.baseline)), - change: aggregateValues(laborSupplyData.map(l => l?.hours?.change)), - income_effect: aggregateValues(laborSupplyData.map(l => l?.hours?.income_effect)), - reform: aggregateValues(laborSupplyData.map(l => l?.hours?.reform)), - substitution_effect: aggregateValues(laborSupplyData.map(l => l?.hours?.substitution_effect)), - }, - income_lsr: aggregateValues(laborSupplyData.map(l => l?.income_lsr)), - substitution_lsr: aggregateValues(laborSupplyData.map(l => l?.substitution_lsr)), - total_change: aggregateValues(laborSupplyData.map(l => l?.total_change)), - revenue_change: aggregateValues(laborSupplyData.map(l => l?.revenue_change)), - decile: { - average: { - income: aggregateDecileComparison(laborSupplyData.map(l => l?.decile?.average?.income)), - substitution: aggregateDecileComparison(laborSupplyData.map(l => l?.decile?.average?.substitution)), - }, - relative: { - income: aggregateDecileComparison(laborSupplyData.map(l => l?.decile?.relative?.income), 'mean'), - substitution: aggregateDecileComparison(laborSupplyData.map(l => l?.decile?.relative?.substitution), 'mean'), - }, - }, - relative_lsr: { - income: aggregateValues(laborSupplyData.map(l => l?.relative_lsr?.income), 'mean'), - substitution: aggregateValues(laborSupplyData.map(l => l?.relative_lsr?.substitution), 'mean'), - }, - }; -} - -function aggregateConstituencyImpactData(impacts) { - if (!impacts || !impacts.length) { - throw new Error('Cannot aggregate empty or undefined impacts'); - } - - return { - by_constituency: aggregateConstituencyData(impacts.by_constituency), - outcomes_by_region: { - england: aggregateWinnersLosersBreakdownSimple(impacts.map(i => i?.outcomes_by_region?.england), "mean"), - northern_ireland: aggregateWinnersLosersBreakdownSimple(impacts.map(i => i?.outcomes_by_region?.northern_ireland), "mean"), - scotland: aggregateWinnersLosersBreakdownSimple(impacts.map(i => i?.outcomes_by_region?.scotland), "mean"), - wales: aggregateWinnersLosersBreakdownSimple(impacts.map(i => i?.outcomes_by_region?.wales), "mean"), - uk: aggregateWinnersLosersBreakdownSimple(impacts.map(i => i?.outcomes_by_region?.uk), "mean"), - } - } -} - -function aggregateConstituencyData(impacts) { - if (!impacts || !impacts.length) { - return null; - } - - const validConstituencyImpacts = impacts - .map(impact => impact.constituency_impact) - .filter(impact => impact !== null && impact !== undefined); - - if (validConstituencyImpacts.length === 0) { - return null; - } - - const constituencyMap = new Map(); - - validConstituencyImpacts.forEach(constituencyImpact => { - Object.keys(constituencyImpact).forEach(constituencyName => { - const constituencyData = constituencyImpact[constituencyName]; - - if (!constituencyMap.has(constituencyName)) { - // Initialize constituency data if it doesn't exist yet - constituencyMap.set(constituencyName, { - // Using snake_case to reflect original data schema - average_household_income_changes: [], - relative_household_income_changes: [], - x: constituencyData.x, - y: constituencyData.y - }); - } - - const mapEntry = constituencyMap.get(constituencyName); - mapEntry.averageHouseholdIncomeChanges.push(constituencyData.average_household_income_change); - mapEntry.relativeHouseholdIncomeChanges.push(constituencyData.relative_household_income_change); - }); - }); - - const aggregatedConstituencies = {}; - - constituencyMap.forEach((data, constituencyName) => { - aggregatedConstituencies[constituencyName] = { - average_household_income_change: aggregateValues( - data.average_household_income_changes, - 'mean' - ), - relative_household_income_change: aggregateValues( - data.relative_household_income_changes, - 'mean' - ), - x: data.x, - y: data.y - }; - }); - - return aggregatedConstituencies; -} - -function aggregateDetailedBudgetData(detailedBudgets) { - if (!detailedBudgets || !detailedBudgets.length) { - return null; - } - - const validBudgets = detailedBudgets.filter(budget => - budget !== null && budget !== undefined - ); - - if (validBudgets.length === 0) { - return {}; - } - - // Identify all unique budget line items across all impact objects - const allLineItems = new Set(); - validBudgets.forEach(budget => { - Object.keys(budget).forEach(lineItem => { - allLineItems.add(lineItem); - }); - }); - - const aggregatedBudget = {}; - - allLineItems.forEach(lineItem => { - // Extract values for this line item from all budgets - const lineItemData = validBudgets.map(budget => budget[lineItem] || null) - .filter(data => data !== null); - - if (lineItemData.length === 0) { - return; - } - - aggregatedBudget[lineItem] = { - baseline: aggregateValues(lineItemData.map(data => data.baseline)), - difference: aggregateValues(lineItemData.map(data => data.difference)), - reform: aggregateValues(lineItemData.map(data => data.reform)) - }; - }); - - return aggregatedBudget; -} - -/** - * Aggregates numeric values using either sum or average based on specified strategy - * @param {Array} values - Array of numbers to aggregate - * @param {'sum' | 'mean'} strategy - Aggregation strategy, either 'sum' or 'mean'; - * add a custom strategy by modifying the aggregators object in aggregationFunctions.js - * and updating JSDoc literal comment - * @returns {number} The aggregated value - */ -export function aggregateValues(values, strategy = "sum") { - if (!Object.keys(aggregators).includes(strategy)) { - throw new Error(`Invalid aggregation strategy: ${strategy}`); - } - - const validValues = values.filter((val) => val !== undefined && val !== null); - - if (validValues.length === 0) { - console.error("Error aggregating values within array:", values); - return null; - } - - return aggregators[strategy](validValues); -} - -/** - * Taking an array of SequentialResult items assumed to be - * successful society-wide impact calculations, validate that - * 1. each item is a succesful response and 2. that each response - * is a valid SocietyWideImpact object, then return all in an array - * @param {Array} sequentialResults An array of SequentialResult objects - * @returns {Array} An array of SocietyWideImpact objects - * @throws {Error} If any of the SequentialResult objects are error responses or - * if any of the responses are not valid SocietyWideImpact objects - */ -export async function parseSocietyWideResultsFromSequentialRequests( - sequentialResults, -) { - let impacts = []; - - try { - for (let i = 0; i < sequentialResults.length; i++) { - const result = sequentialResults[i]; - - if (result.status !== "success") { - throw new Error(`SequentialResult ${i} is not a success`); - } - - const response = result.response; - - const responseJson = await response.json(); - const resultData = responseJson.result; - - const validImpact = SocietyWideImpact.cast(resultData); - - impacts.push(validImpact); - } - } catch (error) { - console.error("Error parsing sequential results:", error); - throw error; - } - return impacts; -} From 71e7d39f60a1c0e3ba4938a801d76fa0e5ac84dc Mon Sep 17 00:00:00 2001 From: Anthony Volk Date: Thu, 10 Apr 2025 16:49:55 -0700 Subject: [PATCH 22/69] feat: Add refactored society-wide aggregation functions --- src/api/aggregationFunctions.js | 12 ------- .../aggregateLaborSupplyModule.js | 31 +++++++++++++++++++ 2 files changed, 31 insertions(+), 12 deletions(-) delete mode 100644 src/api/aggregationFunctions.js create mode 100644 src/api/societyWideAggregation/aggregateLaborSupplyModule.js diff --git a/src/api/aggregationFunctions.js b/src/api/aggregationFunctions.js deleted file mode 100644 index 313a36ccb..000000000 --- a/src/api/aggregationFunctions.js +++ /dev/null @@ -1,12 +0,0 @@ -export function sumArray(arr) { - return arr.reduce((acc, val) => acc + val, 0); -} - -export function findMeanOfArray(arr) { - return arr.reduce((acc, val) => acc + val, 0) / arr.length; -} - -export const aggregators = { - sum: sumArray, - mean: findMeanOfArray, -}; diff --git a/src/api/societyWideAggregation/aggregateLaborSupplyModule.js b/src/api/societyWideAggregation/aggregateLaborSupplyModule.js new file mode 100644 index 000000000..13ddbe793 --- /dev/null +++ b/src/api/societyWideAggregation/aggregateLaborSupplyModule.js @@ -0,0 +1,31 @@ +import { aggregateDecileComparison, aggregateValues } from "./aggregateUtils"; + +export function aggregateLaborSupplyModule(laborSupplyData) { + return { + hours: { + baseline: aggregateValues(laborSupplyData.map(l => l?.hours?.baseline)), + change: aggregateValues(laborSupplyData.map(l => l?.hours?.change)), + income_effect: aggregateValues(laborSupplyData.map(l => l?.hours?.income_effect)), + reform: aggregateValues(laborSupplyData.map(l => l?.hours?.reform)), + substitution_effect: aggregateValues(laborSupplyData.map(l => l?.hours?.substitution_effect)), + }, + income_lsr: aggregateValues(laborSupplyData.map(l => l?.income_lsr)), + substitution_lsr: aggregateValues(laborSupplyData.map(l => l?.substitution_lsr)), + total_change: aggregateValues(laborSupplyData.map(l => l?.total_change)), + revenue_change: aggregateValues(laborSupplyData.map(l => l?.revenue_change)), + decile: { + average: { + income: aggregateDecileComparison(laborSupplyData.map(l => l?.decile?.average?.income)), + substitution: aggregateDecileComparison(laborSupplyData.map(l => l?.decile?.average?.substitution)), + }, + relative: { + income: aggregateDecileComparison(laborSupplyData.map(l => l?.decile?.relative?.income), 'mean'), + substitution: aggregateDecileComparison(laborSupplyData.map(l => l?.decile?.relative?.substitution), 'mean'), + }, + }, + relative_lsr: { + income: aggregateValues(laborSupplyData.map(l => l?.relative_lsr?.income), 'mean'), + substitution: aggregateValues(laborSupplyData.map(l => l?.relative_lsr?.substitution), 'mean'), + }, + }; +} \ No newline at end of file From 6129369bc727a59591aef5901441c34bf8cab175 Mon Sep 17 00:00:00 2001 From: Anthony Volk Date: Thu, 10 Apr 2025 16:57:47 -0700 Subject: [PATCH 23/69] feat: Add refactored aggregation for UK --- .../aggregateLaborSupplyModule.js | 56 +++++++++++++------ 1 file changed, 40 insertions(+), 16 deletions(-) diff --git a/src/api/societyWideAggregation/aggregateLaborSupplyModule.js b/src/api/societyWideAggregation/aggregateLaborSupplyModule.js index 13ddbe793..fcce2f2da 100644 --- a/src/api/societyWideAggregation/aggregateLaborSupplyModule.js +++ b/src/api/societyWideAggregation/aggregateLaborSupplyModule.js @@ -3,29 +3,53 @@ import { aggregateDecileComparison, aggregateValues } from "./aggregateUtils"; export function aggregateLaborSupplyModule(laborSupplyData) { return { hours: { - baseline: aggregateValues(laborSupplyData.map(l => l?.hours?.baseline)), - change: aggregateValues(laborSupplyData.map(l => l?.hours?.change)), - income_effect: aggregateValues(laborSupplyData.map(l => l?.hours?.income_effect)), - reform: aggregateValues(laborSupplyData.map(l => l?.hours?.reform)), - substitution_effect: aggregateValues(laborSupplyData.map(l => l?.hours?.substitution_effect)), + baseline: aggregateValues(laborSupplyData.map((l) => l?.hours?.baseline)), + change: aggregateValues(laborSupplyData.map((l) => l?.hours?.change)), + income_effect: aggregateValues( + laborSupplyData.map((l) => l?.hours?.income_effect), + ), + reform: aggregateValues(laborSupplyData.map((l) => l?.hours?.reform)), + substitution_effect: aggregateValues( + laborSupplyData.map((l) => l?.hours?.substitution_effect), + ), }, - income_lsr: aggregateValues(laborSupplyData.map(l => l?.income_lsr)), - substitution_lsr: aggregateValues(laborSupplyData.map(l => l?.substitution_lsr)), - total_change: aggregateValues(laborSupplyData.map(l => l?.total_change)), - revenue_change: aggregateValues(laborSupplyData.map(l => l?.revenue_change)), + income_lsr: aggregateValues(laborSupplyData.map((l) => l?.income_lsr)), + substitution_lsr: aggregateValues( + laborSupplyData.map((l) => l?.substitution_lsr), + ), + total_change: aggregateValues(laborSupplyData.map((l) => l?.total_change)), + revenue_change: aggregateValues( + laborSupplyData.map((l) => l?.revenue_change), + ), decile: { average: { - income: aggregateDecileComparison(laborSupplyData.map(l => l?.decile?.average?.income)), - substitution: aggregateDecileComparison(laborSupplyData.map(l => l?.decile?.average?.substitution)), + income: aggregateDecileComparison( + laborSupplyData.map((l) => l?.decile?.average?.income), + ), + substitution: aggregateDecileComparison( + laborSupplyData.map((l) => l?.decile?.average?.substitution), + ), }, relative: { - income: aggregateDecileComparison(laborSupplyData.map(l => l?.decile?.relative?.income), 'mean'), - substitution: aggregateDecileComparison(laborSupplyData.map(l => l?.decile?.relative?.substitution), 'mean'), + income: aggregateDecileComparison( + laborSupplyData.map((l) => l?.decile?.relative?.income), + "mean", + ), + substitution: aggregateDecileComparison( + laborSupplyData.map((l) => l?.decile?.relative?.substitution), + "mean", + ), }, }, relative_lsr: { - income: aggregateValues(laborSupplyData.map(l => l?.relative_lsr?.income), 'mean'), - substitution: aggregateValues(laborSupplyData.map(l => l?.relative_lsr?.substitution), 'mean'), + income: aggregateValues( + laborSupplyData.map((l) => l?.relative_lsr?.income), + "mean", + ), + substitution: aggregateValues( + laborSupplyData.map((l) => l?.relative_lsr?.substitution), + "mean", + ), }, }; -} \ No newline at end of file +} From ef2d9df56961e96a74e29516708ba6e7d424d9c3 Mon Sep 17 00:00:00 2001 From: Anthony Volk Date: Thu, 10 Apr 2025 17:44:48 -0700 Subject: [PATCH 24/69] test: Tests for aggregateSocietyWideImpactsUS --- .../societyWideAggregation/aggregate.test.js | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/src/__tests__/api/societyWideAggregation/aggregate.test.js b/src/__tests__/api/societyWideAggregation/aggregate.test.js index e6d81fbf1..3078af087 100644 --- a/src/__tests__/api/societyWideAggregation/aggregate.test.js +++ b/src/__tests__/api/societyWideAggregation/aggregate.test.js @@ -115,3 +115,22 @@ describe("validateImpacts", () => { }); }); }); + +describe("aggregateSocietyWideImpactsUS", () => { + describe("Given a valid US request", () => { + test("it should return a SocietyWideImpactUS object", () => { + const impacts = testObjectsUS; + + expect( + SocietyWideImpactUS.isValidSync(aggregateSocietyWideImpactsUS(impacts)), + ).toBe(true); + }); + }); + describe("Given an invalid US request", () => { + test("it should throw an error", () => { + const impacts = []; + + expect(() => aggregateSocietyWideImpactsUS(impacts)).toThrow(TypeError); + }); + }); +}); From 0f6f72e66398d97df6184348e794f7a287b0978b Mon Sep 17 00:00:00 2001 From: Anthony Volk Date: Thu, 10 Apr 2025 17:47:32 -0700 Subject: [PATCH 25/69] test: Add tests for aggregateSocietyWideImpactsUK --- .../societyWideAggregation/aggregate.test.js | 25 ++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/src/__tests__/api/societyWideAggregation/aggregate.test.js b/src/__tests__/api/societyWideAggregation/aggregate.test.js index 3078af087..c6c5c6abb 100644 --- a/src/__tests__/api/societyWideAggregation/aggregate.test.js +++ b/src/__tests__/api/societyWideAggregation/aggregate.test.js @@ -130,7 +130,30 @@ describe("aggregateSocietyWideImpactsUS", () => { test("it should throw an error", () => { const impacts = []; - expect(() => aggregateSocietyWideImpactsUS(impacts)).toThrow(TypeError); + expect(() => aggregateSocietyWideImpactsUS(impacts)).toThrow( + "Cannot aggregate empty or undefined impacts", + ); + }); + }); +}); + +describe("aggregateSocietyWideImpactsUK", () => { + describe("Given a valid UK request", () => { + test("it should return a SocietyWideImpactUS object", () => { + const impacts = testObjectsUK; + + expect( + SocietyWideImpactUS.isValidSync(aggregateSocietyWideImpactsUK(impacts)), + ).toBe(true); + }); + }); + describe("Given an invalid UK request", () => { + test("it should throw an error", () => { + const impacts = []; + + expect(() => aggregateSocietyWideImpactsUK(impacts)).toThrow( + "Cannot aggregate empty or undefined impacts", + ); }); }); }); From 4fd88470f6f76e7b03ea75fecaf8c77d16d0fc96 Mon Sep 17 00:00:00 2001 From: Anthony Volk Date: Thu, 10 Apr 2025 17:52:21 -0700 Subject: [PATCH 26/69] chore: Test validateImpacts --- .../societyWideAggregation/aggregate.test.js | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/src/__tests__/api/societyWideAggregation/aggregate.test.js b/src/__tests__/api/societyWideAggregation/aggregate.test.js index c6c5c6abb..5864a4fd0 100644 --- a/src/__tests__/api/societyWideAggregation/aggregate.test.js +++ b/src/__tests__/api/societyWideAggregation/aggregate.test.js @@ -157,3 +157,46 @@ describe("aggregateSocietyWideImpactsUK", () => { }); }); }); + +describe("validateImpacts", () => { + describe("Given a valid US impact and US country ID", () => { + test("it should return true", () => { + const impact = testObjectsUS[0]; + const countryId = "us"; + + expect(validateImpacts(countryId, impact)).toBe(true); + }); + }); + describe("Given a valid UK impact and UK country ID", () => { + test("it should return true", () => { + const impact = testObjectsUK[0]; + const countryId = "uk"; + + expect(validateImpacts(countryId, impact)).toBe(true); + }); + }); + describe("Given a valid US impact and UK country ID", () => { + test("it should return false", () => { + const impact = testObjectsUS[0]; + const countryId = "uk"; + + expect(validateImpacts(countryId, impact)).toBe(false); + }); + }); + describe("Given an invalid country ID and US impact", () => { + test("it should return false", () => { + const impact = testObjectsUS[0]; + const countryId = "invalid_country"; + + expect(validateImpacts(countryId, impact)).toBe(false); + }); + }); + describe("Given a valid country ID and invalid impact", () => { + test("it should return false", () => { + const impact = {}; + const countryId = "uk"; + + expect(validateImpacts(countryId, impact)).toBe(false); + }); + }); +}); From 71960545cb02fe3dad7670767be823890bc18af8 Mon Sep 17 00:00:00 2001 From: Anthony Volk Date: Thu, 10 Apr 2025 18:39:00 -0700 Subject: [PATCH 27/69] chore: Test for aggregateInequalityModule --- .../api/societyWideAggregation/aggregateModules.test.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/__tests__/api/societyWideAggregation/aggregateModules.test.js b/src/__tests__/api/societyWideAggregation/aggregateModules.test.js index a8af2c2ae..6befa7fe4 100644 --- a/src/__tests__/api/societyWideAggregation/aggregateModules.test.js +++ b/src/__tests__/api/societyWideAggregation/aggregateModules.test.js @@ -36,4 +36,4 @@ describe("aggregateBudgetModule", () => { ); }); }); -}); +}); \ No newline at end of file From 7f0dea7580bc5a3127670f2df9b220824e49c564 Mon Sep 17 00:00:00 2001 From: Anthony Volk Date: Thu, 10 Apr 2025 19:41:43 -0700 Subject: [PATCH 28/69] test: Add tests for LSR module --- .../__setup__/sampleSocietyWideLSR.js | 339 ++++++++++++++++++ .../aggregateLaborSupplyModule.test.js | 41 +++ 2 files changed, 380 insertions(+) create mode 100644 src/__tests__/__setup__/sampleSocietyWideLSR.js create mode 100644 src/__tests__/api/societyWideAggregation/aggregateLaborSupplyModule.test.js diff --git a/src/__tests__/__setup__/sampleSocietyWideLSR.js b/src/__tests__/__setup__/sampleSocietyWideLSR.js new file mode 100644 index 000000000..408808236 --- /dev/null +++ b/src/__tests__/__setup__/sampleSocietyWideLSR.js @@ -0,0 +1,339 @@ +// Using integers to avoid floating point precision issues +export const emptyInput = []; + +export const validLaborSupplyModuleData = [ + { + hours: { + baseline: 1000, + change: 50, + income_effect: -20, + reform: 1050, + substitution_effect: 70, + }, + income_lsr: 10, + substitution_lsr: 30, + total_change: 50, + revenue_change: 200, + decile: { + average: { + income: { + "-1": 5, + 1: 10, + 2: 20, + 3: 30, + 4: 40, + 5: 50, + 6: 60, + 7: 70, + 8: 80, + 9: 90, + 10: 100, + }, + substitution: { + "-1": 10, + 1: 15, + 2: 25, + 3: 35, + 4: 45, + 5: 55, + 6: 65, + 7: 75, + 8: 85, + 9: 95, + 10: 105, + }, + }, + relative: { + income: { + "-1": 1, + 1: 2, + 2: 3, + 3: 4, + 4: 5, + 5: 6, + 6: 7, + 7: 8, + 8: 9, + 9: 10, + 10: 11, + }, + substitution: { + "-1": 2, + 1: 3, + 2: 4, + 3: 5, + 4: 6, + 5: 7, + 6: 8, + 7: 9, + 8: 10, + 9: 11, + 10: 12, + }, + }, + }, + relative_lsr: { + income: 5, + substitution: 15, + }, + }, + { + hours: { + baseline: 2000, + change: 100, + income_effect: -40, + reform: 2100, + substitution_effect: 140, + }, + income_lsr: 20, + substitution_lsr: 60, + total_change: 100, + revenue_change: 400, + decile: { + average: { + income: { + "-1": 15, + 1: 20, + 2: 30, + 3: 40, + 4: 50, + 5: 60, + 6: 70, + 7: 80, + 8: 90, + 9: 100, + 10: 110, + }, + substitution: { + "-1": 20, + 1: 25, + 2: 35, + 3: 45, + 4: 55, + 5: 65, + 6: 75, + 7: 85, + 8: 95, + 9: 105, + 10: 115, + }, + }, + relative: { + income: { + "-1": 3, + 1: 4, + 2: 5, + 3: 6, + 4: 7, + 5: 8, + 6: 9, + 7: 10, + 8: 11, + 9: 12, + 10: 13, + }, + substitution: { + "-1": 4, + 1: 5, + 2: 6, + 3: 7, + 4: 8, + 5: 9, + 6: 10, + 7: 11, + 8: 12, + 9: 13, + 10: 14, + }, + }, + }, + relative_lsr: { + income: 7, + substitution: 21, + }, + }, +]; + +export const expectedLaborSupplyModuleResult = { + hours: { + baseline: 3000, + change: 150, + income_effect: -60, + reform: 3150, + substitution_effect: 210, + }, + income_lsr: 30, + substitution_lsr: 90, + total_change: 150, + revenue_change: 600, + decile: { + average: { + income: { + "-1": 20, + 1: 30, + 2: 50, + 3: 70, + 4: 90, + 5: 110, + 6: 130, + 7: 150, + 8: 170, + 9: 190, + 10: 210, + }, + substitution: { + "-1": 30, + 1: 40, + 2: 60, + 3: 80, + 4: 100, + 5: 120, + 6: 140, + 7: 160, + 8: 180, + 9: 200, + 10: 220, + }, + }, + relative: { + income: { + "-1": 2, + 1: 3, + 2: 4, + 3: 5, + 4: 6, + 5: 7, + 6: 8, + 7: 9, + 8: 10, + 9: 11, + 10: 12, + }, + substitution: { + "-1": 3, + 1: 4, + 2: 5, + 3: 6, + 4: 7, + 5: 8, + 6: 9, + 7: 10, + 8: 11, + 9: 12, + 10: 13, + }, + }, + }, + relative_lsr: { + income: 6, + substitution: 18, + }, +}; + +export const expectedEmptyLaborSupplyModuleResult = { + hours: { + baseline: null, + change: null, + income_effect: null, + reform: null, + substitution_effect: null, + }, + income_lsr: null, + substitution_lsr: null, + total_change: null, + revenue_change: null, + decile: { + average: { + income: {}, + substitution: {}, + }, + relative: { + income: {}, + substitution: {}, + }, + }, + relative_lsr: { + income: null, + substitution: null, + }, +}; + +// Partial data missing some properties +export const partialLaborSupplyModuleData = [ + { + hours: { + baseline: 1000, + change: 50, + // Missing income_effect + reform: 1050, + substitution_effect: 70, + }, + income_lsr: 10, + // Missing substitution_lsr + total_change: 50, + revenue_change: 200, + decile: { + average: { + income: { + "-1": 5, + 1: 10, + 2: 20, + 3: 30, + 4: 40, + 5: 50, + 6: 60, + 7: 70, + 8: 80, + 9: 90, + 10: 100, + }, + // Missing substitution + }, + // Missing relative + }, + relative_lsr: { + income: 5, + // Missing substitution + }, + }, +]; + +export const expectedPartialLaborSupplyModuleResult = { + hours: { + baseline: 1000, + change: 50, + income_effect: null, + reform: 1050, + substitution_effect: 70, + }, + income_lsr: 10, + substitution_lsr: null, + total_change: 50, + revenue_change: 200, + decile: { + average: { + income: { + "-1": 5, + 1: 10, + 2: 20, + 3: 30, + 4: 40, + 5: 50, + 6: 60, + 7: 70, + 8: 80, + 9: 90, + 10: 100, + }, + substitution: {}, + }, + relative: { + income: {}, + substitution: {}, + }, + }, + relative_lsr: { + income: 5, + substitution: null, + }, +}; diff --git a/src/__tests__/api/societyWideAggregation/aggregateLaborSupplyModule.test.js b/src/__tests__/api/societyWideAggregation/aggregateLaborSupplyModule.test.js new file mode 100644 index 000000000..0afc4c55e --- /dev/null +++ b/src/__tests__/api/societyWideAggregation/aggregateLaborSupplyModule.test.js @@ -0,0 +1,41 @@ +import { aggregateLaborSupplyModule } from "../../../api/societyWideAggregation/aggregateLaborSupplyModule"; + +import { + emptyInput, + validLaborSupplyModuleData, + expectedLaborSupplyModuleResult, + expectedEmptyLaborSupplyModuleResult, + partialLaborSupplyModuleData, + expectedPartialLaborSupplyModuleResult, +} from "../../__setup__/sampleSocietyWideLSR"; + +describe("aggregateLaborSupplyModule", () => { + beforeEach(() => { + jest.clearAllMocks(); + console.error = jest.fn(); // Prevent console error output during tests + }); + + describe("Given valid labor supply data", () => { + test("it should return a correctly aggregated labor supply object", () => { + expect(aggregateLaborSupplyModule(validLaborSupplyModuleData)).toEqual( + expectedLaborSupplyModuleResult, + ); + }); + }); + + describe("Given no labor supply data", () => { + test("it should return an object with appropriate null and empty values", () => { + expect(aggregateLaborSupplyModule(emptyInput)).toEqual( + expectedEmptyLaborSupplyModuleResult, + ); + }); + }); + + describe("Given partial labor supply data with missing properties", () => { + test("it should handle missing properties correctly", () => { + expect(aggregateLaborSupplyModule(partialLaborSupplyModuleData)).toEqual( + expectedPartialLaborSupplyModuleResult, + ); + }); + }); +}); From e07d08d0c0a16f74c4732cccdda55b82c9024aa0 Mon Sep 17 00:00:00 2001 From: Anthony Volk Date: Tue, 15 Apr 2025 17:18:06 -0400 Subject: [PATCH 29/69] feat: Create schema for aggregated society-wide impacts --- src/schemas/aggregatedSocietyWideImpact.js | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 src/schemas/aggregatedSocietyWideImpact.js diff --git a/src/schemas/aggregatedSocietyWideImpact.js b/src/schemas/aggregatedSocietyWideImpact.js new file mode 100644 index 000000000..b6640526f --- /dev/null +++ b/src/schemas/aggregatedSocietyWideImpact.js @@ -0,0 +1,12 @@ +import * as yup from "yup"; +import { + BudgetaryImpactModule, + IntraDecileModule, + PovertyByAgeModule, +} from "./societyWideModules"; + +export const AggregatedSocietyWideImpact = yup.object({ + budget: BudgetaryImpactModule.required(), + poverty: PovertyByAgeModule.required(), + intra_decile: IntraDecileModule.required(), +}); From 07eb1b18e7551b0bec2179f3b7d1d2751cae8aa7 Mon Sep 17 00:00:00 2001 From: Anthony Volk Date: Tue, 15 Apr 2025 17:25:26 -0400 Subject: [PATCH 30/69] fix: Remove unneeded modules --- src/api/societyWideAggregation/aggregate.js | 1 + .../aggregateLaborSupplyModule.js | 55 ------------------- 2 files changed, 1 insertion(+), 55 deletions(-) delete mode 100644 src/api/societyWideAggregation/aggregateLaborSupplyModule.js diff --git a/src/api/societyWideAggregation/aggregate.js b/src/api/societyWideAggregation/aggregate.js index b33c074eb..b6f948726 100644 --- a/src/api/societyWideAggregation/aggregate.js +++ b/src/api/societyWideAggregation/aggregate.js @@ -1,3 +1,4 @@ +import { AggregatedSocietyWideImpact } from "../../schemas/aggregatedSocietyWideImpact"; import { SocietyWideImpactUK, SocietyWideImpactUS, diff --git a/src/api/societyWideAggregation/aggregateLaborSupplyModule.js b/src/api/societyWideAggregation/aggregateLaborSupplyModule.js deleted file mode 100644 index fcce2f2da..000000000 --- a/src/api/societyWideAggregation/aggregateLaborSupplyModule.js +++ /dev/null @@ -1,55 +0,0 @@ -import { aggregateDecileComparison, aggregateValues } from "./aggregateUtils"; - -export function aggregateLaborSupplyModule(laborSupplyData) { - return { - hours: { - baseline: aggregateValues(laborSupplyData.map((l) => l?.hours?.baseline)), - change: aggregateValues(laborSupplyData.map((l) => l?.hours?.change)), - income_effect: aggregateValues( - laborSupplyData.map((l) => l?.hours?.income_effect), - ), - reform: aggregateValues(laborSupplyData.map((l) => l?.hours?.reform)), - substitution_effect: aggregateValues( - laborSupplyData.map((l) => l?.hours?.substitution_effect), - ), - }, - income_lsr: aggregateValues(laborSupplyData.map((l) => l?.income_lsr)), - substitution_lsr: aggregateValues( - laborSupplyData.map((l) => l?.substitution_lsr), - ), - total_change: aggregateValues(laborSupplyData.map((l) => l?.total_change)), - revenue_change: aggregateValues( - laborSupplyData.map((l) => l?.revenue_change), - ), - decile: { - average: { - income: aggregateDecileComparison( - laborSupplyData.map((l) => l?.decile?.average?.income), - ), - substitution: aggregateDecileComparison( - laborSupplyData.map((l) => l?.decile?.average?.substitution), - ), - }, - relative: { - income: aggregateDecileComparison( - laborSupplyData.map((l) => l?.decile?.relative?.income), - "mean", - ), - substitution: aggregateDecileComparison( - laborSupplyData.map((l) => l?.decile?.relative?.substitution), - "mean", - ), - }, - }, - relative_lsr: { - income: aggregateValues( - laborSupplyData.map((l) => l?.relative_lsr?.income), - "mean", - ), - substitution: aggregateValues( - laborSupplyData.map((l) => l?.relative_lsr?.substitution), - "mean", - ), - }, - }; -} From 987c2c82f056f4453802fad7c094aea423988bf3 Mon Sep 17 00:00:00 2001 From: Anthony Volk Date: Tue, 15 Apr 2025 17:38:08 -0400 Subject: [PATCH 31/69] test: Correct aggregate test --- .../api/societyWideAggregation/aggregate.test.js | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/src/__tests__/api/societyWideAggregation/aggregate.test.js b/src/__tests__/api/societyWideAggregation/aggregate.test.js index 5864a4fd0..944a1256f 100644 --- a/src/__tests__/api/societyWideAggregation/aggregate.test.js +++ b/src/__tests__/api/societyWideAggregation/aggregate.test.js @@ -118,11 +118,13 @@ describe("validateImpacts", () => { describe("aggregateSocietyWideImpactsUS", () => { describe("Given a valid US request", () => { - test("it should return a SocietyWideImpactUS object", () => { + test("it should return an AggregatedSocietyWideImpact object", () => { const impacts = testObjectsUS; expect( - SocietyWideImpactUS.isValidSync(aggregateSocietyWideImpactsUS(impacts)), + AggregatedSocietyWideImpact.isValidSync( + aggregateSocietyWideImpactsUS(impacts), + ), ).toBe(true); }); }); @@ -139,11 +141,13 @@ describe("aggregateSocietyWideImpactsUS", () => { describe("aggregateSocietyWideImpactsUK", () => { describe("Given a valid UK request", () => { - test("it should return a SocietyWideImpactUS object", () => { + test("it should return an AggregatedSocietyWideImpacts object", () => { const impacts = testObjectsUK; expect( - SocietyWideImpactUS.isValidSync(aggregateSocietyWideImpactsUK(impacts)), + AggregatedSocietyWideImpact.isValidSync( + aggregateSocietyWideImpactsUK(impacts), + ), ).toBe(true); }); }); From 074f6750260bc6e71967f225cfe4c8d28a95fd3f Mon Sep 17 00:00:00 2001 From: Anthony Volk Date: Tue, 15 Apr 2025 17:42:51 -0400 Subject: [PATCH 32/69] test: Remove unneeded LSR test --- .../__setup__/sampleSocietyWideLSR.js | 339 ------------------ .../aggregateLaborSupplyModule.test.js | 41 --- 2 files changed, 380 deletions(-) delete mode 100644 src/__tests__/__setup__/sampleSocietyWideLSR.js delete mode 100644 src/__tests__/api/societyWideAggregation/aggregateLaborSupplyModule.test.js diff --git a/src/__tests__/__setup__/sampleSocietyWideLSR.js b/src/__tests__/__setup__/sampleSocietyWideLSR.js deleted file mode 100644 index 408808236..000000000 --- a/src/__tests__/__setup__/sampleSocietyWideLSR.js +++ /dev/null @@ -1,339 +0,0 @@ -// Using integers to avoid floating point precision issues -export const emptyInput = []; - -export const validLaborSupplyModuleData = [ - { - hours: { - baseline: 1000, - change: 50, - income_effect: -20, - reform: 1050, - substitution_effect: 70, - }, - income_lsr: 10, - substitution_lsr: 30, - total_change: 50, - revenue_change: 200, - decile: { - average: { - income: { - "-1": 5, - 1: 10, - 2: 20, - 3: 30, - 4: 40, - 5: 50, - 6: 60, - 7: 70, - 8: 80, - 9: 90, - 10: 100, - }, - substitution: { - "-1": 10, - 1: 15, - 2: 25, - 3: 35, - 4: 45, - 5: 55, - 6: 65, - 7: 75, - 8: 85, - 9: 95, - 10: 105, - }, - }, - relative: { - income: { - "-1": 1, - 1: 2, - 2: 3, - 3: 4, - 4: 5, - 5: 6, - 6: 7, - 7: 8, - 8: 9, - 9: 10, - 10: 11, - }, - substitution: { - "-1": 2, - 1: 3, - 2: 4, - 3: 5, - 4: 6, - 5: 7, - 6: 8, - 7: 9, - 8: 10, - 9: 11, - 10: 12, - }, - }, - }, - relative_lsr: { - income: 5, - substitution: 15, - }, - }, - { - hours: { - baseline: 2000, - change: 100, - income_effect: -40, - reform: 2100, - substitution_effect: 140, - }, - income_lsr: 20, - substitution_lsr: 60, - total_change: 100, - revenue_change: 400, - decile: { - average: { - income: { - "-1": 15, - 1: 20, - 2: 30, - 3: 40, - 4: 50, - 5: 60, - 6: 70, - 7: 80, - 8: 90, - 9: 100, - 10: 110, - }, - substitution: { - "-1": 20, - 1: 25, - 2: 35, - 3: 45, - 4: 55, - 5: 65, - 6: 75, - 7: 85, - 8: 95, - 9: 105, - 10: 115, - }, - }, - relative: { - income: { - "-1": 3, - 1: 4, - 2: 5, - 3: 6, - 4: 7, - 5: 8, - 6: 9, - 7: 10, - 8: 11, - 9: 12, - 10: 13, - }, - substitution: { - "-1": 4, - 1: 5, - 2: 6, - 3: 7, - 4: 8, - 5: 9, - 6: 10, - 7: 11, - 8: 12, - 9: 13, - 10: 14, - }, - }, - }, - relative_lsr: { - income: 7, - substitution: 21, - }, - }, -]; - -export const expectedLaborSupplyModuleResult = { - hours: { - baseline: 3000, - change: 150, - income_effect: -60, - reform: 3150, - substitution_effect: 210, - }, - income_lsr: 30, - substitution_lsr: 90, - total_change: 150, - revenue_change: 600, - decile: { - average: { - income: { - "-1": 20, - 1: 30, - 2: 50, - 3: 70, - 4: 90, - 5: 110, - 6: 130, - 7: 150, - 8: 170, - 9: 190, - 10: 210, - }, - substitution: { - "-1": 30, - 1: 40, - 2: 60, - 3: 80, - 4: 100, - 5: 120, - 6: 140, - 7: 160, - 8: 180, - 9: 200, - 10: 220, - }, - }, - relative: { - income: { - "-1": 2, - 1: 3, - 2: 4, - 3: 5, - 4: 6, - 5: 7, - 6: 8, - 7: 9, - 8: 10, - 9: 11, - 10: 12, - }, - substitution: { - "-1": 3, - 1: 4, - 2: 5, - 3: 6, - 4: 7, - 5: 8, - 6: 9, - 7: 10, - 8: 11, - 9: 12, - 10: 13, - }, - }, - }, - relative_lsr: { - income: 6, - substitution: 18, - }, -}; - -export const expectedEmptyLaborSupplyModuleResult = { - hours: { - baseline: null, - change: null, - income_effect: null, - reform: null, - substitution_effect: null, - }, - income_lsr: null, - substitution_lsr: null, - total_change: null, - revenue_change: null, - decile: { - average: { - income: {}, - substitution: {}, - }, - relative: { - income: {}, - substitution: {}, - }, - }, - relative_lsr: { - income: null, - substitution: null, - }, -}; - -// Partial data missing some properties -export const partialLaborSupplyModuleData = [ - { - hours: { - baseline: 1000, - change: 50, - // Missing income_effect - reform: 1050, - substitution_effect: 70, - }, - income_lsr: 10, - // Missing substitution_lsr - total_change: 50, - revenue_change: 200, - decile: { - average: { - income: { - "-1": 5, - 1: 10, - 2: 20, - 3: 30, - 4: 40, - 5: 50, - 6: 60, - 7: 70, - 8: 80, - 9: 90, - 10: 100, - }, - // Missing substitution - }, - // Missing relative - }, - relative_lsr: { - income: 5, - // Missing substitution - }, - }, -]; - -export const expectedPartialLaborSupplyModuleResult = { - hours: { - baseline: 1000, - change: 50, - income_effect: null, - reform: 1050, - substitution_effect: 70, - }, - income_lsr: 10, - substitution_lsr: null, - total_change: 50, - revenue_change: 200, - decile: { - average: { - income: { - "-1": 5, - 1: 10, - 2: 20, - 3: 30, - 4: 40, - 5: 50, - 6: 60, - 7: 70, - 8: 80, - 9: 90, - 10: 100, - }, - substitution: {}, - }, - relative: { - income: {}, - substitution: {}, - }, - }, - relative_lsr: { - income: 5, - substitution: null, - }, -}; diff --git a/src/__tests__/api/societyWideAggregation/aggregateLaborSupplyModule.test.js b/src/__tests__/api/societyWideAggregation/aggregateLaborSupplyModule.test.js deleted file mode 100644 index 0afc4c55e..000000000 --- a/src/__tests__/api/societyWideAggregation/aggregateLaborSupplyModule.test.js +++ /dev/null @@ -1,41 +0,0 @@ -import { aggregateLaborSupplyModule } from "../../../api/societyWideAggregation/aggregateLaborSupplyModule"; - -import { - emptyInput, - validLaborSupplyModuleData, - expectedLaborSupplyModuleResult, - expectedEmptyLaborSupplyModuleResult, - partialLaborSupplyModuleData, - expectedPartialLaborSupplyModuleResult, -} from "../../__setup__/sampleSocietyWideLSR"; - -describe("aggregateLaborSupplyModule", () => { - beforeEach(() => { - jest.clearAllMocks(); - console.error = jest.fn(); // Prevent console error output during tests - }); - - describe("Given valid labor supply data", () => { - test("it should return a correctly aggregated labor supply object", () => { - expect(aggregateLaborSupplyModule(validLaborSupplyModuleData)).toEqual( - expectedLaborSupplyModuleResult, - ); - }); - }); - - describe("Given no labor supply data", () => { - test("it should return an object with appropriate null and empty values", () => { - expect(aggregateLaborSupplyModule(emptyInput)).toEqual( - expectedEmptyLaborSupplyModuleResult, - ); - }); - }); - - describe("Given partial labor supply data with missing properties", () => { - test("it should handle missing properties correctly", () => { - expect(aggregateLaborSupplyModule(partialLaborSupplyModuleData)).toEqual( - expectedPartialLaborSupplyModuleResult, - ); - }); - }); -}); From 243e30e64c60493cd010ee52bbf9d360286981c6 Mon Sep 17 00:00:00 2001 From: Anthony Volk Date: Tue, 15 Apr 2025 18:17:28 -0400 Subject: [PATCH 33/69] test: Fix tests for aggregate.js --- .../societyWideAggregation/aggregate.test.js | 64 ++++++++++--------- 1 file changed, 35 insertions(+), 29 deletions(-) diff --git a/src/__tests__/api/societyWideAggregation/aggregate.test.js b/src/__tests__/api/societyWideAggregation/aggregate.test.js index 944a1256f..ed9e436d8 100644 --- a/src/__tests__/api/societyWideAggregation/aggregate.test.js +++ b/src/__tests__/api/societyWideAggregation/aggregate.test.js @@ -123,40 +123,40 @@ describe("aggregateSocietyWideImpactsUS", () => { expect( AggregatedSocietyWideImpact.isValidSync( - aggregateSocietyWideImpactsUS(impacts), + aggregateSocietyWideImpacts(countryId, impacts), ), ).toBe(true); }); }); - describe("Given an invalid US request", () => { + describe("Given no impacts", () => { test("it should throw an error", () => { const impacts = []; - - expect(() => aggregateSocietyWideImpactsUS(impacts)).toThrow( - "Cannot aggregate empty or undefined impacts", + const countryId = "us"; + const error = "Error in aggregateSocietyWideImpacts: No impacts provided"; + console.error = jest.fn(); // Prevent console error output during tests + expect(() => aggregateSocietyWideImpacts(countryId, impacts)).toThrow( + error, ); }); }); -}); - -describe("aggregateSocietyWideImpactsUK", () => { - describe("Given a valid UK request", () => { - test("it should return an AggregatedSocietyWideImpacts object", () => { - const impacts = testObjectsUK; - - expect( - AggregatedSocietyWideImpact.isValidSync( - aggregateSocietyWideImpactsUK(impacts), - ), - ).toBe(true); - }); - }); - describe("Given an invalid UK request", () => { + describe("Given an invalid country ID", () => { test("it should throw an error", () => { - const impacts = []; + const impacts = [ + { + budget: { + baseline_net_income: 1000, + benefit_spending_impact: 1000, + budgetary_impact: 1000, + households: 1000, + state_tax_revenue_impact: 1000, + tax_revenue_impact: 1000, + }, + }, + ]; + const countryId = "invalid_country"; - expect(() => aggregateSocietyWideImpactsUK(impacts)).toThrow( - "Cannot aggregate empty or undefined impacts", + expect(() => aggregateSocietyWideImpacts(countryId, impacts)).toThrow( + `Invalid country ID: ${countryId}`, ); }); }); @@ -180,27 +180,33 @@ describe("validateImpacts", () => { }); }); describe("Given a valid US impact and UK country ID", () => { - test("it should return false", () => { + test("it should throw", () => { const impact = testObjectsUS[0]; const countryId = "uk"; - expect(validateImpacts(countryId, impact)).toBe(false); + expect(() => validateImpacts(countryId, impact)).toThrow( + "is a required field", + ); }); }); describe("Given an invalid country ID and US impact", () => { - test("it should return false", () => { + test("it should throw", () => { const impact = testObjectsUS[0]; const countryId = "invalid_country"; - expect(validateImpacts(countryId, impact)).toBe(false); + expect(() => validateImpacts(countryId, impact)).toThrow( + "Invalid country ID: invalid_country", + ); }); }); describe("Given a valid country ID and invalid impact", () => { - test("it should return false", () => { + test("it should throw", () => { const impact = {}; const countryId = "uk"; - expect(validateImpacts(countryId, impact)).toBe(false); + expect(() => validateImpacts(countryId, impact)).toThrow( + "is a required field", + ); }); }); }); From bfbb6f480cdb553c0908b4f954ae0b2ea2227288 Mon Sep 17 00:00:00 2001 From: Anthony Volk Date: Wed, 16 Apr 2025 16:26:50 -0400 Subject: [PATCH 34/69] feat: Add simYears URL query param --- src/pages/policy/output/FetchAndDisplayImpact.jsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/pages/policy/output/FetchAndDisplayImpact.jsx b/src/pages/policy/output/FetchAndDisplayImpact.jsx index 14533abc6..ac2e4a676 100644 --- a/src/pages/policy/output/FetchAndDisplayImpact.jsx +++ b/src/pages/policy/output/FetchAndDisplayImpact.jsx @@ -34,6 +34,7 @@ export function FetchAndDisplayImpact(props) { const baselinePolicyId = searchParams.get("baseline"); const maxHouseholds = searchParams.get("mode") === "lite" ? 10_000 : null; const renamed = searchParams.get("renamed"); + const simYears = searchParams.get("simYears"); // Number of years to run for multi-year simulations const [impact, setImpact] = useState(null); const [error, setError] = useState(null); From 0444719aaede700bca1effb64a29237a6735d9c5 Mon Sep 17 00:00:00 2001 From: Anthony Volk Date: Thu, 17 Apr 2025 18:33:28 -0400 Subject: [PATCH 35/69] fix: Make fixes to underlying functions, partially implement logic --- ...js => makeSequentialSimulationRequests.js} | 59 +++++----- .../policy/output/FetchAndDisplayImpact.jsx | 111 ++++++++++++++++++ 2 files changed, 143 insertions(+), 27 deletions(-) rename src/api/{makeSequentialRequests.js => makeSequentialSimulationRequests.js} (60%) diff --git a/src/api/makeSequentialRequests.js b/src/api/makeSequentialSimulationRequests.js similarity index 60% rename from src/api/makeSequentialRequests.js rename to src/api/makeSequentialSimulationRequests.js index 8861cf59c..b4d1da9b2 100644 --- a/src/api/makeSequentialRequests.js +++ b/src/api/makeSequentialSimulationRequests.js @@ -1,20 +1,22 @@ -import { countryApiCall } from "./call.js"; +import { + SocietyWideImpactUK, + SocietyWideImpactUS, +} from "../schemas/societyWideImpact.js"; +import { asyncApiCall } from "./call.js"; import * as yup from "yup"; -export const RequestSetup = yup.object({ - countryId: yup.string().required(), +export const SimulationRequestSetup = yup.object({ path: yup.string().required(), - body: yup.object().notRequired(), - method: yup.string().notRequired(), - secondAttempt: yup.boolean().notRequired(), - fetchMethod: yup.mixed().notRequired(), // Note: yup has no function validator + body: yup.object().notRequired().default(null), + interval: yup.number().default(1000), + firstInterval: yup.number().default(200), }); -export const SequentialResult = yup.object({ +export const SequentialSimulationResult = yup.object({ status: yup.string().required(), requestIndex: yup.number().required(), - requestSetup: RequestSetup.required(), - response: yup.object().notRequired(), + simulationRequestSetup: SimulationRequestSetup.required(), + result: yup.mixed().oneOf([SocietyWideImpactUK, SocietyWideImpactUS]), error: yup .object({ message: yup.string().notRequired(), @@ -26,8 +28,8 @@ export const SequentialResult = yup.object({ }); /** - * Make sequential API requests, waiting for each to complete before starting the next - * @param {Array} requests - Array of RequestSetup objects; keys and values correspond with apiCall args + * Make sequential requests to the simulation worker and API, waiting for each to complete before starting the next + * @param {Array} requests - Array of SimulationRequestSetup objects; keys and values correspond with apiCall args * @param {Function} [onComplete = null] - Optional callback for when an individual request completes * @returns {Promise} - Promise resolving to a formatted object containing the results of each request * and a summary of the request process @@ -35,7 +37,10 @@ export const SequentialResult = yup.object({ * - results {Array}: Array of SequentialResult instances */ -export async function makeSequentialRequests(requests, onComplete = null) { +export async function makeSequentialSimulationRequests( + requests, + onComplete = null, +) { const results = []; let successCount = 0; let errorCount = 0; @@ -43,33 +48,33 @@ export async function makeSequentialRequests(requests, onComplete = null) { try { for (let i = 0; i < requests.length; i++) { const requestSetup = requests[i]; + console.log("requestSetup at beginning"); + console.log(requestSetup); try { // Make the request and wait for it to complete - const response = await countryApiCall( - requestSetup.countryId, + const response = await asyncApiCall( requestSetup.path, requestSetup.body, - requestSetup.method, - requestSetup.secondAttempt, - requestSetup.fetchMethod, + requestSetup.interval, + requestSetup.firstInterval, ); - const validResult = SequentialResult.cast({ - status: "success", - requestIndex: i, - requestSetup: requestSetup, - response: response, - }); - - results.push(validResult); + results.push( + SequentialSimulationResult.cast({ + status: "success", + requestIndex: i, + simulationRequestSetup: requestSetup, + result: response.result, + }), + ); successCount++; } catch (error) { console.error(`Request ${i + 1} failed:`, error.message); results.push( - SequentialResult.cast({ + SequentialSimulationResult.cast({ status: "error", requestIndex: i, error: { diff --git a/src/pages/policy/output/FetchAndDisplayImpact.jsx b/src/pages/policy/output/FetchAndDisplayImpact.jsx index ac2e4a676..2f2693f3f 100644 --- a/src/pages/policy/output/FetchAndDisplayImpact.jsx +++ b/src/pages/policy/output/FetchAndDisplayImpact.jsx @@ -8,6 +8,10 @@ import { updateUserPolicy } from "../../../api/userPolicies"; import useCountryId from "../../../hooks/useCountryId"; import { wrappedResponseJson } from "../../../data/wrappedJson"; import LoadingCentered from "layout/LoadingCentered"; +import { + makeSequentialSimulationRequests, + SimulationRequestSetup, +} from "../../../api/makeSequentialSimulationRequests"; /** * @@ -60,6 +64,88 @@ export function FetchAndDisplayImpact(props) { } useEffect(() => { + /** + * Setup multi-year request objects required by makeSequentialRequests + * @param {String} countryId + * @param {Number} reformPolicyId + * @param {Number} baselinePolicyId + * @param {String} region + * @param {String | Number} timePeriod + * @param {String} version + * @param {String | Number} maxHouseholds + * @param {String} dataset + * @param {String | Number} simYears + * @returns {Array} Array of RequestSetup objects + */ + function setupMultiYearRequests( + countryId, + reformPolicyId, + baselinePolicyId, + region, + timePeriod, + version, + maxHouseholds, + dataset, + simYears, + ) { + const INTERVAL = 1_000; // Chosen to match single-sim run interval lengths + let requests = []; + + const datasetInput = dataset ? `&dataset=${dataset}` : ""; + const maxHouseholdInput = maxHouseholds + ? `&max_households=${maxHouseholds}` + : ""; + + for (let i = 0; i < simYears; i++) { + const yearOfSim = Number(timePeriod) + i; + const url = + `/${countryId}/economy/${reformPolicyId}/over/` + + `${baselinePolicyId}?region=${region}&time_period=${yearOfSim}` + + `&version=${version}${maxHouseholdInput}${datasetInput}`; + + requests.push( + SimulationRequestSetup.cast({ + path: url, + interval: INTERVAL, + firstInterval: INTERVAL, + }), + ); + } + + return requests; + } + + async function runMultiYearRequests( + countryId, + reformPolicyId, + baselinePolicyId, + region, + timePeriod, + version, + maxHouseholds, + dataset, + simYears, + ) { + // Set up requests array + const requests = setupMultiYearRequests( + countryId, + reformPolicyId, + baselinePolicyId, + region, + timePeriod, + version, + maxHouseholds, + dataset, + simYears, + ); + + // Make sequential requests + const results = await makeSequentialSimulationRequests(requests); + + // TODO: Finally, aggregate outputs and return + return results; + } + if ( areObjectsSame(policy?.reform?.data, policyRef.current?.reform?.data) && areObjectsSame( @@ -83,6 +169,31 @@ export function FetchAndDisplayImpact(props) { `&version=${selectedVersion}${maxHouseholdString}${datasetString}`; setImpact(null); setError(null); + // If user requests valid multi-year value, make sequential requests + if ( + simYears && + simYears !== "null" && + simYears !== "undefined" && + simYears > 1 + ) { + runMultiYearRequests( + metadata.countryId, + reformPolicyId, + baselinePolicyId, + region, + timePeriod, + selectedVersion, + maxHouseholds, + dataset, + simYears, + ).then((data) => { + console.log(data); + }); + + // TODO: Aggregate and display data + + return; + } // start counting (but stop when the API call finishes) const interval = setInterval(() => { setSecondsElapsed((secondsElapsed) => secondsElapsed + 1); From e8e609cae7647fa1580be1233cae68d82aecada7 Mon Sep 17 00:00:00 2001 From: Anthony Volk Date: Thu, 17 Apr 2025 19:03:38 -0400 Subject: [PATCH 36/69] feat: Create aggregated output and display --- src/api/makeSequentialSimulationRequests.js | 13 +++++++++- .../policy/output/FetchAndDisplayImpact.jsx | 24 ++++++++++++------- 2 files changed, 28 insertions(+), 9 deletions(-) diff --git a/src/api/makeSequentialSimulationRequests.js b/src/api/makeSequentialSimulationRequests.js index b4d1da9b2..a3e1ec883 100644 --- a/src/api/makeSequentialSimulationRequests.js +++ b/src/api/makeSequentialSimulationRequests.js @@ -27,11 +27,22 @@ export const SequentialSimulationResult = yup.object({ .default(null), }); +export const SequentialSimulationResultCollection = yup.object({ + results: yup.array(SequentialSimulationResult).required(), + summary: yup + .object({ + total: yup.number().required(), + successes: yup.number().required(), + errors: yup.number().required(), + }) + .required(), +}); + /** * Make sequential requests to the simulation worker and API, waiting for each to complete before starting the next * @param {Array} requests - Array of SimulationRequestSetup objects; keys and values correspond with apiCall args * @param {Function} [onComplete = null] - Optional callback for when an individual request completes - * @returns {Promise} - Promise resolving to a formatted object containing the results of each request + * @returns {Promise} - Promise resolving to a formatted object containing the results of each request * and a summary of the request process * The return Object contains the following keys: * - results {Array}: Array of SequentialResult instances diff --git a/src/pages/policy/output/FetchAndDisplayImpact.jsx b/src/pages/policy/output/FetchAndDisplayImpact.jsx index 2f2693f3f..f2882044f 100644 --- a/src/pages/policy/output/FetchAndDisplayImpact.jsx +++ b/src/pages/policy/output/FetchAndDisplayImpact.jsx @@ -12,6 +12,7 @@ import { makeSequentialSimulationRequests, SimulationRequestSetup, } from "../../../api/makeSequentialSimulationRequests"; +import { aggregateSocietyWideImpacts } from "../../../api/societyWideAggregation/aggregate"; /** * @@ -140,10 +141,15 @@ export function FetchAndDisplayImpact(props) { ); // Make sequential requests - const results = await makeSequentialSimulationRequests(requests); + const collection = await makeSequentialSimulationRequests(requests); - // TODO: Finally, aggregate outputs and return - return results; + // Finally, aggregate outputs and return + const aggregatedResult = aggregateSocietyWideImpacts( + countryId, + collection.results.map((item) => item.result), + ); + + return aggregatedResult; } if ( @@ -186,11 +192,13 @@ export function FetchAndDisplayImpact(props) { maxHouseholds, dataset, simYears, - ).then((data) => { - console.log(data); - }); - - // TODO: Aggregate and display data + ) + .then((aggregatedData) => { + setImpact(aggregatedData); + }) + .catch((err) => { + setError(err); + }); return; } From 5a2774b02e004a981152680ed406f1c71e4d7bec Mon Sep 17 00:00:00 2001 From: Anthony Volk Date: Thu, 17 Apr 2025 19:04:57 -0400 Subject: [PATCH 37/69] fix: Remove unnecessary log statements --- src/api/makeSequentialSimulationRequests.js | 2 -- src/api/societyWideAggregation/aggregate.js | 2 +- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/src/api/makeSequentialSimulationRequests.js b/src/api/makeSequentialSimulationRequests.js index a3e1ec883..72b5b70bc 100644 --- a/src/api/makeSequentialSimulationRequests.js +++ b/src/api/makeSequentialSimulationRequests.js @@ -59,8 +59,6 @@ export async function makeSequentialSimulationRequests( try { for (let i = 0; i < requests.length; i++) { const requestSetup = requests[i]; - console.log("requestSetup at beginning"); - console.log(requestSetup); try { // Make the request and wait for it to complete diff --git a/src/api/societyWideAggregation/aggregate.js b/src/api/societyWideAggregation/aggregate.js index b6f948726..b53ac1fd9 100644 --- a/src/api/societyWideAggregation/aggregate.js +++ b/src/api/societyWideAggregation/aggregate.js @@ -17,7 +17,7 @@ export function aggregateMultiYearBudgets(countryId, impacts) { validateImpacts(countryId, impact); } } catch (err) { - console.log("Error validating impacts"); + console.error("Error validating impacts"); throw err; } From fa3dc7bce7476138922b51772e6ef3984656fd19 Mon Sep 17 00:00:00 2001 From: Anthony Volk Date: Thu, 17 Apr 2025 19:40:26 -0400 Subject: [PATCH 38/69] feat: Disable options in multi-year runs --- src/layout/Menu.jsx | 46 +++++++++++++++++++++++++------------- src/layout/StackedMenu.jsx | 21 ++++++++++++++++- 2 files changed, 50 insertions(+), 17 deletions(-) diff --git a/src/layout/Menu.jsx b/src/layout/Menu.jsx index b4cc817bd..abb67b50e 100644 --- a/src/layout/Menu.jsx +++ b/src/layout/Menu.jsx @@ -5,7 +5,7 @@ import { motion } from "framer-motion"; import { Tag } from "antd"; function MenuItem(props) { - const { name, label, selected, onSelect } = props; + const { name, label, selected, onSelect, disabled } = props; const betaBadge = BETA; const isBeta = label.includes(" (experimental)"); @@ -14,7 +14,7 @@ function MenuItem(props) { return ( onSelect(name)} - whileHover={{ backgroundColor: "white" }} + animate={ + !disabled && { + backgroundColor: selected === name ? "white" : "transparent", + } + } + onClick={disabled ? () => undefined : () => onSelect(name)} + whileHover={!disabled && { backgroundColor: "white" }} transition={{ duration: 0.001 }} > -

+

{selected === name && ( BETA; const isBeta = label.includes(" (experimental)"); const adjustedLabel = isBeta ? label.slice(0, -14) : label; @@ -84,6 +93,7 @@ function MenuItemGroup(props) { if (child.children) { expandedChildren.push( undefined : toggleExpanded} style={{ - color: style.colors.BLACK, + color: disabled ? style.colors.DARK_GRAY : style.colors.BLACK, paddingTop: 5, paddingBottom: 5, - cursor: "pointer", + cursor: disabled ? "not-allowed" : "pointer", paddingLeft: 10, borderRadius: 8, display: "flex", alignItems: "center", flexDirection: "row", }} - whileHover={{ backgroundColor: "white" }} + whileHover={!disabled && { backgroundColor: "white" }} >

{selected === name && ( 1; + const isOnOutput = window.location.search.includes("focus=policyOutput") || window.location.search.includes("focus=householdOutput"); + + const isOnMultiYearOutput = isOnOutput && isMultiYear; + let result; - if (isOnOutput) { + if (isOnMultiYearOutput) { + result = ( +

+ ); + } else if (isOnOutput) { result = ; } else { result = ; From 1adfdba38530536371f520bd88930fc754e8468b Mon Sep 17 00:00:00 2001 From: Anthony Volk Date: Thu, 17 Apr 2025 19:41:55 -0400 Subject: [PATCH 39/69] chore: Remove needless logs --- src/layout/Menu.jsx | 1 - src/pages/BlogPage.jsx | 2 -- src/pages/UserProfilePage.jsx | 2 -- 3 files changed, 5 deletions(-) diff --git a/src/layout/Menu.jsx b/src/layout/Menu.jsx index abb67b50e..5af933d47 100644 --- a/src/layout/Menu.jsx +++ b/src/layout/Menu.jsx @@ -173,7 +173,6 @@ export default function Menu(props) { let menuItems = []; for (const item of tree) { - console.log(item); if (item.children) { menuItems.push( @@ -213,7 +212,6 @@ function NotebookOutputMarkdown({ data }) { } function NotebookOutputPlotly({ data }) { - console.log(data); const title = data.layout?.title?.text; const displayCategory = useDisplayCategory(); diff --git a/src/pages/UserProfilePage.jsx b/src/pages/UserProfilePage.jsx index 0fb3398a7..2e132c82d 100644 --- a/src/pages/UserProfilePage.jsx +++ b/src/pages/UserProfilePage.jsx @@ -444,8 +444,6 @@ function UserProfileSection(props) { function PolicySimulationCard(props) { const { metadata, userPolicy, keyValue } = props; - console.log(userPolicy); - const CURRENT_API_VERSION = metadata?.version; const geography = metadata.economy_options.region.filter( From 40e6e53fb4b2dee31e6546e773cbb27aba134a64 Mon Sep 17 00:00:00 2001 From: Anthony Volk Date: Fri, 18 Apr 2025 18:01:28 -0400 Subject: [PATCH 40/69] feat: Correct display of data values on multi-year outputs --- src/pages/policy/output/PolicyBreakdown.jsx | 22 ++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/src/pages/policy/output/PolicyBreakdown.jsx b/src/pages/policy/output/PolicyBreakdown.jsx index 96322c22e..96b0bbbe6 100644 --- a/src/pages/policy/output/PolicyBreakdown.jsx +++ b/src/pages/policy/output/PolicyBreakdown.jsx @@ -6,6 +6,13 @@ export default function PolicyBreakdown(props) { const { policyLabel, metadata, impact, timePeriod, region } = props; const [searchParams, setSearchParams] = useSearchParams(); + const simYears = searchParams.get("simYears"); + + let displayPeriod = null; + if (simYears && simYears > 1) { + displayPeriod = formatDisplayPeriod(timePeriod, simYears); + } + const regionObj = metadata.economy_options.region.find( (elem) => elem.name === region, ); @@ -24,7 +31,7 @@ export default function PolicyBreakdown(props) { regionLabel = "undefined region"; } - const title = `${policyLabel} in ${regionLabel}, ${timePeriod}`; + const title = `${policyLabel} in ${regionLabel}, ${displayPeriod}`; const bottomText = "Click on an option on the left panel to view more details."; @@ -493,3 +500,16 @@ function formatPowers(value) { } return [Number(displayValue), label]; } + +/** + * For multi-year simulations, format display period to show start year hyphen end year + * (e.g. 2020-2025) + * @param {Number | String} startYear + * @param {Number | String} simYears + * @returns {String} + */ +export function formatDisplayPeriod(startYear, simYears) { + // simYears is 1-indexed, so we need to subtract 1 + const endYear = Number(startYear) + Number(simYears) - 1; + return `${startYear}-${endYear}`; +} From 9f6ada2615f463aa0028a012f79b6d499c12f553 Mon Sep 17 00:00:00 2001 From: Anthony Volk Date: Fri, 18 Apr 2025 18:38:18 -0400 Subject: [PATCH 41/69] feat: Partially implemented multi-year selector --- src/pages/policy/PolicyRightSidebar.jsx | 3 +- src/pages/policy/output/PolicyBreakdown.jsx | 2 +- .../policy/rightSidebar/MultiYearSelector.jsx | 92 +++++++++++++++++++ 3 files changed, 95 insertions(+), 2 deletions(-) create mode 100644 src/pages/policy/rightSidebar/MultiYearSelector.jsx diff --git a/src/pages/policy/PolicyRightSidebar.jsx b/src/pages/policy/PolicyRightSidebar.jsx index 52f0cf99b..87ba1d401 100644 --- a/src/pages/policy/PolicyRightSidebar.jsx +++ b/src/pages/policy/PolicyRightSidebar.jsx @@ -26,7 +26,7 @@ import { defaultForeverYear, defaultStartDate } from "../../data/constants"; import Collapsible from "../../layout/Collapsible"; import { formatFullDate } from "../../lang/format"; import useCountryId from "../../hooks/useCountryId"; - +import MultiYearSelector from "./rightSidebar/MultiYearSelector"; function RegionSelector(props) { const { metadata } = props; const [searchParams, setSearchParams] = useSearchParams(); @@ -1140,6 +1140,7 @@ export default function PolicyRightSidebar(props) { timePeriod={timePeriod} /> )} + {metadata.countryId === "uk" && ( diff --git a/src/pages/policy/output/PolicyBreakdown.jsx b/src/pages/policy/output/PolicyBreakdown.jsx index 96b0bbbe6..439f5e670 100644 --- a/src/pages/policy/output/PolicyBreakdown.jsx +++ b/src/pages/policy/output/PolicyBreakdown.jsx @@ -8,7 +8,7 @@ export default function PolicyBreakdown(props) { const simYears = searchParams.get("simYears"); - let displayPeriod = null; + let displayPeriod = timePeriod; if (simYears && simYears > 1) { displayPeriod = formatDisplayPeriod(timePeriod, simYears); } diff --git a/src/pages/policy/rightSidebar/MultiYearSelector.jsx b/src/pages/policy/rightSidebar/MultiYearSelector.jsx new file mode 100644 index 000000000..66afd4f0a --- /dev/null +++ b/src/pages/policy/rightSidebar/MultiYearSelector.jsx @@ -0,0 +1,92 @@ +import { useSearchParams } from "react-router-dom"; +import { useState } from "react"; +import { Dropdown, Switch } from "antd"; +import { useDisplayCategory } from "../../../layout/Responsive"; + +const DEFAULT_SIM_LENGTH = { + us: 10, + uk: 5, + default: 5, +}; + +export default function MultiYearSelector(props) { + const { metadata, startYear } = props; + + const countryId = metadata.countryId; + + const defaultSimLength = calculateDefaultSimLength(metadata, startYear); + + const [searchParams, setSearchParams] = useSearchParams(); + const [simYears, setSimYears] = useState( + searchParams.get("simYears") || defaultSimLength, + ); + const [isMultiYearActive, setIsMultiYearActive] = useState( + !!searchParams.get("simYears"), + ); + const dC = useDisplayCategory(); + + function handleSwitchChange(checked) { + setIsMultiYearActive(checked); + if (checked) { + setSearchParams((prev) => { + const newSearch = new URLSearchParams(prev); + newSearch.set("simYears", simYears); + return newSearch; + }); + } else { + setSearchParams((prev) => { + const newSearch = new URLSearchParams(prev); + newSearch.delete("simYears"); + return newSearch; + }); + } + } + + return ( +
+ +

+ Extend impacts over  +

+ +
+ ); +} + +export function findLastSimYearFromMetadata(metadata) { + const allYearsSorted = metadata.economy_options.time_period.sort( + (a, b) => a.name - b.name, + ); + const lastSimYear = allYearsSorted[allYearsSorted.length - 1].name; + return lastSimYear; +} + +export function calculateDefaultSimLength(metadata, startYear) { + const lastSimYear = findLastSimYearFromMetadata(metadata); + const maxSimLength = lastSimYear - startYear + 1; + + const defaultSimLength = + DEFAULT_SIM_LENGTH[metadata.countryId] || DEFAULT_SIM_LENGTH["default"]; + return Math.min(maxSimLength, defaultSimLength); +} From 8abc0c571028f69c9497baa6507c1fcc5833bc75 Mon Sep 17 00:00:00 2001 From: Anthony Volk Date: Fri, 18 Apr 2025 20:18:05 -0400 Subject: [PATCH 42/69] feat: Add component to enable multi-year outputs --- src/pages/policy/PolicyRightSidebar.jsx | 2 +- .../policy/rightSidebar/MultiYearSelector.jsx | 81 ++++++++++++++++--- 2 files changed, 70 insertions(+), 13 deletions(-) diff --git a/src/pages/policy/PolicyRightSidebar.jsx b/src/pages/policy/PolicyRightSidebar.jsx index 87ba1d401..2f5c40a2b 100644 --- a/src/pages/policy/PolicyRightSidebar.jsx +++ b/src/pages/policy/PolicyRightSidebar.jsx @@ -1140,7 +1140,7 @@ export default function PolicyRightSidebar(props) { timePeriod={timePeriod} /> )} - + {metadata.countryId === "uk" && ( diff --git a/src/pages/policy/rightSidebar/MultiYearSelector.jsx b/src/pages/policy/rightSidebar/MultiYearSelector.jsx index 66afd4f0a..adfb0183c 100644 --- a/src/pages/policy/rightSidebar/MultiYearSelector.jsx +++ b/src/pages/policy/rightSidebar/MultiYearSelector.jsx @@ -1,6 +1,6 @@ import { useSearchParams } from "react-router-dom"; -import { useState } from "react"; -import { Dropdown, Switch } from "antd"; +import { useEffect, useState } from "react"; +import { Select, Switch } from "antd"; import { useDisplayCategory } from "../../../layout/Responsive"; const DEFAULT_SIM_LENGTH = { @@ -12,25 +12,42 @@ const DEFAULT_SIM_LENGTH = { export default function MultiYearSelector(props) { const { metadata, startYear } = props; - const countryId = metadata.countryId; - const defaultSimLength = calculateDefaultSimLength(metadata, startYear); const [searchParams, setSearchParams] = useSearchParams(); - const [simYears, setSimYears] = useState( - searchParams.get("simYears") || defaultSimLength, + const inboundSimYears = searchParams.get("simYears"); + const isInboundSimYearsValid = validateSimYears( + inboundSimYears, + metadata, + startYear, + ); + const [simLength, setSimLength] = useState( + isInboundSimYearsValid ? inboundSimYears : defaultSimLength, ); const [isMultiYearActive, setIsMultiYearActive] = useState( !!searchParams.get("simYears"), ); const dC = useDisplayCategory(); + const simYearsMenuItems = generateSimYearsMenuItems(metadata, startYear); + + useEffect(() => { + setSimLength(isInboundSimYearsValid ? inboundSimYears : defaultSimLength); + setIsMultiYearActive(!!searchParams.get("simYears")); + }, [ + startYear, + searchParams, + isInboundSimYearsValid, + inboundSimYears, + defaultSimLength, + ]); + function handleSwitchChange(checked) { setIsMultiYearActive(checked); if (checked) { setSearchParams((prev) => { const newSearch = new URLSearchParams(prev); - newSearch.set("simYears", simYears); + newSearch.set("simYears", inboundSimYears); return newSearch; }); } else { @@ -42,6 +59,17 @@ export default function MultiYearSelector(props) { } } + function handleSimYearsChange(value) { + setSimLength(value); + if (isMultiYearActive) { + setSearchParams((prev) => { + const newSearch = new URLSearchParams(prev); + newSearch.set("simYears", value); + return newSearch; + }); + } + } + return (
- Extend impacts over  + Extend impacts over

- +

+ years +

); } +export function generateSimYearsMenuItems(metadata, startYear) { + const lastSimYear = findLastSimYearFromMetadata(metadata); + const maxSimLength = lastSimYear - startYear + 1; + + const simLengths = []; + for (let simLength = 1; simLength <= maxSimLength; simLength++) { + simLengths.push({ label: simLength, value: simLength }); + } + + return simLengths; +} + export function findLastSimYearFromMetadata(metadata) { const allYearsSorted = metadata.economy_options.time_period.sort( (a, b) => a.name - b.name, @@ -90,3 +139,11 @@ export function calculateDefaultSimLength(metadata, startYear) { DEFAULT_SIM_LENGTH[metadata.countryId] || DEFAULT_SIM_LENGTH["default"]; return Math.min(maxSimLength, defaultSimLength); } + +export function validateSimYears(simYearParam, metadata, startYear) { + if (simYearParam === null || simYearParam === undefined) { + return false; + } + const maxSimLength = findLastSimYearFromMetadata(metadata) - startYear + 1; + return simYearParam >= 1 && simYearParam <= maxSimLength; +} From 704b5d4dbf0339926d11b85feb5bf0391ef01e01 Mon Sep 17 00:00:00 2001 From: Anthony Volk Date: Fri, 18 Apr 2025 20:29:30 -0400 Subject: [PATCH 43/69] fix: Fixes to logic --- src/pages/policy/output/FetchAndDisplayImpact.jsx | 1 + src/pages/policy/rightSidebar/MultiYearSelector.jsx | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/pages/policy/output/FetchAndDisplayImpact.jsx b/src/pages/policy/output/FetchAndDisplayImpact.jsx index f2882044f..a2f4499de 100644 --- a/src/pages/policy/output/FetchAndDisplayImpact.jsx +++ b/src/pages/policy/output/FetchAndDisplayImpact.jsx @@ -283,6 +283,7 @@ export function FetchAndDisplayImpact(props) { reformPolicyId, baselinePolicyId, maxHouseholds, + simYears, ]); useEffect(() => { diff --git a/src/pages/policy/rightSidebar/MultiYearSelector.jsx b/src/pages/policy/rightSidebar/MultiYearSelector.jsx index adfb0183c..7d2d59573 100644 --- a/src/pages/policy/rightSidebar/MultiYearSelector.jsx +++ b/src/pages/policy/rightSidebar/MultiYearSelector.jsx @@ -47,7 +47,7 @@ export default function MultiYearSelector(props) { if (checked) { setSearchParams((prev) => { const newSearch = new URLSearchParams(prev); - newSearch.set("simYears", inboundSimYears); + newSearch.set("simYears", simLength); return newSearch; }); } else { From 5200392ad7dd7184ca057ca952ede5aef63c3b46 Mon Sep 17 00:00:00 2001 From: Anthony Volk Date: Mon, 21 Apr 2025 17:58:24 -0400 Subject: [PATCH 44/69] fix: Alter how disabling of policy output tree items is done --- src/layout/Menu.jsx | 6 +-- src/layout/StackedMenu.jsx | 20 +-------- src/pages/policy/output/tree.js | 80 +++++++++++++++++++++++++++------ 3 files changed, 71 insertions(+), 35 deletions(-) diff --git a/src/layout/Menu.jsx b/src/layout/Menu.jsx index 5af933d47..c283a1eed 100644 --- a/src/layout/Menu.jsx +++ b/src/layout/Menu.jsx @@ -169,14 +169,14 @@ function MenuItemGroup(props) { } export default function Menu(props) { - const { tree, selected, onSelect, isMultiYear } = props; + const { tree, selected, onSelect } = props; let menuItems = []; for (const item of tree) { if (item.children) { menuItems.push( 1; - const isOnOutput = window.location.search.includes("focus=policyOutput") || window.location.search.includes("focus=householdOutput"); - const isOnMultiYearOutput = isOnOutput && isMultiYear; - let result; - if (isOnMultiYearOutput) { - result = ( - - ); - } else if (isOnOutput) { + if (isOnOutput) { result = ; } else { result = ; diff --git a/src/pages/policy/output/tree.js b/src/pages/policy/output/tree.js index 71803106f..eb990a4ac 100644 --- a/src/pages/policy/output/tree.js +++ b/src/pages/policy/output/tree.js @@ -51,6 +51,13 @@ export function getPolicyOutputTree(countryId, searchParams = {}) { ? searchParams.get("uk_local_areas_beta") === "true" : false; + const isMultiYear = + searchParams && typeof searchParams.get === "function" + ? searchParams.get("simYears") && + !Number.isNaN(searchParams.get("simYears")) && + searchParams.get("simYears") > 1 + : false; + const tree = [ { name: "policyOutput", @@ -59,50 +66,63 @@ export function getPolicyOutputTree(countryId, searchParams = {}) { { name: "policyOutput.policyBreakdown", label: "Overview", + disabled: isMultiYear, }, - { - name: "policyOutput.budgetaryImpact", - label: "Budgetary impact", - children: [ - { - name: "policyOutput.budgetaryImpact.overall", - label: "Overall", - }, - countryId === "uk" && { - name: "policyOutput.budgetaryImpact.byProgram", - label: "By program", + isMultiYear + ? { + name: "policyOutput.budgetaryImpact", + label: "Budgetary impact", + } + : { + name: "policyOutput.budgetaryImpact", + label: "Budgetary impact", + children: [ + { + name: "policyOutput.budgetaryImpact.overall", + label: "Overall", + }, + countryId === "uk" && { + name: "policyOutput.budgetaryImpact.byProgram", + label: "By program", + }, + ].filter((x) => x), }, - ].filter((x) => x), - }, { name: "policyOutput.distributionalImpact", label: "Distributional impact", + disabled: isMultiYear, children: [ { name: "policyOutput.distributionalImpact.incomeDecile", label: "By income decile", + disabled: isMultiYear, children: [ { name: "policyOutput.distributionalImpact.incomeDecile.relative", label: "Relative", + disabled: isMultiYear, }, { name: "policyOutput.distributionalImpact.incomeDecile.average", label: "Average", + disabled: isMultiYear, }, ], }, countryId === "uk" && { name: "policyOutput.distributionalImpact.wealthDecile", label: "By wealth decile", + disabled: isMultiYear, children: [ { name: "policyOutput.distributionalImpact.wealthDecile.relative", label: "Relative", + disabled: isMultiYear, }, { name: "policyOutput.distributionalImpact.wealthDecile.average", label: "Average", + disabled: isMultiYear, }, ], }, @@ -111,14 +131,17 @@ export function getPolicyOutputTree(countryId, searchParams = {}) { { name: "policyOutput.winnersAndLosers", label: "Winners and losers", + disabled: isMultiYear, children: [ { name: "policyOutput.winnersAndLosers.incomeDecile", label: "By income decile", + disabled: isMultiYear, }, countryId === "uk" && { name: "policyOutput.winnersAndLosers.wealthDecile", label: "By wealth decile", + disabled: isMultiYear, }, uk_local_areas_beta && { name: "policyOutput.winnersAndLosers.constituencies", @@ -129,36 +152,44 @@ export function getPolicyOutputTree(countryId, searchParams = {}) { { name: "policyOutput.povertyImpact", label: "Poverty impact", + disabled: isMultiYear, children: [ { name: "policyOutput.povertyImpact.regular", label: "Regular poverty", + disabled: isMultiYear, children: [ { name: "policyOutput.povertyImpact.regular.byAge", label: "By age", + disabled: isMultiYear, }, countryId === "us" && { name: "policyOutput.povertyImpact.regular.byRace", label: "By race", + disabled: isMultiYear, }, { name: "policyOutput.povertyImpact.regular.byGender", label: "By gender", + disabled: isMultiYear, }, ].filter((x) => x), }, { name: "policyOutput.povertyImpact.deep", label: "Deep poverty", + disabled: isMultiYear, children: [ { name: "policyOutput.povertyImpact.deep.byAge", label: "By age", + disabled: isMultiYear, }, { name: "policyOutput.povertyImpact.deep.byGender", label: "By gender", + disabled: isMultiYear, }, ], }, @@ -167,22 +198,27 @@ export function getPolicyOutputTree(countryId, searchParams = {}) { { name: "policyOutput.inequalityImpact", label: "Inequality impact", + disabled: isMultiYear, }, { name: "policyOutput.cliffImpact", label: "Cliff impact", + disabled: isMultiYear, }, uk_local_areas_beta && { name: "policyOutput.constituencies", label: "Parliamentary constituencies (experimental)", + disabled: isMultiYear, children: [ { name: "policyOutput.constituencies.relative", label: "Relative change", + disabled: isMultiYear, }, { name: "policyOutput.constituencies.average", label: "Average change", + disabled: isMultiYear, }, ], }, @@ -192,70 +228,86 @@ export function getPolicyOutputTree(countryId, searchParams = {}) { countryId === "uk" ? "Labour supply impact (experimental)" : "Labor supply impact (experimental)", + disabled: isMultiYear, children: [ uk_local_areas_beta && { name: "policyOutput.constituencies.laborSupplyFTEs", label: "By parliamentary constituency", + disabled: isMultiYear, }, countryId === "us" && { name: "policyOutput.laborSupplyImpact.hours", label: "Hours worked", + disabled: isMultiYear, }, { name: "policyOutput.laborSupplyImpact.earnings", label: "Earnings", + disabled: isMultiYear, children: [ { name: "policyOutput.laborSupplyImpact.earnings.overall", label: "Overall", + disabled: isMultiYear, children: [ { name: "policyOutput.laborSupplyImpact.earnings.overall.relative", label: "Relative", + disabled: isMultiYear, }, { name: "policyOutput.laborSupplyImpact.earnings.overall.absolute", label: "Absolute", + disabled: isMultiYear, }, ], }, { name: "policyOutput.laborSupplyImpact.earnings.byDecile", label: "By decile", + disabled: isMultiYear, children: [ { name: "policyOutput.laborSupplyImpact.earnings.byDecile.relative", label: "Relative", + disabled: isMultiYear, children: [ { name: "policyOutput.laborSupplyImpact.earnings.byDecile.relative.total", label: "Total", + disabled: isMultiYear, }, { name: "policyOutput.laborSupplyImpact.earnings.byDecile.relative.income", label: "Income effect", + disabled: isMultiYear, }, { name: "policyOutput.laborSupplyImpact.earnings.byDecile.relative.substitution", label: "Substitution effect", + disabled: isMultiYear, }, ], }, { name: "policyOutput.laborSupplyImpact.earnings.byDecile.absolute", label: "Absolute", + disabled: isMultiYear, children: [ { name: "policyOutput.laborSupplyImpact.earnings.byDecile.absolute.total", label: "Total", + disabled: isMultiYear, }, { name: "policyOutput.laborSupplyImpact.earnings.byDecile.absolute.income", label: "Income effect", + disabled: isMultiYear, }, { name: "policyOutput.laborSupplyImpact.earnings.byDecile.absolute.substitution", label: "Substitution effect", + disabled: isMultiYear, }, ], }, @@ -268,10 +320,12 @@ export function getPolicyOutputTree(countryId, searchParams = {}) { { name: "policyOutput.analysis", label: "AI summary (experimental)", + disabled: isMultiYear, }, { name: "policyOutput.codeReproducibility", label: "Reproduce in Python", + disabled: isMultiYear, }, ].filter((x) => x), }, From e34e75420e62992a898c894ff6c166e63df8a34b Mon Sep 17 00:00:00 2001 From: Anthony Volk Date: Mon, 21 Apr 2025 18:52:20 -0400 Subject: [PATCH 45/69] fix: Use tree.js to determine what pages to display, then create template for multi-year budgetary impacts --- src/pages/PolicyPage.jsx | 3 +++ src/pages/policy/output/Display.jsx | 3 +++ .../output/budget/MultiYearBudgetaryImpact.jsx | 7 +++++++ src/pages/policy/output/tree.js | 10 ++++------ src/pages/policy/output/utils.js | 14 ++++++++++++++ 5 files changed, 31 insertions(+), 6 deletions(-) create mode 100644 src/pages/policy/output/budget/MultiYearBudgetaryImpact.jsx diff --git a/src/pages/PolicyPage.jsx b/src/pages/PolicyPage.jsx index f024e71b5..9a81d0514 100644 --- a/src/pages/PolicyPage.jsx +++ b/src/pages/PolicyPage.jsx @@ -20,6 +20,7 @@ import SearchParamNavButton from "../controls/SearchParamNavButton"; import style from "../style"; import DeprecationModal from "../modals/DeprecationModal"; import { impactKeys } from "../pages/policy/output/ImpactTypes.jsx"; +import { determineIfMultiYear } from "./policy/output/utils.js"; export function ParameterSearch(props) { const { metadata, callback } = props; @@ -110,6 +111,7 @@ export default function PolicyPage(props) { const [searchParams, setSearchParams] = useSearchParams(); const focus = searchParams.get("focus") || ""; + const isMultiYear = determineIfMultiYear(searchParams); const isOutput = focus.includes("policyOutput"); // Evaluate if policy is deprecated or not @@ -188,6 +190,7 @@ export default function PolicyPage(props) { // Check if the current focus is within validFocusValues if ( focus === "policyOutput.policyBreakdown" || + (isMultiYear && focus === "policyOutput.budgetaryImpact") || focus === "policyOutput.codeReproducibility" || validFocusValues.includes(stripped_focus) ) { diff --git a/src/pages/policy/output/Display.jsx b/src/pages/policy/output/Display.jsx index 20c03666a..8b8a7ecc2 100644 --- a/src/pages/policy/output/Display.jsx +++ b/src/pages/policy/output/Display.jsx @@ -19,6 +19,7 @@ import { Helmet } from "react-helmet"; import useCountryId from "../../../hooks/useCountryId"; import BottomImpactDescription from "../../../layout/BottomImpactDescription"; import { Link } from "react-router-dom"; +import MultiYearBudgetaryImpact from "./budget/MultiYearBudgetaryImpact"; /** * @@ -175,6 +176,8 @@ export function DisplayImpact(props) { policyLabel={policyLabel} /> ); + } else if (impactType === "budgetaryImpact") { + pane = ; } else if (impactType === "policyBreakdown") { pane = ( +

Empty template

+ + ); +} diff --git a/src/pages/policy/output/tree.js b/src/pages/policy/output/tree.js index eb990a4ac..1bce140d3 100644 --- a/src/pages/policy/output/tree.js +++ b/src/pages/policy/output/tree.js @@ -1,6 +1,9 @@ +import { determineIfMultiYear } from "./utils"; + export const policyOutputs = { policyBreakdown: "Overview", netIncome: "Budgetary impact", + budgetaryImpact: "Budgetary impact", detailedBudgetaryImpact: "Budgetary impact by program", decileAverageImpact: "Absolute impact by income decile", wealthDecileAverageImpact: "Absolute impact by wealth decile", @@ -51,12 +54,7 @@ export function getPolicyOutputTree(countryId, searchParams = {}) { ? searchParams.get("uk_local_areas_beta") === "true" : false; - const isMultiYear = - searchParams && typeof searchParams.get === "function" - ? searchParams.get("simYears") && - !Number.isNaN(searchParams.get("simYears")) && - searchParams.get("simYears") > 1 - : false; + const isMultiYear = determineIfMultiYear(searchParams); const tree = [ { diff --git a/src/pages/policy/output/utils.js b/src/pages/policy/output/utils.js index a093bc898..66afd1230 100644 --- a/src/pages/policy/output/utils.js +++ b/src/pages/policy/output/utils.js @@ -57,10 +57,24 @@ function copyLink() { message.info("Link copied to clipboard"); } +/** + * Determines if a given policy output is multi-year based on search params + * @param {Object} searchParams - The search params object + * @returns {boolean} - True if the policy output is multi-year, false otherwise + */ +function determineIfMultiYear(searchParams) { + return ( + searchParams.get("simYears") && + !Number.isNaN(searchParams.get("simYears")) && + searchParams.get("simYears") > 1 + ); +} + export { avgChangeDirection, plotLayoutFont, downloadPng, downloadCsv, copyLink, + determineIfMultiYear, }; From 78494a8892ba4d014b80898e2b46eb8ae298b034 Mon Sep 17 00:00:00 2001 From: Anthony Volk Date: Mon, 21 Apr 2025 21:07:01 -0400 Subject: [PATCH 46/69] feat: Beginnings of multi-year budgetary impact component --- src/api/makeSequentialSimulationRequests.js | 1 + src/pages/policy/output/Display.jsx | 16 +++- .../policy/output/FetchAndDisplayImpact.jsx | 38 +++++++-- .../budget/MultiYearBudgetaryImpact.jsx | 82 ++++++++++++++++++- 4 files changed, 122 insertions(+), 15 deletions(-) diff --git a/src/api/makeSequentialSimulationRequests.js b/src/api/makeSequentialSimulationRequests.js index 72b5b70bc..ea6934711 100644 --- a/src/api/makeSequentialSimulationRequests.js +++ b/src/api/makeSequentialSimulationRequests.js @@ -6,6 +6,7 @@ import { asyncApiCall } from "./call.js"; import * as yup from "yup"; export const SimulationRequestSetup = yup.object({ + year: yup.number().required(), path: yup.string().required(), body: yup.object().notRequired().default(null), interval: yup.number().default(1000), diff --git a/src/pages/policy/output/Display.jsx b/src/pages/policy/output/Display.jsx index 8b8a7ecc2..c39cc4b83 100644 --- a/src/pages/policy/output/Display.jsx +++ b/src/pages/policy/output/Display.jsx @@ -12,7 +12,7 @@ import PolicyReproducibility from "./PolicyReproducibility"; import useMobile from "layout/Responsive"; import ErrorPage from "layout/ErrorPage"; import ResultActions from "layout/ResultActions"; -import { downloadCsv } from "./utils"; +import { determineIfMultiYear, downloadCsv } from "./utils"; import { useReactToPrint } from "react-to-print"; import PolicyBreakdown from "./PolicyBreakdown"; import { Helmet } from "react-helmet"; @@ -153,7 +153,8 @@ function getPolicyLabel(policy) { * performing actions such as downloading data and sharing results */ export function DisplayImpact(props) { - const { impact, policy, metadata, showPolicyImpactPopup } = props; + const { impact, singleYearResults, policy, metadata, showPolicyImpactPopup } = + props; const countryId = useCountryId(); const urlParams = new URLSearchParams(window.location.search); const focus = urlParams.get("focus"); @@ -165,6 +166,8 @@ export function DisplayImpact(props) { const filename = impactType + `${policyLabel}`; let pane, downloadCsvFn; + const isMultiYear = determineIfMultiYear(urlParams); + if (impactType === "analysis") { pane = ( ); - } else if (impactType === "budgetaryImpact") { - pane = ; + } else if (isMultiYear && impactType === "budgetaryImpact") { + pane = ( + + ); } else if (impactType === "policyBreakdown") { pane = ( , SocietyWideImpact>} Object containing singleYearResults and aggregatedResult + */ async function runMultiYearRequests( countryId, reformPolicyId, @@ -143,13 +160,19 @@ export function FetchAndDisplayImpact(props) { // Make sequential requests const collection = await makeSequentialSimulationRequests(requests); + const singleYearResults = collection.results.map((item) => item); + const singleYearImpacts = singleYearResults.map((item) => item.result); + // Finally, aggregate outputs and return const aggregatedResult = aggregateSocietyWideImpacts( countryId, - collection.results.map((item) => item.result), + singleYearImpacts, ); - return aggregatedResult; + return { + singleYearResults: singleYearResults, + aggregatedResult: aggregatedResult, + }; } if ( @@ -176,12 +199,7 @@ export function FetchAndDisplayImpact(props) { setImpact(null); setError(null); // If user requests valid multi-year value, make sequential requests - if ( - simYears && - simYears !== "null" && - simYears !== "undefined" && - simYears > 1 - ) { + if (isMultiYear) { runMultiYearRequests( metadata.countryId, reformPolicyId, @@ -194,7 +212,8 @@ export function FetchAndDisplayImpact(props) { simYears, ) .then((aggregatedData) => { - setImpact(aggregatedData); + setImpact(aggregatedData.aggregatedResult); + setSingleYearResults(aggregatedData.singleYearResults); }) .catch((err) => { setError(err); @@ -318,6 +337,7 @@ export function FetchAndDisplayImpact(props) { return ( { + return item.simulationRequestSetup.year; + }); + + const columns = [ + { + title: "Net revenue impact (billions currency)", + dataIndex: "netRevenueImpact", + key: "netRevenueImpact", + }, + ...years.map((year) => { + return { + title: year, + dataIndex: year, + key: year, + }; + }), + { + title: getYearRangeFromArray(years), + dataIndex: "yearRange", + key: "yearRange", + }, + ]; + + const dataSources = dataSourceHeadersAndPaths.map((item) => { + return { + netRevenueImpact: item.header, + ...getYearlyImpacts(singleYearResults, item.budgetKey), + yearRange: roundToBillions(impact.budget[item.budgetKey]), + }; + }); + return (
-

Empty template

+

Title for this component

+ ); } + +function getYearlyImpacts(singleYearResults, budgetKey) { + const yearlyImpacts = {}; + singleYearResults.forEach((item) => { + const year = item.simulationRequestSetup.year; + const impact = roundToBillions(item.result.budget[budgetKey]); + + yearlyImpacts[year] = impact; + }); + return yearlyImpacts; +} + +function getYearRangeFromArray(years) { + const startYear = years[0]; + const endYearLastTwoDigits = years[years.length - 1].toString().slice(-2); + + return `${startYear}-${endYearLastTwoDigits}`; +} + +function roundToBillions(number, decimals = 1) { + return (number / 1e9).toFixed(decimals); +} From dce518ccfeb8f20d6257049a5bd5869d4fd2ecc5 Mon Sep 17 00:00:00 2001 From: Anthony Volk Date: Tue, 22 Apr 2025 18:33:14 -0400 Subject: [PATCH 47/69] test: Add test for MultiYearSelector --- .../rightSidebar/MultiYearSelector.test.js | 170 ++++++++++++++++++ 1 file changed, 170 insertions(+) create mode 100644 src/__tests__/pages/policy/rightSidebar/MultiYearSelector.test.js diff --git a/src/__tests__/pages/policy/rightSidebar/MultiYearSelector.test.js b/src/__tests__/pages/policy/rightSidebar/MultiYearSelector.test.js new file mode 100644 index 000000000..695a1c710 --- /dev/null +++ b/src/__tests__/pages/policy/rightSidebar/MultiYearSelector.test.js @@ -0,0 +1,170 @@ +import { render, screen, fireEvent } from "@testing-library/react"; +import { useSearchParams } from "react-router-dom"; +import MultiYearSelector, { + calculateDefaultSimLength, + findLastSimYearFromMetadata, + generateSimYearsMenuItems, + validateSimYears, +} from "pages/policy/rightSidebar/MultiYearSelector"; +import "@testing-library/jest-dom"; + +// Mock react-router-dom +jest.mock("react-router-dom", () => ({ + ...jest.requireActual("react-router-dom"), + useSearchParams: jest.fn(), +})); + +// Mock useDisplayCategory +jest.mock("layout/Responsive", () => ({ + useDisplayCategory: () => "desktop", +})); + +describe("MultiYearSelector", () => { + const mockMetadata = { + countryId: "us", + economy_options: { + time_period: [ + { name: 2020 }, + { name: 2021 }, + { name: 2022 }, + { name: 2023 }, + { name: 2024 }, + ], + }, + }; + + const mockStartYear = 2020; + + beforeEach(() => { + // Reset mocks before each test + jest.clearAllMocks(); + useSearchParams.mockReturnValue([new URLSearchParams(), jest.fn()]); + }); + + describe("Utility Functions", () => { + test("findLastSimYearFromMetadata should return the last year from metadata", () => { + // Given + const metadata = mockMetadata; + + // When + const result = findLastSimYearFromMetadata(metadata); + + // Then + expect(result).toBe(2024); + }); + + test("calculateDefaultSimLength should return correct default length", () => { + // Given + const metadata = mockMetadata; + const startYear = mockStartYear; + + // When + const result = calculateDefaultSimLength(metadata, startYear); + + // Then + expect(result).toBe(5); // Default for US is 10, but limited by available years + }); + + test("validateSimYears should validate year range correctly", () => { + // Given + const metadata = mockMetadata; + const startYear = mockStartYear; + + // When/Then + expect(validateSimYears("3", metadata, startYear)).toBe(true); + expect(validateSimYears("0", metadata, startYear)).toBe(false); + expect(validateSimYears("6", metadata, startYear)).toBe(false); + expect(validateSimYears(null, metadata, startYear)).toBe(false); + }); + + test("generateSimYearsMenuItems should create correct menu items", () => { + // Given + const metadata = mockMetadata; + const startYear = mockStartYear; + + // When + const result = generateSimYearsMenuItems(metadata, startYear); + + // Then + expect(result).toHaveLength(5); + expect(result[0]).toEqual({ label: 1, value: 1 }); + expect(result[4]).toEqual({ label: 5, value: 5 }); + }); + }); + + describe("Component Behavior", () => { + test("should initialize with default values when no simYears in URL", () => { + // Given + useSearchParams.mockReturnValue([new URLSearchParams(), jest.fn()]); + + // When + render( + , + ); + + // Then + expect(screen.getByRole("switch")).not.toBeChecked(); + // Ant Design Select component uses title attribute to display the value + expect(screen.getByTitle("5")).toBeInTheDocument(); + }); + + test("should initialize with URL parameters when simYears is present", () => { + // Given + useSearchParams.mockReturnValue([ + new URLSearchParams("simYears=3"), + jest.fn(), + ]); + + // When + render( + , + ); + + // Then + expect(screen.getByRole("switch")).toBeChecked(); + expect(screen.getByTitle("3")).toBeInTheDocument(); + }); + + test("should update URL when switch is toggled on", () => { + // Given + const setSearchParams = jest.fn(); + useSearchParams.mockReturnValue([new URLSearchParams(), setSearchParams]); + + // When + render( + , + ); + fireEvent.click(screen.getByRole("switch")); + + // Then + expect(setSearchParams).toHaveBeenCalledWith(expect.any(Function)); + const searchParamsUpdate = setSearchParams.mock.calls[0][0]( + new URLSearchParams(), + ); + expect(searchParamsUpdate.get("simYears")).toBe("5"); + }); + + test("should update URL when years selection changes", () => { + // Given + const setSearchParams = jest.fn(); + useSearchParams.mockReturnValue([ + new URLSearchParams("simYears=3"), + setSearchParams, + ]); + + // When + render( + , + ); + fireEvent.mouseDown(screen.getByRole("combobox")); + fireEvent.click(screen.getByTitle("2")); + + // Then + expect(setSearchParams).toHaveBeenCalledWith(expect.any(Function)); + const searchParamsUpdate = setSearchParams.mock.calls[0][0]( + new URLSearchParams(), + ); + expect(searchParamsUpdate.get("simYears")).toBe("2"); + }); + }); +}); From 753dfa21dad59d0e06dc9bb49568850ad71e2d5d Mon Sep 17 00:00:00 2001 From: Anthony Volk Date: Tue, 22 Apr 2025 18:50:05 -0400 Subject: [PATCH 48/69] test: Add test for MultiYearBudgetaryImpact.jsx --- .../budget/MultiYearBudgetaryImpact.test.js | 146 ++++++++++++++++++ .../budget/MultiYearBudgetaryImpact.jsx | 6 +- 2 files changed, 149 insertions(+), 3 deletions(-) create mode 100644 src/__tests__/policy/output/budget/MultiYearBudgetaryImpact.test.js diff --git a/src/__tests__/policy/output/budget/MultiYearBudgetaryImpact.test.js b/src/__tests__/policy/output/budget/MultiYearBudgetaryImpact.test.js new file mode 100644 index 000000000..d80f0d046 --- /dev/null +++ b/src/__tests__/policy/output/budget/MultiYearBudgetaryImpact.test.js @@ -0,0 +1,146 @@ +// window.matchMedia used in Ant Design Table components +import { render, screen } from "@testing-library/react"; +import MultiYearBudgetaryImpact, { + getYearlyImpacts, + getYearRangeFromArray, + roundToBillions, +} from "pages/policy/output/budget/MultiYearBudgetaryImpact"; +import "@testing-library/jest-dom"; + +window.matchMedia = + window.matchMedia || + function () { + return { + matches: false, + addListener: function () {}, + removeListener: function () {}, + }; + }; + +describe("MultiYearBudgetaryImpact", () => { + const mockSingleYearResults = [ + { + simulationRequestSetup: { year: 2020 }, + result: { + budget: { + budgetary_impact: 1.5e9, + benefit_spending_impact: 2.3e9, + tax_revenue_impact: 3.1e9, + state_tax_revenue_impact: 0.8e9, + }, + }, + }, + { + simulationRequestSetup: { year: 2021 }, + result: { + budget: { + budgetary_impact: 9.7e9, + benefit_spending_impact: 2.5e9, + tax_revenue_impact: 3.3e9, + state_tax_revenue_impact: 0.9e9, + }, + }, + }, + ]; + + const mockImpact = { + budget: { + budgetary_impact: 3.2e9, + benefit_spending_impact: 4.8e9, + tax_revenue_impact: 6.4e9, + state_tax_revenue_impact: 1.7e9, + }, + }; + + describe("Utility Functions", () => { + test("getYearlyImpacts should correctly format yearly impacts", () => { + // Given + const singleYearResults = mockSingleYearResults; + const budgetKey = "budgetary_impact"; + + // When + const result = getYearlyImpacts(singleYearResults, budgetKey); + + // Then + expect(result).toEqual({ + 2020: "1.5", + 2021: "9.7", + }); + }); + + test("getYearRangeFromArray should format year range correctly", () => { + // Given + const years = [2020, 2021, 2022, 2023, 2024]; + + // When + const result = getYearRangeFromArray(years); + + // Then + expect(result).toBe("2020-24"); + }); + + test("roundToBillions should round numbers correctly", () => { + // Given + const number = 1.2345e9; + const decimals = 2; + + // When + const result = roundToBillions(number, decimals); + + // Then + expect(result).toBe("1.23"); + }); + }); + + describe("Component Behavior", () => { + test("should render with correct title", () => { + // Given + const props = { + impact: mockImpact, + singleYearResults: mockSingleYearResults, + }; + + // When + render(); + + // Then + expect(screen.getByText("Title for this component")).toBeInTheDocument(); + }); + + test("should render table with correct columns", () => { + // Given + const props = { + impact: mockImpact, + singleYearResults: mockSingleYearResults, + }; + + // When + render(); + + // Then + expect( + screen.getByText("Net revenue impact (billions currency)"), + ).toBeInTheDocument(); + expect(screen.getByText("2020")).toBeInTheDocument(); + expect(screen.getByText("2021")).toBeInTheDocument(); + expect(screen.getByText("2020-21")).toBeInTheDocument(); + }); + + test("should render table with correct data", () => { + // Given + const props = { + impact: mockImpact, + singleYearResults: mockSingleYearResults, + }; + + // When + render(); + + // Then + expect(screen.getByText("Federal tax")).toBeInTheDocument(); + expect(screen.getByText("1.5")).toBeInTheDocument(); + expect(screen.getByText("1.7")).toBeInTheDocument(); + expect(screen.getByText("3.2")).toBeInTheDocument(); + }); + }); +}); diff --git a/src/pages/policy/output/budget/MultiYearBudgetaryImpact.jsx b/src/pages/policy/output/budget/MultiYearBudgetaryImpact.jsx index 49e0fc5f1..43abcdca9 100644 --- a/src/pages/policy/output/budget/MultiYearBudgetaryImpact.jsx +++ b/src/pages/policy/output/budget/MultiYearBudgetaryImpact.jsx @@ -62,7 +62,7 @@ export default function MultiYearBudgetaryImpact(props) { ); } -function getYearlyImpacts(singleYearResults, budgetKey) { +export function getYearlyImpacts(singleYearResults, budgetKey) { const yearlyImpacts = {}; singleYearResults.forEach((item) => { const year = item.simulationRequestSetup.year; @@ -73,13 +73,13 @@ function getYearlyImpacts(singleYearResults, budgetKey) { return yearlyImpacts; } -function getYearRangeFromArray(years) { +export function getYearRangeFromArray(years) { const startYear = years[0]; const endYearLastTwoDigits = years[years.length - 1].toString().slice(-2); return `${startYear}-${endYearLastTwoDigits}`; } -function roundToBillions(number, decimals = 1) { +export function roundToBillions(number, decimals = 1) { return (number / 1e9).toFixed(decimals); } From be7a799e17606afe78e771bf9ef1433d9a8d6e73 Mon Sep 17 00:00:00 2001 From: Anthony Volk Date: Tue, 22 Apr 2025 19:08:33 -0400 Subject: [PATCH 49/69] feat: Add correct title to 10-year impact component --- src/pages/policy/output/Display.jsx | 4 +++ src/pages/policy/output/PolicyBreakdown.jsx | 22 +------------- .../budget/MultiYearBudgetaryImpact.jsx | 29 +++++++++++++++++-- 3 files changed, 32 insertions(+), 23 deletions(-) diff --git a/src/pages/policy/output/Display.jsx b/src/pages/policy/output/Display.jsx index c39cc4b83..287be6a15 100644 --- a/src/pages/policy/output/Display.jsx +++ b/src/pages/policy/output/Display.jsx @@ -182,8 +182,12 @@ export function DisplayImpact(props) { } else if (isMultiYear && impactType === "budgetaryImpact") { pane = ( ); } else if (impactType === "policyBreakdown") { diff --git a/src/pages/policy/output/PolicyBreakdown.jsx b/src/pages/policy/output/PolicyBreakdown.jsx index 439f5e670..96322c22e 100644 --- a/src/pages/policy/output/PolicyBreakdown.jsx +++ b/src/pages/policy/output/PolicyBreakdown.jsx @@ -6,13 +6,6 @@ export default function PolicyBreakdown(props) { const { policyLabel, metadata, impact, timePeriod, region } = props; const [searchParams, setSearchParams] = useSearchParams(); - const simYears = searchParams.get("simYears"); - - let displayPeriod = timePeriod; - if (simYears && simYears > 1) { - displayPeriod = formatDisplayPeriod(timePeriod, simYears); - } - const regionObj = metadata.economy_options.region.find( (elem) => elem.name === region, ); @@ -31,7 +24,7 @@ export default function PolicyBreakdown(props) { regionLabel = "undefined region"; } - const title = `${policyLabel} in ${regionLabel}, ${displayPeriod}`; + const title = `${policyLabel} in ${regionLabel}, ${timePeriod}`; const bottomText = "Click on an option on the left panel to view more details."; @@ -500,16 +493,3 @@ function formatPowers(value) { } return [Number(displayValue), label]; } - -/** - * For multi-year simulations, format display period to show start year hyphen end year - * (e.g. 2020-2025) - * @param {Number | String} startYear - * @param {Number | String} simYears - * @returns {String} - */ -export function formatDisplayPeriod(startYear, simYears) { - // simYears is 1-indexed, so we need to subtract 1 - const endYear = Number(startYear) + Number(simYears) - 1; - return `${startYear}-${endYear}`; -} diff --git a/src/pages/policy/output/budget/MultiYearBudgetaryImpact.jsx b/src/pages/policy/output/budget/MultiYearBudgetaryImpact.jsx index 43abcdca9..d8c4dadb8 100644 --- a/src/pages/policy/output/budget/MultiYearBudgetaryImpact.jsx +++ b/src/pages/policy/output/budget/MultiYearBudgetaryImpact.jsx @@ -1,4 +1,5 @@ import { Table } from "antd"; +import { useSearchParams } from "react-router-dom"; const dataSourceHeadersAndPaths = [ { @@ -20,7 +21,7 @@ const dataSourceHeadersAndPaths = [ ]; export default function MultiYearBudgetaryImpact(props) { - const { impact, singleYearResults } = props; + const { metadata, impact, singleYearResults, policyLabel, region } = props; const years = singleYearResults.map((item) => { return item.simulationRequestSetup.year; @@ -54,9 +55,33 @@ export default function MultiYearBudgetaryImpact(props) { }; }); + const displayPeriod = getYearRangeFromArray(years); + + const regionObj = metadata.economy_options.region.find( + (elem) => elem.name === region, + ); + + let regionLabel = "undefined region"; + // This is a workaround for enhanced_us that should be changed + // if and when it is treated as something other than a "region" + // by the back end + if (regionObj?.name === "enhanced_us") { + regionLabel = "the US"; + } else if (regionObj) { + regionLabel = regionObj.label; + } + + const title = `${policyLabel} in ${regionLabel}, ${displayPeriod}`; + return (
-

Title for this component

+

+ {title} +

); From 3ffc49b5afb1d052f61ae50312a190630d545169 Mon Sep 17 00:00:00 2001 From: Anthony Volk Date: Tue, 22 Apr 2025 19:15:53 -0400 Subject: [PATCH 50/69] fix: Correctly show budgetary impact page when multi-year after clicking to run sim --- src/pages/policy/PolicyRightSidebar.jsx | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/pages/policy/PolicyRightSidebar.jsx b/src/pages/policy/PolicyRightSidebar.jsx index 2f5c40a2b..f2ec5132e 100644 --- a/src/pages/policy/PolicyRightSidebar.jsx +++ b/src/pages/policy/PolicyRightSidebar.jsx @@ -27,6 +27,7 @@ import Collapsible from "../../layout/Collapsible"; import { formatFullDate } from "../../lang/format"; import useCountryId from "../../hooks/useCountryId"; import MultiYearSelector from "./rightSidebar/MultiYearSelector"; +import { determineIfMultiYear } from "./output/utils"; function RegionSelector(props) { const { metadata } = props; const [searchParams, setSearchParams] = useSearchParams(); @@ -838,6 +839,8 @@ export default function PolicyRightSidebar(props) { const stateAbbreviation = focus.split(".")[2]; const hasHousehold = searchParams.get("household") !== null; + const isMultiYear = determineIfMultiYear(searchParams); + let dataset = searchParams.get("dataset"); const options = metadata.economy_options.region.map((stateAbbreviation) => { @@ -909,7 +912,11 @@ export default function PolicyRightSidebar(props) { }); } else { let newSearch = copySearchParams(searchParams); - newSearch.set("focus", "policyOutput.policyBreakdown"); + if (isMultiYear) { + newSearch.set("focus", "policyOutput.budgetaryImpact"); + } else { + newSearch.set("focus", "policyOutput.policyBreakdown"); + } setSearchParams(newSearch, { replace: true }); } }; From b38ecc77378154e5c58399d34935fd50f4b297fa Mon Sep 17 00:00:00 2001 From: Anthony Volk Date: Tue, 22 Apr 2025 19:27:28 -0400 Subject: [PATCH 51/69] fix: Toggle between correct views between multi-year and standard outputs --- .../policy/rightSidebar/MultiYearSelector.jsx | 23 ++++++++++++++----- 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/src/pages/policy/rightSidebar/MultiYearSelector.jsx b/src/pages/policy/rightSidebar/MultiYearSelector.jsx index 7d2d59573..a9bb62b95 100644 --- a/src/pages/policy/rightSidebar/MultiYearSelector.jsx +++ b/src/pages/policy/rightSidebar/MultiYearSelector.jsx @@ -43,20 +43,31 @@ export default function MultiYearSelector(props) { ]); function handleSwitchChange(checked) { + const isOnOutput = searchParams.get("focus").includes("policyOutput"); + setIsMultiYearActive(checked); + if (checked) { setSearchParams((prev) => { const newSearch = new URLSearchParams(prev); newSearch.set("simYears", simLength); + if (isOnOutput) { + newSearch.set("focus", "policyOutput.budgetaryImpact"); + } return newSearch; }); - } else { - setSearchParams((prev) => { - const newSearch = new URLSearchParams(prev); - newSearch.delete("simYears"); - return newSearch; - }); + return; } + + setSearchParams((prev) => { + const newSearch = new URLSearchParams(prev); + newSearch.delete("simYears"); + if (isOnOutput) { + newSearch.set("focus", "policyOutput.policyBreakdown"); + } + return newSearch; + }); + return; } function handleSimYearsChange(value) { From 0b9bcba54a559999cc0d49b98c1556282c26bfa2 Mon Sep 17 00:00:00 2001 From: Anthony Volk Date: Tue, 22 Apr 2025 19:35:47 -0400 Subject: [PATCH 52/69] fix: Correct transition when toggling menu --- src/layout/Menu.jsx | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/src/layout/Menu.jsx b/src/layout/Menu.jsx index c283a1eed..e05d35b18 100644 --- a/src/layout/Menu.jsx +++ b/src/layout/Menu.jsx @@ -23,11 +23,9 @@ function MenuItem(props) { borderRadius: 8, }} initial={{ backgroundColor: "transparent" }} - animate={ - !disabled && { - backgroundColor: selected === name ? "white" : "transparent", - } - } + animate={{ + backgroundColor: selected === name ? "white" : "transparent", + }} onClick={disabled ? () => undefined : () => onSelect(name)} whileHover={!disabled && { backgroundColor: "white" }} transition={{ duration: 0.001 }} From 34ba69a842a2109a825ece0b8033c1b0ff2cc55b Mon Sep 17 00:00:00 2001 From: Anthony Volk Date: Tue, 22 Apr 2025 20:07:40 -0400 Subject: [PATCH 53/69] test: Fix makeSequentialSimulationRequests tests --- .../api/makeSequentialRequests.test.js | 251 ------------------ .../makeSequentialSimulationRequests.test.js | 245 +++++++++++++++++ src/api/makeSequentialSimulationRequests.js | 4 +- 3 files changed, 247 insertions(+), 253 deletions(-) delete mode 100644 src/__tests__/api/makeSequentialRequests.test.js create mode 100644 src/__tests__/api/makeSequentialSimulationRequests.test.js diff --git a/src/__tests__/api/makeSequentialRequests.test.js b/src/__tests__/api/makeSequentialRequests.test.js deleted file mode 100644 index d8ef4c8af..000000000 --- a/src/__tests__/api/makeSequentialRequests.test.js +++ /dev/null @@ -1,251 +0,0 @@ -import { makeSequentialRequests } from "../../api/makeSequentialRequests"; -import * as callModule from "../../api/call"; - -jest.mock("../../api/call", () => ({ - ...jest.requireActual("../../api/call"), - countryApiCall: jest.fn(), -})); - -describe("makeSequentialRequests", () => { - beforeEach(() => { - jest.clearAllMocks(); - console.error = jest.fn(); // Prevent console error output during tests - }); - - describe("Given a list of successful requests", () => { - test("it should process all requests sequentially and return successful results", async () => { - const requests = [ - { countryId: "us", path: "/api/users", method: "GET" }, - { countryId: "ca", path: "/api/posts", method: "GET" }, - { countryId: "uk", path: "/api/comments", method: "GET" }, - ]; - - const mockResponses = [ - { data: "users data" }, - { data: "posts data" }, - { data: "comments data" }, - ]; - - // Mock successful responses - callModule.countryApiCall - .mockResolvedValueOnce(mockResponses[0]) - .mockResolvedValueOnce(mockResponses[1]) - .mockResolvedValueOnce(mockResponses[2]); - - const result = await makeSequentialRequests(requests); - - expect(callModule.countryApiCall).toHaveBeenCalledTimes(3); - expect(callModule.countryApiCall).toHaveBeenNthCalledWith( - 1, - "us", - "/api/users", - undefined, - "GET", - undefined, - undefined, - ); - expect(callModule.countryApiCall).toHaveBeenNthCalledWith( - 2, - "ca", - "/api/posts", - undefined, - "GET", - undefined, - undefined, - ); - expect(callModule.countryApiCall).toHaveBeenNthCalledWith( - 3, - "uk", - "/api/comments", - undefined, - "GET", - undefined, - undefined, - ); - - expect(result).toEqual({ - results: [ - { - status: "success", - requestIndex: 0, - requestSetup: requests[0], - response: mockResponses[0], - error: null, - }, - { - status: "success", - requestIndex: 1, - requestSetup: requests[1], - response: mockResponses[1], - error: null, - }, - { - status: "success", - requestIndex: 2, - requestSetup: requests[2], - response: mockResponses[2], - error: null, - }, - ], - summary: { - total: 3, - successes: 3, - errors: 0, - }, - }); - }); - - test("it should call onComplete callback after each request", async () => { - const requests = [ - { countryId: "us", path: "/api/users", method: "GET" }, - { countryId: "ca", path: "/api/posts", method: "GET" }, - ]; - - callModule.countryApiCall - .mockResolvedValueOnce({ data: "users data" }) - .mockResolvedValueOnce({ data: "posts data" }); - - const onComplete = jest.fn(); - - await makeSequentialRequests(requests, onComplete); - - expect(onComplete).toHaveBeenCalledTimes(2); - expect(onComplete).toHaveBeenNthCalledWith(1, { - current: 0, - total: 2, - successCount: 1, - errorCount: 0, - }); - expect(onComplete).toHaveBeenNthCalledWith(2, { - current: 1, - total: 2, - successCount: 2, - errorCount: 0, - }); - }); - }); - - describe("Given a list with mixed successful and failed requests", () => { - test("it should continue processing after failures and track error counts", async () => { - const requests = [ - { countryId: "us", path: "/api/users", method: "GET" }, - { countryId: "ca", path: "/api/posts", method: "GET" }, - { countryId: "uk", path: "/api/comments", method: "GET" }, - ]; - - const mockError = new Error("API Error"); - mockError.response = { status: 404, data: "Not Found" }; - - // First request succeeds, second fails, third succeeds - callModule.countryApiCall - .mockResolvedValueOnce({ data: "users data" }) - .mockRejectedValueOnce(mockError) - .mockResolvedValueOnce({ data: "comments data" }); - - const result = await makeSequentialRequests(requests); - - expect(callModule.countryApiCall).toHaveBeenCalledTimes(3); - expect(result.results[0].status).toBe("success"); - expect(result.results[1].status).toBe("error"); - expect(result.results[1].error).toEqual({ - message: "API Error", - statusCode: 404, - data: "Not Found", - }); - expect(result.results[2].status).toBe("success"); - expect(result.summary).toEqual({ - total: 3, - successes: 2, - errors: 1, - }); - expect(console.error).toHaveBeenCalledWith( - "Request 2 failed:", - "API Error", - ); - }); - }); - - describe("Given a request with secondAttempt flag", () => { - test("it should pass the secondAttempt parameter to the API call", async () => { - const requests = [ - { - countryId: "us", - path: "/api/users", - method: "GET", - secondAttempt: true, - }, - ]; - - callModule.countryApiCall.mockResolvedValueOnce({ data: "users data" }); - - await makeSequentialRequests(requests); - - expect(callModule.countryApiCall).toHaveBeenCalledWith( - "us", - "/api/users", - undefined, - "GET", - true, - undefined, - ); - }); - }); - - describe("Given a POST request with body", () => { - test("it should pass the body to the API call", async () => { - const requestBody = { name: "John", age: 30 }; - const requests = [ - { - countryId: "us", - path: "/api/users", - method: "POST", - body: requestBody, - }, - ]; - - callModule.countryApiCall.mockResolvedValueOnce({ data: "created" }); - - await makeSequentialRequests(requests); - - expect(callModule.countryApiCall).toHaveBeenCalledWith( - "us", - "/api/users", - requestBody, - "POST", - undefined, - undefined, - ); - }); - }); - - describe("Given an empty requests array", () => { - test("it should return empty results with zero counts", async () => { - const requests = []; - - const result = await makeSequentialRequests(requests); - - expect(callModule.countryApiCall).not.toHaveBeenCalled(); - expect(result).toEqual({ - results: [], - summary: { - total: 0, - successes: 0, - errors: 0, - }, - }); - }); - }); - - describe("Given an unexpected error in the main function", () => { - test("it should throw the error", async () => { - // Force an error in the main function by setting requests to null - const requests = null; - - await expect(makeSequentialRequests(requests)).rejects.toThrow(); - expect(console.error).toHaveBeenCalledWith( - "Sequential requests failed:", - expect.any(Error), - ); - }); - }); -}); diff --git a/src/__tests__/api/makeSequentialSimulationRequests.test.js b/src/__tests__/api/makeSequentialSimulationRequests.test.js new file mode 100644 index 000000000..0a1b44727 --- /dev/null +++ b/src/__tests__/api/makeSequentialSimulationRequests.test.js @@ -0,0 +1,245 @@ +import * as callModule from "../../api/call"; +import { makeSequentialSimulationRequests } from "../../api/makeSequentialSimulationRequests"; + +jest.mock("../../api/call", () => ({ + ...jest.requireActual("../../api/call"), + asyncApiCall: jest.fn(), +})); + +describe("makeSequentialSimulationRequests", () => { + beforeEach(() => { + jest.clearAllMocks(); + console.error = jest.fn(); // Prevent console error output during tests + }); + + describe("Given a list of successful requests", () => { + test("it should process all requests sequentially and return successful results", async () => { + const requests = [ + { + year: 2020, + path: "/api/simulation", + body: { policy: "test" }, + interval: 1000, + firstInterval: 200 + }, + { + year: 2021, + path: "/api/simulation", + body: { policy: "test" }, + interval: 1000, + firstInterval: 200 + }, + { + year: 2022, + path: "/api/simulation", + body: { policy: "test" }, + interval: 1000, + firstInterval: 200 + }, + ]; + + const mockResponses = [ + { result: { budget: { impact: 1.5e9 } } }, + { result: { budget: { impact: 2.3e9 } } }, + { result: { budget: { impact: 3.1e9 } } }, + ]; + + // Mock successful responses + callModule.asyncApiCall + .mockResolvedValueOnce(mockResponses[0]) + .mockResolvedValueOnce(mockResponses[1]) + .mockResolvedValueOnce(mockResponses[2]); + + const result = await makeSequentialSimulationRequests(requests); + + expect(callModule.asyncApiCall).toHaveBeenCalledTimes(3); + expect(callModule.asyncApiCall).toHaveBeenNthCalledWith( + 1, + requests[0].path, + requests[0].body, + requests[0].interval, + requests[0].firstInterval, + ); + expect(callModule.asyncApiCall).toHaveBeenNthCalledWith( + 2, + requests[1].path, + requests[1].body, + requests[1].interval, + requests[1].firstInterval, + ); + expect(callModule.asyncApiCall).toHaveBeenNthCalledWith( + 3, + requests[2].path, + requests[2].body, + requests[2].interval, + requests[2].firstInterval, + ); + + expect(result).toEqual({ + results: [ + { + status: "success", + requestIndex: 0, + simulationRequestSetup: requests[0], + result: mockResponses[0].result, + error: null, + }, + { + status: "success", + requestIndex: 1, + simulationRequestSetup: requests[1], + result: mockResponses[1].result, + error: null, + }, + { + status: "success", + requestIndex: 2, + simulationRequestSetup: requests[2], + result: mockResponses[2].result, + error: null, + }, + ], + summary: { + total: 3, + successes: 3, + errors: 0, + }, + }); + }); + + test("it should call onComplete callback after each request", async () => { + const requests = [ + { + year: 2020, + path: "/api/simulation", + body: { policy: "test" }, + interval: 1000, + firstInterval: 200 + }, + { + year: 2021, + path: "/api/simulation", + body: { policy: "test" }, + interval: 1000, + firstInterval: 200 + }, + ]; + + callModule.asyncApiCall + .mockResolvedValueOnce({ result: { budget: { impact: 1.5e9 } } }) + .mockResolvedValueOnce({ result: { budget: { impact: 2.3e9 } } }); + + const onComplete = jest.fn(); + + await makeSequentialSimulationRequests(requests, onComplete); + + expect(onComplete).toHaveBeenCalledTimes(2); + expect(onComplete).toHaveBeenNthCalledWith(1, { + current: 0, + total: 2, + successCount: 1, + errorCount: 0, + }); + expect(onComplete).toHaveBeenNthCalledWith(2, { + current: 1, + total: 2, + successCount: 2, + errorCount: 0, + }); + }); + }); + + describe("Given a list with mixed successful and failed requests", () => { + test("it should continue processing after failures and track error counts", async () => { + const requests = [ + { + year: 2020, + path: "/api/simulation", + body: { policy: "test" }, + interval: 1000, + firstInterval: 200 + }, + { + year: 2021, + path: "/api/simulation", + body: { policy: "test" }, + interval: 1000, + firstInterval: 200 + }, + { + year: 2022, + path: "/api/simulation", + body: { policy: "test" }, + interval: 1000, + firstInterval: 200 + }, + ]; + + const mockError = new Error("API Error"); + mockError.response = { status: 404, data: "Not Found" }; + mockError.config = { + url: "/api/simulation", + data: JSON.stringify({ policy: "test" }), + }; + mockError.request = requests[1]; // Include the request setup in the error + + // First request succeeds, second fails, third succeeds + callModule.asyncApiCall + .mockResolvedValueOnce({ result: { budget: { impact: 1.5e9 } } }) + .mockRejectedValueOnce(mockError) + .mockResolvedValueOnce({ result: { budget: { impact: 3.1e9 } } }); + + const result = await makeSequentialSimulationRequests(requests); + + expect(callModule.asyncApiCall).toHaveBeenCalledTimes(3); + expect(result.results[0].status).toBe("success"); + expect(result.results[1].status).toBe("error"); + expect(result.results[1].error).toEqual({ + message: "API Error", + statusCode: 404, + data: "Not Found", + }); + expect(result.results[2].status).toBe("success"); + expect(result.summary).toEqual({ + total: 3, + successes: 2, + errors: 1, + }); + expect(console.error).toHaveBeenCalledWith( + "Request 2 failed:", + "API Error", + ); + }); + }); + + describe("Given an empty requests array", () => { + test("it should return empty results with zero counts", async () => { + const requests = []; + + const result = await makeSequentialSimulationRequests(requests); + + expect(callModule.asyncApiCall).not.toHaveBeenCalled(); + expect(result).toEqual({ + results: [], + summary: { + total: 0, + successes: 0, + errors: 0, + }, + }); + }); + }); + + describe("Given an unexpected error in the main function", () => { + test("it should throw the error", async () => { + // Force an error in the main function by setting requests to null + const requests = null; + + await expect(makeSequentialSimulationRequests(requests)).rejects.toThrow(); + expect(console.error).toHaveBeenCalledWith( + "Sequential requests failed:", + expect.any(Error), + ); + }); + }); +}); diff --git a/src/api/makeSequentialSimulationRequests.js b/src/api/makeSequentialSimulationRequests.js index ea6934711..870ffb7f6 100644 --- a/src/api/makeSequentialSimulationRequests.js +++ b/src/api/makeSequentialSimulationRequests.js @@ -74,7 +74,7 @@ export async function makeSequentialSimulationRequests( SequentialSimulationResult.cast({ status: "success", requestIndex: i, - simulationRequestSetup: requestSetup, + simulationRequestSetup: SimulationRequestSetup.cast(requestSetup), result: response.result, }), ); @@ -92,7 +92,7 @@ export async function makeSequentialSimulationRequests( statusCode: error.response?.status, data: error.response?.data, }, - requestSetup: requestSetup, + simulationRequestSetup: SimulationRequestSetup.cast(requestSetup), }), ); From 90180a1d6123e6ed7b6f2771e99547124ae1157c Mon Sep 17 00:00:00 2001 From: Anthony Volk Date: Tue, 22 Apr 2025 20:12:30 -0400 Subject: [PATCH 54/69] fix: Fix tests for MultiYearBudgetaryImpact --- .../budget/MultiYearBudgetaryImpact.test.js | 171 ++++++++++++++++++ 1 file changed, 171 insertions(+) create mode 100644 src/__tests__/pages/policy/output/budget/MultiYearBudgetaryImpact.test.js diff --git a/src/__tests__/pages/policy/output/budget/MultiYearBudgetaryImpact.test.js b/src/__tests__/pages/policy/output/budget/MultiYearBudgetaryImpact.test.js new file mode 100644 index 000000000..dae61685a --- /dev/null +++ b/src/__tests__/pages/policy/output/budget/MultiYearBudgetaryImpact.test.js @@ -0,0 +1,171 @@ +// window.matchMedia used in Ant Design Table components +import { render, screen } from "@testing-library/react"; +import MultiYearBudgetaryImpact, { + getYearlyImpacts, + getYearRangeFromArray, + roundToBillions, +} from "pages/policy/output/budget/MultiYearBudgetaryImpact"; +import "@testing-library/jest-dom"; +import data from "../../../__setup__/data.json"; + +window.matchMedia = + window.matchMedia || + function () { + return { + matches: false, + addListener: function () {}, + removeListener: function () {}, + }; + }; + +describe("MultiYearBudgetaryImpact", () => { + const mockSingleYearResults = [ + { + simulationRequestSetup: { + year: 2020, + path: "/api/simulation", + body: null, + interval: 1000, + firstInterval: 200 + }, + result: { + budget: { + budgetary_impact: 1.5e9, + benefit_spending_impact: 2.3e9, + tax_revenue_impact: 3.1e9, + state_tax_revenue_impact: 0.8e9, + }, + }, + }, + { + simulationRequestSetup: { + year: 2021, + path: "/api/simulation", + body: null, + interval: 1000, + firstInterval: 200 + }, + result: { + budget: { + budgetary_impact: 9.7e9, + benefit_spending_impact: 2.5e9, + tax_revenue_impact: 3.3e9, + state_tax_revenue_impact: 0.9e9, + }, + }, + }, + ]; + + const mockImpact = { + budget: { + budgetary_impact: 3.2e9, + benefit_spending_impact: 4.8e9, + tax_revenue_impact: 6.4e9, + state_tax_revenue_impact: 1.7e9, + }, + }; + + describe("Utility Functions", () => { + test("getYearlyImpacts should correctly format yearly impacts", () => { + // Given + const singleYearResults = mockSingleYearResults; + const budgetKey = "budgetary_impact"; + + // When + const result = getYearlyImpacts(singleYearResults, budgetKey); + + // Then + expect(result).toEqual({ + 2020: "1.5", + 2021: "9.7", + }); + }); + + test("getYearRangeFromArray should format year range correctly", () => { + // Given + const years = [2020, 2021, 2022, 2023, 2024]; + + // When + const result = getYearRangeFromArray(years); + + // Then + expect(result).toBe("2020-24"); + }); + + test("roundToBillions should round numbers correctly", () => { + // Given + const number = 1.2345e9; + const decimals = 2; + + // When + const result = roundToBillions(number, decimals); + + // Then + expect(result).toBe("1.23"); + }); + }); + + describe("Component Behavior", () => { + const mockMetadata = data["metadataUS"]; + + test("should render with correct title", () => { + // Given + const props = { + impact: mockImpact, + singleYearResults: mockSingleYearResults, + metadata: mockMetadata, + policyLabel: "Test Policy", + region: "us" + }; + + // When + render(); + + // Then + expect(screen.getByText("Test Policy in the US, 2020-21")).toBeInTheDocument(); + }); + + test("should render table with correct columns", () => { + // Given + const props = { + impact: mockImpact, + singleYearResults: mockSingleYearResults, + metadata: mockMetadata, + policyLabel: "Test Policy", + region: "us" + }; + + // When + render(); + + // Then + expect(screen.getByText("Net revenue impact (billions currency)")).toBeInTheDocument(); + expect(screen.getByText("2020")).toBeInTheDocument(); + expect(screen.getByText("2021")).toBeInTheDocument(); + expect(screen.getByText("2020-21")).toBeInTheDocument(); + }); + + test("should render table with correct data", () => { + // Given + const props = { + impact: mockImpact, + singleYearResults: mockSingleYearResults, + metadata: mockMetadata, + policyLabel: "Test Policy", + region: "us" + }; + + // When + render(); + + // Then + expect(screen.getByText("Federal tax")).toBeInTheDocument(); + expect(screen.getByText("Benefits")).toBeInTheDocument(); + expect(screen.getByText("Federal budget")).toBeInTheDocument(); + expect(screen.getByText("State tax")).toBeInTheDocument(); + expect(screen.getByText("1.5")).toBeInTheDocument(); + expect(screen.getByText("9.7")).toBeInTheDocument(); + expect(screen.getByText("3.2")).toBeInTheDocument(); + }); + }); +}); From 81698f50cf8948d3ff345f5bd886ff4a307034f6 Mon Sep 17 00:00:00 2001 From: Anthony Volk Date: Tue, 22 Apr 2025 20:12:52 -0400 Subject: [PATCH 55/69] fix: Remove deprecated test --- .../budget/MultiYearBudgetaryImpact.test.js | 146 ------------------ 1 file changed, 146 deletions(-) delete mode 100644 src/__tests__/policy/output/budget/MultiYearBudgetaryImpact.test.js diff --git a/src/__tests__/policy/output/budget/MultiYearBudgetaryImpact.test.js b/src/__tests__/policy/output/budget/MultiYearBudgetaryImpact.test.js deleted file mode 100644 index d80f0d046..000000000 --- a/src/__tests__/policy/output/budget/MultiYearBudgetaryImpact.test.js +++ /dev/null @@ -1,146 +0,0 @@ -// window.matchMedia used in Ant Design Table components -import { render, screen } from "@testing-library/react"; -import MultiYearBudgetaryImpact, { - getYearlyImpacts, - getYearRangeFromArray, - roundToBillions, -} from "pages/policy/output/budget/MultiYearBudgetaryImpact"; -import "@testing-library/jest-dom"; - -window.matchMedia = - window.matchMedia || - function () { - return { - matches: false, - addListener: function () {}, - removeListener: function () {}, - }; - }; - -describe("MultiYearBudgetaryImpact", () => { - const mockSingleYearResults = [ - { - simulationRequestSetup: { year: 2020 }, - result: { - budget: { - budgetary_impact: 1.5e9, - benefit_spending_impact: 2.3e9, - tax_revenue_impact: 3.1e9, - state_tax_revenue_impact: 0.8e9, - }, - }, - }, - { - simulationRequestSetup: { year: 2021 }, - result: { - budget: { - budgetary_impact: 9.7e9, - benefit_spending_impact: 2.5e9, - tax_revenue_impact: 3.3e9, - state_tax_revenue_impact: 0.9e9, - }, - }, - }, - ]; - - const mockImpact = { - budget: { - budgetary_impact: 3.2e9, - benefit_spending_impact: 4.8e9, - tax_revenue_impact: 6.4e9, - state_tax_revenue_impact: 1.7e9, - }, - }; - - describe("Utility Functions", () => { - test("getYearlyImpacts should correctly format yearly impacts", () => { - // Given - const singleYearResults = mockSingleYearResults; - const budgetKey = "budgetary_impact"; - - // When - const result = getYearlyImpacts(singleYearResults, budgetKey); - - // Then - expect(result).toEqual({ - 2020: "1.5", - 2021: "9.7", - }); - }); - - test("getYearRangeFromArray should format year range correctly", () => { - // Given - const years = [2020, 2021, 2022, 2023, 2024]; - - // When - const result = getYearRangeFromArray(years); - - // Then - expect(result).toBe("2020-24"); - }); - - test("roundToBillions should round numbers correctly", () => { - // Given - const number = 1.2345e9; - const decimals = 2; - - // When - const result = roundToBillions(number, decimals); - - // Then - expect(result).toBe("1.23"); - }); - }); - - describe("Component Behavior", () => { - test("should render with correct title", () => { - // Given - const props = { - impact: mockImpact, - singleYearResults: mockSingleYearResults, - }; - - // When - render(); - - // Then - expect(screen.getByText("Title for this component")).toBeInTheDocument(); - }); - - test("should render table with correct columns", () => { - // Given - const props = { - impact: mockImpact, - singleYearResults: mockSingleYearResults, - }; - - // When - render(); - - // Then - expect( - screen.getByText("Net revenue impact (billions currency)"), - ).toBeInTheDocument(); - expect(screen.getByText("2020")).toBeInTheDocument(); - expect(screen.getByText("2021")).toBeInTheDocument(); - expect(screen.getByText("2020-21")).toBeInTheDocument(); - }); - - test("should render table with correct data", () => { - // Given - const props = { - impact: mockImpact, - singleYearResults: mockSingleYearResults, - }; - - // When - render(); - - // Then - expect(screen.getByText("Federal tax")).toBeInTheDocument(); - expect(screen.getByText("1.5")).toBeInTheDocument(); - expect(screen.getByText("1.7")).toBeInTheDocument(); - expect(screen.getByText("3.2")).toBeInTheDocument(); - }); - }); -}); From d7464d524ac21559bf09370fcb37e5a1f91753df Mon Sep 17 00:00:00 2001 From: Anthony Volk Date: Tue, 22 Apr 2025 20:27:50 -0400 Subject: [PATCH 56/69] fix: Fix all remaining tests --- .../makeSequentialSimulationRequests.test.js | 36 ++++++++++--------- .../output/PolicyMobileCalculator.test.js | 5 +++ .../budget/MultiYearBudgetaryImpact.test.js | 24 +++++++------ .../rightSidebar/MultiYearSelector.test.js | 5 ++- .../budget/MultiYearBudgetaryImpact.jsx | 1 - 5 files changed, 42 insertions(+), 29 deletions(-) diff --git a/src/__tests__/api/makeSequentialSimulationRequests.test.js b/src/__tests__/api/makeSequentialSimulationRequests.test.js index 0a1b44727..9c405c4a1 100644 --- a/src/__tests__/api/makeSequentialSimulationRequests.test.js +++ b/src/__tests__/api/makeSequentialSimulationRequests.test.js @@ -15,26 +15,26 @@ describe("makeSequentialSimulationRequests", () => { describe("Given a list of successful requests", () => { test("it should process all requests sequentially and return successful results", async () => { const requests = [ - { + { year: 2020, path: "/api/simulation", body: { policy: "test" }, interval: 1000, - firstInterval: 200 + firstInterval: 200, }, - { + { year: 2021, path: "/api/simulation", body: { policy: "test" }, interval: 1000, - firstInterval: 200 + firstInterval: 200, }, - { + { year: 2022, path: "/api/simulation", body: { policy: "test" }, interval: 1000, - firstInterval: 200 + firstInterval: 200, }, ]; @@ -109,19 +109,19 @@ describe("makeSequentialSimulationRequests", () => { test("it should call onComplete callback after each request", async () => { const requests = [ - { + { year: 2020, path: "/api/simulation", body: { policy: "test" }, interval: 1000, - firstInterval: 200 + firstInterval: 200, }, - { + { year: 2021, path: "/api/simulation", body: { policy: "test" }, interval: 1000, - firstInterval: 200 + firstInterval: 200, }, ]; @@ -152,26 +152,26 @@ describe("makeSequentialSimulationRequests", () => { describe("Given a list with mixed successful and failed requests", () => { test("it should continue processing after failures and track error counts", async () => { const requests = [ - { + { year: 2020, path: "/api/simulation", body: { policy: "test" }, interval: 1000, - firstInterval: 200 + firstInterval: 200, }, - { + { year: 2021, path: "/api/simulation", body: { policy: "test" }, interval: 1000, - firstInterval: 200 + firstInterval: 200, }, - { + { year: 2022, path: "/api/simulation", body: { policy: "test" }, interval: 1000, - firstInterval: 200 + firstInterval: 200, }, ]; @@ -235,7 +235,9 @@ describe("makeSequentialSimulationRequests", () => { // Force an error in the main function by setting requests to null const requests = null; - await expect(makeSequentialSimulationRequests(requests)).rejects.toThrow(); + await expect( + makeSequentialSimulationRequests(requests), + ).rejects.toThrow(); expect(console.error).toHaveBeenCalledWith( "Sequential requests failed:", expect.any(Error), diff --git a/src/__tests__/pages/policy/output/PolicyMobileCalculator.test.js b/src/__tests__/pages/policy/output/PolicyMobileCalculator.test.js index 63fa779b2..a38e4831a 100644 --- a/src/__tests__/pages/policy/output/PolicyMobileCalculator.test.js +++ b/src/__tests__/pages/policy/output/PolicyMobileCalculator.test.js @@ -13,6 +13,11 @@ import { impactKeys } from "../../../../pages/policy/output/ImpactTypes.jsx"; jest.mock("react-plotly.js", () => jest.fn()); +// Mock the utils module +jest.mock("../../../../pages/policy/output/utils", () => ({ + determineIfMultiYear: jest.fn().mockReturnValue(false), +})); + jest.mock("react-router-dom", () => ({ ...jest.requireActual("react-router-dom"), useSearchParams: jest.fn(), diff --git a/src/__tests__/pages/policy/output/budget/MultiYearBudgetaryImpact.test.js b/src/__tests__/pages/policy/output/budget/MultiYearBudgetaryImpact.test.js index dae61685a..ee99e7b3d 100644 --- a/src/__tests__/pages/policy/output/budget/MultiYearBudgetaryImpact.test.js +++ b/src/__tests__/pages/policy/output/budget/MultiYearBudgetaryImpact.test.js @@ -6,7 +6,7 @@ import MultiYearBudgetaryImpact, { roundToBillions, } from "pages/policy/output/budget/MultiYearBudgetaryImpact"; import "@testing-library/jest-dom"; -import data from "../../../__setup__/data.json"; +import data from "../../../../__setup__/data.json"; window.matchMedia = window.matchMedia || @@ -21,12 +21,12 @@ window.matchMedia = describe("MultiYearBudgetaryImpact", () => { const mockSingleYearResults = [ { - simulationRequestSetup: { + simulationRequestSetup: { year: 2020, path: "/api/simulation", body: null, interval: 1000, - firstInterval: 200 + firstInterval: 200, }, result: { budget: { @@ -38,12 +38,12 @@ describe("MultiYearBudgetaryImpact", () => { }, }, { - simulationRequestSetup: { + simulationRequestSetup: { year: 2021, path: "/api/simulation", body: null, interval: 1000, - firstInterval: 200 + firstInterval: 200, }, result: { budget: { @@ -115,14 +115,16 @@ describe("MultiYearBudgetaryImpact", () => { singleYearResults: mockSingleYearResults, metadata: mockMetadata, policyLabel: "Test Policy", - region: "us" + region: "us", }; // When render(); // Then - expect(screen.getByText("Test Policy in the US, 2020-21")).toBeInTheDocument(); + expect( + screen.getByText("Test Policy in the US, 2020-21"), + ).toBeInTheDocument(); }); test("should render table with correct columns", () => { @@ -132,14 +134,16 @@ describe("MultiYearBudgetaryImpact", () => { singleYearResults: mockSingleYearResults, metadata: mockMetadata, policyLabel: "Test Policy", - region: "us" + region: "us", }; // When render(); // Then - expect(screen.getByText("Net revenue impact (billions currency)")).toBeInTheDocument(); + expect( + screen.getByText("Net revenue impact (billions currency)"), + ).toBeInTheDocument(); expect(screen.getByText("2020")).toBeInTheDocument(); expect(screen.getByText("2021")).toBeInTheDocument(); expect(screen.getByText("2020-21")).toBeInTheDocument(); @@ -152,7 +156,7 @@ describe("MultiYearBudgetaryImpact", () => { singleYearResults: mockSingleYearResults, metadata: mockMetadata, policyLabel: "Test Policy", - region: "us" + region: "us", }; // When diff --git a/src/__tests__/pages/policy/rightSidebar/MultiYearSelector.test.js b/src/__tests__/pages/policy/rightSidebar/MultiYearSelector.test.js index 695a1c710..5b2a828c4 100644 --- a/src/__tests__/pages/policy/rightSidebar/MultiYearSelector.test.js +++ b/src/__tests__/pages/policy/rightSidebar/MultiYearSelector.test.js @@ -128,7 +128,10 @@ describe("MultiYearSelector", () => { test("should update URL when switch is toggled on", () => { // Given const setSearchParams = jest.fn(); - useSearchParams.mockReturnValue([new URLSearchParams(), setSearchParams]); + useSearchParams.mockReturnValue([ + new URLSearchParams({ focus: "policyOutput.policyBreakdown" }), + setSearchParams, + ]); // When render( diff --git a/src/pages/policy/output/budget/MultiYearBudgetaryImpact.jsx b/src/pages/policy/output/budget/MultiYearBudgetaryImpact.jsx index d8c4dadb8..03d3cab8b 100644 --- a/src/pages/policy/output/budget/MultiYearBudgetaryImpact.jsx +++ b/src/pages/policy/output/budget/MultiYearBudgetaryImpact.jsx @@ -1,5 +1,4 @@ import { Table } from "antd"; -import { useSearchParams } from "react-router-dom"; const dataSourceHeadersAndPaths = [ { From 2d1da392f8783715433b2ebfdce393748381c5a4 Mon Sep 17 00:00:00 2001 From: Anthony Volk Date: Wed, 23 Apr 2025 12:41:06 -0400 Subject: [PATCH 57/69] fix: Show currency marker correctly --- src/pages/policy/output/budget/MultiYearBudgetaryImpact.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/policy/output/budget/MultiYearBudgetaryImpact.jsx b/src/pages/policy/output/budget/MultiYearBudgetaryImpact.jsx index 03d3cab8b..398cf9267 100644 --- a/src/pages/policy/output/budget/MultiYearBudgetaryImpact.jsx +++ b/src/pages/policy/output/budget/MultiYearBudgetaryImpact.jsx @@ -28,7 +28,7 @@ export default function MultiYearBudgetaryImpact(props) { const columns = [ { - title: "Net revenue impact (billions currency)", + title: `Net revenue impact (${metadata.currency}bn)`, dataIndex: "netRevenueImpact", key: "netRevenueImpact", }, From 81021b503cb1180e1ed3274925b798c3107eb480 Mon Sep 17 00:00:00 2001 From: Anthony Volk Date: Thu, 24 Apr 2025 21:52:55 -0400 Subject: [PATCH 58/69] fix: Fix UK formatting --- src/pages/policy/PolicyRightSidebar.jsx | 14 +++- .../budget/MultiYearBudgetaryImpact.jsx | 82 +++++++++++++++---- .../policy/rightSidebar/MultiYearSelector.jsx | 2 + 3 files changed, 82 insertions(+), 16 deletions(-) diff --git a/src/pages/policy/PolicyRightSidebar.jsx b/src/pages/policy/PolicyRightSidebar.jsx index f2ec5132e..5f539cba9 100644 --- a/src/pages/policy/PolicyRightSidebar.jsx +++ b/src/pages/policy/PolicyRightSidebar.jsx @@ -26,8 +26,11 @@ import { defaultForeverYear, defaultStartDate } from "../../data/constants"; import Collapsible from "../../layout/Collapsible"; import { formatFullDate } from "../../lang/format"; import useCountryId from "../../hooks/useCountryId"; -import MultiYearSelector from "./rightSidebar/MultiYearSelector"; +import MultiYearSelector, { + MULTI_YEAR_SELECTOR_PERMITTED_COUNTRIES, +} from "./rightSidebar/MultiYearSelector"; import { determineIfMultiYear } from "./output/utils"; + function RegionSelector(props) { const { metadata } = props; const [searchParams, setSearchParams] = useSearchParams(); @@ -1147,7 +1150,14 @@ export default function PolicyRightSidebar(props) { timePeriod={timePeriod} /> )} - + {MULTI_YEAR_SELECTOR_PERMITTED_COUNTRIES.includes( + metadata.countryId, + ) && ( + + )} {metadata.countryId === "uk" && ( diff --git a/src/pages/policy/output/budget/MultiYearBudgetaryImpact.jsx b/src/pages/policy/output/budget/MultiYearBudgetaryImpact.jsx index 398cf9267..d39a7b1ec 100644 --- a/src/pages/policy/output/budget/MultiYearBudgetaryImpact.jsx +++ b/src/pages/policy/output/budget/MultiYearBudgetaryImpact.jsx @@ -1,6 +1,6 @@ import { Table } from "antd"; -const dataSourceHeadersAndPaths = [ +const usDataSources = [ { header: "Federal tax", budgetKey: "budgetary_impact", @@ -19,6 +19,54 @@ const dataSourceHeadersAndPaths = [ }, ]; +const ukDataSources = [ + { + header: "Tax", + budgetKey: "budgetary_impact", + }, + { + header: "Benefits", + budgetKey: "benefit_spending_impact", + }, + { + header: "Total", + budgetKey: "tax_revenue_impact", + }, +]; + +const dataSourcesByCountry = { + us: usDataSources, + uk: ukDataSources, +}; + +const financialYearTypeByCountry = { + us: "calendar", + uk: "mixed", + default: "calendar", +}; + +/** + * Maps a year to an Ant Design column title Object + * @param {number} year - The year to map + * @param {string} country - The country to map the year for + * @returns {Object} - The Ant Design column title Object + */ +export function mapFinancialYearToColumn(year, country) { + const yearType = + financialYearTypeByCountry[country] || financialYearTypeByCountry.default; + + let displayYear = year; + if (yearType === "mixed") { + displayYear = `${year}-${(year % 100) + 1}`; + } + + return { + title: displayYear, + dataIndex: year, + key: year, + }; +} + export default function MultiYearBudgetaryImpact(props) { const { metadata, impact, singleYearResults, policyLabel, region } = props; @@ -28,25 +76,21 @@ export default function MultiYearBudgetaryImpact(props) { const columns = [ { - title: `Net revenue impact (${metadata.currency}bn)`, + title: `Net revenue impact (billions)`, dataIndex: "netRevenueImpact", key: "netRevenueImpact", }, - ...years.map((year) => { - return { - title: year, - dataIndex: year, - key: year, - }; - }), + ...years.map((year) => mapFinancialYearToColumn(year, metadata.countryId)), { - title: getYearRangeFromArray(years), + title: getYearRangeFromArray(years, metadata.countryId), dataIndex: "yearRange", key: "yearRange", }, ]; - const dataSources = dataSourceHeadersAndPaths.map((item) => { + const countryDataSource = dataSourcesByCountry[region]; + + const dataSources = countryDataSource.map((item) => { return { netRevenueImpact: item.header, ...getYearlyImpacts(singleYearResults, item.budgetKey), @@ -54,7 +98,7 @@ export default function MultiYearBudgetaryImpact(props) { }; }); - const displayPeriod = getYearRangeFromArray(years); + const displayPeriod = getYearRangeFromArray(years, metadata.countryId); const regionObj = metadata.economy_options.region.find( (elem) => elem.name === region, @@ -97,9 +141,19 @@ export function getYearlyImpacts(singleYearResults, budgetKey) { return yearlyImpacts; } -export function getYearRangeFromArray(years) { +export function getYearRangeFromArray(years, country) { + const financialYearType = + financialYearTypeByCountry[country] || financialYearTypeByCountry.default; + const startYear = years[0]; - const endYearLastTwoDigits = years[years.length - 1].toString().slice(-2); + let endYear = years[years.length - 1]; + + // If the financial year type is mixed, we need to add one to the end year + if (financialYearType === "mixed") { + endYear++; + } + + const endYearLastTwoDigits = endYear.toString().slice(-2); return `${startYear}-${endYearLastTwoDigits}`; } diff --git a/src/pages/policy/rightSidebar/MultiYearSelector.jsx b/src/pages/policy/rightSidebar/MultiYearSelector.jsx index a9bb62b95..4fca0f5dc 100644 --- a/src/pages/policy/rightSidebar/MultiYearSelector.jsx +++ b/src/pages/policy/rightSidebar/MultiYearSelector.jsx @@ -9,6 +9,8 @@ const DEFAULT_SIM_LENGTH = { default: 5, }; +export const MULTI_YEAR_SELECTOR_PERMITTED_COUNTRIES = ["us", "uk"]; + export default function MultiYearSelector(props) { const { metadata, startYear } = props; From d026210bb5773ff66d355abcd200863667829741 Mon Sep 17 00:00:00 2001 From: Anthony Volk Date: Thu, 24 Apr 2025 22:30:50 -0400 Subject: [PATCH 59/69] fix: Visual styling changes --- .../budget/MultiYearBudgetaryImpact.jsx | 36 +++++++++++++++++-- 1 file changed, 34 insertions(+), 2 deletions(-) diff --git a/src/pages/policy/output/budget/MultiYearBudgetaryImpact.jsx b/src/pages/policy/output/budget/MultiYearBudgetaryImpact.jsx index d39a7b1ec..36dc0bb85 100644 --- a/src/pages/policy/output/budget/MultiYearBudgetaryImpact.jsx +++ b/src/pages/policy/output/budget/MultiYearBudgetaryImpact.jsx @@ -12,10 +12,16 @@ const usDataSources = [ { header: "Federal budget", budgetKey: "tax_revenue_impact", + style: { + fontWeight: "bold", + }, }, { header: "State tax", budgetKey: "state_tax_revenue_impact", + style: { + fontStyle: "italic", + }, }, ]; @@ -31,6 +37,9 @@ const ukDataSources = [ { header: "Total", budgetKey: "tax_revenue_impact", + style: { + fontWeight: "bold", + }, }, ]; @@ -114,6 +123,25 @@ export default function MultiYearBudgetaryImpact(props) { regionLabel = regionObj.label; } + const recordStylingOverrides = (record, index) => { + let styles = {}; + + if ( + record.netRevenueImpact === "Federal budget" || + record.netRevenueImpact === "Total" + ) { + styles.fontWeight = "bold"; + } + + if (record.netRevenueImpact === "State tax") { + styles.fontStyle = "italic"; + } + + return { + style: styles, + }; + }; + const title = `${policyLabel} in ${regionLabel}, ${displayPeriod}`; return ( @@ -125,7 +153,11 @@ export default function MultiYearBudgetaryImpact(props) { > {title} -
+
); } @@ -158,6 +190,6 @@ export function getYearRangeFromArray(years, country) { return `${startYear}-${endYearLastTwoDigits}`; } -export function roundToBillions(number, decimals = 1) { +export function roundToBillions(number, decimals = 0) { return (number / 1e9).toFixed(decimals); } From 85863baec77120bf852a0603199af1798bc0358b Mon Sep 17 00:00:00 2001 From: Anthony Volk Date: Tue, 20 May 2025 16:30:46 -0400 Subject: [PATCH 60/69] fix: Merge conflict fixes --- src/api/societyWideAggregation/aggregate.js | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/api/societyWideAggregation/aggregate.js b/src/api/societyWideAggregation/aggregate.js index b53ac1fd9..b33c074eb 100644 --- a/src/api/societyWideAggregation/aggregate.js +++ b/src/api/societyWideAggregation/aggregate.js @@ -1,4 +1,3 @@ -import { AggregatedSocietyWideImpact } from "../../schemas/aggregatedSocietyWideImpact"; import { SocietyWideImpactUK, SocietyWideImpactUS, @@ -17,7 +16,7 @@ export function aggregateMultiYearBudgets(countryId, impacts) { validateImpacts(countryId, impact); } } catch (err) { - console.error("Error validating impacts"); + console.log("Error validating impacts"); throw err; } From 5a8342e04fc871c2365828007fcbdf635362cbee Mon Sep 17 00:00:00 2001 From: Anthony Volk Date: Tue, 20 May 2025 16:40:25 -0400 Subject: [PATCH 61/69] fix: Merge conflict fixes --- .../societyWideAggregation/aggregate.test.js | 95 ------------------- 1 file changed, 95 deletions(-) diff --git a/src/__tests__/api/societyWideAggregation/aggregate.test.js b/src/__tests__/api/societyWideAggregation/aggregate.test.js index ed9e436d8..e6d81fbf1 100644 --- a/src/__tests__/api/societyWideAggregation/aggregate.test.js +++ b/src/__tests__/api/societyWideAggregation/aggregate.test.js @@ -115,98 +115,3 @@ describe("validateImpacts", () => { }); }); }); - -describe("aggregateSocietyWideImpactsUS", () => { - describe("Given a valid US request", () => { - test("it should return an AggregatedSocietyWideImpact object", () => { - const impacts = testObjectsUS; - - expect( - AggregatedSocietyWideImpact.isValidSync( - aggregateSocietyWideImpacts(countryId, impacts), - ), - ).toBe(true); - }); - }); - describe("Given no impacts", () => { - test("it should throw an error", () => { - const impacts = []; - const countryId = "us"; - const error = "Error in aggregateSocietyWideImpacts: No impacts provided"; - console.error = jest.fn(); // Prevent console error output during tests - expect(() => aggregateSocietyWideImpacts(countryId, impacts)).toThrow( - error, - ); - }); - }); - describe("Given an invalid country ID", () => { - test("it should throw an error", () => { - const impacts = [ - { - budget: { - baseline_net_income: 1000, - benefit_spending_impact: 1000, - budgetary_impact: 1000, - households: 1000, - state_tax_revenue_impact: 1000, - tax_revenue_impact: 1000, - }, - }, - ]; - const countryId = "invalid_country"; - - expect(() => aggregateSocietyWideImpacts(countryId, impacts)).toThrow( - `Invalid country ID: ${countryId}`, - ); - }); - }); -}); - -describe("validateImpacts", () => { - describe("Given a valid US impact and US country ID", () => { - test("it should return true", () => { - const impact = testObjectsUS[0]; - const countryId = "us"; - - expect(validateImpacts(countryId, impact)).toBe(true); - }); - }); - describe("Given a valid UK impact and UK country ID", () => { - test("it should return true", () => { - const impact = testObjectsUK[0]; - const countryId = "uk"; - - expect(validateImpacts(countryId, impact)).toBe(true); - }); - }); - describe("Given a valid US impact and UK country ID", () => { - test("it should throw", () => { - const impact = testObjectsUS[0]; - const countryId = "uk"; - - expect(() => validateImpacts(countryId, impact)).toThrow( - "is a required field", - ); - }); - }); - describe("Given an invalid country ID and US impact", () => { - test("it should throw", () => { - const impact = testObjectsUS[0]; - const countryId = "invalid_country"; - - expect(() => validateImpacts(countryId, impact)).toThrow( - "Invalid country ID: invalid_country", - ); - }); - }); - describe("Given a valid country ID and invalid impact", () => { - test("it should throw", () => { - const impact = {}; - const countryId = "uk"; - - expect(() => validateImpacts(countryId, impact)).toThrow( - "is a required field", - ); - }); - }); -}); From 4617dbfab2ce84f2120d00a7f7a53ade0f1f0a36 Mon Sep 17 00:00:00 2001 From: Anthony Volk Date: Tue, 20 May 2025 16:51:21 -0400 Subject: [PATCH 62/69] fix: Consume new format --- src/pages/policy/output/FetchAndDisplayImpact.jsx | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/src/pages/policy/output/FetchAndDisplayImpact.jsx b/src/pages/policy/output/FetchAndDisplayImpact.jsx index dd843e235..a35581afe 100644 --- a/src/pages/policy/output/FetchAndDisplayImpact.jsx +++ b/src/pages/policy/output/FetchAndDisplayImpact.jsx @@ -12,8 +12,8 @@ import { makeSequentialSimulationRequests, SimulationRequestSetup, } from "../../../api/makeSequentialSimulationRequests"; -import { aggregateSocietyWideImpacts } from "../../../api/societyWideAggregation/aggregate"; import { determineIfMultiYear } from "./utils"; +import { aggregateMultiYearBudgets } from "../../../api/societyWideAggregation/aggregate"; /** * @@ -163,11 +163,10 @@ export function FetchAndDisplayImpact(props) { const singleYearResults = collection.results.map((item) => item); const singleYearImpacts = singleYearResults.map((item) => item.result); - // Finally, aggregate outputs and return - const aggregatedResult = aggregateSocietyWideImpacts( - countryId, - singleYearImpacts, - ); + // Aggregate budgetary impacts and place into Impact-like object with budget key + const aggregatedResult = { + budget: aggregateMultiYearBudgets(countryId, singleYearImpacts), + } return { singleYearResults: singleYearResults, From 57e822553c020fec614685448a6be75d07f699ff Mon Sep 17 00:00:00 2001 From: Anthony Volk Date: Tue, 20 May 2025 17:27:27 -0400 Subject: [PATCH 63/69] fix: Fix tests --- .../aggregateModules.test.js | 2 +- .../budget/MultiYearBudgetaryImpact.test.js | 70 ++++++++++++------- .../policy/output/FetchAndDisplayImpact.jsx | 2 +- 3 files changed, 48 insertions(+), 26 deletions(-) diff --git a/src/__tests__/api/societyWideAggregation/aggregateModules.test.js b/src/__tests__/api/societyWideAggregation/aggregateModules.test.js index 6befa7fe4..a8af2c2ae 100644 --- a/src/__tests__/api/societyWideAggregation/aggregateModules.test.js +++ b/src/__tests__/api/societyWideAggregation/aggregateModules.test.js @@ -36,4 +36,4 @@ describe("aggregateBudgetModule", () => { ); }); }); -}); \ No newline at end of file +}); diff --git a/src/__tests__/pages/policy/output/budget/MultiYearBudgetaryImpact.test.js b/src/__tests__/pages/policy/output/budget/MultiYearBudgetaryImpact.test.js index ee99e7b3d..8e71206ce 100644 --- a/src/__tests__/pages/policy/output/budget/MultiYearBudgetaryImpact.test.js +++ b/src/__tests__/pages/policy/output/budget/MultiYearBudgetaryImpact.test.js @@ -30,10 +30,10 @@ describe("MultiYearBudgetaryImpact", () => { }, result: { budget: { - budgetary_impact: 1.5e9, - benefit_spending_impact: 2.3e9, - tax_revenue_impact: 3.1e9, - state_tax_revenue_impact: 0.8e9, + budgetary_impact: 5e9, + benefit_spending_impact: 6e9, + tax_revenue_impact: 7e9, + state_tax_revenue_impact: 8e9, }, }, }, @@ -47,10 +47,10 @@ describe("MultiYearBudgetaryImpact", () => { }, result: { budget: { - budgetary_impact: 9.7e9, - benefit_spending_impact: 2.5e9, - tax_revenue_impact: 3.3e9, - state_tax_revenue_impact: 0.9e9, + budgetary_impact: 9e9, + benefit_spending_impact: 10e9, + tax_revenue_impact: 11e9, + state_tax_revenue_impact: 12e9, }, }, }, @@ -58,10 +58,10 @@ describe("MultiYearBudgetaryImpact", () => { const mockImpact = { budget: { - budgetary_impact: 3.2e9, - benefit_spending_impact: 4.8e9, - tax_revenue_impact: 6.4e9, - state_tax_revenue_impact: 1.7e9, + budgetary_impact: 13e9, + benefit_spending_impact: 14e9, + tax_revenue_impact: 15e9, + state_tax_revenue_impact: 16e9, }, }; @@ -76,20 +76,21 @@ describe("MultiYearBudgetaryImpact", () => { // Then expect(result).toEqual({ - 2020: "1.5", - 2021: "9.7", + 2020: String(mockSingleYearResults[0].result.budget[budgetKey] / 1e9), + 2021: String(mockSingleYearResults[1].result.budget[budgetKey] / 1e9), }); }); test("getYearRangeFromArray should format year range correctly", () => { // Given const years = [2020, 2021, 2022, 2023, 2024]; + const yearsMerged = "2020-24"; // When const result = getYearRangeFromArray(years); // Then - expect(result).toBe("2020-24"); + expect(result).toBe(yearsMerged); }); test("roundToBillions should round numbers correctly", () => { @@ -142,7 +143,7 @@ describe("MultiYearBudgetaryImpact", () => { // Then expect( - screen.getByText("Net revenue impact (billions currency)"), + screen.getByText("Net revenue impact (billions)"), ).toBeInTheDocument(); expect(screen.getByText("2020")).toBeInTheDocument(); expect(screen.getByText("2021")).toBeInTheDocument(); @@ -159,17 +160,38 @@ describe("MultiYearBudgetaryImpact", () => { region: "us", }; + const expectedHeaders = [ + "Federal tax", + "Benefits", + "Federal budget", + "State tax", + ]; + // When render(); - // Then - expect(screen.getByText("Federal tax")).toBeInTheDocument(); - expect(screen.getByText("Benefits")).toBeInTheDocument(); - expect(screen.getByText("Federal budget")).toBeInTheDocument(); - expect(screen.getByText("State tax")).toBeInTheDocument(); - expect(screen.getByText("1.5")).toBeInTheDocument(); - expect(screen.getByText("9.7")).toBeInTheDocument(); - expect(screen.getByText("3.2")).toBeInTheDocument(); + // Make sure all headers render + expectedHeaders.forEach((header) => { + expect(screen.getByText(header)).toBeInTheDocument(); + }); + + // Check for expected values from mock impacts + expect( + screen.getByText(mockImpact.budget.budgetary_impact / 1e9), + ).toBeInTheDocument(); + expect( + screen.getByText(mockImpact.budget.benefit_spending_impact / 1e9), + ).toBeInTheDocument(); + expect( + screen.getByText( + mockSingleYearResults[0].result.budget.budgetary_impact / 1e9, + ), + ).toBeInTheDocument(); + expect( + screen.getByText( + mockSingleYearResults[1].result.budget.budgetary_impact / 1e9, + ), + ).toBeInTheDocument(); }); }); }); diff --git a/src/pages/policy/output/FetchAndDisplayImpact.jsx b/src/pages/policy/output/FetchAndDisplayImpact.jsx index a35581afe..61c8fe574 100644 --- a/src/pages/policy/output/FetchAndDisplayImpact.jsx +++ b/src/pages/policy/output/FetchAndDisplayImpact.jsx @@ -166,7 +166,7 @@ export function FetchAndDisplayImpact(props) { // Aggregate budgetary impacts and place into Impact-like object with budget key const aggregatedResult = { budget: aggregateMultiYearBudgets(countryId, singleYearImpacts), - } + }; return { singleYearResults: singleYearResults, From a9bc0735e5897c703834dc22bb03cdbdbcdb6294 Mon Sep 17 00:00:00 2001 From: Anthony Volk Date: Tue, 20 May 2025 18:45:07 -0400 Subject: [PATCH 64/69] fix: Fix switching issue --- src/pages/policy/output/Display.jsx | 12 +++++++++--- src/pages/policy/output/FetchAndDisplayImpact.jsx | 12 ++++++++++-- 2 files changed, 19 insertions(+), 5 deletions(-) diff --git a/src/pages/policy/output/Display.jsx b/src/pages/policy/output/Display.jsx index 287be6a15..e5e278ded 100644 --- a/src/pages/policy/output/Display.jsx +++ b/src/pages/policy/output/Display.jsx @@ -153,8 +153,14 @@ function getPolicyLabel(policy) { * performing actions such as downloading data and sharing results */ export function DisplayImpact(props) { - const { impact, singleYearResults, policy, metadata, showPolicyImpactPopup } = - props; + const { + impact, + multiYearImpact, + singleYearResults, + policy, + metadata, + showPolicyImpactPopup, + } = props; const countryId = useCountryId(); const urlParams = new URLSearchParams(window.location.search); const focus = urlParams.get("focus"); @@ -183,7 +189,7 @@ export function DisplayImpact(props) { pane = ( { - setImpact(aggregatedData.aggregatedResult); + setMultiYearImpact(aggregatedData.aggregatedResult); setSingleYearResults(aggregatedData.singleYearResults); }) .catch((err) => { @@ -220,6 +223,7 @@ export function FetchAndDisplayImpact(props) { return; } + // start counting (but stop when the API call finishes) const interval = setInterval(() => { setSecondsElapsed((secondsElapsed) => secondsElapsed + 1); @@ -323,7 +327,10 @@ export function FetchAndDisplayImpact(props) { return ; } - if (!impact) { + if ( + (!isMultiYear && !impact) || + (isMultiYear && !multiYearImpact && !singleYearResults) + ) { return ( Date: Tue, 20 May 2025 18:50:11 -0400 Subject: [PATCH 65/69] fix: Remove enhanced_us maintenance code --- .../policy/output/budget/MultiYearBudgetaryImpact.jsx | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/src/pages/policy/output/budget/MultiYearBudgetaryImpact.jsx b/src/pages/policy/output/budget/MultiYearBudgetaryImpact.jsx index 36dc0bb85..3c1223dc8 100644 --- a/src/pages/policy/output/budget/MultiYearBudgetaryImpact.jsx +++ b/src/pages/policy/output/budget/MultiYearBudgetaryImpact.jsx @@ -114,12 +114,7 @@ export default function MultiYearBudgetaryImpact(props) { ); let regionLabel = "undefined region"; - // This is a workaround for enhanced_us that should be changed - // if and when it is treated as something other than a "region" - // by the back end - if (regionObj?.name === "enhanced_us") { - regionLabel = "the US"; - } else if (regionObj) { + if (regionObj) { regionLabel = regionObj.label; } From 9535e186afada54ff41b6f274f49f5985396b3ed Mon Sep 17 00:00:00 2001 From: Anthony Volk Date: Tue, 20 May 2025 20:21:21 -0400 Subject: [PATCH 66/69] fix: Add variable rounding by country package --- .../budget/MultiYearBudgetaryImpact.jsx | 26 ++++++++++++++++--- 1 file changed, 22 insertions(+), 4 deletions(-) diff --git a/src/pages/policy/output/budget/MultiYearBudgetaryImpact.jsx b/src/pages/policy/output/budget/MultiYearBudgetaryImpact.jsx index 3c1223dc8..40d73d8f0 100644 --- a/src/pages/policy/output/budget/MultiYearBudgetaryImpact.jsx +++ b/src/pages/policy/output/budget/MultiYearBudgetaryImpact.jsx @@ -54,6 +54,12 @@ const financialYearTypeByCountry = { default: "calendar", }; +const roundingPrecisionByCountry = { + us: 0, + uk: 1, + default: 0, +}; + /** * Maps a year to an Ant Design column title Object * @param {number} year - The year to map @@ -102,8 +108,16 @@ export default function MultiYearBudgetaryImpact(props) { const dataSources = countryDataSource.map((item) => { return { netRevenueImpact: item.header, - ...getYearlyImpacts(singleYearResults, item.budgetKey), - yearRange: roundToBillions(impact.budget[item.budgetKey]), + ...getYearlyImpacts( + singleYearResults, + item.budgetKey, + metadata.countryId, + ), + yearRange: roundToBillions( + impact.budget[item.budgetKey], + roundingPrecisionByCountry[metadata.countryId] || + roundingPrecisionByCountry.default, + ), }; }); @@ -157,11 +171,15 @@ export default function MultiYearBudgetaryImpact(props) { ); } -export function getYearlyImpacts(singleYearResults, budgetKey) { +export function getYearlyImpacts(singleYearResults, budgetKey, countryId) { const yearlyImpacts = {}; singleYearResults.forEach((item) => { const year = item.simulationRequestSetup.year; - const impact = roundToBillions(item.result.budget[budgetKey]); + const impact = roundToBillions( + item.result.budget[budgetKey], + roundingPrecisionByCountry[countryId] || + roundingPrecisionByCountry.default, + ); yearlyImpacts[year] = impact; }); From 042c124162b15c469fc48f0cd42a791a29892271 Mon Sep 17 00:00:00 2001 From: Anthony Volk Date: Fri, 23 May 2025 16:12:54 -0400 Subject: [PATCH 67/69] fix: Properly assign output categories --- .../policy/output/budget/MultiYearBudgetaryImpact.jsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/pages/policy/output/budget/MultiYearBudgetaryImpact.jsx b/src/pages/policy/output/budget/MultiYearBudgetaryImpact.jsx index 40d73d8f0..8f5122949 100644 --- a/src/pages/policy/output/budget/MultiYearBudgetaryImpact.jsx +++ b/src/pages/policy/output/budget/MultiYearBudgetaryImpact.jsx @@ -3,7 +3,7 @@ import { Table } from "antd"; const usDataSources = [ { header: "Federal tax", - budgetKey: "budgetary_impact", + budgetKey: "tax_revenue_impact", }, { header: "Benefits", @@ -11,7 +11,7 @@ const usDataSources = [ }, { header: "Federal budget", - budgetKey: "tax_revenue_impact", + budgetKey: "budgetary_impact", style: { fontWeight: "bold", }, @@ -28,7 +28,7 @@ const usDataSources = [ const ukDataSources = [ { header: "Tax", - budgetKey: "budgetary_impact", + budgetKey: "tax_revenue_impact", }, { header: "Benefits", @@ -36,7 +36,7 @@ const ukDataSources = [ }, { header: "Total", - budgetKey: "tax_revenue_impact", + budgetKey: "budgetary_impact", style: { fontWeight: "bold", }, From deb97d124a23fbbeccf240363b443f84a33a6551 Mon Sep 17 00:00:00 2001 From: Anthony Volk Date: Sat, 24 May 2025 01:41:32 -0400 Subject: [PATCH 68/69] fix: Add capability to calculate formulas --- .../budget/MultiYearBudgetaryImpact.test.js | 45 +++++++++++++++++- .../budget/MultiYearBudgetaryImpact.jsx | 47 +++++++++++++++---- 2 files changed, 83 insertions(+), 9 deletions(-) diff --git a/src/__tests__/pages/policy/output/budget/MultiYearBudgetaryImpact.test.js b/src/__tests__/pages/policy/output/budget/MultiYearBudgetaryImpact.test.js index 8e71206ce..c4d3fa3e3 100644 --- a/src/__tests__/pages/policy/output/budget/MultiYearBudgetaryImpact.test.js +++ b/src/__tests__/pages/policy/output/budget/MultiYearBudgetaryImpact.test.js @@ -4,6 +4,7 @@ import MultiYearBudgetaryImpact, { getYearlyImpacts, getYearRangeFromArray, roundToBillions, + getFederalBudgetImpact, } from "pages/policy/output/budget/MultiYearBudgetaryImpact"; import "@testing-library/jest-dom"; import data from "../../../../__setup__/data.json"; @@ -70,9 +71,10 @@ describe("MultiYearBudgetaryImpact", () => { // Given const singleYearResults = mockSingleYearResults; const budgetKey = "budgetary_impact"; + const countryId = "us"; // When - const result = getYearlyImpacts(singleYearResults, budgetKey); + const result = getYearlyImpacts(singleYearResults, countryId, budgetKey); // Then expect(result).toEqual({ @@ -80,6 +82,30 @@ describe("MultiYearBudgetaryImpact", () => { 2021: String(mockSingleYearResults[1].result.budget[budgetKey] / 1e9), }); }); + test("getYearlyImpacts should correctly format yearly impacts with formula", () => { + // Given + const singleYearResults = mockSingleYearResults; + const formula = (budget) => + budget.tax_revenue_impact - budget.state_tax_revenue_impact; + const countryId = "us"; + const budgetKey = null; + const expectedImpact = singleYearResults.map((item) => + formula(item.result.budget), + ); + const expectedResult = { + 2020: String(expectedImpact[0] / 1e9), + 2021: String(expectedImpact[1] / 1e9), + }; + // When + const result = getYearlyImpacts( + singleYearResults, + countryId, + budgetKey, + formula, + ); + // Then + expect(result).toEqual(expectedResult); + }); test("getYearRangeFromArray should format year range correctly", () => { // Given @@ -194,4 +220,21 @@ describe("MultiYearBudgetaryImpact", () => { ).toBeInTheDocument(); }); }); + describe("Formula functions", () => { + test("getFederalBudgetImpact should return correct value", () => { + // Given + const budget = { + budgetary_impact: 5e9, + benefit_spending_impact: 6e9, + tax_revenue_impact: 7e9, + state_tax_revenue_impact: 4e9, + }; + + // When + const result = getFederalBudgetImpact(budget); + + // Then + expect(result).toBe(3e9); + }); + }); }); diff --git a/src/pages/policy/output/budget/MultiYearBudgetaryImpact.jsx b/src/pages/policy/output/budget/MultiYearBudgetaryImpact.jsx index 8f5122949..d932925da 100644 --- a/src/pages/policy/output/budget/MultiYearBudgetaryImpact.jsx +++ b/src/pages/policy/output/budget/MultiYearBudgetaryImpact.jsx @@ -3,7 +3,7 @@ import { Table } from "antd"; const usDataSources = [ { header: "Federal tax", - budgetKey: "tax_revenue_impact", + formula: getFederalBudgetImpact, }, { header: "Benefits", @@ -85,6 +85,8 @@ export function mapFinancialYearToColumn(year, country) { export default function MultiYearBudgetaryImpact(props) { const { metadata, impact, singleYearResults, policyLabel, region } = props; + console.log(singleYearResults); + const years = singleYearResults.map((item) => { return item.simulationRequestSetup.year; }); @@ -110,11 +112,14 @@ export default function MultiYearBudgetaryImpact(props) { netRevenueImpact: item.header, ...getYearlyImpacts( singleYearResults, - item.budgetKey, metadata.countryId, + item.budgetKey ? item.budgetKey : null, + item.formula ? item.formula : null, ), yearRange: roundToBillions( - impact.budget[item.budgetKey], + item.budgetKey + ? impact.budget[item.budgetKey] + : item.formula(impact.budget), roundingPrecisionByCountry[metadata.countryId] || roundingPrecisionByCountry.default, ), @@ -171,17 +176,39 @@ export default function MultiYearBudgetaryImpact(props) { ); } -export function getYearlyImpacts(singleYearResults, budgetKey, countryId) { +/** + * Create yearly impacts structured data for chart component + * @param {SequentialSimulationResult} singleYearResults + * @param {String} countryId + * @param {String | null} budgetKey The specific budgetary impact key for a table row to use, unless formula is provided + * @param {Callable | null} formula If necessary, a more complex formula drawn from the budgetary impact object to use instead of the budgetKey + * @returns {Object} The yearly impacts object expected by Ant Design Table component + */ +export function getYearlyImpacts( + singleYearResults, + countryId, + budgetKey = null, + formula = null, +) { const yearlyImpacts = {}; singleYearResults.forEach((item) => { const year = item.simulationRequestSetup.year; - const impact = roundToBillions( - item.result.budget[budgetKey], + let impact = null; + if (formula) { + impact = formula(item.result.budget); + console.log("Formula impact", impact); + } else { + impact = item.result.budget[budgetKey]; + console.log("Budget impact", impact); + } + + console.log("impact", impact); + + yearlyImpacts[year] = roundToBillions( + impact, roundingPrecisionByCountry[countryId] || roundingPrecisionByCountry.default, ); - - yearlyImpacts[year] = impact; }); return yearlyImpacts; } @@ -206,3 +233,7 @@ export function getYearRangeFromArray(years, country) { export function roundToBillions(number, decimals = 0) { return (number / 1e9).toFixed(decimals); } + +export function getFederalBudgetImpact(budget) { + return budget.tax_revenue_impact - budget.state_tax_revenue_impact; +} From a6ca8c50e6df7ec2de3b19887b53b53d094e0f83 Mon Sep 17 00:00:00 2001 From: Anthony Volk Date: Sat, 24 May 2025 01:42:04 -0400 Subject: [PATCH 69/69] chore: Remove console logs --- src/pages/policy/output/budget/MultiYearBudgetaryImpact.jsx | 6 ------ 1 file changed, 6 deletions(-) diff --git a/src/pages/policy/output/budget/MultiYearBudgetaryImpact.jsx b/src/pages/policy/output/budget/MultiYearBudgetaryImpact.jsx index d932925da..6d9ed6c8d 100644 --- a/src/pages/policy/output/budget/MultiYearBudgetaryImpact.jsx +++ b/src/pages/policy/output/budget/MultiYearBudgetaryImpact.jsx @@ -85,8 +85,6 @@ export function mapFinancialYearToColumn(year, country) { export default function MultiYearBudgetaryImpact(props) { const { metadata, impact, singleYearResults, policyLabel, region } = props; - console.log(singleYearResults); - const years = singleYearResults.map((item) => { return item.simulationRequestSetup.year; }); @@ -196,14 +194,10 @@ export function getYearlyImpacts( let impact = null; if (formula) { impact = formula(item.result.budget); - console.log("Formula impact", impact); } else { impact = item.result.budget[budgetKey]; - console.log("Budget impact", impact); } - console.log("impact", impact); - yearlyImpacts[year] = roundToBillions( impact, roundingPrecisionByCountry[countryId] ||