/* @component-map * App — Main container, tab navigation [app.jsx] * FeedingLog — Record new feedings with details [components/FeedingLog.jsx] * Timeline — View feeding history as a timeline [components/Timeline.jsx] * Observations — Record and view observations [components/Observations.jsx] * Stats — Starter health overview and stats [components/Stats.jsx] * @end-component-map */ // DEPLOY_CONFIG: {"cron": [{"name": "feeding-reminder-every-12-hours", "schedule": "0 */12 * * *", "action": "event", "config": {"event_type": "feeding.reminder"}}]} import { useEffect, useMemo, useState } from 'react'; import { useCollection, exportCSV, exportJSON } from '@deplixo/sdk'; import { FeedingLog } from './components/FeedingLog.jsx'; import { Timeline } from './components/Timeline.jsx'; import { Observations } from './components/Observations.jsx'; import { Stats } from './components/Stats.jsx'; function formatDateTime(value) { if (!value) return null; const date = new Date(value); if (Number.isNaN(date.getTime())) return null; return date; } function formatNumber(value) { return Number.isFinite(value) ? value : ''; } function getFeedEntries(items) { return (items || []) .filter(item => item?.type === 'feeding' || item?.entryType === 'feeding') .map(item => { const timestamp = item?.timestamp || item?.createdAt || item?.date || item?.time; const date = formatDateTime(timestamp); const flour = String(item?.flour || item?.flourType || '').trim().toLowerCase(); const starter = Number(item?.starterAmount ?? item?.starter ?? item?.starter_g ?? item?.starterGrams ?? 0); const water = Number(item?.waterAmount ?? item?.water ?? item?.water_g ?? item?.waterGrams ?? 0); const flourAmount = Number(item?.flourAmount ?? item?.flour_g ?? item?.flourGrams ?? 0); const total = Number.isFinite(starter + water + flourAmount) ? starter + water + flourAmount : 0; const hydration = Number(item?.hydration ?? (flourAmount > 0 ? (water / flourAmount) * 100 : null)); const feedRatio = Number(item?.ratioValue); return { ...item, date, flour, starter, water, flourAmount, total, hydration, feedRatio, }; }) .filter(entry => entry.date) .sort((a, b) => b.date - a.date); } function computeInsights(feedEntries) { const insights = []; const suggestions = []; if (!feedEntries.length) { return { insights: [ 'No feeding history yet. Log a few feedings to unlock consistency, timing, and volume insights.', ], suggestions: [ 'Track your next 3–5 feedings to establish a baseline for hydration, timing, and starter behavior.', ], }; } const now = new Date(); const sortedAsc = [...feedEntries].sort((a, b) => a.date - b.date); const sortedDesc = [...feedEntries]; const intervals = []; for (let i = 1; i < sortedAsc.length; i += 1) { const diffHours = (sortedAsc[i].date - sortedAsc[i - 1].date) / (1000 * 60 * 60); if (diffHours > 0) intervals.push(diffHours); } const avgInterval = intervals.length ? intervals.reduce((sum, value) => sum + value, 0) / intervals.length : null; if (avgInterval !== null) { insights.push(`Average feeding interval is ${avgInterval.toFixed(1)} hours.`); if (avgInterval >= 20 && avgInterval <= 28) { suggestions.push('Your feeding rhythm looks consistent. Keep this cadence if the starter is peaking reliably between feedings.'); } else if (avgInterval < 20) { suggestions.push('Feedings are happening close together. Consider stretching intervals slightly if the starter is still rising strongly.'); } else { suggestions.push('Feedings are spaced out more than a day. If the starter is sluggish, try tightening the schedule for steadier activity.'); } } const lastFeed = sortedDesc[0]; if (lastFeed?.date) { const hoursSinceLastFeed = (now - lastFeed.date) / (1000 * 60 * 60); insights.push(`Last feeding was ${hoursSinceLastFeed.toFixed(1)} hours ago.`); if (hoursSinceLastFeed > 14) { suggestions.push('It may be time to feed again soon to prevent the starter from getting too hungry.'); } } const hydrations = feedEntries.map(entry => entry.hydration).filter(value => Number.isFinite(value) && value > 0); if (hydrations.length) { const avgHydration = hydrations.reduce((sum, value) => sum + value, 0) / hydrations.length; const minHydration = Math.min(...hydrations); const maxHydration = Math.max(...hydrations); insights.push(`Average hydration is ${avgHydration.toFixed(0)}% across ${hydrations.length} feedings.`); if (maxHydration - minHydration > 20) { suggestions.push('Hydration varies quite a bit. Try narrowing your water-to-flour ratio for more predictable rise and texture.'); } else { suggestions.push('Hydration is fairly stable. That consistency should make behavior easier to compare from feeding to feeding.'); } } const totals = feedEntries.map(entry => entry.total).filter(value => Number.isFinite(value) && value > 0); if (totals.length >= 2) { const firstHalf = totals.slice(0, Math.ceil(totals.length / 2)); const secondHalf = totals.slice(Math.floor(totals.length / 2)); const firstAvg = firstHalf.reduce((sum, value) => sum + value, 0) / firstHalf.length; const secondAvg = secondHalf.reduce((sum, value) => sum + value, 0) / secondHalf.length; const delta = secondAvg - firstAvg; insights.push(`Feeding volume trend is ${delta >= 0 ? 'increasing' : 'decreasing'} by ${Math.abs(delta).toFixed(0)}g on average.`); if (delta > 25) { suggestions.push('Your feed amounts are trending upward. Make sure the starter can reliably consume this volume before the next feeding.'); } else if (delta < -25) { suggestions.push('Your feed amounts are trending downward. If the starter seems sluggish, consider restoring a slightly larger feeding.'); } } const flourCounts = feedEntries.reduce((acc, entry) => { const key = entry.flour || 'unknown'; acc[key] = (acc[key] || 0) + 1; return acc; }, {}); const dominantFlour = Object.entries(flourCounts).sort((a, b) => b[1] - a[1])[0]; if (dominantFlour && dominantFlour[1] >= 2) { insights.push(`${dominantFlour[0]} is your most used flour type.`); suggestions.push(`If the starter is behaving well, continue with ${dominantFlour[0]} for a few more feedings to reduce variables.`); } return { insights, suggestions }; } function buildShareText(feedEntries, analysis) { const visibleEntries = feedEntries.slice(0, 5); const totalFeeds = feedEntries.length; const latest = visibleEntries[0]; const avgHydration = feedEntries .map(entry => entry.hydration) .filter(value => Number.isFinite(value) && value > 0); const hydrationText = avgHydration.length ? `${Math.round(avgHydration.reduce((sum, value) => sum + value, 0) / avgHydration.length)}% avg hydration` : 'Hydration not yet tracked'; const lines = [ 'Starter Journal feeding summary', `Feedings logged: ${totalFeeds}`, hydrationText, ]; if (latest?.date) { lines.push(`Latest feeding: ${latest.date.toLocaleString()}`); } if (analysis?.insights?.length) { lines.push('', 'Top insight:', analysis.insights[0]); } if (analysis?.suggestions?.length) { lines.push('', 'Suggestion:', analysis.suggestions[0]); } if (visibleEntries.length) { lines.push('', 'Recent feedings:'); visibleEntries.forEach(entry => { const parts = []; if (entry.flour) parts.push(entry.flour); if (Number.isFinite(entry.total) && entry.total > 0) parts.push(`${Math.round(entry.total)}g total`); if (Number.isFinite(entry.hydration) && entry.hydration > 0) parts.push(`${Math.round(entry.hydration)}% hydration`); lines.push(`- ${entry.date.toLocaleDateString()}: ${parts.join(' • ') || 'Feeding logged'}`); }); } return lines.join('\n'); } function buildExportRows(feedEntries) { return feedEntries.map(entry => ({ id: entry.id ?? '', type: entry.type ?? 'feeding', date: entry.date ? entry.date.toISOString() : '', dateLocal: entry.date ? entry.date.toLocaleString() : '', timestamp: entry.timestamp || entry.createdAt || entry.date || entry.time || '', flour: entry.flour || '', starter: formatNumber(entry.starter), water: formatNumber(entry.water), flourAmount: formatNumber(entry.flourAmount), total: formatNumber(entry.total), hydration: formatNumber(entry.hydration), feedRatio: formatNumber(entry.feedRatio), notes: entry.notes || '', temperature: entry.temperature || entry.temp || '', })); } function downloadCSV(feedEntries) { exportCSV(buildExportRows(feedEntries), { filename: 'starter-feeding-history.csv', columns: ['id', 'type', 'date', 'dateLocal', 'timestamp', 'flour', 'starter', 'water', 'flourAmount', 'total', 'hydration', 'feedRatio', 'notes', 'temperature'], }); } function downloadJSON(feedEntries) { exportJSON(buildExportRows(feedEntries), { filename: 'starter-feeding-history.json' }); } // PROGRESS:sc_001:complete:Setting up sourdough starter log function App() { const [activeTab, setActiveTab] = useState('timeline'); const [shareOpen, setShareOpen] = useState(false); const { items: logItems, loading } = useCollection('starter_logs', { personal: true }); const tabs = [ { id: 'timeline', label: '📋 Timeline', icon: '📋' }, { id: 'feed', label: '🍞 Feed', icon: '🍞' }, { id: 'observe', label: '👀 Observe', icon: '👀' }, { id: 'stats', label: '📊 Stats', icon: '📊' }, ]; const feedEntries = useMemo(() => getFeedEntries(logItems), [logItems]); const analysis = useMemo(() => computeInsights(feedEntries), [feedEntries]); const sharePayload = useMemo(() => buildShareText(feedEntries, analysis), [feedEntries, analysis]); const shareUrl = useMemo(() => { const url = new URL(window.location.href); url.searchParams.set('view', 'share'); return url.toString(); }, []); useEffect(() => { const params = new URLSearchParams(window.location.search); if (params.get('view') === 'share') { setActiveTab('timeline'); } }, []); const handleShare = async () => { const shareText = `${sharePayload}\n\nView sharing link: ${shareUrl}`; try { if (navigator.share) { await navigator.share({ title: 'Starter Journal feeding summary', text: sharePayload, url: shareUrl, }); } else if (navigator.clipboard?.writeText) { await navigator.clipboard.writeText(shareText); setShareOpen(true); } else { setShareOpen(true); } } catch (error) { setShareOpen(true); } }; const copyShareLink = async () => { try { if (navigator.clipboard?.writeText) { await navigator.clipboard.writeText(shareUrl); } } catch (error) { // ignore clipboard errors and still show the modal content } setShareOpen(true); }; return (
🫙

Starter Journal

Sourdough maintenance log

{activeTab === 'timeline' && } {activeTab === 'feed' && } {activeTab === 'observe' && } {activeTab === 'stats' && }

Share with fellow bakers

Create a shareable link or copy a summary of your feeding history.

Shareable view
{shareUrl}
{sharePayload}

Export feeding history

Download all feeding records as a CSV or JSON file.

{feedEntries.length ? `${feedEntries.length} feeding record${feedEntries.length === 1 ? '' : 's'} ready to export.` : 'No feeding records available to export yet.'}

AI Feeding Analysis

Actionable insights from your feeding history{loading ? ' — loading logs…' : ''}.

Insights

    {analysis.insights.map((item, index) => (
  • {item}
  • ))}

Suggestions

    {analysis.suggestions.map((item, index) => (
  • {item}
  • ))}
{shareOpen && (

Shareable feeding summary

Copy the link below and send it to another baker, or share the summary text directly.

{shareUrl}
{sharePayload}
)}
); } ReactDOM.createRoot(document.getElementById("root")).render();