UNPKG

nutri-calc

Version:

A TypeScript library for calculating Health Star Ratings according to the Australian and New Zealand food labelling system

334 lines (333 loc) 13.8 kB
"use strict"; /** * Health Star Rating Calculator * * This module implements the Health Star Rating (HSR) system, which is a front-of-pack labeling system * that rates the nutritional profile of packaged foods from 0.5 to 5 stars. The system helps consumers * make healthier food choices by comparing similar packaged foods. */ Object.defineProperty(exports, "__esModule", { value: true }); exports.Attributes = exports.Category = void 0; exports.isCategory1 = isCategory1; exports.isCategory1D = isCategory1D; exports.isCategory2 = isCategory2; exports.isCategory2D = isCategory2D; exports.isCategory3 = isCategory3; exports.isCategory3D = isCategory3D; exports.calculateFruitVegetableNutLegumePoints = calculateFruitVegetableNutLegumePoints; exports.calculateProteinPoints = calculateProteinPoints; exports.calculateFibrePoints = calculateFibrePoints; exports.calculateBaselinePoints = calculateBaselinePoints; exports.ratingFromTotalPoints = ratingFromTotalPoints; exports.calculateHealthStarRating = calculateHealthStarRating; /** * Represents the different food categories in the Health Star Rating system. * Each category has specific calculation rules and thresholds. */ var Category; (function (Category) { Category[Category["DairyBeverages"] = 0] = "DairyBeverages"; Category[Category["DairyFoods"] = 1] = "DairyFoods"; Category[Category["FatsOilsAndSpreads"] = 2] = "FatsOilsAndSpreads"; Category[Category["Cheese"] = 3] = "Cheese"; Category[Category["PlainWater"] = 4] = "PlainWater"; Category[Category["UnsweetenedFlavouredWater"] = 5] = "UnsweetenedFlavouredWater"; Category[Category["UnprocessedFruitAndVegetables"] = 6] = "UnprocessedFruitAndVegetables"; Category[Category["NonDairyBeverages"] = 7] = "NonDairyBeverages"; Category[Category["Jellies"] = 8] = "Jellies"; Category[Category["WaterBasedIcedConfection"] = 9] = "WaterBasedIcedConfection"; Category[Category["OtherFoods"] = 100] = "OtherFoods"; })(Category || (exports.Category = Category = {})); /** * Represents additional attributes that can affect the HSR calculation. */ var Attributes; (function (Attributes) { Attributes[Attributes["ContainsFruitOrVegetable"] = 0] = "ContainsFruitOrVegetable"; Attributes[Attributes["ContainsNutsOrLegumes"] = 1] = "ContainsNutsOrLegumes"; })(Attributes || (exports.Attributes = Attributes = {})); /** * Checks if a food belongs to Category 1 (Non-dairy beverages). */ function isCategory1(category) { return [Category.NonDairyBeverages, Category.Jellies, Category.WaterBasedIcedConfection].includes(category); } /** * Checks if a food belongs to Category 1D (Dairy beverages). */ function isCategory1D(category) { return category === Category.DairyBeverages; } /** * Checks if a food belongs to Category 2 (Other foods). */ function isCategory2(category) { return category === Category.OtherFoods; } /** * Checks if a food belongs to Category 2D (Dairy foods). */ function isCategory2D(category) { return category === Category.DairyFoods; } /** * Checks if a food belongs to Category 3 (Fats, oils, and spreads). */ function isCategory3(category) { return category === Category.FatsOilsAndSpreads; } /** * Checks if a food belongs to Category 3D (Cheese). */ function isCategory3D(category) { return category === Category.Cheese; } /** * Calculates points based on fruit, vegetable, nut, and legume (FVNL) content. * * @param category - The food category * @param attributes - Array of additional food attributes * @param percentageFruitVegetableNutLegume - Percentage of FVNL content * @returns Number of points (0-8 for most categories, 0-10 for Category 1) */ function calculateFruitVegetableNutLegumePoints(category, attributes, percentageFruitVegetableNutLegume) { // Return 0 if no relevant attributes are present if (!(attributes === null || attributes === void 0 ? void 0 : attributes.length) || !(attributes.includes(Attributes.ContainsFruitOrVegetable) || attributes.includes(Attributes.ContainsNutsOrLegumes))) { return 0; } let range = []; // Define thresholds based on category and attributes if (isCategory1D(category) || isCategory2(category) || isCategory2D(category) || isCategory3(category) || isCategory3D(category)) { if (attributes.includes(Attributes.ContainsFruitOrVegetable)) { range = [25, 43, 52, 63, 67, 80, 90, 100]; // Fruit/vegetable content thresholds } else if (attributes.includes(Attributes.ContainsNutsOrLegumes)) { range = [40, 60, 67, 75, 80, 90, 95, 100]; // Nut/legume content thresholds } } else if (isCategory1(category)) { range = [25, 33, 41, 49, 57, 65, 73, 81, 89, 96]; // Category 1 specific thresholds } else { return 0; } // Calculate points based on percentage and thresholds const points = range.findIndex(value => percentageFruitVegetableNutLegume < value); if (points >= 0) { return points; } else if (points === -1 && percentageFruitVegetableNutLegume >= range[range.length - 1]) { return range.length; } return 0; } /** * Calculates protein points based on protein content and other factors. * * @param category - The food category * @param baselinePoints - Previously calculated baseline points * @param fruitVegetableNutLegumePoints - Previously calculated FVNL points * @param proteinGrams - Protein content in grams * @returns Number of protein points (0-15) */ function calculateProteinPoints(category, baselinePoints, fruitVegetableNutLegumePoints, proteinGrams) { let range = []; // Category-specific rules for protein points if (isCategory1(category)) { return 0; // No protein points for Category 1 } else if (baselinePoints >= 13 && fruitVegetableNutLegumePoints < 5) { return 0; // No protein points if baseline is high and FVNL is low } else if (isCategory1D(category) || isCategory2(category) || isCategory2D(category) || isCategory3(category) || isCategory3D(category)) { range = [1.6, 3.1, 4.8, 6.4, 8, 9.6, 11.6, 13.9, 16.7, 20, 24, 28.9, 34.7, 41.6, 50]; } else { return 0; } // Calculate points based on protein content const points = range.findIndex(value => proteinGrams <= value); if (points >= 0) { return points; } else if (points === -1 && proteinGrams >= range[range.length - 1]) { return range.length; } return 0; } /** * Calculates fibre points based on fibre content. * * @param category - The food category * @param fibreGrams - Fibre content in grams * @returns Number of fibre points (0-15) */ function calculateFibrePoints(category, fibreGrams) { let range = []; // Category-specific rules for fibre points if (isCategory1(category) || isCategory1D(category)) { return 0; // No fibre points for beverages } else if (isCategory2(category) || isCategory2D(category) || isCategory3(category) || isCategory3D(category)) { range = [0.9, 1.9, 2.8, 3.7, 4.7, 5.4, 6.3, 7.3, 8.4, 9.7, 11.2, 13, 15, 17.3, 20]; } else { return 0; } // Calculate points based on fibre content const points = range.findIndex(value => fibreGrams <= value); if (points >= 0) { return points; } else if (points === -1 && fibreGrams >= range[range.length - 1]) { return range.length; } return 0; } /** * Calculates baseline points based on energy, saturated fat, sugar, and sodium content. * * @param category - The food category * @param nutritionalInformation - Basic nutritional information * @returns Total baseline points */ function calculateBaselinePoints(category, nutritionalInformation) { const ranges = { energykJ: [], saturatedFatGrams: [], totalSugarsGrams: [], sodiumMilligrams: [], }; // Common ranges used across multiple categories const commonEnergyRange = [335, 670, 1005, 1340, 1675, 2010, 2345, 2680, 3015, 3350, 3685]; const commonSodiumRange = [90, 180, 270, 360, 450, 540, 630, 720, 810, 900, 990, 1080, 1170, 1260, 1350, 1440, 1530, 1620, 1710, 1800, 1890, 1980, 2070, 2160, 2250, 2340, 2430, 2520, 2610, 2700]; // Set category-specific ranges if (isCategory1D(category) || isCategory2(category) || isCategory2D(category)) { ranges.energykJ = commonEnergyRange; ranges.saturatedFatGrams = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11.2, 12.5, 13.9, 15.5, 17.3, 19.3, 21.6, 24.1, 26.9, 30, 33.5, 37.4, 41.7, 46.6, 52, 58, 64.7, 72.3, 80.6, 90]; ranges.totalSugarsGrams = [5, 8.9, 12.8, 16.8, 20.7, 24.6, 28.5, 32.4, 36.3, 40.3, 44.2, 48.1, 52, 55.9, 59.8, 63.8, 67.7, 71.6, 75.5, 79.4, 83.3, 87.3, 91.2, 95.1, 99]; ranges.sodiumMilligrams = commonSodiumRange; } else if (isCategory3(category) || isCategory3D(category)) { ranges.energykJ = commonEnergyRange; ranges.saturatedFatGrams = Array.from({ length: 30 }, (_, i) => i + 1); // [1..30] ranges.totalSugarsGrams = [5, 9, 13.5, 18, 22.5, 27, 31, 36, 40, 45]; ranges.sodiumMilligrams = commonSodiumRange; } else if (isCategory1(category)) { ranges.energykJ = [-1, 31, 61, 91, 121, 151, 181, 211, 241, 271]; ranges.totalSugarsGrams = [0.1, 1.6, 3.1, 4.6, 6.1, 7.6, 9.1, 10.6, 12.1, 13.6]; } else { return 0; } // Calculate total points across all nutritional components let total = 0; for (const [key, range] of Object.entries(ranges)) { if (!(range === null || range === void 0 ? void 0 : range.length)) { continue; } const nutrition = nutritionalInformation[key]; const points = range.findIndex(value => nutrition <= value); if (points >= 0) { total += points; } else if (points === -1 && nutrition >= range[range.length - 1]) { total += range.length; } } return total; } /** * Converts total points to a Health Star Rating based on category-specific thresholds. * * @param category - The food category * @param totalPoints - Previously calculated total points * @returns Health Star Rating (0.5-5 stars, or null if invalid) */ function ratingFromTotalPoints(category, totalPoints) { let pointRange = []; const ratings = [5, 4.5, 4, 3.5, 3, 2.5, 2, 1.5, 1, 0.5]; const roundedPoints = Math.round(totalPoints); // Category-specific point ranges for ratings if (isCategory1(category)) { pointRange = [-9999, -999, 0, 1, 3, 5, 7, 9, 11, 12]; // Non-dairy beverages } else if (isCategory1D(category)) { pointRange = [-2, -1, 0, 1, 2, 3, 4, 5, 6, 7]; // Dairy beverages } else if (isCategory2(category)) { pointRange = [-11, -7, -2, 2, 6, 11, 15, 20, 24, 25]; // Other foods } else if (isCategory2D(category)) { pointRange = [-2, 0, 2, 3, 5, 7, 8, 10, 12, 13]; // Dairy foods } else if (isCategory3(category)) { pointRange = [13, 16, 20, 23, 27, 30, 34, 37, 41, 42]; // Oils and spreads } else if (isCategory3D(category)) { pointRange = [24, 26, 28, 30, 31, 33, 35, 37, 39, 40]; // Cheese } else { return null; } // Find the appropriate rating based on total points const ratingIndex = pointRange.findIndex(value => roundedPoints <= value); if (ratingIndex >= 0) { return ratings[ratingIndex]; } else if (ratingIndex === -1 && roundedPoints >= pointRange[pointRange.length - 1]) { return ratings[ratings.length - 1]; } return null; } /** * Main function to calculate the Health Star Rating for a food product. * * @param category - The food category * @param nutritionalInfo - Complete nutritional information for the product * @returns Health Star Rating (0.5-5 stars, or null if invalid) * * @example * ```typescript * const rating = calculateHealthStarRating(Category.DairyBeverages, { * energykJ: 500, * saturatedFatGrams: 2.5, * totalSugarsGrams: 12, * sodiumMilligrams: 120, * percentageFruitVegetableNutLegume: 0, * fibreGrams: 0, * proteinGrams: 3.2, * attributes: [] * }); * ``` */ function calculateHealthStarRating(category, { energykJ = 0, saturatedFatGrams = 0, totalSugarsGrams = 0, sodiumMilligrams = 0, percentageFruitVegetableNutLegume = 0, fibreGrams = 0, proteinGrams = 0, attributes = [], } = {}) { // Validate category input if (typeof category !== 'number' || !Object.values(Category).includes(category)) { throw new TypeError(`Expected a Category, got ${typeof category}`); } // Handle special categories with fixed ratings if (category === Category.PlainWater || category === Category.UnprocessedFruitAndVegetables) { return 5; } if (category === Category.UnsweetenedFlavouredWater) { return 4.5; } // Calculate baseline points (negative components) const baselinePoints = calculateBaselinePoints(category, { energykJ, saturatedFatGrams, totalSugarsGrams, sodiumMilligrams, }); // Calculate modifying points (positive components) const fibrePoints = calculateFibrePoints(category, fibreGrams); const fruitVegetableNutLegumePoints = calculateFruitVegetableNutLegumePoints(category, attributes, percentageFruitVegetableNutLegume); const proteinPoints = calculateProteinPoints(category, baselinePoints, fruitVegetableNutLegumePoints, proteinGrams); // Calculate final score // Total points = negative points - positive points const totalPoints = baselinePoints - fibrePoints - fruitVegetableNutLegumePoints - proteinPoints; // Convert points to star rating return ratingFromTotalPoints(category, totalPoints); }