sundog-calculator/src/js/calculator.js
Dayowe 73793f43a1 Reorganize source structure for better maintainability
- Move from 3-file structure to organized 5-file structure
- Create css/ and js/ subdirectories for better organization
- Split styles into main.css and themes.css for clarity
- Extract configuration constants to separate config.js file
- Rename template.html to index.html for clarity
- Update build.js to handle new organized structure
- Replace magic numbers with CALCULATOR_CONFIG constants

New structure:
  src/
    ├── index.html       (HTML template)
    ├── css/
    │   ├── main.css     (Core styles)
    │   └── themes.css   (Theme variations)
    └── js/
        ├── config.js    (Configuration constants)
        └── calculator.js (Main logic)

This provides a good balance between organization and simplicity,
making the codebase easier to maintain without over-modularization.
2025-08-18 09:05:00 +02:00

1441 lines
68 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Dog Calorie Calculator - iframe version
* by Canine Nutrition and Wellness
*/
class DogCalorieCalculator {
constructor() {
this.currentMER = 0;
this.isImperial = false;
this.theme = this.getThemeFromURL() || CALCULATOR_CONFIG.defaultTheme;
this.scale = this.getScaleFromURL() || CALCULATOR_CONFIG.defaultScale;
this.foodSources = [];
this.maxFoodSources = CALCULATOR_CONFIG.maxFoodSources;
this.init();
}
init() {
this.applyTheme();
this.applyScale();
this.initializeFoodSources();
this.bindEvents();
this.updateUnitLabels();
this.setupIframeResize();
// Show the calculator with fade-in
const container = document.getElementById('dogCalculator');
container.classList.add('loaded');
}
getThemeFromURL() {
const urlParams = new URLSearchParams(window.location.search);
const theme = urlParams.get('theme');
return ['light', 'dark', 'system'].includes(theme) ? theme : null;
}
getScaleFromURL() {
const urlParams = new URLSearchParams(window.location.search);
const scale = parseFloat(urlParams.get('scale'));
return (!isNaN(scale) && scale >= CALCULATOR_CONFIG.minScale && scale <= CALCULATOR_CONFIG.maxScale) ? scale : null;
}
applyTheme() {
const container = document.getElementById('dogCalculator');
container.classList.remove('theme-light', 'theme-dark', 'theme-system');
container.classList.add('theme-' + this.theme);
}
applyScale() {
const container = document.getElementById('dogCalculator');
if (!container) return;
// Clamp scale between min and max for usability
const clampedScale = Math.max(CALCULATOR_CONFIG.minScale, Math.min(CALCULATOR_CONFIG.maxScale, this.scale));
if (clampedScale !== 1.0) {
container.style.transform = `scale(${clampedScale})`;
container.style.transformOrigin = 'top center';
// Adjust container to account for scaling
setTimeout(() => {
const actualHeight = container.offsetHeight * clampedScale;
container.style.marginBottom = `${(clampedScale - 1) * container.offsetHeight}px`;
this.sendHeightToParent();
}, 100);
}
}
// Food Source Management Methods
initializeFoodSources() {
this.addFoodSource();
this.updateAddButton();
}
addFoodSource() {
if (this.foodSources.length >= this.maxFoodSources) {
return;
}
const id = this.generateFoodSourceId();
const foodSource = {
id: id,
name: `Food Source ${this.foodSources.length + 1}`,
energy: '',
energyUnit: this.isImperial ? 'kcalcup' : 'kcal100g',
percentage: this.foodSources.length === 0 ? 100 : 0,
isLocked: false
};
this.foodSources.push(foodSource);
this.redistributePercentages();
this.renderFoodSource(foodSource);
this.updateAddButton();
this.updateRemoveButtons();
this.refreshAllPercentageUI();
}
removeFoodSource(id) {
if (this.foodSources.length <= 1) {
return; // Cannot remove the last food source
}
const index = this.foodSources.findIndex(fs => fs.id === id);
if (index === -1) return;
this.foodSources.splice(index, 1);
// Remove the DOM element
const element = document.getElementById(`foodSource-${id}`);
if (element) {
element.remove();
}
// Redistribute percentages among remaining sources
this.redistributePercentages();
this.updateFoodSourceNames();
this.updateAddButton();
this.updateRemoveButtons();
this.refreshAllPercentageUI();
}
generateFoodSourceId() {
return 'fs_' + Date.now() + '_' + Math.random().toString(36).substr(2, 5);
}
redistributePercentages() {
const count = this.foodSources.length;
if (count === 0) return;
// Only redistribute among unlocked sources
const unlockedSources = this.foodSources.filter(fs => !fs.isLocked);
const lockedSources = this.foodSources.filter(fs => fs.isLocked);
// Calculate total locked percentage
const totalLockedPercentage = lockedSources.reduce((sum, fs) => sum + fs.percentage, 0);
// Available percentage for unlocked sources
const availablePercentage = 100 - totalLockedPercentage;
if (unlockedSources.length > 0) {
const equalPercentage = Math.floor(availablePercentage / unlockedSources.length);
const remainder = availablePercentage - (equalPercentage * unlockedSources.length);
unlockedSources.forEach((fs, index) => {
fs.percentage = equalPercentage + (index < remainder ? 1 : 0);
});
}
// Update the UI sliders and inputs
this.refreshAllPercentageUI();
}
// OBSOLETE METHODS - Replaced by new validation system
// Keeping for reference but these are no longer used
/*
updatePercentageInputs() {
this.foodSources.forEach(fs => {
const slider = document.getElementById(`percentage-slider-${fs.id}`);
const input = document.getElementById(`percentage-input-${fs.id}`);
const display = document.getElementById(`percentage-display-${fs.id}`);
if (slider) slider.value = fs.percentage;
if (input) input.value = fs.percentage;
if (display) display.textContent = `${fs.percentage}%`;
});
// Update constraints after values are set
this.updatePercentageConstraints();
}
updatePercentageConstraints() {
this.foodSources.forEach(fs => {
const slider = document.getElementById(`percentage-slider-${fs.id}`);
const input = document.getElementById(`percentage-input-${fs.id}`);
if (!slider || !input) return;
// Always keep full 0-100 scale for all sliders
slider.max = 100;
input.max = 100;
if (fs.isLocked) {
// Locked sources can't be changed
slider.disabled = true;
input.disabled = true;
} else {
// Calculate the maximum this source can have
const lockedSources = this.foodSources.filter(other => other.id !== fs.id && other.isLocked);
const totalLockedPercentage = lockedSources.reduce((sum, other) => sum + other.percentage, 0);
const maxAllowed = 100 - totalLockedPercentage;
// Re-enable
slider.disabled = false;
input.disabled = false;
// Store max allowed for validation (we'll check this in event handlers)
slider.dataset.maxAllowed = maxAllowed;
input.dataset.maxAllowed = maxAllowed;
// If current value exceeds max, adjust it
if (fs.percentage > maxAllowed) {
fs.percentage = maxAllowed;
slider.value = maxAllowed;
input.value = maxAllowed;
document.getElementById(`percentage-display-${fs.id}`).textContent = `${maxAllowed}%`;
}
}
});
}
adjustPercentages(changedId, newPercentage) {
const changedIndex = this.foodSources.findIndex(fs => fs.id === changedId);
if (changedIndex === -1) return;
const oldPercentage = this.foodSources[changedIndex].percentage;
const difference = newPercentage - oldPercentage;
this.foodSources[changedIndex].percentage = newPercentage;
// Only redistribute among unlocked sources (excluding the changed one)
const otherUnlockedSources = this.foodSources.filter((fs, index) =>
index !== changedIndex && !fs.isLocked
);
// If this is the only unlocked source, force it to fill remaining percentage
if (otherUnlockedSources.length === 0) {
const lockedSources = this.foodSources.filter((fs, index) =>
index !== changedIndex && fs.isLocked
);
const totalLockedPercentage = lockedSources.reduce((sum, fs) => sum + fs.percentage, 0);
const requiredPercentage = 100 - totalLockedPercentage;
// Force the changed source to the required percentage
this.foodSources[changedIndex].percentage = requiredPercentage;
this.updatePercentageInputs();
this.updateFoodCalculations();
return;
}
// Calculate total locked percentage (excluding the changed source)
const lockedSources = this.foodSources.filter((fs, index) =>
index !== changedIndex && fs.isLocked
);
const totalLockedPercentage = lockedSources.reduce((sum, fs) => sum + fs.percentage, 0);
// Available percentage for unlocked sources
const availablePercentage = 100 - newPercentage - totalLockedPercentage;
const totalUnlockedPercentage = otherUnlockedSources.reduce((sum, fs) => sum + fs.percentage, 0);
if (totalUnlockedPercentage === 0) {
// If all other unlocked sources are 0, distribute equally
const equalShare = Math.floor(availablePercentage / otherUnlockedSources.length);
const remainder = availablePercentage - (equalShare * otherUnlockedSources.length);
otherUnlockedSources.forEach((fs, index) => {
fs.percentage = equalShare + (index < remainder ? 1 : 0);
});
} else {
// Distribute proportionally among unlocked sources
const scale = availablePercentage / totalUnlockedPercentage;
let distributedTotal = 0;
otherUnlockedSources.forEach((fs, index) => {
if (index === otherUnlockedSources.length - 1) {
// Last item gets the remainder to ensure exact 100%
fs.percentage = availablePercentage - distributedTotal;
} else {
fs.percentage = Math.round(fs.percentage * scale);
distributedTotal += fs.percentage;
}
});
}
this.updatePercentageInputs();
this.updateFoodCalculations();
}
*/
// New validation system methods
validatePercentageChange(sourceId, requestedValue) {
// Find the source being changed
const changedSource = this.foodSources.find(fs => fs.id === sourceId);
if (!changedSource) {
return { isValid: false, reason: 'Source not found' };
}
// If the source is locked, no change allowed
if (changedSource.isLocked) {
return { isValid: false, reason: 'Source is locked' };
}
// Ensure requested value is within bounds
const clampedValue = Math.max(0, Math.min(100, requestedValue));
// Calculate locked and other unlocked totals
const lockedSources = this.foodSources.filter(fs => fs.id !== sourceId && fs.isLocked);
const otherUnlockedSources = this.foodSources.filter(fs => fs.id !== sourceId && !fs.isLocked);
const totalLocked = lockedSources.reduce((sum, fs) => sum + fs.percentage, 0);
// Check if the only unlocked source
if (otherUnlockedSources.length === 0) {
// This is the only unlocked source, must fill remaining percentage
const requiredPercentage = 100 - totalLocked;
return {
isValid: true,
actualValue: requiredPercentage,
affectedSources: [{ id: sourceId, newPercentage: requiredPercentage }],
reason: 'Only unlocked source, forced to fill remainder'
};
}
// Calculate available percentage for redistribution
const availableForOthers = 100 - clampedValue - totalLocked;
// Check if redistribution is possible
if (availableForOthers < 0) {
// Cannot accommodate this value
const maxAllowed = 100 - totalLocked;
return {
isValid: true,
actualValue: maxAllowed,
affectedSources: this.calculateRedistribution(sourceId, maxAllowed, otherUnlockedSources),
reason: 'Value clamped to maximum allowed'
};
}
// Calculate redistribution
const affectedSources = this.calculateRedistribution(sourceId, clampedValue, otherUnlockedSources);
return {
isValid: true,
actualValue: clampedValue,
affectedSources: affectedSources,
reason: 'Valid change'
};
}
calculateRedistribution(sourceId, newValue, otherUnlockedSources) {
const result = [{ id: sourceId, newPercentage: newValue }];
if (otherUnlockedSources.length === 0) {
return result;
}
// Calculate total locked percentage
const lockedSources = this.foodSources.filter(fs => fs.id !== sourceId && fs.isLocked);
const totalLocked = lockedSources.reduce((sum, fs) => sum + fs.percentage, 0);
// Available percentage for other unlocked sources
const availableForOthers = 100 - newValue - totalLocked;
// Current total of other unlocked sources
const currentOtherTotal = otherUnlockedSources.reduce((sum, fs) => sum + fs.percentage, 0);
if (currentOtherTotal === 0 || availableForOthers === 0) {
// Distribute equally among other unlocked sources
const equalShare = Math.floor(availableForOthers / otherUnlockedSources.length);
const remainder = availableForOthers - (equalShare * otherUnlockedSources.length);
otherUnlockedSources.forEach((fs, index) => {
const newPercentage = equalShare + (index < remainder ? 1 : 0);
result.push({ id: fs.id, newPercentage });
});
} else {
// Distribute proportionally
const scale = availableForOthers / currentOtherTotal;
let distributedTotal = 0;
otherUnlockedSources.forEach((fs, index) => {
let newPercentage;
if (index === otherUnlockedSources.length - 1) {
// Last item gets remainder to ensure exact total
newPercentage = availableForOthers - distributedTotal;
} else {
newPercentage = Math.round(fs.percentage * scale);
distributedTotal += newPercentage;
}
result.push({ id: fs.id, newPercentage });
});
}
return result;
}
applyValidatedChanges(validationResult) {
if (!validationResult.isValid) {
return false;
}
// Apply all percentage changes
validationResult.affectedSources.forEach(change => {
const source = this.foodSources.find(fs => fs.id === change.id);
if (source) {
source.percentage = change.newPercentage;
}
});
return true;
}
refreshAllPercentageUI() {
this.foodSources.forEach(fs => {
// Update all UI elements from single source of truth
const slider = document.getElementById(`percentage-slider-${fs.id}`);
const input = document.getElementById(`percentage-input-${fs.id}`);
const display = document.getElementById(`percentage-display-${fs.id}`);
if (slider) slider.value = fs.percentage;
if (input) input.value = fs.percentage;
if (display) display.textContent = `${fs.percentage}%`;
// Update constraints and disabled states
this.updateSliderConstraints(fs);
});
// Update food calculations
this.updateFoodCalculations();
}
updateSliderConstraints(foodSource) {
const slider = document.getElementById(`percentage-slider-${foodSource.id}`);
const input = document.getElementById(`percentage-input-${foodSource.id}`);
if (!slider || !input) return;
// Always keep 0-100 scale
slider.max = 100;
input.max = 100;
if (foodSource.isLocked) {
slider.disabled = true;
input.disabled = true;
} else {
// Calculate maximum allowed and store for validation
const maxAllowed = this.calculateMaxAllowed(foodSource.id);
slider.disabled = (maxAllowed <= 0);
input.disabled = (maxAllowed <= 0);
slider.dataset.maxAllowed = maxAllowed;
input.dataset.maxAllowed = maxAllowed;
}
}
calculateMaxAllowed(sourceId) {
const lockedSources = this.foodSources.filter(fs => fs.id !== sourceId && fs.isLocked);
const otherUnlockedSources = this.foodSources.filter(fs => fs.id !== sourceId && !fs.isLocked);
const totalLocked = lockedSources.reduce((sum, fs) => sum + fs.percentage, 0);
// If this is the only unlocked source, it must take up the remainder
if (otherUnlockedSources.length === 0) {
return 100 - totalLocked;
}
// Otherwise, maximum is 100 minus locked percentages
return Math.max(0, 100 - totalLocked);
}
updateFoodSourceNames() {
this.foodSources.forEach((fs, index) => {
// Only update if the name is still the default pattern
if (fs.name.match(/^Food Source \d+$/)) {
fs.name = `Food Source ${index + 1}`;
const titleElement = document.getElementById(`food-title-${fs.id}`);
if (titleElement) {
titleElement.value = fs.name;
}
}
});
}
updateAddButton() {
const addBtn = document.getElementById('addFoodBtn');
if (addBtn) {
const remaining = this.maxFoodSources - this.foodSources.length;
const buttonText = addBtn.querySelector('span:last-child');
if (remaining <= 0) {
// Disable button and show max reached message
addBtn.disabled = true;
if (buttonText) {
buttonText.textContent = `Maximum ${this.maxFoodSources} sources reached`;
}
} else {
// Enable button with normal text
addBtn.disabled = false;
if (buttonText) {
buttonText.textContent = 'Add another food source';
}
}
}
}
updateRemoveButtons() {
// Show/hide remove buttons based on whether we have more than one source
const hasMultipleSources = this.foodSources.length > 1;
this.foodSources.forEach(fs => {
const removeBtn = document.getElementById(`remove-${fs.id}`);
if (removeBtn) {
removeBtn.style.display = hasMultipleSources ? 'block' : 'none';
}
});
}
renderFoodSource(foodSource) {
const container = document.getElementById('foodSources');
if (!container) return;
const cardHTML = `
<div class="dog-calculator-food-source-card" id="foodSource-${foodSource.id}">
<div class="dog-calculator-food-source-header">
<input type="text" class="dog-calculator-food-source-name-input" id="food-title-${foodSource.id}" value="${foodSource.name}" placeholder="Enter food name" maxlength="50" title="Click to edit food source name">
<button class="dog-calculator-remove-food-btn" id="remove-${foodSource.id}" type="button" title="Remove this food source" style="display: ${this.foodSources.length > 1 ? 'block' : 'none'}">×</button>
</div>
<div class="dog-calculator-input-group">
<div class="dog-calculator-form-group">
<label for="energy-${foodSource.id}">Energy Content:</label>
<input type="number" id="energy-${foodSource.id}" min="1" step="1" placeholder="Enter energy content" value="${foodSource.energy}">
</div>
<div class="dog-calculator-form-group">
<label for="energy-unit-${foodSource.id}">Unit:</label>
<select id="energy-unit-${foodSource.id}" class="dog-calculator-unit-select">
<option value="kcal100g" ${foodSource.energyUnit === 'kcal100g' ? 'selected' : ''}>kcal/100g</option>
<option value="kcalkg" ${foodSource.energyUnit === 'kcalkg' ? 'selected' : ''}>kcal/kg</option>
<option value="kcalcup" ${foodSource.energyUnit === 'kcalcup' ? 'selected' : ''}>kcal/cup</option>
<option value="kcalcan" ${foodSource.energyUnit === 'kcalcan' ? 'selected' : ''}>kcal/can</option>
</select>
</div>
</div>
<div id="energy-error-${foodSource.id}" class="dog-calculator-error dog-calculator-hidden">Please enter a valid energy content</div>
<div class="dog-calculator-percentage-group">
<label class="dog-calculator-percentage-label" for="percentage-slider-${foodSource.id}">
Percentage of Diet: <span id="percentage-display-${foodSource.id}">${foodSource.percentage}%</span>
<span class="dog-calculator-lock-icon unlocked" id="lock-${foodSource.id}" title="Lock this percentage">🔒</span>
</label>
<div class="dog-calculator-percentage-input-group">
<input type="range" id="percentage-slider-${foodSource.id}" class="dog-calculator-percentage-slider"
min="0" max="100" value="${foodSource.percentage}">
<input type="number" id="percentage-input-${foodSource.id}" class="dog-calculator-percentage-input"
min="0" max="100" value="${foodSource.percentage}">
</div>
</div>
</div>
`;
container.insertAdjacentHTML('beforeend', cardHTML);
// Bind events for the new food source
this.bindFoodSourceEvents(foodSource.id);
}
bindFoodSourceEvents(id) {
// Name input events
const nameInput = document.getElementById(`food-title-${id}`);
// Energy input events
const energyInput = document.getElementById(`energy-${id}`);
const energyUnitSelect = document.getElementById(`energy-unit-${id}`);
const percentageSlider = document.getElementById(`percentage-slider-${id}`);
const percentageInput = document.getElementById(`percentage-input-${id}`);
const removeBtn = document.getElementById(`remove-${id}`);
const lockBtn = document.getElementById(`lock-${id}`);
if (nameInput) {
nameInput.addEventListener('input', () => {
const newName = nameInput.value.trim() || `Food Source ${this.foodSources.findIndex(fs => fs.id === id) + 1}`;
this.updateFoodSourceData(id, 'name', newName);
this.updateFoodCalculations(); // This will refresh the food amount breakdown with new names
});
nameInput.addEventListener('blur', () => {
// If field is empty, restore default name
if (!nameInput.value.trim()) {
const defaultName = `Food Source ${this.foodSources.findIndex(fs => fs.id === id) + 1}`;
nameInput.value = defaultName;
this.updateFoodSourceData(id, 'name', defaultName);
this.updateFoodCalculations();
}
});
}
if (energyInput) {
energyInput.addEventListener('input', () => {
this.updateFoodSourceData(id, 'energy', energyInput.value);
this.updateFoodCalculations();
});
energyInput.addEventListener('blur', () => this.validateFoodSourceEnergy(id));
}
if (energyUnitSelect) {
energyUnitSelect.addEventListener('change', () => {
this.updateFoodSourceData(id, 'energyUnit', energyUnitSelect.value);
this.updateFoodCalculations();
});
}
if (percentageSlider) {
percentageSlider.addEventListener('input', () => {
const requestedValue = parseInt(percentageSlider.value);
const result = this.validatePercentageChange(id, requestedValue);
if (result.isValid) {
this.applyValidatedChanges(result);
}
// Always refresh to ensure valid state
this.refreshAllPercentageUI();
});
}
if (percentageInput) {
percentageInput.addEventListener('change', () => {
const requestedValue = parseInt(percentageInput.value) || 0;
const result = this.validatePercentageChange(id, requestedValue);
if (result.isValid) {
this.applyValidatedChanges(result);
}
this.refreshAllPercentageUI();
});
}
if (removeBtn) {
removeBtn.addEventListener('click', () => this.removeFoodSource(id));
}
if (lockBtn) {
lockBtn.addEventListener('click', () => this.toggleLock(id));
}
}
toggleLock(id) {
const foodSource = this.foodSources.find(fs => fs.id === id);
if (!foodSource) return;
// Check if we're trying to lock the last unlocked source
const unlockedSources = this.foodSources.filter(fs => !fs.isLocked);
if (unlockedSources.length === 1 && unlockedSources[0].id === id) {
// Cannot lock the last unlocked source
alert('At least one food source must remain flexible for percentage adjustments.');
return;
}
// Toggle lock state
foodSource.isLocked = !foodSource.isLocked;
this.updateLockIcon(id);
this.updateLockStates();
this.refreshAllPercentageUI();
}
updateLockIcon(id) {
const foodSource = this.foodSources.find(fs => fs.id === id);
const lockIcon = document.getElementById(`lock-${id}`);
if (!lockIcon || !foodSource) return;
if (foodSource.isLocked) {
lockIcon.classList.remove('unlocked');
lockIcon.classList.add('locked');
lockIcon.title = 'Unlock this percentage';
} else {
lockIcon.classList.remove('locked');
lockIcon.classList.add('unlocked');
lockIcon.title = 'Lock this percentage';
}
}
updateLockStates() {
const unlockedSources = this.foodSources.filter(fs => !fs.isLocked);
// Update lock icon states - disable lock for last unlocked source
this.foodSources.forEach(fs => {
const lockIcon = document.getElementById(`lock-${fs.id}`);
if (lockIcon) {
if (!fs.isLocked && unlockedSources.length === 1) {
lockIcon.classList.add('disabled');
lockIcon.title = 'Cannot lock - at least one source must remain flexible';
} else {
lockIcon.classList.remove('disabled');
lockIcon.title = fs.isLocked ? 'Unlock this percentage' : 'Lock this percentage';
}
}
});
// Update percentage constraints based on lock states
this.refreshAllPercentageUI();
}
updateFoodSourceData(id, field, value) {
const foodSource = this.foodSources.find(fs => fs.id === id);
if (foodSource) {
foodSource[field] = value;
}
}
validateFoodSourceEnergy(id) {
const energyInput = document.getElementById(`energy-${id}`);
const energyUnitSelect = document.getElementById(`energy-unit-${id}`);
const errorElement = document.getElementById(`energy-error-${id}`);
if (!energyInput || !energyUnitSelect || !errorElement) return;
const energy = parseFloat(energyInput.value);
const unit = energyUnitSelect.value;
let minValue = 1;
switch (unit) {
case 'kcal100g': minValue = 1; break;
case 'kcalkg': minValue = 10; break;
case 'kcalcup': minValue = 50; break;
case 'kcalcan': minValue = 100; break;
}
if (!this.validateInput(energy, minValue)) {
errorElement.classList.remove('dog-calculator-hidden');
} else {
errorElement.classList.add('dog-calculator-hidden');
}
}
bindEvents() {
const weightInput = document.getElementById('weight');
const dogTypeSelect = document.getElementById('dogType');
const daysInput = document.getElementById('days');
const unitSelect = document.getElementById('unit');
const unitToggle = document.getElementById('unitToggle');
const addFoodBtn = document.getElementById('addFoodBtn');
if (weightInput) {
weightInput.addEventListener('input', () => this.updateCalorieCalculations());
weightInput.addEventListener('blur', () => this.validateWeight());
}
if (dogTypeSelect) dogTypeSelect.addEventListener('change', () => this.updateCalorieCalculations());
if (daysInput) {
daysInput.addEventListener('input', () => {
this.updateDayLabel();
this.updateFoodCalculations();
});
daysInput.addEventListener('blur', () => this.validateDays());
}
if (unitSelect) unitSelect.addEventListener('change', () => this.updateFoodCalculations());
// Unit button event listeners
const unitButtons = document.querySelectorAll('.dog-calculator-unit-btn');
unitButtons.forEach(button => {
button.addEventListener('click', (e) => {
const selectedUnit = e.target.dataset.unit;
this.setActiveUnitButton(selectedUnit);
// Update hidden select to trigger existing logic
if (unitSelect) {
unitSelect.value = selectedUnit;
this.updateFoodCalculations();
}
});
});
if (unitToggle) unitToggle.addEventListener('change', () => this.toggleUnits());
if (addFoodBtn) addFoodBtn.addEventListener('click', () => this.addFoodSource());
// Modal event listeners
const shareBtn = document.getElementById('shareBtn');
const embedBtn = document.getElementById('embedBtn');
const shareModalClose = document.getElementById('shareModalClose');
const embedModalClose = document.getElementById('embedModalClose');
if (shareBtn) shareBtn.addEventListener('click', () => this.showShareModal());
if (embedBtn) embedBtn.addEventListener('click', () => this.showEmbedModal());
if (shareModalClose) shareModalClose.addEventListener('click', () => this.hideShareModal());
if (embedModalClose) embedModalClose.addEventListener('click', () => this.hideEmbedModal());
// Share buttons
const shareFacebook = document.getElementById('shareFacebook');
const shareTwitter = document.getElementById('shareTwitter');
const shareLinkedIn = document.getElementById('shareLinkedIn');
const shareEmail = document.getElementById('shareEmail');
const shareCopy = document.getElementById('shareCopy');
if (shareFacebook) shareFacebook.addEventListener('click', () => this.shareToFacebook());
if (shareTwitter) shareTwitter.addEventListener('click', () => this.shareToTwitter());
if (shareLinkedIn) shareLinkedIn.addEventListener('click', () => this.shareToLinkedIn());
if (shareEmail) shareEmail.addEventListener('click', () => this.shareViaEmail());
if (shareCopy) shareCopy.addEventListener('click', () => this.copyShareLink());
// Copy buttons
const copyWidget = document.getElementById('copyWidget');
const copyIframe = document.getElementById('copyIframe');
if (copyWidget) copyWidget.addEventListener('click', () => this.copyEmbedCode('widget'));
if (copyIframe) copyIframe.addEventListener('click', () => this.copyEmbedCode('iframe'));
// Close modals on outside click
const shareModal = document.getElementById('shareModal');
const embedModal = document.getElementById('embedModal');
if (shareModal) {
shareModal.addEventListener('click', (e) => {
if (e.target === shareModal) this.hideShareModal();
});
}
if (embedModal) {
embedModal.addEventListener('click', (e) => {
if (e.target === embedModal) this.hideEmbedModal();
});
}
}
toggleUnits() {
const toggle = document.getElementById('unitToggle');
this.isImperial = toggle.checked;
this.updateUnitLabels();
this.convertExistingValues();
this.updateCalorieCalculations();
}
updateUnitLabels() {
const metricLabel = document.getElementById('metricLabel');
const imperialLabel = document.getElementById('imperialLabel');
const weightLabel = document.getElementById('weightLabel');
const weightInput = document.getElementById('weight');
const unitSelect = document.getElementById('unit');
if (metricLabel && imperialLabel) {
metricLabel.classList.toggle('active', !this.isImperial);
imperialLabel.classList.toggle('active', this.isImperial);
}
if (this.isImperial) {
if (weightLabel) weightLabel.textContent = "Dog's Weight (lbs):";
if (weightInput) {
weightInput.placeholder = "Enter weight in lbs";
weightInput.min = "0.2";
weightInput.step = "0.1";
}
if (unitSelect) {
unitSelect.innerHTML = '<option value="oz">ounces (oz)</option>' +
'<option value="lb">pounds (lb)</option>' +
'<option value="g">grams (g)</option>' +
'<option value="kg">kilograms (kg)</option>';
unitSelect.value = 'oz'; // Auto-select ounces for imperial
this.setActiveUnitButton('oz'); // Sync unit buttons
}
// Update energy units for all food sources to kcal/cup for imperial
this.foodSources.forEach(fs => {
if (fs.energyUnit === 'kcal100g') {
fs.energyUnit = 'kcalcup';
const energyUnitSelect = document.getElementById(`energy-unit-${fs.id}`);
if (energyUnitSelect) {
energyUnitSelect.value = 'kcalcup';
}
}
});
} else {
if (weightLabel) weightLabel.textContent = "Dog's Weight (kg):";
if (weightInput) {
weightInput.placeholder = "Enter weight in kg";
weightInput.min = "0.1";
weightInput.step = "0.1";
}
if (unitSelect) {
unitSelect.innerHTML = '<option value="g">grams (g)</option>' +
'<option value="kg">kilograms (kg)</option>' +
'<option value="oz">ounces (oz)</option>' +
'<option value="lb">pounds (lb)</option>';
unitSelect.value = 'g'; // Auto-select grams for metric
this.setActiveUnitButton('g'); // Sync unit buttons
}
// Update energy units for all food sources to kcal/100g for metric
this.foodSources.forEach(fs => {
if (fs.energyUnit === 'kcalcup') {
fs.energyUnit = 'kcal100g';
const energyUnitSelect = document.getElementById(`energy-unit-${fs.id}`);
if (energyUnitSelect) {
energyUnitSelect.value = 'kcal100g';
}
}
});
}
}
convertExistingValues() {
const weightInput = document.getElementById('weight');
if (weightInput && weightInput.value) {
const currentWeight = parseFloat(weightInput.value);
if (!isNaN(currentWeight)) {
if (this.isImperial) {
weightInput.value = this.formatNumber(currentWeight * 2.20462, 1);
} else {
weightInput.value = this.formatNumber(currentWeight / 2.20462, 1);
}
}
}
}
getWeightInKg() {
const weightInput = document.getElementById('weight');
if (!weightInput || !weightInput.value) return null;
const weight = parseFloat(weightInput.value);
if (isNaN(weight)) return null;
return this.isImperial ? weight / 2.20462 : weight;
}
calculateRER(weightKg) {
return 70 * Math.pow(weightKg, 0.75);
}
calculateMER(rer, factor) {
return rer * factor;
}
validateInput(value, min = 0, isInteger = false) {
const num = parseFloat(value);
if (isNaN(num) || num < min) return false;
if (isInteger && !Number.isInteger(num)) return false;
return true;
}
showError(elementId, show = true) {
const errorElement = document.getElementById(elementId);
if (errorElement) {
if (show) {
errorElement.classList.remove('dog-calculator-hidden');
} else {
errorElement.classList.add('dog-calculator-hidden');
}
}
}
convertUnits(grams, unit) {
switch (unit) {
case 'kg':
return grams / 1000;
case 'oz':
return grams / 28.3495;
case 'lb':
return grams / 453.592;
default:
return grams;
}
}
formatNumber(num, decimals = 0) {
if (decimals === 0) {
return Math.round(num).toString();
}
return num.toFixed(decimals).replace(/\.?0+$/, '');
}
validateWeight() {
const weightKg = this.getWeightInKg();
if (weightKg !== null && weightKg < 0.1) {
this.showError('weightError', true);
} else {
this.showError('weightError', false);
}
}
validateDays() {
const days = document.getElementById('days')?.value;
if (days && !this.validateInput(days, 1, true)) {
this.showError('daysError', true);
} else {
this.showError('daysError', false);
}
}
updateDayLabel() {
const days = document.getElementById('days')?.value;
const dayLabel = document.getElementById('dayLabel');
if (dayLabel && days) {
const numDays = parseInt(days);
dayLabel.textContent = numDays === 1 ? 'day' : 'days';
}
}
setActiveUnitButton(unit) {
const unitButtons = document.querySelectorAll('.dog-calculator-unit-btn');
unitButtons.forEach(button => {
button.classList.remove('active');
if (button.dataset.unit === unit) {
button.classList.add('active');
}
});
}
updateCalorieCalculations() {
const dogTypeSelect = document.getElementById('dogType');
const calorieResults = document.getElementById('calorieResults');
const rerValue = document.getElementById('rerValue');
const merValue = document.getElementById('merValue');
if (!dogTypeSelect || !calorieResults || !rerValue || !merValue) {
return;
}
const weightKg = this.getWeightInKg();
const dogTypeFactor = dogTypeSelect.value;
this.showError('weightError', false);
if (!weightKg || weightKg < 0.1) {
const weightInput = document.getElementById('weight');
if (weightInput && weightInput.value) this.showError('weightError', true);
calorieResults.style.display = 'none';
return;
}
if (!dogTypeFactor) {
calorieResults.style.display = 'none';
return;
}
const factor = parseFloat(dogTypeFactor);
const rer = this.calculateRER(weightKg);
const mer = this.calculateMER(rer, factor);
this.currentMER = mer;
rerValue.textContent = this.formatNumber(rer, 0) + ' cal/day';
merValue.textContent = this.formatNumber(mer, 0) + ' cal/day';
calorieResults.style.display = 'block';
this.updateFoodCalculations();
this.sendHeightToParent();
}
updateFoodCalculations() {
if (this.currentMER === 0) return;
const daysInput = document.getElementById('days');
const unitSelect = document.getElementById('unit');
const dailyFoodResults = document.getElementById('dailyFoodResults');
const dailyFoodValue = document.getElementById('dailyFoodValue');
const foodAmountsSection = document.getElementById('foodAmountsSection');
const foodAmountsList = document.getElementById('foodAmountsList');
const totalAmountDisplay = document.getElementById('totalAmountDisplay');
const foodBreakdownResults = document.getElementById('foodBreakdownResults');
const foodBreakdownList = document.getElementById('foodBreakdownList');
if (!daysInput || !unitSelect || !dailyFoodResults || !dailyFoodValue || !foodAmountsSection) {
return;
}
const days = daysInput.value;
const unit = unitSelect.value;
const unitLabel = unit === 'g' ? 'g' : unit === 'kg' ? 'kg' : unit === 'oz' ? 'oz' : 'lb';
const decimals = unit === 'g' ? 0 : unit === 'kg' ? 2 : 1;
// Clear all food source errors first
this.foodSources.forEach(fs => {
this.showError(`energy-error-${fs.id}`, false);
});
this.showError('daysError', false);
// Validate days input
if (!days || !this.validateInput(days, 1, true)) {
if (days) this.showError('daysError', true);
foodAmountsSection.style.display = 'none';
dailyFoodResults.style.display = 'none';
if (foodBreakdownResults) foodBreakdownResults.style.display = 'none';
// Hide unit buttons when validation fails
const unitButtons = document.getElementById('unitButtons');
if (unitButtons) unitButtons.style.display = 'none';
return;
}
const numDays = parseInt(days);
// Calculate per-food breakdown
const foodBreakdowns = [];
let totalDailyGrams = 0;
let hasValidFoods = false;
this.foodSources.forEach(fs => {
const energyPer100g = this.getFoodSourceEnergyPer100g(fs);
if (energyPer100g && energyPer100g > 0.1 && fs.percentage > 0) {
const dailyCaloriesForThisFood = (this.currentMER * fs.percentage) / 100;
const dailyGramsForThisFood = (dailyCaloriesForThisFood / energyPer100g) * 100;
foodBreakdowns.push({
name: fs.name,
percentage: fs.percentage,
dailyGrams: dailyGramsForThisFood,
calories: dailyCaloriesForThisFood,
isLocked: fs.isLocked,
hasEnergyContent: true
});
totalDailyGrams += dailyGramsForThisFood;
hasValidFoods = true;
} else if (fs.percentage > 0) {
// Include food sources without energy content but show them as needing energy content
foodBreakdowns.push({
name: fs.name,
percentage: fs.percentage,
dailyGrams: 0,
calories: 0,
isLocked: fs.isLocked,
hasEnergyContent: false
});
}
});
if (!hasValidFoods) {
// Show errors for invalid food sources
this.foodSources.forEach(fs => {
const energyInput = document.getElementById(`energy-${fs.id}`);
if (energyInput && energyInput.value && (!this.getFoodSourceEnergyPer100g(fs) || this.getFoodSourceEnergyPer100g(fs) <= 0.1)) {
this.showError(`energy-error-${fs.id}`, true);
}
});
dailyFoodResults.style.display = 'none';
if (foodBreakdownResults) foodBreakdownResults.style.display = 'none';
// Hide unit buttons when no valid foods
const unitButtons = document.getElementById('unitButtons');
if (unitButtons) unitButtons.style.display = 'none';
// If we have any food sources without energy content, still show the breakdown section
if (foodBreakdowns.length > 0) {
// Show food amounts section with warnings for missing energy content
const unitLabel = unit === 'g' ? 'g' : unit === 'kg' ? 'kg' : unit === 'oz' ? 'oz' : 'lb';
const foodAmountsHTML = foodBreakdowns.map(breakdown => {
const lockIndicator = breakdown.isLocked ? '<span class="dog-calculator-lock-indicator">🔒</span>' : '';
return `
<div class="dog-calculator-food-amount-item">
<div class="dog-calculator-food-amount-label">
<span>${breakdown.name}</span>
<span class="dog-calculator-food-percentage">${breakdown.percentage}%</span>
${lockIndicator}
</div>
<div class="dog-calculator-food-amount-value dog-calculator-warning" title="Enter energy content to calculate amount">
⚠️
</div>
</div>
`;
}).join('');
if (foodAmountsList) {
foodAmountsList.innerHTML = foodAmountsHTML;
}
if (totalAmountDisplay) {
totalAmountDisplay.textContent = "Enter energy content for all foods";
}
foodAmountsSection.style.display = 'block';
this.sendHeightToParent();
} else {
foodAmountsSection.style.display = 'none';
}
return;
}
// Update daily food results (total) - will be updated with proper units later
dailyFoodResults.style.display = 'block';
// Show unit buttons when daily results are shown
const unitButtons = document.getElementById('unitButtons');
if (unitButtons) unitButtons.style.display = 'flex';
// Update per-food breakdown
if (foodBreakdownList && foodBreakdowns.length > 1) {
const breakdownHTML = foodBreakdowns.map(breakdown => {
const valueContent = breakdown.hasEnergyContent
? `${this.formatNumber(this.convertUnits(breakdown.dailyGrams, unit), decimals)} ${unitLabel}/day`
: `<span class="dog-calculator-warning" title="Enter energy content to calculate amount">⚠️</span>`;
return `
<div class="dog-calculator-food-result-item">
<span class="dog-calculator-food-result-label">${breakdown.name} (${breakdown.percentage}%${breakdown.isLocked ? ' - locked' : ''}):</span>
<span class="dog-calculator-food-result-value">${valueContent}</span>
</div>
`;
}).join('');
foodBreakdownList.innerHTML = breakdownHTML;
if (foodBreakdownResults) foodBreakdownResults.style.display = 'block';
} else {
if (foodBreakdownResults) foodBreakdownResults.style.display = 'none';
}
// Generate individual food amount breakdown
// Update daily food value with correct units
const convertedDailyTotal = this.convertUnits(totalDailyGrams, unit);
dailyFoodValue.textContent = this.formatNumber(convertedDailyTotal, decimals) + ` ${unitLabel}/day`;
// Build HTML for individual food amounts
const foodAmountsHTML = foodBreakdowns.map(breakdown => {
const lockIndicator = breakdown.isLocked ? '<span class="dog-calculator-lock-indicator">🔒</span>' : '';
if (!breakdown.hasEnergyContent) {
// Show warning for food sources without energy content
return `
<div class="dog-calculator-food-amount-item">
<div class="dog-calculator-food-amount-label">
<span>${breakdown.name}</span>
<span class="dog-calculator-food-percentage">${breakdown.percentage}%</span>
${lockIndicator}
</div>
<div class="dog-calculator-food-amount-value dog-calculator-warning" title="Enter energy content to calculate amount">
⚠️
</div>
</div>
`;
} else {
const totalGramsForDays = breakdown.dailyGrams * numDays;
const convertedAmount = this.convertUnits(totalGramsForDays, unit);
return `
<div class="dog-calculator-food-amount-item">
<div class="dog-calculator-food-amount-label">
<span>${breakdown.name}</span>
<span class="dog-calculator-food-percentage">${breakdown.percentage}%</span>
${lockIndicator}
</div>
<div class="dog-calculator-food-amount-value">
${this.formatNumber(convertedAmount, decimals)} ${unitLabel}
</div>
</div>
`;
}
}).join('');
// Calculate and display total
const totalFoodGrams = totalDailyGrams * numDays;
const totalConverted = this.convertUnits(totalFoodGrams, unit);
// Update the display
if (foodAmountsList) {
foodAmountsList.innerHTML = foodAmountsHTML;
}
if (totalAmountDisplay) {
totalAmountDisplay.textContent = `${this.formatNumber(totalConverted, decimals)} ${unitLabel}`;
}
foodAmountsSection.style.display = 'block';
this.sendHeightToParent();
}
getFoodSourceEnergyPer100g(foodSource) {
if (!foodSource.energy || !foodSource.energyUnit) return null;
const energy = parseFloat(foodSource.energy);
if (isNaN(energy)) return null;
const unit = foodSource.energyUnit;
// Convert all units to kcal/100g for internal calculations
switch (unit) {
case 'kcal100g':
return energy;
case 'kcalkg':
return energy / 10; // 1 kg = 10 × 100g
case 'kcalcup':
return energy / 1.2; // Assume 1 cup ≈ 120g for dry dog food
case 'kcalcan':
return energy / 4.5; // Assume 1 can ≈ 450g for wet dog food
default:
return energy;
}
}
setupIframeResize() {
// Send height to parent window for iframe auto-resize
this.sendHeightToParent();
// Monitor for content changes that might affect height
const observer = new MutationObserver(() => {
setTimeout(() => this.sendHeightToParent(), 100);
});
observer.observe(document.body, {
childList: true,
subtree: true,
attributes: true
});
// Send height on window resize
window.addEventListener('resize', () => this.sendHeightToParent());
}
sendHeightToParent() {
const height = Math.max(document.body.scrollHeight, document.documentElement.scrollHeight);
if (window.parent && window.parent !== window) {
window.parent.postMessage({
type: 'dogCalculatorResize',
height: height
}, '*');
}
}
// Modal functionality
showShareModal() {
const modal = document.getElementById('shareModal');
const shareUrl = document.getElementById('shareUrl');
if (modal && shareUrl) {
shareUrl.value = window.location.href;
modal.style.display = 'block';
}
}
hideShareModal() {
const modal = document.getElementById('shareModal');
if (modal) modal.style.display = 'none';
}
showEmbedModal() {
const modal = document.getElementById('embedModal');
const widgetCode = document.getElementById('widgetCode');
const iframeCode = document.getElementById('iframeCode');
if (modal && widgetCode && iframeCode) {
// Build embed URL
const baseUrl = window.location.protocol + '//embed.' + window.location.hostname;
// Create widget code using createElement to avoid quote issues
const scriptTag = document.createElement('script');
scriptTag.src = baseUrl + '/dog-calorie-calculator/dog-food-calculator-widget.js';
const divTag = document.createElement('div');
divTag.id = 'dog-calorie-calculator';
const widgetHtml = scriptTag.outerHTML + '\n' + divTag.outerHTML;
widgetCode.textContent = widgetHtml;
// Create iframe code using createElement
const iframe = document.createElement('iframe');
iframe.src = baseUrl + '/dog-calorie-calculator/iframe.html';
iframe.width = '100%';
iframe.height = '600';
iframe.frameBorder = '0';
iframe.title = 'Dog Calorie Calculator';
iframeCode.textContent = iframe.outerHTML;
modal.style.display = 'block';
}
}
hideEmbedModal() {
const modal = document.getElementById('embedModal');
if (modal) modal.style.display = 'none';
}
shareToFacebook() {
const url = encodeURIComponent(window.location.href);
window.open('https://www.facebook.com/sharer/sharer.php?u=' + url, '_blank', 'width=600,height=400');
}
shareToTwitter() {
const url = encodeURIComponent(window.location.href);
const text = encodeURIComponent('Check out this useful dog calorie calculator!');
window.open('https://twitter.com/intent/tweet?url=' + url + '&text=' + text, '_blank', 'width=600,height=400');
}
shareToLinkedIn() {
const url = encodeURIComponent(window.location.href);
window.open('https://www.linkedin.com/sharing/share-offsite/?url=' + url, '_blank', 'width=600,height=400');
}
shareViaEmail() {
const subject = encodeURIComponent('Dog Calorie Calculator');
const body = encodeURIComponent('Check out this useful dog calorie calculator: ' + window.location.href);
window.location.href = 'mailto:?subject=' + subject + '&body=' + body;
}
async copyShareLink() {
const shareUrl = document.getElementById('shareUrl');
const copyBtn = document.getElementById('shareCopy');
if (shareUrl && copyBtn) {
try {
await navigator.clipboard.writeText(shareUrl.value);
const originalText = copyBtn.textContent;
copyBtn.textContent = 'Copied!';
copyBtn.classList.add('copied');
setTimeout(() => {
copyBtn.textContent = originalText;
copyBtn.classList.remove('copied');
}, 2000);
} catch (err) {
// Fallback for older browsers
shareUrl.select();
document.execCommand('copy');
}
}
}
async copyEmbedCode(type) {
const codeElement = document.getElementById(type === 'widget' ? 'widgetCode' : 'iframeCode');
const copyBtn = document.getElementById(type === 'widget' ? 'copyWidget' : 'copyIframe');
if (codeElement && copyBtn) {
try {
await navigator.clipboard.writeText(codeElement.textContent);
const originalText = copyBtn.textContent;
copyBtn.textContent = 'Copied!';
copyBtn.classList.add('copied');
setTimeout(() => {
copyBtn.textContent = originalText;
copyBtn.classList.remove('copied');
}, 2000);
} catch (err) {
// Fallback for older browsers
console.log('Copy fallback needed');
}
}
}
}
// Initialize calculator when DOM is ready
document.addEventListener('DOMContentLoaded', function() {
new DogCalorieCalculator();
});