feat: Add "Show other options" to surface nutritionally equivalent food alternatives (#10)

* Initial plan

* feat: Add 'Show other options' feature to find similar foods by category and bestFor

Agent-Logs-Url: https://github.com/RMCampos/calories-tracker/sessions/00437e19-3c33-48b4-8d15-f29ac843a20f

Co-authored-by: RMCampos <2219519+RMCampos@users.noreply.github.com>

* fix: Address code review issues - string type, dedup logic, HTML escaping

Agent-Logs-Url: https://github.com/RMCampos/calories-tracker/sessions/00437e19-3c33-48b4-8d15-f29ac843a20f

Co-authored-by: RMCampos <2219519+RMCampos@users.noreply.github.com>

* fix: Add full HTML escaping including quotes in alternatives display

Agent-Logs-Url: https://github.com/RMCampos/calories-tracker/sessions/00437e19-3c33-48b4-8d15-f29ac843a20f

Co-authored-by: RMCampos <2219519+RMCampos@users.noreply.github.com>

* feat: improve food options by food type

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: RMCampos <2219519+RMCampos@users.noreply.github.com>
Co-authored-by: Ricardo Campos <ricardompcampos@gmail.com>
This commit is contained in:
Copilot
2026-04-28 11:07:34 -03:00
committed by GitHub
parent 44847d561f
commit 6837e4f139
4 changed files with 242 additions and 7 deletions
+15
View File
@@ -298,6 +298,21 @@
</div>
</div>
</div>
<div class="form-group checkbox-group">
<label class="checkbox-label">
<input type="checkbox" id="enableAlternatives" class="ai-checkbox">
<span>Show other options</span>
</label>
</div>
<!-- Alternatives Container -->
<div class="display-none" id="alternatives-container">
<div class="alternatives-list-container">
<h3 class="alternatives-title">Similar Food Options</h3>
<div id="alternatives-list"></div>
</div>
</div>
<div class="form-group checkbox-group">
<label class="checkbox-label">
<input type="checkbox" id="enableAINutrition" class="ai-checkbox">
+8 -7
View File
@@ -3,11 +3,11 @@ import { FoodItem } from "./types";
export const foodDatabase: FoodItem[] = [
// Gorduras - Fats
{ name: 'Abacate', nameEn: 'Avocado', info: { calories: 96, protein: 1.2, fat: 8.4, carbs: 6, fiber: 6.3, category: 'fats', alkaline: true, bestFor: ['breakfast', 'brunch', 'snacks'] } },
{ name: 'Amêndoas', nameEn: 'Almonds', info: { calories: 581, protein: 18.6, fat: 47.3, carbs: 29.5, fiber: 11.6, category: 'fats', alkaline: false, bestFor: ['breakfast', 'brunch', 'snacks', 'lunch', 'dinner'] } },
{ name: 'Amendoin natural', nameEn: 'Natural Peanut', info: { calories: 611, protein: 26, fat: 49, carbs: 16, fiber: 8.5, category: 'fats', alkaline: false, bestFor: ['breakfast', 'brunch', 'snacks', 'lunch', 'dinner'] } },
{ name: 'Amêndoas', nameEn: 'Almonds', info: { calories: 581, protein: 18.6, fat: 47.3, carbs: 29.5, fiber: 11.6, category: 'fats', alkaline: false, bestFor: ['breakfast', 'brunch', 'snacks'] } },
{ name: 'Amendoin natural', nameEn: 'Natural Peanut', info: { calories: 611, protein: 26, fat: 49, carbs: 16, fiber: 8.5, category: 'fats', alkaline: false, bestFor: ['breakfast', 'brunch', 'snacks'] } },
{ name: 'Azeite de Oliva EV', nameEn: 'Extra Virgin Olive Oil', info: { calories: 844, protein: 0, fat: 100, carbs: 0, fiber: 0, category: 'fats', alkaline: false, bestFor: ['lunch', 'dinner'] } },
{ name: 'Castanha de Caju', nameEn: 'Cashew Nut', info: { calories: 570, protein: 18.5, fat: 46.3, carbs: 29.1, fiber: 3.7, category: 'fats', alkaline: false, bestFor: ['breakfast', 'brunch', 'snacks', 'lunch', 'dinner'] } },
{ name: 'Castanha do Pará', nameEn: 'Brazil Nut', info: { calories: 643, protein: 14.5, fat: 63.5, carbs: 15.1, fiber: 7.9, category: 'fats', alkaline: false, bestFor: ['breakfast', 'brunch', 'snacks', 'lunch', 'dinner'] } },
{ name: 'Castanha de Caju', nameEn: 'Cashew Nut', info: { calories: 570, protein: 18.5, fat: 46.3, carbs: 29.1, fiber: 3.7, category: 'fats', alkaline: false, bestFor: ['breakfast', 'brunch', 'snacks'] } },
{ name: 'Castanha do Pará', nameEn: 'Brazil Nut', info: { calories: 643, protein: 14.5, fat: 63.5, carbs: 15.1, fiber: 7.9, category: 'fats', alkaline: false, bestFor: ['breakfast', 'brunch', 'snacks'] } },
{ name: 'Semente de Chia', nameEn: 'Chia Seed', info: { calories: 490, protein: 15.6, fat: 30.7, carbs: 43.8, fiber: 37.7, category: 'fats', alkaline: true, bestFor: ['breakfast', 'brunch'] } },
{ name: 'Semente de Linhaça Dourada', nameEn: 'Golden Flaxseed', info: { calories: 515, protein: 20, fat: 35, carbs: 30, fiber: 26, category: 'fats', alkaline: true, bestFor: ['breakfast', 'brunch'] } },
{ name: 'Semente de Gergelim', nameEn: 'Sesame Seed', info: { calories: 584, protein: 21.2, fat: 50.4, carbs: 21.6, fiber: 11.9, category: 'fats', alkaline: true, bestFor: ['breakfast', 'brunch'] } },
@@ -44,9 +44,6 @@ export const foodDatabase: FoodItem[] = [
// Carboidratos, amiláceos - Carbs
{ name: 'Grão de Bico, cozido', nameEn: 'Chickpeas, cooked', info: { calories: 180, protein: 9.5, fat: 3, carbs: 30, fiber: 8.6, category: 'carbs', alkaline: false, bestFor: ['lunch', 'dinner'] } },
{ name: 'Banana Nanica', nameEn: 'Banana Nanica', info: { calories: 92, protein: 1.4, fat: 0.1, carbs: 23.8, fiber: 1.9, category: 'carbs', alkaline: true, bestFor: ['breakfast', 'brunch', 'snacks'] } },
{ name: 'Manga Palmer', nameEn: 'Mango Palmer', info: { calories: 72, protein: 0.4, fat: 0.2, carbs: 19.4, fiber: 1.6, category: 'carbs', alkaline: true, bestFor: ['breakfast', 'brunch', 'snacks'] } },
{ name: 'Mamão', nameEn: 'Papaya', info: { calories: 40, protein: 0.5, fat: 0.1, carbs: 10.4, fiber: 1, category: 'carbs', alkaline: true, bestFor: ['breakfast', 'brunch', 'snacks'] } },
{ name: 'Batata doce, assada', nameEn: 'Sweet Potato, baked', info: { calories: 90, protein: 2.0, fat: 0.1, carbs: 20.7, fiber: 3.3, category: 'carbs', alkaline: true, bestFor: ['lunch', 'dinner'] } },
{ name: 'Batata doce, cozida', nameEn: 'Sweet Potato, boiled', info: { calories: 77, protein: 0.6, fat: 0.1, carbs: 18.4, fiber: 2.2, category: 'carbs', alkaline: true, bestFor: ['lunch', 'dinner'] } },
{ name: 'Batata inglesa, assada', nameEn: 'Potato, baked', info: { calories: 94, protein: 2.1, fat: 0.1, carbs: 21.8, fiber: 2.1, category: 'carbs', alkaline: true, bestFor: ['lunch', 'dinner'] } },
@@ -92,7 +89,11 @@ export const foodDatabase: FoodItem[] = [
// Frutas - Fruits
{ name: 'Ameixa, crua', nameEn: 'Raw Plum', info: { calories: 53, protein: 0.8, fat: 0, carbs: 13.9, fiber: 2.4, category: 'fruits', alkaline: true, bestFor: ['breakfast', 'brunch', 'snacks'] } },
{ name: 'Abacaxi', nameEn: 'Pineapple', info: { calories: 48, protein: 0.9, fat: 0.1, carbs: 12.3, fiber: 1, category: 'fruits', alkaline: true, bestFor: ['breakfast', 'brunch', 'snacks'] } },
{ name: 'Banana Nanica', nameEn: 'Banana Nanica', info: { calories: 92, protein: 1.4, fat: 0.1, carbs: 23.8, fiber: 1.9, category: 'fruits', alkaline: true, bestFor: ['breakfast', 'brunch', 'snacks'] } },
{ name: 'Laranja', nameEn: 'Orange', info: { calories: 37, protein: 1, fat: 0.1, carbs: 8.9, fiber: 0.8, category: 'fruits', alkaline: false, bestFor: ['breakfast', 'brunch', 'snacks'] } },
{ name: 'Manga Palmer', nameEn: 'Mango Palmer', info: { calories: 72, protein: 0.4, fat: 0.2, carbs: 19.4, fiber: 1.6, category: 'fruits', alkaline: true, bestFor: ['breakfast', 'brunch', 'snacks'] } },
{ name: 'Mamão', nameEn: 'Papaya', info: { calories: 40, protein: 0.5, fat: 0.1, carbs: 10.4, fiber: 1, category: 'fruits', alkaline: true, bestFor: ['breakfast', 'brunch', 'snacks'] } },
{ name: 'Maçã', nameEn: 'Apple', info: { calories: 56, protein: 0.3, fat: 0, carbs: 15.2, fiber: 1.3, category: 'fruits', alkaline: true, bestFor: ['breakfast', 'brunch', 'snacks'] } },
{ name: 'Melancia', nameEn: 'Watermelon', info: { calories: 33, protein: 0.9, fat: 0, carbs: 8.1, fiber: 0.1, category: 'fruits', alkaline: true, bestFor: ['breakfast', 'brunch', 'snacks'] } },
{ name: 'Melão', nameEn: 'Melon', info: { calories: 29, protein: 0.7, fat: 0, carbs: 7.5, fiber: 0.3, category: 'fruits', alkaline: true, bestFor: ['breakfast', 'brunch', 'snacks'] } },
+165
View File
@@ -420,6 +420,7 @@ function clearEditing() {
getInputById('gramAmount').value = '100';
showFoodPreview(false);
hideAINutritionCard();
hideAlternativesContainer();
getDivById('add-foot-title').innerHTML = 'Add Food Entry';
getButtonById('add-food-btn').innerHTML = 'Add Food';
getButtonById('cancel-food-btn').style.display = 'none';
@@ -476,6 +477,162 @@ const getFoodItemByName = (foodName: string): FoodItem => {
return foodDataSearch[0];
}
type AlternativeResult = {
food: FoodItem;
quantityGrams: number;
};
function findAlternatives(
current: FoodItem,
currentQuantityGrams: number,
allFoods: FoodItem[]
): AlternativeResult[] {
if (currentQuantityGrams <= 0) return [];
const ratio = currentQuantityGrams / 100;
const target = {
calories: current.info.calories * ratio,
protein: current.info.protein * ratio,
carbs: current.info.carbs * ratio,
fat: current.info.fat * ratio,
};
const sameBestFor = (a: string[], b: string[]) =>
[...a].sort().join(',') === [...b].sort().join(',');
// Consider different macros based in the food category
let filtered = allFoods
.filter(f =>
f.name !== current.name &&
f.info.category === current.info.category &&
sameBestFor(f.info.bestFor, current.info.bestFor)
);
// Consider calories and fat
if (current.info.category === 'fats' || current.info.category === 'dairy') {
filtered = filtered
.flatMap(candidate => {
const p = candidate.info;
const maxGrams = Math.min(
p.calories > 0 ? (target.calories / p.calories) * 100 : Infinity,
p.fat > 0 ? (target.fat / p.fat) * 100 : Infinity,
);
return maxGrams > 0 ? [{ food: candidate, quantityGrams: Math.floor(maxGrams) }] : [];
});
}
// Consider calories and proteins
else if (current.info.category === 'proteins') {
filtered = filtered
.flatMap(candidate => {
const p = candidate.info;
const maxGrams = Math.min(
p.calories > 0 ? (target.calories / p.calories) * 100 : Infinity,
p.protein > 0 ? (target.protein / p.protein) * 100 : Infinity,
);
return maxGrams > 0 ? [{ food: candidate, quantityGrams: Math.floor(maxGrams) }] : [];
});
}
// Consider calories and carbs
else if (current.info.category === 'carbs' || current.info.category === 'fruits' || current.info.category === 'low carb') {
filtered = filtered
.flatMap(candidate => {
const p = candidate.info;
const maxGrams = Math.min(
p.calories > 0 ? (target.calories / p.calories) * 100 : Infinity,
p.carbs > 0 ? (target.carbs / p.carbs) * 100 : Infinity,
);
return maxGrams > 0 ? [{ food: candidate, quantityGrams: Math.floor(maxGrams) }] : [];
});
}
// Consider only calories
else if (current.info.category === 'leaves') {
filtered = filtered
.flatMap(candidate => {
const p = candidate.info;
const maxGrams = Math.min(
p.calories > 0 ? (target.calories / p.calories) * 100 : Infinity,
);
return maxGrams > 0 ? [{ food: candidate, quantityGrams: Math.floor(maxGrams) }] : [];
});
}
return filtered
.filter(f => f.quantityGrams > 0)
.sort((a, b) => a.food.name.localeCompare(b.food.name));
}
function hideAlternativesContainer() {
const container = document.getElementById('alternatives-container');
if (container) {
container.classList.add('display-none');
container.classList.remove('display-block');
}
const list = document.getElementById('alternatives-list');
if (list) {
list.innerHTML = '';
}
}
function updateAlternativesDisplay() {
const checkbox = document.getElementById('enableAlternatives') as HTMLInputElement;
if (!checkbox || !checkbox.checked) return;
const list = document.getElementById('alternatives-list');
if (!list) return;
if (!selectedFood) {
list.innerHTML = '';
return;
}
const gramsInput = getInputById('gramAmount');
const grams = parseFloat(gramsInput.value);
if (isNaN(grams) || grams <= 0) {
list.innerHTML = '';
return;
}
const alternatives = findAlternatives(selectedFood, grams, foodDatabase);
const escapeHtml = (text: string): string =>
text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
if (alternatives.length === 0) {
list.innerHTML = '<div class="no-alternatives">No equivalent alternatives found</div>';
} else {
list.innerHTML = alternatives.map(alt => `
<div class="alternative-item">
<span class="alternative-name">${escapeHtml(alt.food.name)}</span>
<span class="alternative-quantity">${alt.quantityGrams}g</span>
</div>
`).join('');
}
}
function handleAlternativesCheckboxChange(e: Event) {
const checkbox = e.target as HTMLInputElement;
const container = document.getElementById('alternatives-container');
if (!container) return;
if (checkbox.checked) {
container.classList.remove('display-none');
container.classList.add('display-block');
updateAlternativesDisplay();
} else {
hideAlternativesContainer();
}
}
const previewCalories = (foodSelected?: FoodItem) => {
const foodToPreview = foodSelected ?? selectedFood;
const gramAmount = getInputById('gramAmount');
@@ -500,6 +657,7 @@ const previewCalories = (foodSelected?: FoodItem) => {
getDivById('fiberValuePreview').textContent = (Math.round(proportion.info.fiber * 10) / 10).toString ();
showFoodPreview(true);
updateAlternativesDisplay();
}
// ---- Goal management ----
@@ -724,6 +882,10 @@ const setupEventListeners = () => {
previewCalories();
});
getInputById('gramAmount').addEventListener('input', () => {
updateAlternativesDisplay();
});
getButtonById('add-food-btn').addEventListener('click', () => {
if (getButtonById('add-food-btn').innerHTML === 'Add Food') {
addFood();
@@ -797,6 +959,9 @@ const setupEventListeners = () => {
// AI nutrition checkbox
document.getElementById('enableAINutrition')?.addEventListener('change', handleAICheckboxChange);
// Alternatives checkbox
document.getElementById('enableAlternatives')?.addEventListener('change', handleAlternativesCheckboxChange);
// Share button event listeners
getButtonById('shareBtn').addEventListener('click', handleShareClick);
getButtonById('shareBtnMobile').addEventListener('click', handleShareClick);
+54
View File
@@ -1198,3 +1198,57 @@ label {
font-size: 0.85rem;
}
}
/* Alternatives Card */
#alternatives-container {
margin-top: 20px;
animation: fadeIn 0.3s ease-in;
}
.alternatives-list-container {
background: rgba(40, 40, 40, 0.6);
backdrop-filter: blur(10px);
border: 1px solid rgba(107, 141, 214, 0.3);
border-radius: 12px;
padding: 20px 25px;
}
.alternatives-title {
font-size: 1rem;
font-weight: 600;
color: white;
margin: 0 0 15px 0;
}
.alternative-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 0;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
}
.alternative-item:last-child {
border-bottom: none;
}
.alternative-name {
color: #e0e0e0;
font-size: 0.9rem;
}
.alternative-quantity {
color: #6B8DD6;
font-weight: 600;
font-size: 0.9rem;
white-space: nowrap;
margin-left: 12px;
}
.no-alternatives {
color: #b0b0b0;
font-size: 0.9rem;
text-align: center;
padding: 10px 0;
font-style: italic;
}