Let Dr. Sous turn any recipe into a cooking timeline.
import React, { useState, useMemo, useRef, useEffect } from "react";
import { createRoot } from "react-dom/client";
import { ChefHat, Play, Timer, List, Clock } from "lucide-react";
interface Ingredient {
item: string;
amount: string;
}
interface RecipeStep {
id: string;
task: string;
details: string;
startTime: number;
duration: number;
type: "prep" | "cook" | "rest" | "plating";
}
interface RecipeTimeline {
recipeTitle: string;
ingredients: Ingredient[];
totalTimeMinutes: number;
steps: RecipeStep[];
}
const TYPE_COLORS = {
prep: "#f59e0b",
cook: "#ef4444",
rest: "#3b82f6",
plating: "#10b981",
};
function App() {
const [timeline, setTimeline] = useState<RecipeTimeline | null>(tb.insight<RecipeTimeline>());
const [loading, setLoading] = useState(false);
const [serveTime, setServeTime] = useState<string>("18:00");
const detailRefs = useRef<Map<string, HTMLDivElement>>(new Map());
const miniGanttRef = useRef<HTMLDivElement>(null);
const handleInfer = async () => {
setLoading(true);
try {
const result = await tb.infer<RecipeTimeline>();
if (result) setTimeline(result);
} finally {
setLoading(false);
}
};
// Calculate clock time from minutes-from-start
const getClockTime = (minutesFromStart: number): string => {
if (!timeline) return "";
const [h, m] = serveTime.split(":").map(Number);
const serveMins = h * 60 + m;
const startMins = serveMins - timeline.totalTimeMinutes;
const actualMins = startMins + minutesFromStart;
const hours = Math.floor(((actualMins % 1440) + 1440) % 1440 / 60);
const mins = ((actualMins % 60) + 60) % 60;
const ampm = hours >= 12 ? "pm" : "am";
const h12 = hours % 12 || 12;
return `${h12}:${mins.toString().padStart(2, "0")}${ampm}`;
};
// Assign rows to steps for mini-gantt stacking
const stepsWithRows = useMemo(() => {
if (!timeline) return [];
const sorted = [...timeline.steps].sort((a, b) => a.startTime - b.startTime);
const rows: { end: number }[] = [];
return sorted.map(step => {
const stepEnd = step.startTime + step.duration;
let row = rows.findIndex(r => r.end <= step.startTime);
if (row === -1) {
row = rows.length;
rows.push({ end: stepEnd });
} else {
rows[row].end = stepEnd;
}
return { ...step, row };
});
}, [timeline]);
const maxRow = useMemo(() => Math.max(0, ...stepsWithRows.map(s => s.row)), [stepsWithRows]);
// Group steps for detail view
const groupedSteps = useMemo(() => {
if (!timeline) return [];
const sorted = [...timeline.steps].sort((a, b) => a.startTime - b.startTime);
const groups: RecipeStep[][] = [];
sorted.forEach(step => {
const lastGroup = groups[groups.length - 1];
if (lastGroup && step.startTime < Math.max(...lastGroup.map(s => s.startTime + s.duration))) {
lastGroup.push(step);
} else {
groups.push([step]);
}
});
return groups;
}, [timeline]);
const scrollToStep = (stepId: string) => {
const el = detailRefs.current.get(stepId);
el?.scrollIntoView({ behavior: "smooth", block: "center" });
};
const BAR_HEIGHT = 14;
const ROW_GAP = 3;
const getPct = (mins: number) => {
if (!timeline) return 0;
return (mins / timeline.totalTimeMinutes) * 100;
};
const ticks = useMemo(() => {
if (!timeline) return [];
const [h, m] = serveTime.split(":").map(Number);
const serveMins = h * 60 + m;
const startMins = serveMins - timeline.totalTimeMinutes;
const firstHourMins = Math.ceil(startMins / 60) * 60;
const lastHourMins = Math.floor(serveMins / 60) * 60;
const res = [];
for (let t = firstHourMins; t <= lastHourMins; t += 60) {
res.push({
offset: t - startMins,
label: ((Math.floor(t / 60) % 12) || 12).toString()
});
}
return res;
}, [timeline, serveTime]);
return (
<div className="container">
<header className="app-header">
<div className="header-left">
<div className="brand-badge">
<ChefHat size={20} />
<span>DR. SOUS</span>
</div>
<p className="app-tagline">Let Dr. Sous turn any recipe into a cooking timeline.</p>
<h1 className="recipe-title">
{timeline?.recipeTitle || "Paste a recipe to begin"}
</h1>
</div>
<div className="header-right">
{timeline && (
<div className="serve-time-picker">
<Clock size={14} />
<label>Serve at</label>
<input
type="time"
value={serveTime}
onChange={e => setServeTime(e.target.value)}
/>
</div>
)}
<button onClick={handleInfer} disabled={loading} className="generate-btn">
{loading ? "Analyzing..." : timeline ? "Switch Recipe" : "Analyze Recipe"}
<Play size={14} fill="currentColor" />
</button>
</div>
</header>
{timeline ? (
<main className="app-main">
<section className="mini-gantt-section">
<div className="mini-gantt-header">
<span className="start-time">{getClockTime(0)}</span>
<span className="total-time">{Math.floor(timeline.totalTimeMinutes / 60)}h {timeline.totalTimeMinutes % 60}m total</span>
<span className="end-time">{getClockTime(timeline.totalTimeMinutes)}</span>
</div>
<div className="mini-gantt-scroll" ref={miniGanttRef}>
<div className="mini-gantt-wrapper">
<div
className="mini-gantt"
style={{
height: (maxRow + 1) * (BAR_HEIGHT + ROW_GAP) + 20,
}}
>
{ticks.map((tick, i) => (
<div key={i} className="gantt-tick" style={{ left: `${getPct(tick.offset)}%` }}>
<span className="tick-label">{tick.label}</span>
</div>
))}
<div className="gantt-bars-area">
{stepsWithRows.map(step => (
<div
key={step.id}
className="mini-bar"
title={`${step.task} (${step.duration}m)`}
style={{
left: `${getPct(step.startTime)}%`,
width: `${Math.max(getPct(step.duration), 0.5)}%`,
top: step.row * (BAR_HEIGHT + ROW_GAP) + ROW_GAP,
height: BAR_HEIGHT,
backgroundColor: TYPE_COLORS[step.type],
}}
onClick={() => scrollToStep(step.id)}
/>
))}
</div>
</div>
</div>
</div>
<div className="mini-gantt-legend">
{Object.entries(TYPE_COLORS).map(([type, color]) => (
<span key={type} className="legend-item">
<span className="legend-dot" style={{ backgroundColor: color }} />
{type}
</span>
))}
</div>
</section>
{/* Ingredients */}
<section className="ingredients-section card">
<div className="card-header"><List size={18} /> <h2>Ingredients</h2></div>
<ul className="ingredients-grid">
{timeline.ingredients.map((ing, i) => (
<li key={i}><span className="amount">{ing.amount}</span> {ing.item}</li>
))}
</ul>
</section>
{/* Detailed Timeline */}
<section className="stream-view">
{groupedSteps.map((group, groupIdx) => (
<div key={groupIdx} className="step-group">
<div className="group-time">
<span className="time-label">{getClockTime(group[0].startTime)}</span>
<div className="time-line" />
</div>
<div className={`group-content ${group.length > 1 ? "parallel" : ""}`}>
{group.map(step => (
<div
key={step.id}
ref={el => { if (el) detailRefs.current.set(step.id, el); }}
className={`step-card ${step.type}`}
>
<div className="step-header">
<span className="step-title">{step.task}</span>
<span className="step-duration">{step.duration}m</span>
</div>
<p className="step-details">{step.details}</p>
<div className="step-footer">
<span className="type-pill">{step.type}</span>
</div>
</div>
))}
</div>
</div>
))}
<div className="finish-marker">
<div className="group-time">
<span className="time-label">{getClockTime(timeline.totalTimeMinutes)}</span>
</div>
<div className="finish-label">
<div className="finish-dot" />
<span>Ready to serve!</span>
</div>
</div>
</section>
</main>
) : (
<div className="welcome-state">
<Timer size={64} className="empty-icon" />
<h2>Ready to Cook?</h2>
<p>Paste a recipe in the Data tab, then click Analyze Recipe.</p>
</div>
)}
</div>
);
}
const container = document.getElementById("root");
const root = createRoot(container!);
root.render(<App />);