// 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 = `

${sharedBread.name} (Shared Recipe)

${sharedBread.sender ? `

Shared by: ${sharedBread.sender}

` : ''}

Starter Information

Feeding Ratio: ${sharedBread.starter.feedingRatio}

Ingredients

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'}

Process

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 += `

Results (${sharedBread.results.length})

`; // Add tabs for each result detailsHtml += `
`; sharedBread.results.forEach((result, index) => { const date = result.timestamp ? new Date(result.timestamp).toLocaleDateString() : 'No date'; detailsHtml += `
Result ${index + 1} (${date})
`; }); detailsHtml += `
`; // Add content for each result sharedBread.results.forEach((result, index) => { detailsHtml += `

Appearance

Crust Color: ${result.appearance.crustColor}

Oven Spring: ${result.appearance.earDevelopment}

Crumb Structure: ${result.appearance.crumbStructure}

Taste and Texture

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'}

Overall Rating

Score: ${result.overall.score}/10

Would Make Again: ${result.overall.makeAgain}

Notes for Improvement: ${result.overall.improvementNotes || 'None'}

`; }); } else { // Single result detailsHtml += `

Results

Appearance

Crust Color: ${sharedBread.results.appearance.crustColor}

Oven Spring: ${sharedBread.results.appearance.earDevelopment}

Crumb Structure: ${sharedBread.results.appearance.crumbStructure}

Taste and Texture

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'}

Overall Rating

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 += `

Save Options

`; 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 += `

${bread.name}

${ratingDisplay}

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 += `
`; }); breadEntriesDiv.innerHTML = html; } // Toggle bread card accordion function toggleBreadCard(breadId) { const card = document.getElementById(`bread-${breadId}`); if (card) { card.classList.toggle('active'); } } // Update the bread select dropdown function updateBreadSelect() { const breadSelect = document.getElementById('bread-select'); // Clear existing options except the first one while (breadSelect.options.length > 1) { breadSelect.remove(1); } // Add options for all breads breadData.forEach(bread => { const option = document.createElement('option'); option.value = bread.id; option.textContent = bread.name; // Add a note if results already exist if (bread.results) { option.textContent += ' (update existing results)'; } breadSelect.appendChild(option); }); } // Function to share a bread experiment as a URL function shareBreadAsUrl(breadId, includeResults = true) { const bread = breadData.find(b => b.id === breadId); if (!bread) return null; // Create a copy of the bread object const breadCopy = JSON.parse(JSON.stringify(bread)); // Remove results if not requested if (!includeResults) { delete breadCopy.results; } // Add sender information const userName = localStorage.getItem('userName') || prompt('Enter your name for sharing:'); if (userName) { localStorage.setItem('userName', userName); breadCopy.sender = userName; } // Add a unique share ID breadCopy.shareId = generateUUID(); // Compress the data using LZString const compressedData = LZString.compressToEncodedURIComponent(JSON.stringify(breadCopy)); // Create the URL const baseUrl = window.location.origin + window.location.pathname; const shareUrl = `${baseUrl}?shared=${compressedData}`; return shareUrl; } // Function to show share options function showShareOptions(breadId, includeResults = true) { const modal = document.createElement('div'); modal.className = 'modal'; const modalContent = document.createElement('div'); modalContent.className = 'modal-content'; const title = document.createElement('h2'); title.textContent = 'Share Options'; modalContent.appendChild(title); const description = document.createElement('p'); description.textContent = `Share your ${includeResults ? 'recipe with results' : 'recipe'} using one of these options:`; modalContent.appendChild(description); const optionsDiv = document.createElement('div'); optionsDiv.className = 'share-options'; // URL Copy button const copyUrlButton = document.createElement('button'); copyUrlButton.textContent = 'Copy URL to Clipboard'; copyUrlButton.addEventListener('click', () => { copyShareUrlToClipboard(breadId, includeResults); document.body.removeChild(modal); }); optionsDiv.appendChild(copyUrlButton); // QR Code button const showQrButton = document.createElement('button'); showQrButton.textContent = 'Show QR Code'; showQrButton.addEventListener('click', () => { document.body.removeChild(modal); showQrCode(breadId, includeResults); }); optionsDiv.appendChild(showQrButton); modalContent.appendChild(optionsDiv); // Close button const closeButton = document.createElement('button'); closeButton.className = 'close-button'; closeButton.innerHTML = '×'; closeButton.addEventListener('click', () => { document.body.removeChild(modal); }); // Close modal when clicking outside the content modal.addEventListener('click', (e) => { if (e.target === modal) { document.body.removeChild(modal); } }); modalContent.appendChild(closeButton); modal.appendChild(modalContent); document.body.appendChild(modal); } // Function to show QR code function showQrCode(breadId, includeResults = true) { const shareUrl = shareBreadAsUrl(breadId, includeResults); if (!shareUrl) { alert('Failed to generate share URL'); return; } const modal = document.createElement('div'); modal.className = 'modal'; const modalContent = document.createElement('div'); modalContent.className = 'modal-content'; const title = document.createElement('h2'); title.textContent = 'QR Code for Sharing'; modalContent.appendChild(title); const description = document.createElement('p'); description.textContent = `Scan this QR code to share your ${includeResults ? 'recipe with results' : 'recipe'}:`; modalContent.appendChild(description); const qrContainer = document.createElement('div'); qrContainer.className = 'qr-code-container'; const qrCanvas = document.createElement('canvas'); qrCanvas.id = 'qr-code'; qrContainer.appendChild(qrCanvas); modalContent.appendChild(qrContainer); // Generate QR code QRCode.toCanvas(qrCanvas, shareUrl, { width: 250, margin: 2, color: { dark: '#8b5a2b', light: '#ffffff' } }, function(error) { if (error) { console.error('Error generating QR code:', error); alert('Failed to generate QR code'); } }); // Copy URL button const copyButton = document.createElement('button'); copyButton.textContent = 'Copy URL to Clipboard'; copyButton.style.marginTop = '15px'; copyButton.addEventListener('click', () => { copyShareUrlToClipboard(breadId, includeResults); }); modalContent.appendChild(copyButton); // Close button const closeButton = document.createElement('button'); closeButton.className = 'close-button'; closeButton.innerHTML = '×'; closeButton.addEventListener('click', () => { document.body.removeChild(modal); }); // Close modal when clicking outside the content modal.addEventListener('click', (e) => { if (e.target === modal) { document.body.removeChild(modal); } }); modalContent.appendChild(closeButton); modal.appendChild(modalContent); document.body.appendChild(modal); } // Function to copy URL to clipboard function copyShareUrlToClipboard(breadId, includeResults = true) { const shareUrl = shareBreadAsUrl(breadId, includeResults); if (shareUrl) { navigator.clipboard.writeText(shareUrl) .then(() => { alert('Share URL copied to clipboard!'); }) .catch(err => { console.error('Failed to copy URL: ', err); // Fallback const textArea = document.createElement('textarea'); textArea.value = shareUrl; document.body.appendChild(textArea); textArea.select(); document.execCommand('copy'); document.body.removeChild(textArea); alert('Share URL copied to clipboard!'); }); } } // View bread details function viewBreadDetails(breadId) { const bread = breadData.find(b => b.id === breadId); if (!bread) { alert('Bread not found!'); return; } let detailsHtml = `

${bread.name} Details

Starter Information

Feeding Ratio: ${bread.starter.feedingRatio}

Ingredients

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'}

Process

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 += `

Results (${bread.results.length})

`; // Add tabs for each result detailsHtml += `
`; bread.results.forEach((result, index) => { const date = result.timestamp ? new Date(result.timestamp).toLocaleDateString() : 'No date'; detailsHtml += `
Result ${index + 1} (${date})
`; }); detailsHtml += `
`; // Add content for each result bread.results.forEach((result, index) => { detailsHtml += `

Appearance

Crust Color: ${result.appearance.crustColor}

Oven Spring: ${result.appearance.earDevelopment}

Crumb Structure: ${result.appearance.crumbStructure}

Taste and Texture

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'}

Overall Rating

Score: ${result.overall.score}/10

Would Make Again: ${result.overall.makeAgain}

Notes for Improvement: ${result.overall.improvementNotes || 'None'}

`; }); } else if (bread.results) { // Handle legacy single result format detailsHtml += `

Results

Appearance

Crust Color: ${bread.results.appearance.crustColor}

Oven Spring: ${bread.results.appearance.earDevelopment}

Crumb Structure: ${bread.results.appearance.crumbStructure}

Taste and Texture

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'}

Overall Rating

Score: ${bread.results.overall.score}/10

Would Make Again: ${bread.results.overall.makeAgain}

Notes for Improvement: ${bread.results.overall.improvementNotes || 'None'}

`; } else { detailsHtml += `

Results

Results not yet recorded

`; } // Add sharing options detailsHtml += `

Share This Recipe

${bread.results ? `` : ''}
`; // Create a modal to display the details const modal = document.createElement('div'); modal.className = 'modal'; const modalContent = document.createElement('div'); modalContent.className = 'modal-content'; modalContent.innerHTML = detailsHtml; const closeButton = document.createElement('button'); closeButton.className = 'close-button'; closeButton.innerHTML = '×'; closeButton.addEventListener('click', () => { document.body.removeChild(modal); }); // Close modal when clicking outside the content modal.addEventListener('click', (e) => { if (e.target === modal) { document.body.removeChild(modal); } }); modalContent.appendChild(closeButton); modal.appendChild(modalContent); document.body.appendChild(modal); } // Function to switch between result tabs 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'); }; // Delete a bread async function deleteBread(breadId) { if (confirm('Are you sure you want to delete this bread?')) { breadData = breadData.filter(bread => bread.id !== breadId); await storage.saveAllBread(breadData); updateBreadList(); updateBreadSelect(); } } // Reset forms when switching tabs document.querySelectorAll('.tab').forEach(tab => { tab.addEventListener('click', function() { if (this.textContent === 'Recipe Input' && editingBreadId) { // Keep the form filled if we're editing } else { // Reset forms document.getElementById('recipe-form').reset(); document.getElementById('results-form').reset(); // Reset editing state if switching to results tab if (this.textContent === 'Results Input') { editingBreadId = null; document.getElementById('recipe-form').querySelector('button[type="submit"]').textContent = 'Save Recipe'; } } }); }); // iOS detection and Add to Home Screen banner function isIOS() { return /iPhone|iPad|iPod/.test(navigator.userAgent) && !window.MSStream; } function isInStandaloneMode() { return ('standalone' in window.navigator) && (window.navigator.standalone); } function showIOSInstallBanner() { // Only show on iOS Safari when not in standalone mode if (!isIOS() || isInStandaloneMode()) { return; } // Check if user has dismissed the banner before const dismissed = localStorage.getItem('iosBannerDismissed'); if (dismissed) { return; } // Create the banner const banner = document.createElement('div'); banner.id = 'ios-install-banner'; banner.style.cssText = ` position: fixed; top: 0; left: 0; right: 0; background: linear-gradient(135deg, #8b5a2b 0%, #a67c52 100%); color: white; padding: 15px; text-align: center; z-index: 10000; box-shadow: 0 2px 10px rgba(0,0,0,0.3); font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; `; banner.innerHTML = `
📱 Keep Your Recipes Safe!

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"

`; document.body.insertBefore(banner, document.body.firstChild); // Add some padding to the body to prevent content from being hidden document.body.style.paddingTop = '180px'; } function dismissIOSBanner() { const banner = document.getElementById('ios-install-banner'); if (banner) { banner.remove(); document.body.style.paddingTop = '0'; localStorage.setItem('iosBannerDismissed', 'true'); } } // Make dismissIOSBanner globally available window.dismissIOSBanner = dismissIOSBanner; // Load data from IndexedDB on page load window.addEventListener('load', async function() { // First, try to migrate from localStorage if data exists there await storage.migrateFromLocalStorage(); // Load data from IndexedDB breadData = await storage.getAllBread(); if (breadData && breadData.length > 0) { updateBreadList(); updateBreadSelect(); // Open the first bread card by default if any exist setTimeout(() => { const firstCard = document.querySelector('.bread-card'); if (firstCard) { firstCard.classList.add('active'); } }, 100); } // Request persistent storage permission const isPersisted = await storage.requestPersistentStorage(); if (isPersisted) { console.log('Persistent storage granted - your data is protected!'); } else { console.log('Persistent storage not granted - data may be cleared after 7 days on iOS Safari'); } // Show iOS install banner if applicable showIOSInstallBanner(); // Handle shared URLs handleSharedUrl(); });