/* @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();