<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>The Perfect Loaf Timeline Calculator</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
padding: 20px;
max-width: 650px;
margin: 0 auto;
background: #fdfbf7;
color: #333;
}
h1, h2 { color: #8b5a2b; text-align: center; }
.card {
background: white;
padding: 20px;
border-radius: 12px;
box-shadow: 0 4px 6px rgba(0,0,0,0.05);
margin-bottom: 20px;
border: 1px solid #eee;
}
label { display: block; margin-top: 15px; font-weight: bold; color: #555; }
select, input {
width: 100%;
padding: 12px;
margin-top: 5px;
border: 1px solid #ccc;
border-radius: 6px;
box-sizing: border-box;
font-size: 16px;
}
.step-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 14px 0;
border-bottom: 1px solid #f0f0f0;
flex-wrap: wrap;
gap: 10px;
}
.step-row.overlap {
background-color: #f7f4eb;
margin: 4px 0;
padding: 14px 8px;
border-radius: 6px;
border-left: 4px solid #d4b28c;
}
.step-info {
flex: 1;
min-width: 180px;
}
.step-time {
flex: 1;
min-width: 250px;
text-align: right;
}
.step-time input {
width: 100%;
max-width: 260px;
font-size: 15px;
padding: 8px;
text-align: center;
background-color: #fff;
}
button {
background: #8b5a2b;
color: white;
border: none;
padding: 14px;
width: 100%;
border-radius: 6px;
font-size: 16px;
font-weight: bold;
cursor: pointer;
margin-top: 20px;
}
button:hover { background: #6f441f; }
@media (max-width: 480px) {
.step-time { text-align: left; }
.step-time input { max-width: 100%; }
}
</style>
</head>
<body>
<h1>Baking Timeline Calculator</h1>
<h3 style="text-align:center; color:#666; font-weight:normal; margin-top:-10px;">Inspired by <em>The Perfect Loaf</em></h3>
<div class="card">
<label for="recipeSelect">Select Recipe:</label>
<select id="recipeSelect">
<option value="beginners">The Beginner's Sourdough</option>
<option value="country">Pain de Campagne</option>
</select>
<label for="calcMode">Scheduling Mode:</label>
<select id="calcMode" onchange="toggleModeInputs()">
<option value="forward">Forward (Plan from Levain Start)</option>
<option value="backward">Backward (Plan from Target End Time)</option>
</select>
<div id="forwardInputGroup">
<label for="startTime">Start Date & Time (Mix Levain):</label>
<input type="datetime-local" id="startTime">
</div>
<div id="backwardInputGroup" style="display:none;">
<label for="endTime">Target End Date & Time (Out of Oven):</label>
<input type="datetime-local" id="endTime">
</div>
<button onclick="calculateInitialTimeline()">Generate Timeline</button>
</div>
<div class="card" id="timelineCard" style="display:none;">
<h2>Your Custom Schedule</h2>
<p style="font-size: 0.85em; color: #666; text-align: center; margin-bottom: 15px;">
💡 <strong>Parallel Steps:</strong> Levain and Autolyse run together. Adjusting <em>Begin Mix & Knead</em> shifts all future steps if your levain is slow. Adjusting <em>Divide & Pre-shape</em> updates your schedule if bulk fermentation speeds up or slows down.
</p>
<div id="timelineSteps"></div>
</div>
<script>
// Durations define how long THAT specific step lasts before the next action occurs.
const recipes = {
beginners: [
{ name: "Start Levain Build", duration: 300, desc: "Mix starter, flour, and water", isOverlap: true }, // 5h
{ name: "Start Autolyse", duration: 60, desc: "Mix flour and water; rest until levain ready", isOverlap: true }, // 1h
{ name: "Begin Mix & Knead", duration: 30, desc: "Combine levain, autolyse, salt, and water" },
{ name: "Begin Bulk Fermentation", duration: 240, desc: "Perform stretch & folds throughout" },
{ name: "Divide, Pre-shape & Rest", duration: 50, desc: "Divide dough and let rest on bench" },
{ name: "Final Shape & Cold Proof", duration: 960, desc: "Shape into bannetons and retard in fridge" },
{ name: "Bake", duration: 50, desc: "Bake in preheated combo cooker / Dutch oven" },
{ name: "Out of Oven 🎉", duration: 0, desc: "Cool on a wire rack before slicing!" }
],
country: [
{ name: "Start Levain Build", duration: 300, desc: "Mix build parameters", isOverlap: true },
{ name: "Start Autolyse", duration: 45, desc: "Mix flour and water ahead of mix", isOverlap: true },
{ name: "Begin Mix & Knead", duration: 20, desc: "Incorporate levain and salt" },
{ name: "Begin Bulk Fermentation", duration: 270, desc: "Ferment with ambient structural folds" },
{ name: "Divide & Shape", duration: 30, desc: "Divide and shape cleanly" },
{ name: "Cold Proof", duration: 840, desc: "Place in cold storage overnight" },
{ name: "Bake", duration: 50, desc: "Bake until deep mahogany crust forms" },
{ name: "Out of Oven 🎉", duration: 0, desc: "Loaf complete!" }
]
};
window.onload = function() {
const now = new Date();
now.setMinutes(now.getMinutes() - now.getTimezoneOffset());
document.getElementById('startTime').value = now.toISOString().slice(0, 16);
const tomorrow = new Date();
tomorrow.setDate(tomorrow.getDate() + 1);
tomorrow.setMinutes(tomorrow.getMinutes() - tomorrow.getTimezoneOffset());
document.getElementById('endTime').value = tomorrow.toISOString().slice(0, 16);
};
function toggleModeInputs() {
const mode = document.getElementById('calcMode').value;
document.getElementById('forwardInputGroup').style.display = mode === 'forward' ? 'block' : 'none';
document.getElementById('backwardInputGroup').style.display = mode === 'backward' ? 'block' : 'none';
}
let currentTimelineData = [];
function calculateInitialTimeline() {
const recipeKey = document.getElementById('recipeSelect').value;
const steps = recipes[recipeKey];
const mode = document.getElementById('calcMode').value;
currentTimelineData = [];
steps.forEach(s => currentTimelineData.push({ name: s.name, desc: s.desc, duration: s.duration, isOverlap: s.isOverlap, time: null }));
if (mode === 'forward') {
const startVal = document.getElementById('startTime').value;
if (!startVal) return alert("Please pick a start time.");
let levainStart = new Date(startVal);
let levainDuration = steps[0].duration;
let autolyseDuration = steps[1].duration;
// The magic meeting point where both parallel tracks finish
let mixStart = new Date(levainStart.getTime() + levainDuration * 60000);
let autolyseStart = new Date(mixStart.getTime() - autolyseDuration * 60000);
currentTimelineData[0].time = levainStart;
currentTimelineData[1].time = autolyseStart;
currentTimelineData[2].time = mixStart;
let runningTime = new Date(mixStart);
for (let i = 3; i < steps.length; i++) {
runningTime = new Date(runningTime.getTime() + steps[i-1].duration * 60000);
currentTimelineData[i].time = new Date(runningTime);
}
} else {
const endVal = document.getElementById('endTime').value;
if (!endVal) return alert("Please pick a target end time.");
let runningTime = new Date(endVal);
currentTimelineData[steps.length - 1].time = new Date(runningTime);
// Work backward from out-of-oven to mix step
for (let i = steps.length - 2; i >= 2; i--) {
runningTime = new Date(runningTime.getTime() - steps[i].duration * 60000);
currentTimelineData[i].time = new Date(runningTime);
}
// Calculate parallel track starting points based on target mix time
let mixStart = currentTimelineData[2].time;
currentTimelineData[1].time = new Date(mixStart.getTime() - steps[1].duration * 60000);
currentTimelineData[0].time = new Date(mixStart.getTime() - steps[0].duration * 60000);
}
renderTimeline();
}
function renderTimeline() {
const container = document.getElementById('timelineSteps');
container.innerHTML = '';
currentTimelineData.forEach((step, index) => {
const row = document.createElement('div');
row.className = 'step-row' + (step.isOverlap ? ' overlap' : '');
const info = document.createElement('div');
info.className = 'step-info';
let durationLabel = step.duration > 0 ? `Duration: ${formatDuration(step.duration)}` : 'Milestone';
info.innerHTML = `<strong>${step.name}</strong><br><small style="color:#666;">${step.desc}</small><br><small style="color:#999; font-style:italic;">${durationLabel}</small>`;
const timeDiv = document.createElement('div');
timeDiv.className = 'step-time';
const input = document.createElement('input');
input.type = 'datetime-local';
const localTime = new Date(step.time.getTime() - step.time.getTimezoneOffset() * 60000);
input.value = localTime.toISOString().slice(0, 16);
input.onchange = function() {
recalculateFromStep(index, new Date(this.value));
};
timeDiv.appendChild(input);
row.appendChild(info);
row.appendChild(timeDiv);
container.appendChild(row);
});
document.getElementById('timelineCard').style.display = 'block';
}
function recalculateFromStep(changedIndex, newTime) {
currentTimelineData[changedIndex].time = new Date(newTime);
const recipeKey = document.getElementById('recipeSelect').value;
const steps = recipes[recipeKey];
if (changedIndex === 0) {
// Changing Levain Start recalculates everything forward
let levainStart = currentTimelineData[0].time;
let mixStart = new Date(levainStart.getTime() + steps[0].duration * 60000);
currentTimelineData[1].time = new Date(mixStart.getTime() - steps[1].duration * 60000);
currentTimelineData[2].time = mixStart;
let runningTime = new Date(mixStart);
for (let i = 3; i < steps.length; i++) {
runningTime = new Date(runningTime.getTime() + steps[i-1].duration * 60000);
currentTimelineData[i].time = new Date(runningTime);
}
}
else if (changedIndex === 1) {
// If you shift Autolyse Start, it forces a shift in the target Mix Time
let autolyseStart = currentTimelineData[1].time;
let mixStart = new Date(autolyseStart.getTime() + steps[1].duration * 60000);
currentTimelineData[2].time = mixStart;
let runningTime = new Date(mixStart);
for (let i = 3; i < steps.length; i++) {
runningTime = new Date(runningTime.getTime() + steps[i-1].duration * 60000);
currentTimelineData[i].time = new Date(runningTime);
}
}
else if (changedIndex === 2) {
// Crucial: Changing Mix Time (e.g. Levain took longer) shifts everything downstream
let mixStart = currentTimelineData[2].time;
currentTimelineData[1].time = new Date(mixStart.getTime() - steps[1].duration * 60000);
let runningTime = new Date(mixStart);
for (let i = 3; i < steps.length; i++) {
runningTime = new Date(runningTime.getTime() + steps[i-1].duration * 60000);
currentTimelineData[i].time = new Date(runningTime);
}
}
else {
// Changing any step downstream (e.g., Bulk Fermentation ending late) ripples forward
let runningTime = new Date(newTime);
for (let i = changedIndex + 1; i < steps.length; i++) {
runningTime = new Date(runningTime.getTime() + steps[i-1].duration * 60000);
currentTimelineData[i].time = new Date(runningTime);
}
}
renderTimeline();
}
function formatDuration(mins) {
if (mins < 60) return `${mins} mins`;
const hours = Math.floor(mins / 60);
const remainingMins = mins % 60;
return remainingMins > 0 ? `${hours}h ${remainingMins}m` : `${hours}h`;
}
</script>
</body>
</html>