Some checks failed
Build and Push Docker Images / docker (push) Has been cancelled
- Add beautiful HTML email templates for receipts, admin notifications, and contact forms - Implement SMTP email service with Nodemailer and Handlebars templating - Add carbon equivalency calculations with EPA/DEFRA/IMO 2024 conversion factors - Add portfolio color palette system for project visualization - Integrate Wren API portfolio fetching in webhook handler - Add light mode enforcement for email client compatibility - Include Puffin logo from MinIO S3 in all templates - Add test email endpoint for template validation 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
257 lines
6.7 KiB
JavaScript
257 lines
6.7 KiB
JavaScript
/**
|
|
* Carbon Equivalencies - EPA/DEFRA/IMO 2024 Verified Conversion Factors
|
|
* Ported from frontend src/utils/carbonEquivalencies.ts and impactSelector.ts
|
|
*/
|
|
|
|
// Carbon equivalency conversion factors (units per metric ton of CO2e)
|
|
export const CARBON_EQUIVALENCIES = {
|
|
// TRANSPORTATION (EPA 2024)
|
|
MILES_DRIVEN_AVG_CAR: 2560,
|
|
GALLONS_GASOLINE: 113,
|
|
|
|
// AVIATION (DEFRA 2024)
|
|
FLIGHT_KM_ECONOMY_SHORT: 6667,
|
|
FLIGHT_KM_ECONOMY_LONG: 7692,
|
|
|
|
// NATURAL (EPA 2024)
|
|
TREE_SEEDLINGS_10YR: 16.7,
|
|
FOREST_ACRES_1YR: 1.0,
|
|
FOREST_ACRES_10YR: 1.32,
|
|
|
|
// ENERGY (EPA 2024)
|
|
ELECTRICITY_KWH: 2540,
|
|
HOME_ENERGY_YEARS: 0.134,
|
|
BARRELS_OIL: 2.33,
|
|
|
|
// YACHT-SPECIFIC (IMO 2024)
|
|
MGO_LITERS_AVOIDED: 373,
|
|
MGO_TONNES_AVOIDED: 0.312,
|
|
CRUISING_HOURS_60M: 1.24,
|
|
SUPERYACHT_NAUTICAL_MILES: 149,
|
|
SHORE_POWER_DAYS: 25.4,
|
|
|
|
// LIFESTYLE
|
|
SMARTPHONE_CHARGES: 115000,
|
|
POUNDS_COAL_BURNED: 1000,
|
|
};
|
|
|
|
// Comparison definitions with emoji icons (email-compatible)
|
|
const COMPARISON_DEFINITIONS = {
|
|
MILES_DRIVEN: {
|
|
icon: '🚗',
|
|
unit: 'miles',
|
|
label: 'Miles driven in an average car',
|
|
},
|
|
GALLONS_GASOLINE: {
|
|
icon: '⛽',
|
|
unit: 'gallons',
|
|
label: 'Gallons of gasoline not burned',
|
|
},
|
|
FLIGHT_KM_SHORT: {
|
|
icon: '✈️',
|
|
unit: 'passenger-km',
|
|
label: 'Short-haul flight distance (economy)',
|
|
},
|
|
FLIGHT_KM_LONG: {
|
|
icon: '🛫',
|
|
unit: 'passenger-km',
|
|
label: 'Long-haul flight distance (economy)',
|
|
},
|
|
TREE_SEEDLINGS: {
|
|
icon: '🌲',
|
|
unit: 'trees',
|
|
label: 'Tree seedlings grown for 10 years',
|
|
},
|
|
FOREST_ACRES_1YR: {
|
|
icon: '🌳',
|
|
unit: 'acres',
|
|
label: 'Acres of forest preserved for 1 year',
|
|
},
|
|
FOREST_ACRES_10YR: {
|
|
icon: '🌳',
|
|
unit: 'acres',
|
|
label: 'Acres of forest carbon sequestration over 10 years',
|
|
},
|
|
ELECTRICITY_KWH: {
|
|
icon: '⚡',
|
|
unit: 'kWh',
|
|
label: 'Kilowatt-hours of electricity',
|
|
},
|
|
HOME_ENERGY_YEARS: {
|
|
icon: '🏠',
|
|
unit: 'home-years',
|
|
label: 'Years of home electricity use',
|
|
},
|
|
BARRELS_OIL: {
|
|
icon: '💧',
|
|
unit: 'barrels',
|
|
label: 'Barrels of oil not consumed',
|
|
},
|
|
MGO_LITERS: {
|
|
icon: '🌊',
|
|
unit: 'liters',
|
|
label: 'Liters of Marine Gas Oil avoided',
|
|
},
|
|
MGO_TONNES: {
|
|
icon: '💧',
|
|
unit: 'tonnes',
|
|
label: 'Tonnes of Marine Gas Oil avoided',
|
|
},
|
|
CRUISING_HOURS: {
|
|
icon: '⚓',
|
|
unit: 'hours',
|
|
label: 'Hours of 60m yacht cruising avoided',
|
|
},
|
|
SUPERYACHT_NAUTICAL_MILES: {
|
|
icon: '🚢',
|
|
unit: 'nautical miles',
|
|
label: 'Nautical miles of superyacht voyaging',
|
|
},
|
|
SHORE_POWER_DAYS: {
|
|
icon: '🔋',
|
|
unit: 'days',
|
|
label: 'Days of shore power for large yacht',
|
|
},
|
|
SMARTPHONE_CHARGES: {
|
|
icon: '📱',
|
|
unit: 'charges',
|
|
label: 'Smartphone charges',
|
|
},
|
|
POUNDS_COAL: {
|
|
icon: '🔥',
|
|
unit: 'pounds',
|
|
label: 'Pounds of coal not burned',
|
|
},
|
|
};
|
|
|
|
// Smart scaling ranges - different comparisons for different CO2 amounts
|
|
const SCALING_RANGES = [
|
|
{
|
|
minTons: 0,
|
|
maxTons: 0.1,
|
|
comparisonKeys: ['MILES_DRIVEN', 'SMARTPHONE_CHARGES', 'TREE_SEEDLINGS'],
|
|
},
|
|
{
|
|
minTons: 0.1,
|
|
maxTons: 1,
|
|
comparisonKeys: ['MILES_DRIVEN', 'TREE_SEEDLINGS', 'GALLONS_GASOLINE'],
|
|
},
|
|
{
|
|
minTons: 1,
|
|
maxTons: 5,
|
|
comparisonKeys: ['FLIGHT_KM_SHORT', 'TREE_SEEDLINGS', 'ELECTRICITY_KWH'],
|
|
},
|
|
{
|
|
minTons: 5,
|
|
maxTons: 20,
|
|
comparisonKeys: ['FLIGHT_KM_LONG', 'FOREST_ACRES_1YR', 'MGO_LITERS'],
|
|
},
|
|
{
|
|
minTons: 20,
|
|
maxTons: 100,
|
|
comparisonKeys: ['CRUISING_HOURS', 'FOREST_ACRES_1YR', 'HOME_ENERGY_YEARS'],
|
|
},
|
|
{
|
|
minTons: 100,
|
|
maxTons: Infinity,
|
|
comparisonKeys: ['FOREST_ACRES_10YR', 'CRUISING_HOURS', 'SUPERYACHT_NAUTICAL_MILES'],
|
|
},
|
|
];
|
|
|
|
// Factor mapping for conversion
|
|
const FACTOR_MAP = {
|
|
MILES_DRIVEN: CARBON_EQUIVALENCIES.MILES_DRIVEN_AVG_CAR,
|
|
GALLONS_GASOLINE: CARBON_EQUIVALENCIES.GALLONS_GASOLINE,
|
|
FLIGHT_KM_SHORT: CARBON_EQUIVALENCIES.FLIGHT_KM_ECONOMY_SHORT,
|
|
FLIGHT_KM_LONG: CARBON_EQUIVALENCIES.FLIGHT_KM_ECONOMY_LONG,
|
|
TREE_SEEDLINGS: CARBON_EQUIVALENCIES.TREE_SEEDLINGS_10YR,
|
|
FOREST_ACRES_1YR: CARBON_EQUIVALENCIES.FOREST_ACRES_1YR,
|
|
FOREST_ACRES_10YR: CARBON_EQUIVALENCIES.FOREST_ACRES_10YR,
|
|
ELECTRICITY_KWH: CARBON_EQUIVALENCIES.ELECTRICITY_KWH,
|
|
HOME_ENERGY_YEARS: CARBON_EQUIVALENCIES.HOME_ENERGY_YEARS,
|
|
BARRELS_OIL: CARBON_EQUIVALENCIES.BARRELS_OIL,
|
|
MGO_LITERS: CARBON_EQUIVALENCIES.MGO_LITERS_AVOIDED,
|
|
MGO_TONNES: CARBON_EQUIVALENCIES.MGO_TONNES_AVOIDED,
|
|
CRUISING_HOURS: CARBON_EQUIVALENCIES.CRUISING_HOURS_60M,
|
|
SUPERYACHT_NAUTICAL_MILES: CARBON_EQUIVALENCIES.SUPERYACHT_NAUTICAL_MILES,
|
|
SHORE_POWER_DAYS: CARBON_EQUIVALENCIES.SHORE_POWER_DAYS,
|
|
SMARTPHONE_CHARGES: CARBON_EQUIVALENCIES.SMARTPHONE_CHARGES,
|
|
POUNDS_COAL: CARBON_EQUIVALENCIES.POUNDS_COAL_BURNED,
|
|
};
|
|
|
|
/**
|
|
* Calculate equivalency value
|
|
*/
|
|
function calculateEquivalency(tons, factor) {
|
|
return tons * factor;
|
|
}
|
|
|
|
/**
|
|
* Format large numbers with appropriate precision
|
|
*/
|
|
function formatEquivalencyValue(value) {
|
|
if (value < 0.01) {
|
|
return value.toExponential(2);
|
|
} else if (value < 1) {
|
|
return value.toFixed(2);
|
|
} else if (value < 100) {
|
|
return value.toFixed(1);
|
|
} else if (value < 1000) {
|
|
return Math.round(value).toLocaleString();
|
|
} else {
|
|
return Math.round(value).toLocaleString();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Select the most appropriate comparisons for the given CO2 amount
|
|
* @param {number} tons - Metric tons of CO2e to offset
|
|
* @param {number} count - Number of comparisons to return (default: 3)
|
|
* @returns {Array} Array of carbon comparisons with calculated values
|
|
*/
|
|
export function selectComparisons(tons, count = 3) {
|
|
// Find the appropriate range for this amount
|
|
const range = SCALING_RANGES.find(
|
|
(r) => tons >= r.minTons && tons < r.maxTons
|
|
);
|
|
|
|
if (!range) {
|
|
console.warn(`No comparison range found for ${tons} tons, using default`);
|
|
return getDefaultComparisons(tons, count);
|
|
}
|
|
|
|
// Get the comparison definitions for this range
|
|
const comparisons = range.comparisonKeys
|
|
.slice(0, count)
|
|
.map((key) => {
|
|
const definition = COMPARISON_DEFINITIONS[key];
|
|
const factor = FACTOR_MAP[key];
|
|
const value = calculateEquivalency(tons, factor);
|
|
|
|
return {
|
|
...definition,
|
|
value: formatEquivalencyValue(value),
|
|
};
|
|
});
|
|
|
|
return comparisons;
|
|
}
|
|
|
|
/**
|
|
* Get default comparisons when no range matches
|
|
*/
|
|
function getDefaultComparisons(tons, count) {
|
|
const defaultKeys = ['MILES_DRIVEN', 'TREE_SEEDLINGS', 'ELECTRICITY_KWH'];
|
|
|
|
return defaultKeys.slice(0, count).map((key) => {
|
|
const definition = COMPARISON_DEFINITIONS[key];
|
|
const factor = FACTOR_MAP[key];
|
|
const value = calculateEquivalency(tons, factor);
|
|
|
|
return {
|
|
...definition,
|
|
value: formatEquivalencyValue(value),
|
|
};
|
|
});
|
|
}
|