Create Your Blend

Calculated max for this blend:
Select oils and expand IFRA under each to enter certificate limits
20%

Enter max % from this oil's IFRA certificate. Leave blank if unknown.

20%

Enter max % from this oil's IFRA certificate. Leave blank if unknown.

20%

Enter max % from this oil's IFRA certificate. Leave blank if unknown.

20%

Enter max % from this oil's IFRA certificate. Leave blank if unknown.

20%

Enter max % from this oil's IFRA certificate. Leave blank if unknown.

Your Scent Profile

Blend visualization appears here

Guide
Recipes
Performance Log
Custom Scents
Inventory
Compare
📊 Analytics
📦 Batches
📋 SDS
Backup

Quick Reference Guide

Car & Home Fragrance Tips

  • Car diffusers: Use 20-30% total oil concentration. Fresh, uplifting scents like citrus, mint, and light florals work best.
  • Home diffusers: 10-20% concentration is usually sufficient. Consider room purpose - calming for bedrooms, fresh for bathrooms, warm for living areas.
  • Strong scents: Peppermint, patchouli, and citrus need smaller percentages (5-15%).
  • Subtle scents: Vanilla, sandalwood, and rose can use higher percentages (15-30%).
  • Let your blend rest for 24-48 hours before final evaluation. Test in small batches first.

How IFRA Works in This App

  • Limits are fragrance-specific — each oil has its own supplier certificate.
  • Click IFRA Limits under any oil to expand and enter max % from the certificate.
  • The box under Target Category shows the calculated max total concentration for your blend.
  • Green = compliant · Yellow = close · Red = over the limit.
  • Sea Minerals is pre-loaded with its CandleScience IFRA 51 certificate.

IFRA Certificate Scanner

  • Upload your IFRA certificate PDF or image.
  • The system will attempt to extract the limits automatically.
  • You can then save the extracted limits to your oil database.

Saved Recipes

No recipes saved yet.

Performance Log

View all saved performance notes and ratings for your recipes.

No performance data logged yet. Save a recipe and add performance notes!

Custom Library

No custom fragrances added yet.

Inventory

Stock auto-deducts when you save a recipe and confirm the batch usage.

Add oils below.

Compare Blends

Formulation Analytics

Analyze your formulation patterns and performance metrics.

Total Recipes

0

Avg. Satisfaction

⭐ 0.0

Most Used Oil

Top 5 Most Used Oils

No data yet

Note Distribution

No data yet

Performance Trends

Batch Tracking & Production Planning

Track your production batches, lot numbers, and shelf life.

Create New Batch

No batches created yet.

Safety Data Sheet Generator

Generate professional Safety Data Sheets for your blends.

SDS Information

IFRA Certificate Scanner

Upload IFRA certificates for each oil in your blend. The system will extract limits and match them to the correct oil.

Upload all certificates at once, then assign each to the correct oil

© Professional Fragrance Labs. All rights reserved.

`); win.document.close(); showToast('📄 SDS generated! Print window opened.', 'success'); } else { showToast('Please allow popups to generate SDS.', 'error'); } }); // =========================== // INVENTORY // =========================== window.inventory = JSON.parse(localStorage.getItem('fragranceInventory') || '{}'); function checkLowStockAlerts() { const lowStockThreshold = 30, criticalThreshold = 10; let lowStockItems = [], criticalItems = []; for (const [name, qty] of Object.entries(inventory)) { if (qty <= 0) criticalItems.push(name + ' (Out of Stock)'); else if (qty <= criticalThreshold) criticalItems.push(name + ' (' + qty.toFixed(1) + 'ml left)'); else if (qty <= lowStockThreshold) lowStockItems.push(name + ' (' + qty.toFixed(1) + 'ml left)'); } if (criticalItems.length > 0) showToast('🚨 CRITICAL: ' + criticalItems.join(', '), 'error'); else if (lowStockItems.length > 0) showToast('⚠️ LOW STOCK: ' + lowStockItems.join(', '), 'warning'); } function displayInventory() { const container = document.getElementById('inventory-list'); const keys = Object.keys(inventory); if (keys.length === 0) { container.innerHTML = '

No inventory items.

'; return; } let html = '
'; keys.sort((a,b) => inventory[a] - inventory[b]).forEach(name => { const qty = inventory[name]; let stockClass = 'ok', label = 'OK'; if (qty <= 0) { stockClass = 'critical'; label = 'EMPTY'; } else if (qty < 10) { stockClass = 'critical'; label = 'CRITICAL'; } else if (qty < 30) { stockClass = 'low'; label = 'LOW'; } html += '
' + '' + name + ' ' + qty.toFixed(1) + ' ml ' + label + '' + '
' + '' + '' + '' + '
' + '
'; }); container.innerHTML = html; } window.updateInventory = function(name) { const input = document.getElementById('inv-qty-' + name.replace(/\s/g, '_')); if(!input) return; const newQty = parseFloat(input.value); if (isNaN(newQty) || newQty < 0) { showToast('Enter a valid quantity', 'error'); return; } inventory[name] = newQty; localStorage.setItem('fragranceInventory', JSON.stringify(inventory)); displayInventory(); checkLowStockAlerts(); showToast('Updated ' + name + ' to ' + newQty.toFixed(1) + 'ml'); }; window.deleteInventory = function(name) { if (confirm('Remove "' + name + '" from inventory?')) { delete inventory[name]; localStorage.setItem('fragranceInventory', JSON.stringify(inventory)); displayInventory(); showToast('Removed ' + name); } }; document.getElementById('inventory-add-btn')?.addEventListener('click', function() { const name = document.getElementById('inventory-add-name').value.trim(); const qty = parseFloat(document.getElementById('inventory-add-qty').value); if (!name || isNaN(qty) || qty < 0) { showToast('Enter valid name & qty', 'error'); return; } inventory[name] = (inventory[name] || 0) + qty; localStorage.setItem('fragranceInventory', JSON.stringify(inventory)); document.getElementById('inventory-add-name').value = ''; document.getElementById('inventory-add-qty').value = ''; displayInventory(); checkLowStockAlerts(); showToast('✅ Added ' + qty.toFixed(1) + 'ml of ' + name); }); // =========================== // UI BUILDERS // =========================== function buildFragranceSelectOptions(selectElement) { const currentValue = selectElement.value; selectElement.innerHTML = ''; const byType = { top: [], middle: [], base: [] }; for (const id in fragranceDB) { const frag = fragranceDB[id]; byType[frag.type].push({ id: id, name: frag.name, custom: frag.custom }); } ['top','middle','base'].forEach(type => { if (byType[type].length > 0) { const optgroup = document.createElement('optgroup'); optgroup.label = type.charAt(0).toUpperCase() + type.slice(1) + ' Notes'; byType[type].forEach(f => { const o = document.createElement('option'); o.value = f.id; o.textContent = f.custom ? f.name + ' (Custom)' : f.name; optgroup.appendChild(o); }); selectElement.appendChild(optgroup); } }); const customOption = document.createElement('option'); customOption.value = 'custom'; customOption.textContent = '+ Add Custom Fragrance'; selectElement.appendChild(customOption); if (currentValue && selectElement.querySelector('option[value="' + currentValue + '"]')) selectElement.value = currentValue; } function initializeAllSelects() { for (let i = 1; i <= 5; i++) { buildFragranceSelectOptions(document.getElementById('fragrance' + i)); } } function setupCustomFragranceForms() { document.querySelectorAll('.custom-fragrance-form').forEach(form => { const btns = form.querySelectorAll('.note-selector-btn'); btns.forEach(btn => { btn.addEventListener('click', function() { btns.forEach(b => b.classList.remove('active')); this.classList.add('active'); }); }); }); document.querySelectorAll('.fragrance-select').forEach(select => { select.addEventListener('change', function() { const index = this.id.replace('fragrance', ''); const form = document.getElementById('custom-form' + index); form.style.display = this.value === 'custom' ? 'block' : 'none'; }); }); for (let i = 1; i <= 5; i++) { document.getElementById('save-custom' + i)?.addEventListener('click', function() { const customName = document.getElementById('custom-name' + i).value.trim(); if (!customName) { showToast('Please enter a name', 'error'); return; } const parentForm = this.closest('.custom-fragrance-form'); const activeBtn = parentForm.querySelector('.note-selector-btn.active'); const type = activeBtn ? activeBtn.getAttribute('data-type') : 'middle'; const id = 'custom-' + customName.toLowerCase().replace(/[^a-z0-9]/g, '-') + '-' + Date.now(); fragranceDB[id] = { name: customName, type: type, intensity: type === 'top' ? 2 : type === 'middle' ? 3 : 1, custom: true, notes: "Custom fragrance", ifra: null }; const customFrags = Object.fromEntries(Object.entries(fragranceDB).filter(([k,v]) => v.custom)); localStorage.setItem('customFragrances', JSON.stringify(customFrags)); initializeAllSelects(); document.getElementById('fragrance' + i).value = id; document.getElementById('custom-form' + i).style.display = 'none'; document.getElementById('custom-name' + i).value = ''; displayCustomFragrances(); showToast('Custom scent "' + customName + '" added!'); if (!inventory[customName]) { inventory[customName] = 50; localStorage.setItem('fragranceInventory', JSON.stringify(inventory)); } }); } } function displayCustomFragrances() { const customFrags = Object.entries(fragranceDB).filter(([id, f]) => f.custom); const container = document.getElementById('custom-fragrances-list'); if (customFrags.length === 0) { container.innerHTML = '

No custom fragrances added yet.

'; return; } container.innerHTML = ''; customFrags.forEach(([id, fragrance]) => { const card = document.createElement('div'); card.className = 'recipe-card'; card.innerHTML = `

${fragrance.name}

${fragrance.type.charAt(0).toUpperCase()+fragrance.type.slice(1)}
`; container.appendChild(card); }); document.querySelectorAll('.delete-custom').forEach(btn => { btn.addEventListener('click', function() { if (confirm('Delete this custom fragrance?')) { delete fragranceDB[this.dataset.id]; localStorage.setItem('customFragrances', JSON.stringify(Object.fromEntries(Object.entries(fragranceDB).filter(([k,v]) => v.custom)))); initializeAllSelects(); displayCustomFragrances(); showToast('Custom fragrance deleted'); } }); }); } function normalizeRatios() { const sliders = [1,2,3,4,5].map(i => document.getElementById('ratio' + i)); const selects = [1,2,3,4,5].map(i => document.getElementById('fragrance' + i)); const values = sliders.map(s => parseInt(s.value) || 0); const activeIndexes = selects.map((s, i) => s.value && s.value !== 'custom' ? i : -1).filter(i => i !== -1); if (activeIndexes.length === 0) return; const total = activeIndexes.reduce((sum, i) => sum + values[i], 0); if (total !== 100 && total > 0) { const adjustment = 100 - total; const perUnit = adjustment / total; activeIndexes.forEach(i => { const newVal = Math.max(0, Math.round(values[i] + (values[i] * perUnit))); sliders[i].value = Math.min(100, newVal); sliders[i].nextElementSibling.textContent = sliders[i].value + '%'; }); } else if (total === 0 && activeIndexes.length > 0) { const share = Math.floor(100 / activeIndexes.length); let remainder = 100 - (share * activeIndexes.length); activeIndexes.forEach((i, idx) => { const val = share + (idx < remainder ? 1 : 0); sliders[i].value = val; sliders[i].nextElementSibling.textContent = val + '%'; }); } } function getCurrentBlend() { const blend = { fragrances: [], topNotes: [], middleNotes: [], baseNotes: [], totalIntensity: 0 }; for (let i = 1; i <= 5; i++) { const select = document.getElementById('fragrance' + i); const ratio = parseInt(document.getElementById('ratio' + i).value) || 0; if (select.value && select.value !== '' && select.value !== 'custom' && ratio > 0) { const fragrance = fragranceDB[select.value]; if (!fragrance) continue; blend.fragrances.push({ name: fragrance.name, id: select.value, ratio: ratio, type: fragrance.type, intensity: fragrance.intensity, notes: fragrance.notes || '' }); const noteList = fragrance.type === 'top' ? 'topNotes' : fragrance.type === 'middle' ? 'middleNotes' : 'baseNotes'; blend[noteList].push({ name: fragrance.name, ratio: ratio, notes: fragrance.notes || '' }); blend.totalIntensity += (fragrance.intensity * ratio / 100); } } return blend; } // =========================== // ANALYZE BLEND // =========================== function generatePracticalGuide(blend) { if (!blend || blend.fragrances.length === 0) return; const container = document.getElementById('practical-guide'); container.style.display = 'block'; const content = document.getElementById('guide-content'); const application = document.getElementById('target-application').value; const concentration = parseFloat(document.getElementById('batch-concentration').value) || 20; const catKey = getIfraKeyForApplication(application); const appName = getAppDisplayName(application); const ifraResult = calculateBlendIfraLimit(blend, catKey); const maxIFRA = ifraResult.max; const topRatio = blend.topNotes.reduce((sum, n) => sum + n.ratio, 0); const midRatio = blend.middleNotes.reduce((sum, n) => sum + n.ratio, 0); const baseRatio = blend.baseNotes.reduce((sum, n) => sum + n.ratio, 0); const totalIntensity = blend.totalIntensity; let tips = []; let recommendations = []; let warnings = []; if (topRatio < 15 && blend.fragrances.length > 1) { tips.push("💡 Your blend has low top notes (<15%). Consider increasing citrus or fresh notes for a brighter opening."); } if (baseRatio < 15 && blend.fragrances.length > 1) { tips.push("💡 Your blend has low base notes (<15%). Add more woody or resinous notes for better longevity."); } if (topRatio > 50) { tips.push("⚠️ High top note concentration (>50%). This blend may evaporate quickly. Consider adding more middle/base notes."); } if (baseRatio > 50) { tips.push("⚠️ High base note concentration (>50%). The opening may be heavy. Consider adding more top/middle notes."); } if (totalIntensity < 2) { recommendations.push("🟢 This is a subtle blend. Perfect for home diffusers, bedrooms, or personal fragrance. Consider increasing concentration for cars."); } else if (totalIntensity < 3) { recommendations.push("🟡 This is a moderate blend. Good for most applications. Works well in both car and home diffusers."); } else if (totalIntensity < 3.8) { recommendations.push("🟠 This is a strong blend. Excellent for cars and large spaces. Reduce concentration for enclosed spaces."); } else { recommendations.push("🔴 This is an intense blend. Use sparingly. Great for candles and reed diffusers. Dilute well for body applications."); } if (application === 'cat12' || application === 'cat10A' || application === 'household') { if (totalIntensity > 3.5) { recommendations.push("🚗 For car use, consider reducing the concentration to 20-25% to avoid overwhelming the small space."); } if (topRatio > 40) { recommendations.push("🏠 For home diffusers, high citrus content evaporates quickly. Consider adding a base note like vanilla or sandalwood for longevity."); } } if (ifraResult && ifraResult.missing && ifraResult.missing.length > 0) { warnings.push('IFRA data missing for: ' + ifraResult.missing.join(', ') + '. Expand IFRA under each oil.'); } if (maxIFRA !== null && concentration > maxIFRA) { warnings.push('Concentration (' + concentration + '%) exceeds calculated IFRA max for ' + appName + ' (' + maxIFRA + '%). Limited by ' + ifraResult.limitingOil + '.'); } else if (maxIFRA !== null) { recommendations.push('IFRA compliant for ' + appName + '. Calculated max: ' + maxIFRA + '%.'); } if ((application === 'cat4' || application === 'cat5A') && totalIntensity > 3.5) { recommendations.push("For body products, this may be too strong. Consider diluting further or using more subtle scents."); } const topNames = blend.topNotes.map(n => n.name); const midNames = blend.middleNotes.map(n => n.name); const baseNames = blend.baseNotes.map(n => n.name); if (topNames.some(n => ['Lemon', 'Bergamot', 'Peppermint'].includes(n)) && !midNames.some(n => ['Lavender', 'Jasmine', 'Rose'].includes(n))) { recommendations.push("🌸 Your citrus notes would pair beautifully with floral middle notes like Lavender or Jasmine."); } if (baseNames.some(n => ['Vanilla', 'Sandalwood', 'Teakwood'].includes(n)) && !topNames.some(n => ['Bergamot', 'Lemon'].includes(n))) { recommendations.push("🍊 Your woody base notes could benefit from bright top notes like Bergamot or Lemon for balance."); } let harmonyScore = 5; if (Math.abs(topRatio - baseRatio) > 30) harmonyScore -= 1; if (topRatio < 10 || baseRatio < 10) harmonyScore -= 1; if (blend.fragrances.length < 2) harmonyScore -= 1; if (totalIntensity > 4) harmonyScore -= 0.5; harmonyScore = Math.max(1, Math.min(5, harmonyScore)); const harmonyStars = '★'.repeat(Math.round(harmonyScore)) + '☆'.repeat(5 - Math.round(harmonyScore)); let html = `
${appName} Concentration: ${concentration}% Intensity: ${totalIntensity.toFixed(2)}/5 Harmony: ${harmonyStars}
`; if (warnings.length > 0) { html += '

⚠️ Warnings

' + warnings.map(w => '

' + w + '

').join('') + '
'; } if (tips.length > 0) { html += '

💡 Tips

' + tips.map(t => '

' + t + '

').join('') + '
'; } if (recommendations.length > 0) { html += '

📋 Recommendations

' + recommendations.map(r => '

' + r + '

').join('') + '
'; } html += `

📊 Composition Breakdown

Top Notes
${topRatio}%
Middle Notes
${midRatio}%
Base Notes
${baseRatio}%
`; if (blend.fragrances.length > 0) { html += '

📝 Oil Notes

' + blend.fragrances.map(f => '
' + f.name + ' (' + f.ratio + '%)' + (f.notes || '') + '
').join('') + '
'; } html += '
'; content.innerHTML = html; } function analyzeBlend() { const blend = getCurrentBlend(); if (blend.fragrances.length === 0) { showToast('Please select at least one oil', 'error'); return; } const application = document.getElementById('target-application').value; const concentration = parseFloat(document.getElementById('batch-concentration').value) || 20; const catKey = getIfraKeyForApplication(application); const appName = getAppDisplayName(application); const ifraResult = calculateBlendIfraLimit(blend, catKey); updateIfraBlendLimitDisplay(); if (ifraResult.max === null) { if (ifraResult.missing.length > 0) showToast('IFRA data missing for: ' + ifraResult.missing.join(', ') + '. Expand IFRA under each oil.', 'warning'); else showToast('Blend analyzed. No IFRA data available.', 'warning'); } else if (concentration > ifraResult.max) { showToast('IFRA Warning: ' + appName + ' max for this blend is ' + ifraResult.max + '%. You set ' + concentration + '%.', 'error'); } else { showToast('Blend analyzed! IFRA compliant (max ' + ifraResult.max + '% for ' + appName + ').', 'success'); } document.querySelector('.save-recipe-form').style.display = 'block'; document.getElementById('performance-form').style.display = 'block'; generateNameSuggestions(blend); displayScentProfile(blend); generateAISuggestions(blend); showUsageRecommendations(blend); generatePracticalGuide(blend); updateLongevityLabel(application); } document.getElementById('analyze-btn').addEventListener('click', analyzeBlend); function updateLongevityLabel(application) { const label = document.getElementById('longevity-label'); const unitSelector = document.getElementById('perf-longevity-unit'); if(application === 'cat12') { label.innerHTML = 'Longevity / Diffusion Life (Days/Weeks)'; unitSelector.value = 'Days'; } else { label.innerHTML = 'Longevity (Hours)'; unitSelector.value = 'Hours'; } } document.getElementById('target-application').addEventListener('change', function() { updateLongevityLabel(this.value); updateIfraBlendLimitDisplay(); }); function generateNameSuggestions(blend) { const container = document.getElementById('name-suggestions'); container.innerHTML = ''; const top = blend.topNotes.length ? blend.topNotes[0].name : ''; const mid = blend.middleNotes.length ? blend.middleNotes[0].name : ''; const base = blend.baseNotes.length ? blend.baseNotes[0].name : ''; const luxuryPrefixes = ["Noir", "Elixir", "Essence", "Lumiere", "Opulence", "Sauvage", "Allure", "Aura", "Elysium", "Mystique"]; const luxurySuffixes = ["Collection", "Exclusive", "Premier", "Private Blend", "Classique"]; let mood = blend.totalIntensity < 2 ? "Lumiere" : blend.totalIntensity < 3 ? "Essence" : blend.totalIntensity < 3.5 ? "Noir" : "Elixir"; let primaryNote = mid || top || "Fusion"; let suggestions = [ primaryNote + ' ' + mood, top + ' & ' + base + ' Opulence', mood + ' ' + primaryNote + ' Collection', mood + ' ' + luxuryPrefixes[Math.floor(Math.random() * luxuryPrefixes.length)], primaryNote + ' ' + luxurySuffixes[Math.floor(Math.random() * luxurySuffixes.length)] ].filter(s => !s.includes('undefined') && !s.includes('null')); suggestions = [...new Set(suggestions)].slice(0, 5); suggestions.forEach(suggestion => { const el = document.createElement('div'); el.className = 'name-suggestion'; el.textContent = suggestion; el.style.cursor = 'pointer'; el.addEventListener('click', () => document.getElementById('recipe-name').value = suggestion); container.appendChild(el); }); } function displayScentProfile(blend) { document.getElementById('scent-profile').style.display = 'block'; document.getElementById('blend-description').textContent = blend.fragrances.map(f => f.name + ' (' + f.ratio + '%)').join(' + '); ['top-notes','middle-notes','base-notes'].forEach((id, idx) => { const notes = [blend.topNotes, blend.middleNotes, blend.baseNotes][idx]; document.getElementById(id).innerHTML = notes.length ? notes.map(n => '
  • ' + n.name + ' (' + n.ratio + '%)' + (n.notes ? ' - ' + n.notes : '') + '
  • ').join('') : '
  • None
  • '; }); const viz = document.getElementById('blend-visualization'); viz.innerHTML = ''; blend.fragrances.forEach(f => { const seg = document.createElement('div'); seg.className = 'blend-segment fade-in'; seg.style.width = f.ratio + '%'; seg.style.backgroundColor = getColorForFragrance(f.id, f.type); seg.textContent = f.name; seg.setAttribute('data-ratio', f.ratio + '%'); viz.appendChild(seg); }); } function generateAISuggestions(blend) { document.getElementById('ai-suggestions').style.display = 'block'; let text = ''; const top = blend.topNotes.reduce((s, n) => s + n.ratio, 0); const base = blend.baseNotes.reduce((s, n) => s + n.ratio, 0); if (top < 15 && blend.fragrances.length > 1) text += "Increase top notes for a brighter opening.

    "; if (base < 10 && blend.fragrances.length > 1) text += "Add more base notes (10-25%) for better longevity.

    "; if (!text) text = "This blend is well balanced for its intended application."; document.getElementById('ai-suggestion-text').innerHTML = text; } function showUsageRecommendations(blend) { document.getElementById('usage-recommendations').style.display = 'block'; const intensity = blend.totalIntensity; document.getElementById('intensity-indicator').style.width = Math.min(100, intensity * 25) + '%'; let text = intensity < 1.5 ? 'Subtle' : intensity < 2.5 ? 'Moderate' : intensity < 3.5 ? 'Strong' : 'Intense'; document.getElementById('usage-text').innerHTML = text; } // =========================== // SAVE RECIPE & PERFORMANCE // =========================== function displaySavedRecipes() { const recipes = JSON.parse(localStorage.getItem('scentRecipes') || '[]'); const container = document.getElementById('saved-recipes-list'); if (recipes.length === 0) { container.innerHTML = '

    No recipes saved yet.

    '; return; } container.innerHTML = ''; recipes.forEach((recipe, index) => { const card = document.createElement('div'); card.className = 'recipe-card'; const perf = recipe.performance; const versions = versionControl.getVersions(recipe.name); const versionCount = versions.length; let perfTags = ''; if(perf) { card.classList.add('has-performance'); perfTags = '⭐ ' + perf.satisfaction + '/5⏱ ' + (perf.longevity || 'N/A') + '' + (perf.notes ? '
    📝 ' + perf.notes + '' : ''); } card.innerHTML = `

    ${recipe.name}

    ${recipe.fragrances.map(f => f.name + ' (' + f.ratio + '%)').join(', ')}

    ${recipe.tags.map(t => '' + t + '').join('')} Intensity: ${(recipe.intensity||0).toFixed(2)} ${perfTags} ${versionCount > 0 ? '📝 ' + versionCount + ' versions' : ''}
    Created: ${recipe.date}
    ${versionCount > 0 ? '' : ''}
    `; container.appendChild(card); }); document.querySelectorAll('.view-versions').forEach(btn => { btn.addEventListener('click', function() { showVersionHistory(this.dataset.name); }); }); document.querySelectorAll('.load-recipe').forEach(btn => { btn.addEventListener('click', function() { loadRecipe(parseInt(this.dataset.index)); }); }); document.querySelectorAll('.delete-recipe').forEach(btn => { btn.addEventListener('click', function() { if (confirm('Delete this recipe?')) { let recipes = JSON.parse(localStorage.getItem('scentRecipes') || '[]'); recipes.splice(parseInt(this.dataset.index), 1); localStorage.setItem('scentRecipes', JSON.stringify(recipes)); displaySavedRecipes(); showToast('Recipe deleted'); } }); }); } function showVersionHistory(recipeName) { const versions = versionControl.getVersions(recipeName); if (versions.length === 0) { showToast('No versions found for this recipe', 'warning'); return; } const modal = document.createElement('div'); modal.style.cssText = 'position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.8); z-index: 2000; display: flex; align-items: center; justify-content: center; padding: 20px;'; let html = `

    ${recipeName} - Version History

    Total Versions: ${versions.length}Latest: ${new Date(versions[versions.length-1].date).toLocaleDateString()}
    `; versions.slice().reverse().forEach((v, idx) => { const actualVersion = versions.length - idx; const blendDesc = v.blend.fragrances.map(f => f.name + ' (' + f.ratio + '%)').join(', '); html += `
    Version ${actualVersion} ${new Date(v.date).toLocaleDateString()} ${new Date(v.date).toLocaleTimeString()}
    ${v.tags && v.tags.length > 0 ? v.tags.map(t => '' + t + '').join('') : ''}

    ${blendDesc}

    ${v.blend.totalIntensity ? 'Intensity: ' + v.blend.totalIntensity.toFixed(2) + '' : ''}
    `; }); html += `
    `; modal.innerHTML = html; document.body.appendChild(modal); modal.querySelectorAll('.restore-version-btn').forEach(btn => { btn.addEventListener('click', function() { const recipeName = this.dataset.recipe; const versionNum = parseInt(this.dataset.version); const blend = versionControl.restoreVersion(recipeName, versionNum); if (blend) { for (let i = 1; i <= 5; i++) { document.getElementById('fragrance' + i).value = ''; document.getElementById('ratio' + i).value = '0'; document.getElementById('ratio' + i).nextElementSibling.textContent = '0%'; } blend.fragrances.forEach((f, i) => { if (i < 5 && fragranceDB[f.id]) { document.getElementById('fragrance' + (i+1)).value = f.id; document.getElementById('ratio' + (i+1)).value = f.ratio; document.getElementById('ratio' + (i+1)).nextElementSibling.textContent = f.ratio + '%'; } }); normalizeRatios(); analyzeBlend(); showToast('🔄 Restored version ' + versionNum + ' of "' + recipeName + '"'); modal.remove(); } }); }); modal.querySelector('.close-version-modal')?.addEventListener('click', function() { modal.remove(); }); modal.addEventListener('click', function(e) { if (e.target === this) modal.remove(); }); } function loadRecipe(index) { const recipes = JSON.parse(localStorage.getItem('scentRecipes') || '[]'); if (index < 0 || index >= recipes.length) return; const recipe = recipes[index]; for (let i = 1; i <= 5; i++) { document.getElementById('fragrance' + i).value = ''; document.getElementById('ratio' + i).value = '0'; document.getElementById('ratio' + i).nextElementSibling.textContent = '0%'; document.getElementById('custom-form' + i).style.display = 'none'; } recipe.fragrances.forEach((f, i) => { if (i < 5 && fragranceDB[f.id]) { document.getElementById('fragrance' + (i+1)).value = f.id; document.getElementById('ratio' + (i+1)).value = f.ratio; document.getElementById('ratio' + (i+1)).nextElementSibling.textContent = f.ratio + '%'; } }); normalizeRatios(); analyzeBlend(); currentRecipeName = recipe.name; document.getElementById('recipe-name').value = recipe.name; updatePerformanceDisplay(recipe.name); document.querySelector('.tab[data-tab="guide"]').click(); window.scrollTo(0, 0); showToast('Loaded "' + recipe.name + '"'); } function updatePerformanceDisplay(recipeName) { if (recipeName) { document.getElementById('performance-recipe-name').style.display = 'block'; document.getElementById('perf-recipe-name-display').textContent = recipeName; document.getElementById('last-saved-recipe').style.display = 'block'; document.getElementById('current-recipe-name-display').textContent = recipeName; } } document.getElementById('save-recipe-btn')?.addEventListener('click', function() { const name = document.getElementById('recipe-name').value.trim(); const tags = document.getElementById('recipe-tags').value.trim(); if (!name) { showToast('Please enter a recipe name', 'error'); return; } const blend = getCurrentBlend(); if (blend.fragrances.length === 0) { showToast('Please create a blend first', 'error'); return; } const batchSize = parseFloat(document.getElementById('batch-size').value) || 100; const concentration = parseFloat(document.getElementById('batch-concentration').value) || 20; const totalFragranceNeeded = batchSize * (concentration / 100); blend.fragrances.forEach(f => { f.amount_ml = totalFragranceNeeded * (f.ratio / 100); }); let deductions = blend.fragrances.filter(f => inventory[f.name] !== undefined && inventory[f.name] > 0); let totalDeductible = deductions.reduce((sum, f) => sum + f.amount_ml, 0); if (deductions.length > 0) { if (confirm('Deduct ' + totalDeductible.toFixed(2) + 'ml total fragrance oil from inventory?')) { let hasError = false; deductions.forEach(f => { if(inventory[f.name] < f.amount_ml) { showToast('Insufficient stock for ' + f.name + '!', 'error'); hasError = true; } else { inventory[f.name] = Math.max(0, inventory[f.name] - f.amount_ml); } }); if(!hasError) { localStorage.setItem('fragranceInventory', JSON.stringify(inventory)); displayInventory(); checkLowStockAlerts(); showToast('Inventory updated! Deducted ' + totalDeductible.toFixed(2) + 'ml.'); } else { return; } } } let recipes = JSON.parse(localStorage.getItem('scentRecipes') || '[]'); const existingIdx = recipes.findIndex(r => r.name === name); let existingPerf = null; if(existingIdx >= 0 && recipes[existingIdx].performance) { existingPerf = recipes[existingIdx].performance; } const recipe = { name: name, tags: tags ? tags.split(/\s*,\s*/).filter(t => t) : [], fragrances: blend.fragrances, date: new Date().toLocaleDateString(), intensity: blend.totalIntensity, performance: existingPerf }; if (existingIdx >= 0) recipes[existingIdx] = recipe; else recipes.push(recipe); localStorage.setItem('scentRecipes', JSON.stringify(recipes)); // Save version versionControl.saveVersion(name, blend); currentRecipeName = name; updatePerformanceDisplay(name); displaySavedRecipes(); document.getElementById('recipe-name').value = ''; document.getElementById('recipe-tags').value = ''; showToast('✅ Recipe "' + name + '" saved! Version ' + versionControl.getVersions(name).length + ' created.', 'success'); document.querySelector('.tab[data-tab="recipes"]').click(); }); // =========================== // PERFORMANCE TRACKING // =========================== function setupStarRatings() { document.querySelectorAll('.star-rating').forEach(container => { const stars = container.querySelectorAll('.star'); stars.forEach(star => { star.addEventListener('click', function() { const value = parseInt(this.dataset.value); stars.forEach(s => s.classList.remove('active')); stars.forEach(s => { if (parseInt(s.dataset.value) <= value) { s.classList.add('active'); } }); container.dataset.value = value; }); }); }); } document.getElementById('save-performance')?.addEventListener('click', function() { const recipeName = currentRecipeName; if (!recipeName) { showToast('Please save a recipe first before adding performance data.', 'error'); return; } let recipes = JSON.parse(localStorage.getItem('scentRecipes') || '[]'); const recipeIdx = recipes.findIndex(r => r.name === recipeName); if (recipeIdx < 0) { showToast('Recipe "' + recipeName + '" not found. Please save the recipe first.', 'error'); return; } const longevityNum = document.getElementById('perf-longevity').value.trim(); const longevityUnit = document.getElementById('perf-longevity-unit').value; const longevity = longevityNum ? longevityNum + ' ' + longevityUnit : 'N/A'; const projection = parseInt(document.querySelector('#projection-rating').dataset.value) || 0; const satisfaction = parseInt(document.querySelector('#satisfaction-rating').dataset.value) || 0; const notes = document.getElementById('perf-notes').value.trim(); if (!projection || !satisfaction) { showToast('Please select a star rating for Projection and Satisfaction.', 'error'); return; } const performance = { recipeName: recipeName, longevity: longevity, projection: projection, satisfaction: satisfaction, notes: notes, date: new Date().toLocaleDateString() }; recipes[recipeIdx].performance = performance; localStorage.setItem('scentRecipes', JSON.stringify(recipes)); displaySavedRecipes(); displayPerformanceLog(); showToast('✅ Performance saved for "' + recipeName + '"!', 'success'); }); function displayPerformanceLog() { const container = document.getElementById('performance-log-list'); const recipes = JSON.parse(localStorage.getItem('scentRecipes') || '[]'); const loggedRecipes = recipes.filter(r => r.performance !== null && r.performance !== undefined); if (loggedRecipes.length === 0) { container.innerHTML = '

    No performance data logged yet. Save a recipe and add performance notes!

    '; return; } let html = ''; loggedRecipes.forEach(recipe => { const p = recipe.performance; const stars = '★'.repeat(p.satisfaction) + '☆'.repeat(5 - p.satisfaction); html += `

    ${recipe.name}

    Date: ${p.date || recipe.date}
    Diffusion Life: ${p.longevity || 'N/A'}
    Projection: ${'★'.repeat(p.projection) + '☆'.repeat(5-p.projection)}
    Satisfaction: ${stars}
    Notes: ${p.notes || 'No notes provided.'}
    `; }); container.innerHTML = html; } // =========================== // COMPARE // =========================== function populateCompareSelects() { const recipes = JSON.parse(localStorage.getItem('scentRecipes') || '[]'); ['compare-recipe1', 'compare-recipe2'].forEach(id => { const select = document.getElementById(id); const currentVal = select.value; select.innerHTML = ''; recipes.forEach((r, i) => { const opt = document.createElement('option'); opt.value = i; opt.textContent = r.name; select.appendChild(opt); }); if (currentVal && select.querySelector('option[value="' + currentVal + '"]')) select.value = currentVal; }); } document.getElementById('compare-btn')?.addEventListener('click', function() { const idx1 = document.getElementById('compare-recipe1').value; const idx2 = document.getElementById('compare-recipe2').value; if (!idx1 || !idx2 || idx1 === idx2) { showToast('Please select two different recipes', 'error'); return; } const recipes = JSON.parse(localStorage.getItem('scentRecipes') || '[]'); const r1 = recipes[parseInt(idx1)], r2 = recipes[parseInt(idx2)]; if (!r1 || !r2) { showToast('Recipe not found', 'error'); return; } const container = document.getElementById('comparison-results'); container.innerHTML = `

    ${r1.name}

    ${r1.fragrances.map(f => f.name + ' (' + f.ratio + '%)').join(' + ')}

    Intensity: ${(r1.intensity || 0).toFixed(2)}/5

    ${r2.name}

    ${r2.fragrances.map(f => f.name + ' (' + f.ratio + '%)').join(' + ')}

    Intensity: ${(r2.intensity || 0).toFixed(2)}/5

    Comparison Summary

    Diff: ${((r1.intensity||0) - (r2.intensity||0)).toFixed(2)}
    Top Notes: ${r1.fragrances.filter(f => f.type === 'top').length} vs ${r2.fragrances.filter(f => f.type === 'top').length}
    Base Notes: ${r1.fragrances.filter(f => f.type === 'base').length} vs ${r2.fragrances.filter(f => f.type === 'base').length}
    `; showToast('Comparison complete!'); }); function setupTabs() { document.querySelectorAll('.tab').forEach(tab => { tab.addEventListener('click', function() { document.querySelectorAll('.tab').forEach(t => t.classList.remove('active')); document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active')); this.classList.add('active'); const tabId = this.getAttribute('data-tab'); document.getElementById(tabId + '-tab').classList.add('active'); if (tabId === 'recipes') displaySavedRecipes(); else if (tabId === 'perflog') displayPerformanceLog(); else if (tabId === 'custom') displayCustomFragrances(); else if (tabId === 'inventory') displayInventory(); else if (tabId === 'compare') populateCompareSelects(); else if (tabId === 'analytics') setTimeout(updateAnalytics, 300); else if (tabId === 'batches') { populateBatchRecipeSelect(); setDefaultProductionDate(); renderBatchList(); } else if (tabId === 'sds') { const dateInput = document.getElementById('sds-date'); if (dateInput) dateInput.value = new Date().toISOString().split('T')[0]; } }); }); } // =========================== // BATCH SCALING // =========================== document.getElementById('batch-scale-btn')?.addEventListener('click', function() { document.getElementById('batch-scaling').style.display = document.getElementById('batch-scaling').style.display === 'none' ? 'block' : 'none'; }); document.getElementById('calculate-batch')?.addEventListener('click', function() { const blend = getCurrentBlend(); if (blend.fragrances.length === 0) { showToast('Please create a blend first', 'error'); return; } const batchSize = parseFloat(document.getElementById('batch-size').value) || 100; const concentration = parseFloat(document.getElementById('batch-concentration').value) || 20; const totalFragrance = batchSize * (concentration / 100); const carrierAmount = batchSize - totalFragrance; let html = '

    Batch Recipe: ' + batchSize + 'ml total

    Concentration: ' + concentration + '% | Carrier: ' + document.getElementById('carrier-oil').value + '

    Total Fragrance Oil: ' + totalFragrance.toFixed(2) + ' ml

    Carrier Oil: ' + carrierAmount.toFixed(2) + ' ml

    Individual Amounts:
    '; blend.fragrances.forEach(f => { const amount = totalFragrance * (f.ratio / 100); html += '
    ' + f.name + '' + amount.toFixed(2) + ' ml
    '; }); document.getElementById('batch-results').innerHTML = html; showToast('Batch calculated!'); }); // =========================== // BACKUP & RESTORE // =========================== function getFullBackupData() { const data = { recipes: JSON.parse(localStorage.getItem('scentRecipes') || '[]'), inventory: JSON.parse(localStorage.getItem('fragranceInventory') || '{}'), customFragrances: JSON.parse(localStorage.getItem('customFragrances') || '{}'), ifraOverrides: JSON.parse(localStorage.getItem('ifraOverrides') || '{}'), oilCosts: JSON.parse(localStorage.getItem('oilCosts') || '{}'), productionBatches: JSON.parse(localStorage.getItem('productionBatches') || '[]'), version: '3.0', exportedAt: new Date().toISOString() }; return data; } function restoreFullBackup(backupData) { try { const data = typeof backupData === 'string' ? JSON.parse(backupData) : backupData; if (data.recipes) localStorage.setItem('scentRecipes', JSON.stringify(data.recipes)); if (data.inventory) { localStorage.setItem('fragranceInventory', JSON.stringify(data.inventory)); window.inventory = data.inventory; } if (data.customFragrances) { localStorage.setItem('customFragrances', JSON.stringify(data.customFragrances)); const customFrags = data.customFragrances; for (const id in customFrags) { if (!fragranceDB[id]) fragranceDB[id] = customFrags[id]; } } if (data.ifraOverrides) { localStorage.setItem('ifraOverrides', JSON.stringify(data.ifraOverrides)); const overrides = data.ifraOverrides; for (const id in overrides) { if (fragranceDB[id]) fragranceDB[id].ifra = overrides[id]; } } if (data.oilCosts) { localStorage.setItem('oilCosts', JSON.stringify(data.oilCosts)); costCalculator.oilCosts = data.oilCosts; } if (data.productionBatches) { localStorage.setItem('productionBatches', JSON.stringify(data.productionBatches)); batchTracker.batches = data.productionBatches; } displaySavedRecipes(); displayPerformanceLog(); displayCustomFragrances(); displayInventory(); renderBatchList(); updateAnalytics(); return true; } catch (e) { showToast('Error restoring data: ' + e.message, 'error'); return false; } } document.getElementById('backup-btn')?.addEventListener('click', function() { const container = document.getElementById('backup-data-container'); container.style.display = container.style.display === 'none' ? 'block' : 'none'; if (container.style.display === 'block') { container.dataset.backupData = JSON.stringify(getFullBackupData()); } }); document.getElementById('close-backup-btn')?.addEventListener('click', function() { document.getElementById('backup-data-container').style.display = 'none'; }); document.getElementById('download-backup-btn')?.addEventListener('click', function() { const container = document.getElementById('backup-data-container'); let data; if (container.dataset.backupData) { data = JSON.parse(container.dataset.backupData); } else { data = getFullBackupData(); } const jsonStr = JSON.stringify(data, null, 2); const blob = new Blob([jsonStr], { type: 'application/json' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = 'fragrance_backup_' + new Date().toISOString().slice(0,10) + '.json'; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); showToast('✅ JSON file downloaded successfully!'); document.getElementById('backup-data-container').style.display = 'none'; }); document.getElementById('restore-btn')?.addEventListener('click', function() { const container = document.getElementById('restore-data-container'); container.style.display = container.style.display === 'none' ? 'block' : 'none'; document.getElementById('restore-file-input').value = ''; document.getElementById('restore-status').innerHTML = ''; }); document.getElementById('close-restore-btn')?.addEventListener('click', function() { document.getElementById('restore-data-container').style.display = 'none'; }); document.getElementById('restore-file-input')?.addEventListener('change', function(e) { const file = e.target.files[0]; if (!file) return; const statusDiv = document.getElementById('restore-status'); statusDiv.innerHTML = '

    📖 Reading file...

    '; const reader = new FileReader(); reader.onload = function(event) { try { const data = JSON.parse(event.target.result); if (data.recipes || data.inventory || data.customFragrances) { if (confirm('⚠️ Restoring will replace ALL current data. Are you sure you want to continue?')) { if (restoreFullBackup(data)) { statusDiv.innerHTML = '

    ✅ Data restored successfully! All data has been restored.

    '; showToast('✅ Data restored successfully from file!', 'success'); setTimeout(() => { document.getElementById('restore-data-container').style.display = 'none'; }, 2000); } } else { statusDiv.innerHTML = '

    Restore cancelled.

    '; } } else { statusDiv.innerHTML = '

    ❌ Invalid backup file format. Please select a valid JSON backup file.

    '; showToast('Invalid backup file format.', 'error'); } } catch (e) { statusDiv.innerHTML = '

    ❌ Error reading file: ' + e.message + '

    '; showToast('Error reading file: ' + e.message, 'error'); } }; reader.readAsText(file); }); // =========================== // EXPORT PNG // =========================== document.getElementById('export-png-btn')?.addEventListener('click', function() { const recipes = JSON.parse(localStorage.getItem('scentRecipes') || '[]'); if (recipes.length === 0) { showToast('No recipes to export. Save some recipes first.', 'error'); return; } const pngContent = document.createElement('div'); pngContent.style.padding = '40px'; pngContent.style.fontFamily = 'Arial, sans-serif'; pngContent.style.color = '#1a1a1a'; pngContent.style.backgroundColor = '#ffffff'; pngContent.style.maxWidth = '900px'; pngContent.style.margin = '0 auto'; pngContent.style.lineHeight = '1.6'; pngContent.style.borderRadius = '12px'; let html = `

    🧪 IFRA-Compliant Scent Designer v3.0

    Recipe Export • ${new Date().toLocaleDateString()} at ${new Date().toLocaleTimeString()}

    Total Recipes: ${recipes.length}

    `; recipes.forEach((recipe, index) => { const perf = recipe.performance; html += `

    ${index + 1}. ${recipe.name}

    ${recipe.tags.map(t => '' + t + '').join('')} Intensity: ${(recipe.intensity || 0).toFixed(2)} ${perf ? '⭐ ' + perf.satisfaction + '/5' : ''}
    Composition: ${recipe.fragrances.map(f => f.name + ' (' + f.ratio + '%)').join(' + ')}
    Created: ${recipe.date} Notes: ${recipe.fragrances.length} oils
    ${perf ? `

    📊 Performance Notes

    Longevity: ${perf.longevity || 'N/A'}
    Projection: ${'★'.repeat(perf.projection)}${'☆'.repeat(5 - perf.projection)}
    Satisfaction: ${'★'.repeat(perf.satisfaction)}${'☆'.repeat(5 - perf.satisfaction)}
    Date: ${perf.date || recipe.date}
    ${perf.notes ? '

    "' + perf.notes + '"

    ' : ''}
    ` : ''}
    `; }); html += `
    Exported from Scent Profile Designer v3.0 • ${new Date().toLocaleDateString()}
    `; pngContent.innerHTML = html; document.body.appendChild(pngContent); html2canvas(pngContent, { scale: 2, useCORS: true, logging: false, backgroundColor: '#ffffff', width: 900, height: pngContent.scrollHeight }).then(canvas => { const link = document.createElement('a'); link.download = 'Fragrance_Recipes_' + new Date().toISOString().slice(0,10) + '.png'; link.href = canvas.toDataURL('image/png'); link.click(); document.body.removeChild(pngContent); showToast('✅ PNG exported successfully!', 'success'); }).catch(err => { document.body.removeChild(pngContent); console.error('PNG Error:', err); showToast('Error exporting PNG: ' + err.message, 'error'); }); }); // =========================== // INITIALIZATION // =========================== document.getElementById('copyright-year').textContent = new Date().getFullYear(); const storedCustom = localStorage.getItem('customFragrances'); if (storedCustom) { try { const customFrags = JSON.parse(storedCustom); for (const id in customFrags) { if (!fragranceDB[id]) fragranceDB[id] = customFrags[id]; } } catch(e) {} } // Restore IFRA overrides try { const storedIfra = localStorage.getItem('ifraOverrides'); if (storedIfra) { const overrides = JSON.parse(storedIfra); for (const id in overrides) { if (fragranceDB[id]) fragranceDB[id].ifra = overrides[id]; } } } catch(e) {} // Restore oil costs try { const storedCosts = localStorage.getItem('oilCosts'); if (storedCosts) { costCalculator.oilCosts = JSON.parse(storedCosts); } } catch(e) {} // Restore batches try { const storedBatches = localStorage.getItem('productionBatches'); if (storedBatches) { batchTracker.batches = JSON.parse(storedBatches); } } catch(e) {} initializeAllSelects(); setupTabs(); setupCustomFragranceForms(); setupStarRatings(); setupIfraUI(); displaySavedRecipes(); displayPerformanceLog(); displayCustomFragrances(); displayInventory(); updateLongevityLabel(document.getElementById('target-application').value); updateIfraBlendLimitDisplay(); populateBatchRecipeSelect(); setDefaultProductionDate(); renderBatchList(); updateAnalytics(); document.querySelectorAll('input[type="range"]').forEach(function(slider) { slider.addEventListener('input', function() { this.nextElementSibling.textContent = this.value + '%'; normalizeRatios(); updateIfraBlendLimitDisplay(); }); }); var batchConc = document.getElementById('batch-concentration'); if (batchConc) batchConc.addEventListener('input', updateIfraBlendLimitDisplay); setTimeout(function() { if (Object.keys(inventory).length > 0) checkLowStockAlerts(); }, 500); });