// Register service worker for PWA offline capability if ('serviceWorker' in navigator) { window.addEventListener('load', () => { navigator.serviceWorker.register('/sw.js') .then(registration => { console.log('Service Worker registered successfully:', registration.scope); }) .catch(error => { console.log('Service Worker registration failed:', error); }); }); } // Store bread data let breadData = []; // Helper function to convert string values to enum values function getEnumValue(enumType, stringValue) { if (!stringValue) return null; // Convert string like "bread-flour" to "BREAD_FLOUR" const enumKey = stringValue.toUpperCase().replace(/-/g, '_'); return enumType.values[enumKey]; } // Function to export bread as protobuf function exportAsProtobuf(breadId) { const bread = breadData.find(b => b.id === breadId); if (!bread) return null; // Create a copy of the bread object to convert values const protoBread = JSON.parse(JSON.stringify(bread)); // Convert string values to appropriate types if (protoBread.ingredients) { if (protoBread.ingredients.flourAmount) protoBread.ingredients.flourAmount = parseInt(protoBread.ingredients.flourAmount); if (protoBread.ingredients.flourType) protoBread.ingredients.flourType = getEnumValue(root.lookupEnum("Bread.FlourType"), protoBread.ingredients.flourType); if (protoBread.ingredients.hydration) protoBread.ingredients.hydration = parseFloat(protoBread.ingredients.hydration); if (protoBread.ingredients.starterPercentage) protoBread.ingredients.starterPercentage = parseFloat(protoBread.ingredients.starterPercentage); if (protoBread.ingredients.saltPercentage) protoBread.ingredients.saltPercentage = parseFloat(protoBread.ingredients.saltPercentage); } if (protoBread.process) { if (protoBread.process.autolyseTime) protoBread.process.autolyseTime = parseInt(protoBread.process.autolyseTime); if (protoBread.process.bulkFermentation) protoBread.process.bulkFermentation = parseFloat(protoBread.process.bulkFermentation); if (protoBread.process.stretchFolds) protoBread.process.stretchFolds = parseInt(protoBread.process.stretchFolds); if (protoBread.process.proofTime) protoBread.process.proofTime = parseFloat(protoBread.process.proofTime); if (protoBread.process.bakingTemp) protoBread.process.bakingTemp = parseInt(protoBread.process.bakingTemp); } // Convert results if they exist if (Array.isArray(protoBread.results)) { protoBread.results.forEach(result => { convertResultToProto(result); }); } else if (protoBread.results) { convertResultToProto(protoBread.results); } function convertResultToProto(result) { if (result.appearance) { if (result.appearance.crustColor) result.appearance.crustColor = getEnumValue(root.lookupEnum("Bread.CrustColor"), result.appearance.crustColor); if (result.appearance.earDevelopment) result.appearance.earDevelopment = getEnumValue(root.lookupEnum("Bread.EarDevelopment"), result.appearance.earDevelopment); if (result.appearance.crumbStructure) result.appearance.crumbStructure = getEnumValue(root.lookupEnum("Bread.CrumbStructure"), result.appearance.crumbStructure); } if (result.tasteTexture) { if (result.tasteTexture.sourness) result.tasteTexture.sourness = getEnumValue(root.lookupEnum("Bread.Sourness"), result.tasteTexture.sourness); if (result.tasteTexture.crustTexture) result.tasteTexture.crustTexture = getEnumValue(root.lookupEnum("Bread.CrustTexture"), result.tasteTexture.crustTexture); if (result.tasteTexture.crumbTexture) result.tasteTexture.crumbTexture = getEnumValue(root.lookupEnum("Bread.CrumbTexture"), result.tasteTexture.crumbTexture); if (result.tasteTexture.keepingQuality) result.tasteTexture.keepingQuality = parseInt(result.tasteTexture.keepingQuality); if (result.tasteTexture.yieldCount) result.tasteTexture.yieldCount = parseInt(result.tasteTexture.yieldCount); } if (result.overall) { if (result.overall.score) result.overall.score = parseInt(result.overall.score); if (result.overall.makeAgain) result.overall.makeAgain = getEnumValue(root.lookupEnum("Bread.MakeAgain"), result.overall.makeAgain); } } // Define the schema const root = protobuf.Root.fromJSON({ nested: { Bread: { fields: { id: { type: "string", id: 1 }, name: { type: "string", id: 2 }, date: { type: "string", id: 3 }, starter: { type: "Starter", id: 4 }, ingredients: { type: "Ingredients", id: 5 }, process: { type: "Process", id: 6 }, notes: { type: "string", id: 7, optional: true }, results: { type: "Results", id: 8, repeated: true } }, nested: { FlourType: { values: { BREAD_FLOUR: 0, ALL_PURPOSE: 1, WHOLE_WHEAT: 2, RYE: 3, SPELT: 4, SEMOLINA: 5, OTHER: 6 } }, CrustColor: { values: { LIGHT: 0, MEDIUM: 1, DARK: 2, VERY_DARK: 3 } }, EarDevelopment: { values: { NONE: 0, SLIGHT: 1, GOOD: 2, EXCELLENT: 3 } }, CrumbStructure: { values: { TIGHT: 0, MEDIUM: 1, OPEN: 2, VERY_OPEN: 3 } }, Sourness: { values: { NONE: 0, MILD: 1, MEDIUM: 2, STRONG: 3 } }, CrustTexture: { values: { SOFT: 0, MEDIUM: 1, CRISPY: 2, VERY_CRISPY: 3 } }, CrumbTexture: { values: { DENSE: 0, CHEWY: 1, TENDER: 2, LIGHT: 3 } }, MakeAgain: { values: { YES: 0, MAYBE: 1, NO: 2 } }, Starter: { fields: { feedingRatio: { type: "string", id: 1, optional: true } } }, Ingredients: { fields: { flourAmount: { type: "int32", id: 1, optional: true }, flourType: { type: "FlourType", id: 2, optional: true }, hydration: { type: "float", id: 3, optional: true }, starterPercentage: { type: "float", id: 4, optional: true }, saltPercentage: { type: "float", id: 5, optional: true }, additionalIngredients: { type: "string", id: 6, optional: true } } }, Process: { fields: { autolyseTime: { type: "int32", id: 1, optional: true }, bulkFermentation: { type: "float", id: 2, optional: true }, fermentationLocation: { type: "string", id: 3, optional: true }, stretchFolds: { type: "int32", id: 4, optional: true }, proofTime: { type: "float", id: 5, optional: true }, proofLocation: { type: "string", id: 6, optional: true }, bakingTemp: { type: "int32", id: 7, optional: true } } }, Results: { fields: { appearance: { type: "Appearance", id: 1 }, tasteTexture: { type: "TasteTexture", id: 2 }, overall: { type: "Overall", id: 3 }, timestamp: { type: "string", id: 4, optional: true } }, nested: { Appearance: { fields: { crustColor: { type: "CrustColor", id: 1, optional: true }, earDevelopment: { type: "EarDevelopment", id: 2, optional: true }, crumbStructure: { type: "CrumbStructure", id: 3, optional: true } } }, TasteTexture: { fields: { sourness: { type: "Sourness", id: 1, optional: true }, crustTexture: { type: "CrustTexture", id: 2, optional: true }, crumbTexture: { type: "CrumbTexture", id: 3, optional: true }, flavorProfile: { type: "string", id: 4, optional: true }, keepingQuality: { type: "int32", id: 5, optional: true }, yieldCount: { type: "int32", id: 6, optional: true } } }, Overall: { fields: { score: { type: "int32", id: 1, optional: true }, makeAgain: { type: "MakeAgain", id: 2, optional: true }, improvementNotes: { type: "string", id: 3, optional: true } } } } } } } } }); // Get the message type const BreadMessage = root.lookupType("Bread"); // Verify the payload const payload = bread; const errMsg = BreadMessage.verify(payload); if (errMsg) throw Error(errMsg); // Create a new message const message = BreadMessage.create(payload); // Encode the message const buffer = BreadMessage.encode(message).finish(); // Convert to base64 for easier transport return btoa(Array.from(new Uint8Array(buffer)) .map(val => String.fromCharCode(val)) .join('')); } // Helper function to convert enum values back to strings function getStringFromEnum(enumType, enumValue) { if (enumValue === null || enumValue === undefined) return null; // Find the key for this value for (const key in enumType.values) { if (enumType.values[key] === enumValue) { // Convert "BREAD_FLOUR" to "bread-flour" return key.toLowerCase().replace(/_/g, '-'); } } return null; } // Function to import from protobuf function importFromProtobuf(protoData) { try { // Convert from base64 const binaryString = atob(protoData); const bytes = new Uint8Array(binaryString.length); for (let i = 0; i < binaryString.length; i++) { bytes[i] = binaryString.charCodeAt(i); } // Define the schema (same as above) const root = protobuf.Root.fromJSON({ nested: { Bread: { fields: { id: { type: "string", id: 1 }, name: { type: "string", id: 2 }, date: { type: "string", id: 3 }, starter: { type: "Starter", id: 4 }, ingredients: { type: "Ingredients", id: 5 }, process: { type: "Process", id: 6 }, notes: { type: "string", id: 7, optional: true }, results: { type: "Results", id: 8, repeated: true } }, nested: { FlourType: { values: { BREAD_FLOUR: 0, ALL_PURPOSE: 1, WHOLE_WHEAT: 2, RYE: 3, SPELT: 4, SEMOLINA: 5, OTHER: 6 } }, CrustColor: { values: { LIGHT: 0, MEDIUM: 1, DARK: 2, VERY_DARK: 3 } }, EarDevelopment: { values: { NONE: 0, SLIGHT: 1, GOOD: 2, EXCELLENT: 3 } }, CrumbStructure: { values: { TIGHT: 0, MEDIUM: 1, OPEN: 2, VERY_OPEN: 3 } }, Sourness: { values: { NONE: 0, MILD: 1, MEDIUM: 2, STRONG: 3 } }, CrustTexture: { values: { SOFT: 0, MEDIUM: 1, CRISPY: 2, VERY_CRISPY: 3 } }, CrumbTexture: { values: { DENSE: 0, CHEWY: 1, TENDER: 2, LIGHT: 3 } }, MakeAgain: { values: { YES: 0, MAYBE: 1, NO: 2 } }, Starter: { fields: { feedingRatio: { type: "string", id: 1, optional: true } } }, Ingredients: { fields: { flourAmount: { type: "int32", id: 1, optional: true }, flourType: { type: "FlourType", id: 2, optional: true }, hydration: { type: "float", id: 3, optional: true }, saltPercentage: { type: "float", id: 4, optional: true }, additionalIngredients: { type: "string", id: 5, optional: true } } }, Process: { fields: { autolyseTime: { type: "int32", id: 1, optional: true }, bulkFermentation: { type: "float", id: 2, optional: true }, fermentationLocation: { type: "string", id: 3, optional: true }, stretchFolds: { type: "int32", id: 4, optional: true }, proofTime: { type: "float", id: 5, optional: true }, proofLocation: { type: "string", id: 6, optional: true }, bakingTemp: { type: "int32", id: 7, optional: true } } }, Results: { fields: { appearance: { type: "Appearance", id: 1 }, tasteTexture: { type: "TasteTexture", id: 2 }, overall: { type: "Overall", id: 3 }, timestamp: { type: "string", id: 4, optional: true } }, nested: { Appearance: { fields: { crustColor: { type: "CrustColor", id: 1, optional: true }, earDevelopment: { type: "EarDevelopment", id: 2, optional: true }, crumbStructure: { type: "CrumbStructure", id: 3, optional: true } } }, TasteTexture: { fields: { sourness: { type: "Sourness", id: 1, optional: true }, crustTexture: { type: "CrustTexture", id: 2, optional: true }, crumbTexture: { type: "CrumbTexture", id: 3, optional: true }, flavorProfile: { type: "string", id: 4, optional: true }, keepingQuality: { type: "int32", id: 5, optional: true }, yieldCount: { type: "int32", id: 6, optional: true } } }, Overall: { fields: { score: { type: "int32", id: 1, optional: true }, makeAgain: { type: "MakeAgain", id: 2, optional: true }, improvementNotes: { type: "string", id: 3, optional: true } } } } } } } } }); // Get the message type const BreadMessage = root.lookupType("Bread"); // Decode the message const message = BreadMessage.decode(bytes); // Convert to plain object const bread = BreadMessage.toObject(message); // Convert enum values back to strings for UI compatibility if (bread.ingredients) { if (bread.ingredients.flourType !== null && bread.ingredients.flourType !== undefined) { bread.ingredients.flourType = getStringFromEnum(root.lookupEnum("Bread.FlourType"), bread.ingredients.flourType); } } // Convert results if they exist if (Array.isArray(bread.results)) { bread.results.forEach(result => { convertProtoToResult(result); }); } else if (bread.results) { convertProtoToResult(bread.results); } function convertProtoToResult(result) { if (result.appearance) { if (result.appearance.crustColor !== null && result.appearance.crustColor !== undefined) result.appearance.crustColor = getStringFromEnum(root.lookupEnum("Bread.CrustColor"), result.appearance.crustColor); if (result.appearance.earDevelopment !== null && result.appearance.earDevelopment !== undefined) result.appearance.earDevelopment = getStringFromEnum(root.lookupEnum("Bread.EarDevelopment"), result.appearance.earDevelopment); if (result.appearance.crumbStructure !== null && result.appearance.crumbStructure !== undefined) result.appearance.crumbStructure = getStringFromEnum(root.lookupEnum("Bread.CrumbStructure"), result.appearance.crumbStructure); } if (result.tasteTexture) { if (result.tasteTexture.sourness !== null && result.tasteTexture.sourness !== undefined) result.tasteTexture.sourness = getStringFromEnum(root.lookupEnum("Bread.Sourness"), result.tasteTexture.sourness); if (result.tasteTexture.crustTexture !== null && result.tasteTexture.crustTexture !== undefined) result.tasteTexture.crustTexture = getStringFromEnum(root.lookupEnum("Bread.CrustTexture"), result.tasteTexture.crustTexture); if (result.tasteTexture.crumbTexture !== null && result.tasteTexture.crumbTexture !== undefined) result.tasteTexture.crumbTexture = getStringFromEnum(root.lookupEnum("Bread.CrumbTexture"), result.tasteTexture.crumbTexture); } if (result.overall) { if (result.overall.makeAgain !== null && result.overall.makeAgain !== undefined) result.overall.makeAgain = getStringFromEnum(root.lookupEnum("Bread.MakeAgain"), result.overall.makeAgain); } } return bread; } catch (e) { console.error("Failed to import protobuf data:", e); return null; } } // Show the selected tab function showTab(tabId) { // Hide all tab contents document.querySelectorAll('.tab-content').forEach(content => { content.classList.remove('active'); }); // Remove active class from all tabs document.querySelectorAll('.tab').forEach(tab => { tab.classList.remove('active'); }); // Show the selected tab content document.getElementById(tabId).classList.add('active'); // Add active class to the clicked tab event.currentTarget.classList.add('active'); } // Track if we're editing an existing bread let editingBreadId = null; // Function to encode form state as URL parameters function encodeFormState(formId) { const form = document.getElementById(formId); const formData = new FormData(form); const params = new URLSearchParams(); for (const [key, value] of formData.entries()) { params.append(key, value); } return params.toString(); } // Function to decode URL parameters and fill form function decodeFormState(formId, paramString) { const form = document.getElementById(formId); const params = new URLSearchParams(paramString); // Reset form first form.reset(); // Fill form with values from URL parameters for (const [key, value] of params.entries()) { const element = form.elements[key]; if (element) { if (element.type === 'checkbox') { element.checked = value === 'true'; } else if (element.type === 'radio') { const radio = form.querySelector(`input[name="${key}"][value="${value}"]`); if (radio) radio.checked = true; } else { element.value = value; } } } } // Generate a UUID for unique identification function generateUUID() { return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) { const r = Math.random() * 16 | 0; const v = c === 'x' ? r : (r & 0x3 | 0x8); return v.toString(16); }); } // Handle recipe form submission document.getElementById('recipe-form').addEventListener('submit', async function(e) { e.preventDefault(); const breadName = document.getElementById('bread-name').value; const breadId = editingBreadId || Date.now().toString(); // Format the feeding ratio (convert "123" to "1:2:3") let feedingRatio = document.getElementById('feeding-ratio').value; if (feedingRatio && /^\d+$/.test(feedingRatio)) { // If it's just digits with no colons, format it feedingRatio = feedingRatio.split('').join(':'); } // Create a new bread object const newBread = { id: breadId, name: breadName, date: new Date().toLocaleDateString(), starter: { feedingRatio: feedingRatio }, ingredients: { flourAmount: document.getElementById('flour-amount').value, flourType: document.getElementById('flour-type').value, hydration: document.getElementById('hydration').value, starterPercentage: document.getElementById('starter-percentage').value, saltPercentage: document.getElementById('salt-percentage').value, additionalIngredients: document.getElementById('additional-ingredients').value }, process: { autolyseTime: document.getElementById('autolyse-time').value, bulkFermentation: document.getElementById('bulk-fermentation').value, fermentationLocation: document.getElementById('fermentation-location').value, stretchFolds: document.getElementById('stretch-folds').value, proofTime: document.getElementById('proof-time').value, proofLocation: document.getElementById('proof-location').value, bakingTemp: document.getElementById('baking-temp').value }, notes: document.getElementById('recipe-notes').value, results: null }; if (editingBreadId) { // Update existing bread const breadIndex = breadData.findIndex(bread => bread.id === editingBreadId); if (breadIndex !== -1) { // Preserve the results if they exist if (breadData[breadIndex].results) { newBread.results = breadData[breadIndex].results; } breadData[breadIndex] = newBread; } // Reset editing state editingBreadId = null; document.getElementById('recipe-form').querySelector('button[type="submit"]').textContent = 'Save Recipe'; } else { // Add the new bread to the array breadData.push(newBread); } // Save to IndexedDB await storage.saveAllBread(breadData); // Update the bread list updateBreadList(); // Update the bread select dropdown updateBreadSelect(); // Reset the form this.reset(); // Show success message alert(editingBreadId ? 'Recipe updated successfully!' : 'Recipe saved successfully!'); }); // Edit a bread function editBread(breadId) { const bread = breadData.find(b => b.id === breadId); if (!bread) { alert('Bread not found!'); return; } // Set editing state editingBreadId = breadId; // Show the recipe tab showTab('recipe-tab'); // Fill the form with the bread data document.getElementById('bread-name').value = bread.name; // Remove colons from feeding ratio when populating the form let feedingRatio = bread.starter.feedingRatio || ''; document.getElementById('feeding-ratio').value = feedingRatio.replace(/:/g, ''); document.getElementById('flour-amount').value = bread.ingredients.flourAmount || ''; document.getElementById('flour-type').value = bread.ingredients.flourType || 'bread-flour'; document.getElementById('hydration').value = bread.ingredients.hydration || ''; document.getElementById('starter-percentage').value = bread.ingredients.starterPercentage || ''; document.getElementById('salt-percentage').value = bread.ingredients.saltPercentage || ''; document.getElementById('additional-ingredients').value = bread.ingredients.additionalIngredients || ''; document.getElementById('autolyse-time').value = bread.process.autolyseTime || ''; document.getElementById('bulk-fermentation').value = bread.process.bulkFermentation || ''; document.getElementById('fermentation-location').value = bread.process.fermentationLocation || ''; document.getElementById('stretch-folds').value = bread.process.stretchFolds || ''; document.getElementById('proof-time').value = bread.process.proofTime || ''; document.getElementById('proof-location').value = bread.process.proofLocation || ''; document.getElementById('baking-temp').value = bread.process.bakingTemp || ''; document.getElementById('recipe-notes').value = bread.notes || ''; // Change the submit button text document.getElementById('recipe-form').querySelector('button[type="submit"]').textContent = 'Update Recipe'; // Scroll to the form document.querySelector('.form-section').scrollIntoView({ behavior: 'smooth' }); } // Function to save results async function saveResults(breadId, resultsData) { const breadIndex = breadData.findIndex(bread => bread.id === breadId); if (breadIndex === -1) { alert('Bread not found!'); return false; } // Initialize results array if it doesn't exist if (!Array.isArray(breadData[breadIndex].results)) { // Handle legacy single result format if (breadData[breadIndex].results) { // Convert existing single result to array breadData[breadIndex].results = [breadData[breadIndex].results]; } else { breadData[breadIndex].results = []; } } // Add timestamp to the results resultsData.timestamp = new Date().toISOString(); // Add the new results to the array breadData[breadIndex].results.push(resultsData); // Save to IndexedDB await storage.saveAllBread(breadData); // Update the bread list updateBreadList(); return true; } // Handle results form submission document.getElementById('results-form').addEventListener('submit', async function(e) { e.preventDefault(); const breadId = document.getElementById('bread-select').value; if (!breadId) { alert('Please select a bread first!'); return; } // Get the rating value let ratingValue = 0; document.querySelectorAll('input[name="rating"]').forEach(input => { if (input.checked) { ratingValue = input.value; } }); // Create results object const resultsData = { appearance: { crustColor: document.getElementById('crust-color').value, earDevelopment: document.getElementById('ear-development').value, crumbStructure: document.getElementById('crumb-structure').value }, tasteTexture: { sourness: document.getElementById('sourness').value, crustTexture: document.getElementById('crust-texture').value, crumbTexture: document.getElementById('crumb-texture').value, flavorProfile: document.getElementById('flavor-profile').value, keepingQuality: document.getElementById('keeping-quality').value, yieldCount: document.getElementById('yield-count').value }, overall: { score: ratingValue, makeAgain: document.getElementById('make-again').value, improvementNotes: document.getElementById('improvement-notes').value } }; // Save the results if (await saveResults(breadId, resultsData)) { // Reset the form this.reset(); // Show success message alert('Results saved successfully!'); } }); // Function to handle shared URLs on page load function handleSharedUrl() { const urlParams = new URLSearchParams(window.location.search); const sharedData = urlParams.get('shared'); if (sharedData) { try { console.log("Raw shared data from URL:", sharedData); // Decompress the data const decompressedData = LZString.decompressFromEncodedURIComponent(sharedData); console.log("Decompressed data:", decompressedData); const sharedBread = JSON.parse(decompressedData); console.log("Parsed shared bread:", sharedBread); // Store the shared bread for later saving currentSharedBread = sharedBread; // Show the shared bread in a modal showSharedBreadModal(sharedBread); } catch (e) { console.error('Failed to parse shared data:', e); console.error('Error details:', e.message, e.stack); alert('Invalid shared data: ' + e.message); } } } // Function to show/hide the About modal function showAboutModal() { document.getElementById('about-modal').style.display = 'flex'; } function hideAboutModal() { document.getElementById('about-modal').style.display = 'none'; } // Function to show shared bread in a modal function showSharedBreadModal(sharedBread) { // Create modal similar to viewBreadDetails but with save options const modal = document.createElement('div'); modal.className = 'modal'; let modalContent = document.createElement('div'); modalContent.className = 'modal-content'; // Generate HTML for bread details (similar to viewBreadDetails) let detailsHtml = `
Shared by: ${sharedBread.sender}
` : ''}Feeding Ratio: ${sharedBread.starter.feedingRatio}
Flour: ${sharedBread.ingredients.flourAmount}g ${sharedBread.ingredients.flourType ? sharedBread.ingredients.flourType.replace('-', ' ') : 'bread flour'}
Hydration: ${sharedBread.ingredients.hydration}%
Starter: ${sharedBread.ingredients.starterPercentage || 'Not specified'}%
Salt: ${sharedBread.ingredients.saltPercentage}%
Additional Ingredients: ${sharedBread.ingredients.additionalIngredients || 'None'}
Autolyse Time: ${sharedBread.process.autolyseTime} minutes
Bulk Fermentation: ${sharedBread.process.bulkFermentation} ${sharedBread.process.bulkFermentation == 1 ? 'hour' : 'hours'}, ${sharedBread.process.fermentationLocation}
Stretch and Folds: ${sharedBread.process.stretchFolds}
Final Proof: ${sharedBread.process.proofTime} ${sharedBread.process.proofTime == 1 ? 'hour' : 'hours'}, ${sharedBread.process.proofLocation || 'Not specified'}
Baking: ${sharedBread.process.bakingTemp}°F
Notes: ${sharedBread.notes || 'None'}
`; // Add results if they exist if (sharedBread.results) { if (Array.isArray(sharedBread.results)) { // Multiple results detailsHtml += `Crust Color: ${result.appearance.crustColor}
Oven Spring: ${result.appearance.earDevelopment}
Crumb Structure: ${result.appearance.crumbStructure}
Sourness: ${result.tasteTexture.sourness}
Crust Texture: ${result.tasteTexture.crustTexture}
Crumb Texture: ${result.tasteTexture.crumbTexture}
Flavor Profile: ${result.tasteTexture.flavorProfile || 'Not specified'}
Keeping Quality: ${result.tasteTexture.keepingQuality} days
Yield: ${result.tasteTexture.yieldCount || '1'} ${result.tasteTexture.yieldCount > 1 ? 'loaves' : 'loaf'}
Score: ${result.overall.score}/10
Would Make Again: ${result.overall.makeAgain}
Notes for Improvement: ${result.overall.improvementNotes || 'None'}
Crust Color: ${sharedBread.results.appearance.crustColor}
Oven Spring: ${sharedBread.results.appearance.earDevelopment}
Crumb Structure: ${sharedBread.results.appearance.crumbStructure}
Sourness: ${sharedBread.results.tasteTexture.sourness}
Crust Texture: ${sharedBread.results.tasteTexture.crustTexture}
Crumb Texture: ${sharedBread.results.tasteTexture.crumbTexture}
Flavor Profile: ${sharedBread.results.tasteTexture.flavorProfile || 'Not specified'}
Keeping Quality: ${sharedBread.results.tasteTexture.keepingQuality} days
Yield: ${sharedBread.results.tasteTexture.yieldCount || '1'} ${sharedBread.results.tasteTexture.yieldCount > 1 ? 'loaves' : 'loaf'}
Score: ${sharedBread.results.overall.score}/10
Would Make Again: ${sharedBread.results.overall.makeAgain}
Notes for Improvement: ${sharedBread.results.overall.improvementNotes || 'None'}
`; } } // Add save options detailsHtml += ` `; modalContent.innerHTML = detailsHtml; // Add close button const closeButton = document.createElement('button'); closeButton.className = 'close-button'; closeButton.innerHTML = '×'; closeButton.addEventListener('click', () => { document.body.removeChild(modal); // Clear the URL parameters window.history.replaceState({}, document.title, window.location.pathname); }); // Close modal when clicking outside the content modal.addEventListener('click', (e) => { if (e.target === modal) { document.body.removeChild(modal); // Clear the URL parameters window.history.replaceState({}, document.title, window.location.pathname); } }); modalContent.appendChild(closeButton); modal.appendChild(modalContent); document.body.appendChild(modal); // Add function to switch between result tabs in the modal window.showResultTab = function(index) { // Hide all result contents document.querySelectorAll('.result-content').forEach(content => { content.classList.remove('active'); }); // Remove active class from all tabs document.querySelectorAll('.result-tab').forEach(tab => { tab.classList.remove('active'); }); // Show the selected result content document.getElementById(`result-${index}`).classList.add('active'); // Add active class to the clicked tab event.currentTarget.classList.add('active'); }; } // Store the current shared bread for saving let currentSharedBread = null; // Function to save a shared bread to IndexedDB async function saveSharedBread() { if (!currentSharedBread) { alert('No bread data available to save.'); return; } try { // Create a deep copy of the shared bread const sharedBread = JSON.parse(JSON.stringify(currentSharedBread)); // Check if a similar bread already exists // We'll compare name and basic ingredients to find potential matches const existingBreads = breadData.filter(bread => bread.name.toLowerCase() === sharedBread.name.toLowerCase() && bread.ingredients.flourType === sharedBread.ingredients.flourType && Math.abs(parseFloat(bread.ingredients.hydration) - parseFloat(sharedBread.ingredients.hydration)) < 5 ); if (existingBreads.length > 0) { // If we have matches, ask the user if they want to add results to an existing bread if (sharedBread.results && confirm('A similar bread recipe already exists in your experiments. Would you like to add these results to your existing recipe instead of creating a new one?')) { // Let user select which bread to add results to if multiple matches let selectedBreadIndex = 0; if (existingBreads.length > 1) { const options = existingBreads.map((bread, index) => `${index + 1}: ${bread.name} (${bread.date})` ).join('\n'); const selection = prompt(`Multiple matching recipes found. Enter the number of the recipe you want to add results to:\n\n${options}`); if (selection && !isNaN(parseInt(selection)) && parseInt(selection) <= existingBreads.length) { selectedBreadIndex = parseInt(selection) - 1; } else if (!selection) { // User cancelled, abort return; } } const selectedBread = existingBreads[selectedBreadIndex]; const breadIndex = breadData.findIndex(bread => bread.id === selectedBread.id); // Initialize results array if it doesn't exist if (!Array.isArray(breadData[breadIndex].results)) { // Handle legacy single result format if (breadData[breadIndex].results) { // Convert existing single result to array breadData[breadIndex].results = [breadData[breadIndex].results]; } else { breadData[breadIndex].results = []; } } // Add the shared results to the existing bread if (Array.isArray(sharedBread.results)) { // Add each result with a timestamp sharedBread.results.forEach(result => { result.timestamp = result.timestamp || new Date().toISOString(); breadData[breadIndex].results.push(result); }); } else if (sharedBread.results) { // Add single result with timestamp sharedBread.results.timestamp = sharedBread.results.timestamp || new Date().toISOString(); breadData[breadIndex].results.push(sharedBread.results); } // Save to IndexedDB await storage.saveAllBread(breadData); // Update the bread list updateBreadList(); updateBreadSelect(); // Close the modal document.querySelector('.modal').remove(); // Clear the URL parameters window.history.replaceState({}, document.title, window.location.pathname); // Show success message alert('Results added to your existing experiment!'); return; } } // If no match or user chose not to add to existing, create a new entry // Generate a new ID to avoid conflicts sharedBread.id = Date.now().toString(); sharedBread.date = new Date().toLocaleDateString(); // Add to bread data breadData.push(sharedBread); // Save to IndexedDB await storage.saveAllBread(breadData); // Update the bread list updateBreadList(); updateBreadSelect(); // Close the modal document.querySelector('.modal').remove(); // Clear the URL parameters window.history.replaceState({}, document.title, window.location.pathname); // Show success message alert('Recipe saved to your experiments!'); } catch (e) { console.error('Failed to save shared bread:', e); alert('Failed to save the recipe. Please try again.'); } } // Calculate average score from results function calculateAverageScore(results) { if (!results) return 0; // Handle array of results if (Array.isArray(results)) { if (results.length === 0) return 0; let totalScore = 0; let validScores = 0; results.forEach(result => { if (result.overall && result.overall.score) { totalScore += parseInt(result.overall.score); validScores++; } }); return validScores > 0 ? (totalScore / validScores).toFixed(1) : 0; } // Handle single result object else if (results.overall && results.overall.score) { return results.overall.score; } return 0; } // Update the bread list display function updateBreadList() { const breadEntriesDiv = document.getElementById('bread-entries'); if (breadData.length === 0) { breadEntriesDiv.innerHTML = 'No bread experiments yet. Start by adding a new recipe!
'; return; } // Sort bread data by date (newest first) const sortedBreadData = [...breadData].sort((a, b) => { // Convert dates to timestamps for comparison const dateA = new Date(a.date).getTime(); const dateB = new Date(b.date).getTime(); return dateB - dateA; // Descending order (newest first) }); let html = ''; sortedBreadData.forEach(bread => { // Determine the rating display for the header let ratingDisplay = '(no results yet)'; if (bread.results) { if (Array.isArray(bread.results) && bread.results.length > 0) { // Show the average score of all results const avgScore = calculateAverageScore(bread.results); ratingDisplay = `${avgScore}/10 (avg of ${bread.results.length})`; } else if (bread.results.overall && bread.results.overall.score) { // Legacy single result ratingDisplay = `${bread.results.overall.score}/10`; } } html += `Date: ${bread.date}
Hydration: ${bread.ingredients.hydration}%
Flour: ${bread.ingredients.flourAmount}g ${bread.ingredients.flourType ? bread.ingredients.flourType.replace('-', ' ') : 'bread flour'}
`; if (bread.results) { if (Array.isArray(bread.results) && bread.results.length > 0) { // Show the average score const avgScore = calculateAverageScore(bread.results); // Get the most recent result for "Make Again" const latestResult = bread.results[bread.results.length - 1]; html += `Rating: ${avgScore}/10 (average)
Make Again: ${latestResult.overall.makeAgain}
Results: ${bread.results.length} recorded
`; } else { // Legacy single result html += `Rating: ${bread.results.overall.score}/10
Make Again: ${bread.results.overall.makeAgain}
`; } } else { html += `Results not yet recorded
`; } html += `Feeding Ratio: ${bread.starter.feedingRatio}
Flour: ${bread.ingredients.flourAmount}g ${bread.ingredients.flourType ? bread.ingredients.flourType.replace('-', ' ') : 'bread flour'}
Hydration: ${bread.ingredients.hydration}%
Starter: ${bread.ingredients.starterPercentage || 'Not specified'}%
Salt: ${bread.ingredients.saltPercentage}%
Additional Ingredients: ${bread.ingredients.additionalIngredients || 'None'}
Autolyse Time: ${bread.process.autolyseTime} minutes
Bulk Fermentation: ${bread.process.bulkFermentation} ${bread.process.bulkFermentation == 1 ? 'hour' : 'hours'}, ${bread.process.fermentationLocation}
Stretch and Folds: ${bread.process.stretchFolds}
Final Proof: ${bread.process.proofTime} ${bread.process.proofTime == 1 ? 'hour' : 'hours'}, ${bread.process.proofLocation || 'Not specified'}
Baking: ${bread.process.bakingTemp}°F
Notes: ${bread.notes || 'None'}
`; // Check if results exist and are in array format if (Array.isArray(bread.results) && bread.results.length > 0) { detailsHtml += `Crust Color: ${result.appearance.crustColor}
Oven Spring: ${result.appearance.earDevelopment}
Crumb Structure: ${result.appearance.crumbStructure}
Sourness: ${result.tasteTexture.sourness}
Crust Texture: ${result.tasteTexture.crustTexture}
Crumb Texture: ${result.tasteTexture.crumbTexture}
Flavor Profile: ${result.tasteTexture.flavorProfile || 'Not specified'}
Keeping Quality: ${result.tasteTexture.keepingQuality} days
Yield: ${result.tasteTexture.yieldCount || '1'} ${result.tasteTexture.yieldCount > 1 ? 'loaves' : 'loaf'}
Score: ${result.overall.score}/10
Would Make Again: ${result.overall.makeAgain}
Notes for Improvement: ${result.overall.improvementNotes || 'None'}
Crust Color: ${bread.results.appearance.crustColor}
Oven Spring: ${bread.results.appearance.earDevelopment}
Crumb Structure: ${bread.results.appearance.crumbStructure}
Sourness: ${bread.results.tasteTexture.sourness}
Crust Texture: ${bread.results.tasteTexture.crustTexture}
Crumb Texture: ${bread.results.tasteTexture.crumbTexture}
Flavor Profile: ${bread.results.tasteTexture.flavorProfile || 'Not specified'}
Keeping Quality: ${bread.results.tasteTexture.keepingQuality} days
Yield: ${bread.results.tasteTexture.yieldCount || '1'} ${bread.results.tasteTexture.yieldCount > 1 ? 'loaves' : 'loaf'}
Score: ${bread.results.overall.score}/10
Would Make Again: ${bread.results.overall.makeAgain}
Notes for Improvement: ${bread.results.overall.improvementNotes || 'None'}
`; } else { detailsHtml += `Results not yet recorded
`; } // Add sharing options detailsHtml += `On iOS, recipes may disappear after 7 days. Add this app to your Home Screen for permanent storage!
Tap ⎘ (Share) then "Add to Home Screen"