Dr. Sous

Let Dr. Sous turn any recipe into a cooking timeline.

code.tsx

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 />);

Markdown source · More bulbs by samples · Typebulb home