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
JavaScript
;
/**
* 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);
}