From f781bbae74e4ed3ce711b5b1952e6cc06b3efa93 Mon Sep 17 00:00:00 2001 From: Dayowe Date: Thu, 26 Jun 2025 13:10:32 +0200 Subject: [PATCH] Update --- sundog-dog-food-calculator.js | 1619 ++++++++++++++++++++++++++++++--- 1 file changed, 1495 insertions(+), 124 deletions(-) diff --git a/sundog-dog-food-calculator.js b/sundog-dog-food-calculator.js index c9ee05e..d9e04ba 100644 --- a/sundog-dog-food-calculator.js +++ b/sundog-dog-food-calculator.js @@ -400,14 +400,38 @@ } .dog-calculator-input-group { - flex-direction: column; - gap: 20px; + flex-direction: row; + gap: 12px; + align-items: flex-end; } .dog-calculator-input-group .dog-calculator-form-group { - margin-bottom: 20px; + margin-bottom: 0; } + /* First form group takes 55%, second takes 40% with some flex */ + .dog-calculator-input-group .dog-calculator-form-group:first-child { + flex: 0 0 55%; + } + + .dog-calculator-input-group .dog-calculator-form-group:last-child { + flex: 1 1 40%; + min-width: 100px; + } + + /* Make sure number inputs don't get too wide */ + .dog-calculator-input-group input[type="number"] { + max-width: 100%; + } + + /* Ensure dropdowns don't overflow their containers */ + .dog-calculator-input-group select { + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + } + + .dog-calculator-result-item { flex-direction: column; align-items: flex-start; @@ -920,6 +944,637 @@ .dog-calculator-container.theme-system .dog-calculator-code-container code { color: #f5f3f7; } + } + + /* Multi-Food Source Styles */ + .dog-calculator-food-sources { + display: flex; + flex-direction: column; + gap: 16px; + } + + .dog-calculator-food-source-card { + background: #ffffff; + border: 1px solid #e8e3ed; + border-radius: 8px; + padding: 20px; + position: relative; + transition: all 0.2s ease; + } + + .dog-calculator-food-source-card:hover { + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); + } + + .dog-calculator-food-source-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 16px; + } + + .dog-calculator-food-source-title { + font-weight: 600; + color: #6f3f6d; + font-size: 1.1rem; + margin: 0; + } + + .dog-calculator-remove-food-btn { + background: #e87159; + color: white; + border: none; + border-radius: 50%; + width: 28px; + height: 28px; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + font-size: 16px; + font-weight: 600; + transition: all 0.2s ease; + line-height: 1; + } + + .dog-calculator-remove-food-btn:hover { + background: #d65a47; + transform: scale(1.1); + } + + .dog-calculator-percentage-group { + margin-top: 16px; + padding-top: 16px; + border-top: 1px solid #e8e3ed; + } + + .dog-calculator-percentage-label { + display: block; + margin-bottom: 8px; + font-weight: 500; + color: #6f3f6d; + font-size: 1rem; + } + + .dog-calculator-percentage-input-group { + display: flex; + align-items: center; + gap: 12px; + } + + .dog-calculator-percentage-slider { + flex: 1; + height: 6px; + border-radius: 3px; + background: #e8e3ed; + outline: none; + transition: all 0.2s ease; + -webkit-appearance: none; + appearance: none; + } + + .dog-calculator-percentage-slider::-webkit-slider-thumb { + -webkit-appearance: none; + appearance: none; + width: 20px; + height: 20px; + border-radius: 50%; + background: #f19a5f; + cursor: pointer; + border: 2px solid white; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); + transition: all 0.2s ease; + } + + .dog-calculator-percentage-slider::-webkit-slider-thumb:hover { + background: #e87741; + transform: scale(1.1); + } + + .dog-calculator-percentage-slider::-moz-range-thumb { + width: 20px; + height: 20px; + border-radius: 50%; + background: #f19a5f; + cursor: pointer; + border: 2px solid white; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); + transition: all 0.2s ease; + } + + .dog-calculator-percentage-input { + width: 70px; + padding: 8px 12px; + border: 1px solid #e8e3ed; + border-radius: 6px; + font-size: 0.9rem; + text-align: center; + background-color: #ffffff; + color: #6f3f6d; + } + + .dog-calculator-percentage-input:focus { + outline: none; + border-color: #f19a5f; + box-shadow: 0 0 0 3px rgba(241, 154, 95, 0.1); + } + + .dog-calculator-add-food-btn { + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + width: 100%; + padding: 16px; + border: 2px dashed #e8e3ed; + border-radius: 8px; + background: transparent; + color: #635870; + font-size: 1rem; + font-weight: 500; + cursor: pointer; + transition: all 0.2s ease; + font-family: inherit; + margin-top: 16px; + } + + .dog-calculator-add-food-btn:hover { + border-color: #f19a5f; + color: #f19a5f; + background: rgba(241, 154, 95, 0.05); + } + + .dog-calculator-add-food-btn:disabled { + opacity: 0.5; + cursor: not-allowed; + border-color: #e8e3ed; + color: #635870; + background: transparent; + } + + .dog-calculator-add-food-btn:disabled:hover { + border-color: #e8e3ed; + color: #635870; + background: transparent; + } + + .dog-calculator-food-results { + background: linear-gradient(135deg, rgba(241, 154, 95, 0.08) 0%, rgba(241, 154, 95, 0.04) 100%); + border: 1px solid rgba(241, 154, 95, 0.2); + border-radius: 6px; + padding: 16px; + margin-top: 20px; + } + + .dog-calculator-food-result-item { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 8px; + font-size: 0.9rem; + } + + .dog-calculator-food-result-item:last-child { + margin-bottom: 0; + } + + .dog-calculator-food-result-label { + font-weight: 500; + color: #6f3f6d; + } + + .dog-calculator-food-result-value { + font-weight: 600; + color: #6f3f6d; + padding: 2px 8px; + background: rgba(241, 154, 95, 0.15); + border-radius: 3px; + font-size: 0.85rem; + } + + /* Dark theme support for food sources */ + .dog-calculator-container.theme-dark .dog-calculator-food-source-card { + background: #312b3b; + border-color: #433c4f; + } + + .dog-calculator-container.theme-dark .dog-calculator-food-source-title { + color: #f5f3f7; + } + + .dog-calculator-container.theme-dark .dog-calculator-percentage-label { + color: #f5f3f7; + } + + .dog-calculator-container.theme-dark .dog-calculator-percentage-slider { + background: #433c4f; + } + + .dog-calculator-container.theme-dark .dog-calculator-percentage-input { + background: #433c4f; + border-color: #524a5f; + color: #f5f3f7; + } + + .dog-calculator-container.theme-dark .dog-calculator-percentage-group { + border-color: #433c4f; + } + + .dog-calculator-container.theme-dark .dog-calculator-add-food-btn { + border-color: #433c4f; + color: #b8b0c2; + } + + .dog-calculator-container.theme-dark .dog-calculator-add-food-btn:hover { + border-color: #f19a5f; + color: #f19a5f; + background: rgba(241, 154, 95, 0.1); + } + + .dog-calculator-container.theme-dark .dog-calculator-food-results { + background: linear-gradient(135deg, rgba(241, 154, 95, 0.15) 0%, rgba(241, 154, 95, 0.08) 100%); + border-color: rgba(241, 154, 95, 0.3); + } + + .dog-calculator-container.theme-dark .dog-calculator-food-result-label { + color: #f5f3f7; + } + + .dog-calculator-container.theme-dark .dog-calculator-food-result-value { + color: #f5f3f7; + background: rgba(241, 154, 95, 0.2); + } + + /* System theme support for food sources */ + @media (prefers-color-scheme: dark) { + .dog-calculator-container.theme-system .dog-calculator-food-source-card { + background: #312b3b; + border-color: #433c4f; + } + + .dog-calculator-container.theme-system .dog-calculator-food-source-title { + color: #f5f3f7; + } + + .dog-calculator-container.theme-system .dog-calculator-percentage-label { + color: #f5f3f7; + } + + .dog-calculator-container.theme-system .dog-calculator-percentage-slider { + background: #433c4f; + } + + .dog-calculator-container.theme-system .dog-calculator-percentage-input { + background: #433c4f; + border-color: #524a5f; + color: #f5f3f7; + } + + .dog-calculator-container.theme-system .dog-calculator-percentage-group { + border-color: #433c4f; + } + + .dog-calculator-container.theme-system .dog-calculator-add-food-btn { + border-color: #433c4f; + color: #b8b0c2; + } + + .dog-calculator-container.theme-system .dog-calculator-add-food-btn:hover { + border-color: #f19a5f; + color: #f19a5f; + background: rgba(241, 154, 95, 0.1); + } + + .dog-calculator-container.theme-system .dog-calculator-food-results { + background: linear-gradient(135deg, rgba(241, 154, 95, 0.15) 0%, rgba(241, 154, 95, 0.08) 100%); + border-color: rgba(241, 154, 95, 0.3); + } + + .dog-calculator-container.theme-system .dog-calculator-food-result-label { + color: #f5f3f7; + } + + .dog-calculator-container.theme-system .dog-calculator-food-result-value { + color: #f5f3f7; + background: rgba(241, 154, 95, 0.2); + } + } + + /* Mobile responsive design for food sources */ + @media (max-width: 576px) { + .dog-calculator-food-source-card { + padding: 16px; + } + + .dog-calculator-food-source-header { + flex-direction: column; + align-items: flex-start; + gap: 12px; + } + + .dog-calculator-remove-food-btn { + align-self: flex-end; + margin-top: -8px; + } + + .dog-calculator-percentage-input-group { + flex-direction: column; + gap: 8px; + align-items: stretch; + } + + .dog-calculator-percentage-input { + width: 100%; + } + + .dog-calculator-add-food-btn { + padding: 12px; + font-size: 0.9rem; + } + + .dog-calculator-food-result-item { + flex-direction: column; + align-items: flex-start; + gap: 4px; + } + + .dog-calculator-food-result-value { + align-self: stretch; + text-align: center; + } + } + + /* Lock Icon Styles */ + .dog-calculator-lock-icon { + display: inline-block; + width: 16px; + height: 16px; + margin-left: 8px; + cursor: pointer; + font-size: 14px; + line-height: 1; + vertical-align: middle; + transition: all 0.2s ease; + user-select: none; + opacity: 0.6; + } + + .dog-calculator-lock-icon:hover { + opacity: 1; + transform: scale(1.1); + } + + .dog-calculator-lock-icon.locked { + color: #f19a5f; + opacity: 1; + font-weight: bold; + } + + .dog-calculator-lock-icon.unlocked { + color: #635870; + } + + .dog-calculator-lock-icon.disabled { + opacity: 0.3; + cursor: not-allowed; + } + + .dog-calculator-lock-icon.disabled:hover { + opacity: 0.3; + transform: none; + } + + /* Dark theme support for lock icons */ + .dog-calculator-container.theme-dark .dog-calculator-lock-icon.unlocked { + color: #b8b0c2; + } + + .dog-calculator-container.theme-dark .dog-calculator-lock-icon.locked { + color: #f19a5f; + } + + /* System theme support for lock icons */ + @media (prefers-color-scheme: dark) { + .dog-calculator-container.theme-system .dog-calculator-lock-icon.unlocked { + color: #b8b0c2; + } + + .dog-calculator-container.theme-system .dog-calculator-lock-icon.locked { + color: #f19a5f; + } + } + + /* Disabled slider and input styles */ + .dog-calculator-percentage-slider:disabled { + opacity: 0.5; + cursor: not-allowed; + background: #f0f0f0; + pointer-events: none; + } + + .dog-calculator-percentage-slider:disabled::-webkit-slider-thumb { + background: #ccc; + cursor: not-allowed; + } + + .dog-calculator-percentage-slider:disabled::-webkit-slider-thumb:hover { + background: #ccc; + transform: none; + } + + .dog-calculator-percentage-slider:disabled::-moz-range-thumb { + background: #ccc; + cursor: not-allowed; + } + + .dog-calculator-percentage-input:disabled { + opacity: 0.5; + cursor: not-allowed; + background-color: #f8f8f8; + border-color: #ddd; + pointer-events: none; + } + + /* Dark theme disabled styles */ + .dog-calculator-container.theme-dark .dog-calculator-percentage-slider:disabled { + background: #2a2530; + } + + .dog-calculator-container.theme-dark .dog-calculator-percentage-input:disabled { + background-color: #2a2530; + border-color: #3a3442; + color: #8a8a8a; + } + + /* System theme disabled styles */ + @media (prefers-color-scheme: dark) { + .dog-calculator-container.theme-system .dog-calculator-percentage-slider:disabled { + background: #2a2530; + } + + .dog-calculator-container.theme-system .dog-calculator-percentage-input:disabled { + background-color: #2a2530; + border-color: #3a3442; + color: #8a8a8a; + } + } + + /* Food Amount Breakdown Styling */ + .dog-calculator-food-amounts-section { + margin-top: 1.5rem; + padding: 1rem; + background: var(--bg-secondary); + border-radius: 8px; + border: 1px solid var(--border-color); + } + + .dog-calculator-section-title { + margin: 0 0 1rem 0; + font-size: 1rem; + font-weight: 600; + color: var(--text-primary); + } + + .dog-calculator-food-amounts-list { + display: flex; + flex-direction: column; + gap: 0.75rem; + margin-bottom: 1rem; + } + + .dog-calculator-food-amount-item { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.75rem; + background: var(--bg-primary); + border-radius: 6px; + border: 1px solid var(--border-color); + } + + .dog-calculator-food-amount-label { + font-weight: 500; + color: var(--text-primary); + display: flex; + align-items: center; + gap: 0.5rem; + } + + .dog-calculator-food-percentage { + background: var(--primary-color); + color: white; + padding: 0.2rem 0.5rem; + border-radius: 12px; + font-size: 0.8rem; + font-weight: 500; + } + + .dog-calculator-lock-indicator { + font-size: 0.8rem; + opacity: 0.7; + } + + .dog-calculator-food-amount-value { + font-weight: 600; + color: var(--text-primary); + font-size: 1rem; + } + + .dog-calculator-total-row { + display: flex; + justify-content: space-between; + align-items: center; + padding: 1rem; + background: var(--primary-color); + color: white; + border-radius: 6px; + font-weight: 600; + margin-top: 0.5rem; + } + + .dog-calculator-total-label { + font-size: 1rem; + } + + .dog-calculator-total-value { + font-size: 1.1rem; + font-weight: 700; + } + + .dog-calculator-full-width { + flex: 1; + } + + /* Editable Food Source Name Styling */ + .dog-calculator-food-source-name-input { + background: transparent; + border: 2px solid transparent; + color: var(--text-primary); + font-size: 1.1rem; + font-weight: 600; + font-family: inherit; + padding: 0.5rem 0; + border-radius: 4px; + width: 100%; + outline: none; + transition: all 0.2s ease; + cursor: text; + } + + .dog-calculator-food-source-name-input:hover { + border-color: var(--border-color); + background: var(--bg-secondary); + padding: 0.5rem; + } + + .dog-calculator-food-source-name-input:focus { + border-color: var(--primary-color); + background: var(--bg-primary); + box-shadow: 0 0 0 3px rgba(241, 154, 95, 0.1); + padding: 0.5rem; + } + + .dog-calculator-food-source-name-input::placeholder { + color: var(--text-secondary); + opacity: 0.7; + } + + /* Dark theme adjustments */ + .dog-calculator-container.theme-dark .dog-calculator-food-source-name-input:hover { + background: #2a2530; + } + + .dog-calculator-container.theme-dark .dog-calculator-food-source-name-input:focus { + background: #1e1a24; + } + + /* System theme adjustments */ + @media (prefers-color-scheme: dark) { + .dog-calculator-container.theme-system .dog-calculator-food-source-name-input:hover { + background: #2a2530; + } + + .dog-calculator-container.theme-system .dog-calculator-food-source-name-input:focus { + background: #1e1a24; + } + } + + /* Responsive adjustments */ + @media (max-width: 576px) { + .dog-calculator-food-amount-item { + flex-direction: column; + gap: 0.5rem; + text-align: center; + } + + .dog-calculator-food-amount-label { + justify-content: center; + } + + .dog-calculator-food-source-name-input { + font-size: 1rem; + } + }`; function injectStyles() { @@ -950,6 +1605,8 @@ this.currentMER = 0; this.isImperial = false; + this.foodSources = []; + this.maxFoodSources = 5; this.init(); } @@ -1011,43 +1668,41 @@
-
-
- - -
Please enter a valid energy content
-
-
- - + +
+ +
+ + + + + + + -
- - -
Please enter a valid number of days (minimum 1)
-
- -
+ +
- - + + +
Please enter a valid number of days (minimum 1)
- +
+ +
@@ -1139,6 +1805,9 @@ this.applyScale(); // Continue with original init logic + this.applyTheme(); + this.applyScale(); + this.initializeFoodSources(); this.bindEvents(); this.updateUnitLabels(); this.setupIframeResize(); @@ -1186,13 +1855,656 @@ } } + // 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.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.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 = this.container.querySelector('#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'; + } + } + } + } + + renderFoodSource(foodSource) { + const container = this.container.querySelector('#foodSources'); + if (!container) return; + + const cardHTML = ` +
+
+ + ${this.foodSources.length > 1 ? `` : ''} +
+ +
+
+ + +
Please enter a valid energy content
+
+
+ + +
+
+ +
+ +
+ + +
+
+
+ `; + + 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 = this.container.querySelector('#weight'); const dogTypeSelect = this.container.querySelector('#dogType'); - const foodEnergyInput = this.container.querySelector('#foodEnergy'); const daysInput = this.container.querySelector('#days'); const unitSelect = this.container.querySelector('#unit'); const unitToggle = this.container.querySelector('#unitToggle'); + const addFoodBtn = this.container.querySelector('#addFoodBtn'); if (weightInput) { weightInput.addEventListener('input', () => this.updateCalorieCalculations()); @@ -1201,14 +2513,6 @@ if (dogTypeSelect) dogTypeSelect.addEventListener('change', () => this.updateCalorieCalculations()); - if (foodEnergyInput) { - foodEnergyInput.addEventListener('input', () => this.updateFoodCalculations()); - foodEnergyInput.addEventListener('blur', () => this.validateFoodEnergy()); - } - - const energyUnitSelect = this.container.querySelector('#energyUnit'); - if (energyUnitSelect) energyUnitSelect.addEventListener('change', () => this.updateFoodCalculations()); - if (daysInput) { daysInput.addEventListener('input', () => this.updateFoodCalculations()); daysInput.addEventListener('blur', () => this.validateDays()); @@ -1218,6 +2522,8 @@ if (unitToggle) unitToggle.addEventListener('change', () => this.toggleUnits()); + if (addFoodBtn) addFoodBtn.addEventListener('click', () => this.addFoodSource()); + // Modal event listeners const shareBtn = this.container.querySelector('#shareBtn'); const embedBtn = this.container.querySelector('#embedBtn'); @@ -1281,7 +2587,6 @@ const weightLabel = this.container.querySelector('#weightLabel'); const weightInput = this.container.querySelector('#weight'); const unitSelect = this.container.querySelector('#unit'); - const energyUnitSelect = this.container.querySelector('#energyUnit'); if (metricLabel && imperialLabel) { metricLabel.classList.toggle('active', !this.isImperial); @@ -1301,10 +2606,17 @@ '' + ''; } - // Set energy unit to kcal/cup for imperial - if (energyUnitSelect && energyUnitSelect.value === 'kcal100g') { - energyUnitSelect.value = 'kcalcup'; - } + + // 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) { @@ -1318,10 +2630,17 @@ '' + ''; } - // Set energy unit to kcal/100g for metric - if (energyUnitSelect && energyUnitSelect.value === 'kcalcup') { - energyUnitSelect.value = 'kcal100g'; - } + + // 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'; + } + } + }); } } @@ -1350,30 +2669,6 @@ return this.isImperial ? weight / 2.20462 : weight; } - getFoodEnergyPer100g() { - const foodEnergyInput = this.container.querySelector('#foodEnergy'); - const energyUnitSelect = this.container.querySelector('#energyUnit'); - if (!foodEnergyInput || !foodEnergyInput.value || !energyUnitSelect) return null; - - const energy = parseFloat(foodEnergyInput.value); - if (isNaN(energy)) return null; - - const unit = energyUnitSelect.value; - - // 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; - } - } calculateRER(weightKg) { return 70 * Math.pow(weightKg, 0.75); @@ -1430,40 +2725,6 @@ } } - validateFoodEnergy() { - const energyInput = this.container.querySelector('#foodEnergy'); - const energyUnitSelect = this.container.querySelector('#energyUnit'); - - if (!energyInput || !energyInput.value) { - this.showError('foodEnergyError', false); - return; - } - - const energy = parseFloat(energyInput.value); - const unit = energyUnitSelect?.value || 'kcal100g'; - - 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)) { - this.showError('foodEnergyError', true); - } else { - this.showError('foodEnergyError', false); - } - } validateDays() { const days = this.container.querySelector('#days')?.value; @@ -1523,50 +2784,160 @@ const unitSelect = this.container.querySelector('#unit'); const dailyFoodResults = this.container.querySelector('#dailyFoodResults'); const dailyFoodValue = this.container.querySelector('#dailyFoodValue'); - const totalFoodDisplay = this.container.querySelector('#totalFoodDisplay'); + const foodAmountsSection = this.container.querySelector('#foodAmountsSection'); + const foodAmountsList = this.container.querySelector('#foodAmountsList'); + const totalAmountDisplay = this.container.querySelector('#totalAmountDisplay'); + const foodBreakdownResults = this.container.querySelector('#foodBreakdownResults'); + const foodBreakdownList = this.container.querySelector('#foodBreakdownList'); - if (!daysInput || !unitSelect || !dailyFoodResults || !dailyFoodValue || !totalFoodDisplay) { + if (!daysInput || !unitSelect || !dailyFoodResults || !dailyFoodValue || !foodAmountsSection) { return; } - const energyPer100g = this.getFoodEnergyPer100g(); const days = daysInput.value; const unit = unitSelect.value; - this.showError('foodEnergyError', false); + // Clear all food source errors first + this.foodSources.forEach(fs => { + this.showError(`energy-error-${fs.id}`, false); + }); this.showError('daysError', false); - - if (!energyPer100g || energyPer100g < 0.1) { - const foodEnergyInput = this.container.querySelector('#foodEnergy'); - if (foodEnergyInput && foodEnergyInput.value) this.showError('foodEnergyError', true); - dailyFoodResults.style.display = 'none'; - totalFoodDisplay.value = ''; - return; - } + // Validate days input if (!days || !this.validateInput(days, 1, true)) { if (days) this.showError('daysError', true); - totalFoodDisplay.value = ''; + foodAmountsSection.style.display = 'none'; + dailyFoodResults.style.display = 'none'; + if (foodBreakdownResults) foodBreakdownResults.style.display = 'none'; return; } const numDays = parseInt(days); - const dailyFoodGrams = (this.currentMER / energyPer100g) * 100; - const totalFoodGrams = dailyFoodGrams * numDays; - - dailyFoodValue.textContent = this.formatNumber(dailyFoodGrams, 1) + ' g/day'; + // 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 + }); + + totalDailyGrams += dailyGramsForThisFood; + hasValidFoods = true; + } + }); + + 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'; + foodAmountsSection.style.display = 'none'; + if (foodBreakdownResults) foodBreakdownResults.style.display = 'none'; + return; + } + + // Update daily food results (total) + dailyFoodValue.textContent = this.formatNumber(totalDailyGrams, 1) + ' g/day'; dailyFoodResults.style.display = 'block'; - - const convertedAmount = this.convertUnits(totalFoodGrams, unit); + + // Update per-food breakdown + if (foodBreakdownList && foodBreakdowns.length > 1) { + const breakdownHTML = foodBreakdowns.map(breakdown => ` +
+ ${breakdown.name} (${breakdown.percentage}%${breakdown.isLocked ? ' - locked' : ''}): + ${this.formatNumber(breakdown.dailyGrams, 1)} g/day +
+ `).join(''); + + foodBreakdownList.innerHTML = breakdownHTML; + if (foodBreakdownResults) foodBreakdownResults.style.display = 'block'; + } else { + if (foodBreakdownResults) foodBreakdownResults.style.display = 'none'; + } + + // Generate individual food amount breakdown const unitLabel = unit === 'g' ? 'g' : unit === 'kg' ? 'kg' : unit === 'oz' ? 'oz' : 'lb'; const decimals = unit === 'g' ? 0 : unit === 'kg' ? 2 : 1; - totalFoodDisplay.value = this.formatNumber(convertedAmount, decimals) + ' ' + unitLabel; + // Build HTML for individual food amounts + const foodAmountsHTML = foodBreakdowns.map(breakdown => { + const totalGramsForDays = breakdown.dailyGrams * numDays; + const convertedAmount = this.convertUnits(totalGramsForDays, unit); + const lockIndicator = breakdown.isLocked ? '🔒' : ''; + + return ` +
+
+ ${breakdown.name} + ${breakdown.percentage}% + ${lockIndicator} +
+
+ ${this.formatNumber(convertedAmount, decimals)} ${unitLabel} +
+
+ `; + }).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();