Recipe Management App Implementation Plan

Goal: Build a lightweight recipe management app on Val Town that lets users save recipes from URLs via AI extraction, view their collection, and generate shopping lists.

Architecture: Single Val Town project with Hono backend serving a React frontend. SQLite for persistence. OpenAI for recipe extraction from scraped web pages. Simple REST API with three core endpoints: recipes CRUD, URL ingestion, and shopping list generation.

Tech Stack: Val Town, TypeScript, Hono, React 18.2.0, SQLite, OpenAI GPT-4o-mini, TailwindCSS (via Twind)


Data Model

// shared/types.ts interface Recipe { id: number; title: string; source_url: string | null; servings: number; prep_time_minutes: number | null; cook_time_minutes: number | null; created_at: string; updated_at: string; } interface Ingredient { id: number; recipe_id: number; name: string; // "chicken breast" quantity: string; // "2" or "1/2" unit: string | null; // "lb", "cup", "piece", null for "2 cloves garlic" notes: string | null; // "diced", "optional" sort_order: number; } interface Instruction { id: number; recipe_id: number; step_number: number; text: string; }

Why this model:

  • Normalized ingredients enable smart shopping list aggregation
  • sort_order preserves ingredient grouping (proteins together, etc.)
  • quantity as string handles fractions ("1/2") without float precision issues
  • Extensible: add tags, categories, notes tables later without schema changes

Task 1: Project Scaffolding

Files:

  • Create: backend/index.ts
  • Create: backend/README.md
  • Create: frontend/index.html
  • Create: frontend/README.md
  • Create: shared/types.ts
  • Create: shared/README.md
  • Create: README.md

Step 1: Create shared types

// shared/types.ts export interface Recipe { id: number; title: string; source_url: string | null; servings: number; prep_time_minutes: number | null; cook_time_minutes: number | null; created_at: string; updated_at: string; } export interface Ingredient { id: number; recipe_id: number; name: string; quantity: string; unit: string | null; notes: string | null; sort_order: number; } export interface Instruction { id: number; recipe_id: number; step_number: number; text: string; } // API request/response types export interface RecipeWithDetails extends Recipe { ingredients: Ingredient[]; instructions: Instruction[]; } export interface IngestRequest { url: string; } export interface ShoppingListRequest { recipe_ids: number[]; } export interface ShoppingListItem { name: string; total_quantity: string; unit: string | null; recipes: string[]; // which recipes need this ingredient }

Step 2: Create minimal backend entry point

// backend/index.ts import { Hono } from "https://esm.sh/hono@4.4.2"; const app = new Hono(); // Unwrap Hono errors to see original error details app.onError((err) => Promise.reject(err)); app.get("/api/health", (c) => c.json({ status: "ok" })); export default app.fetch;

Step 3: Create minimal frontend HTML

<!-- frontend/index.html --> <!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Recipes</title> <script src="https://cdn.twind.style" crossorigin></script> <script src="https://esm.town/v/std/catch"></script> </head> <body> <div id="root"></div> <script type="module" src="/frontend/index.tsx"></script> </body> </html>

Step 4: Create README files

<!-- README.md --> # Recipes A simple recipe management app built on Val Town. ## Features - Save recipes from URLs (AI-powered extraction) - View and manage recipe collection - Generate shopping lists from selected recipes ## Development Main entry: `backend/index.ts`
<!-- backend/README.md --> # Backend Hono-based API server. - `index.ts` - Main entry point and route registration - `database/` - SQLite schema and queries - `routes/` - API route handlers
<!-- frontend/README.md --> # Frontend React 18.2.0 SPA with TailwindCSS (Twind). - `index.html` - HTML shell - `index.tsx` - React entry point - `components/` - React components
<!-- shared/README.md --> # Shared Types and utilities used by both frontend and backend. Note: Code here must work in both Deno (backend) and browser (frontend). Do not use `Deno` keyword directly.

Step 5: Verify backend starts

Run: Deploy to Val Town and hit /api/health Expected: {"status":"ok"}

Step 6: Commit

git add . git commit -m "feat: scaffold project structure with types and minimal backend"

Task 2: Database Schema and Migrations

Files:

  • Create: backend/database/migrations.ts
  • Create: backend/database/queries.ts
  • Create: backend/database/README.md
  • Modify: backend/index.ts

Step 1: Create migrations file

// backend/database/migrations.ts import { sqlite } from "https://esm.town/v/stevekrouse/sqlite/main.tsx"; const RECIPES_TABLE = "recipes_v1"; const INGREDIENTS_TABLE = "ingredients_v1"; const INSTRUCTIONS_TABLE = "instructions_v1"; export async function runMigrations() { await sqlite.execute(` CREATE TABLE IF NOT EXISTS ${RECIPES_TABLE} ( id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT NOT NULL, source_url TEXT, servings INTEGER NOT NULL DEFAULT 4, prep_time_minutes INTEGER, cook_time_minutes INTEGER, created_at TEXT NOT NULL DEFAULT (datetime('now')), updated_at TEXT NOT NULL DEFAULT (datetime('now')) ) `); await sqlite.execute(` CREATE TABLE IF NOT EXISTS ${INGREDIENTS_TABLE} ( id INTEGER PRIMARY KEY AUTOINCREMENT, recipe_id INTEGER NOT NULL, name TEXT NOT NULL, quantity TEXT NOT NULL, unit TEXT, notes TEXT, sort_order INTEGER NOT NULL DEFAULT 0, FOREIGN KEY (recipe_id) REFERENCES ${RECIPES_TABLE}(id) ON DELETE CASCADE ) `); await sqlite.execute(` CREATE TABLE IF NOT EXISTS ${INSTRUCTIONS_TABLE} ( id INTEGER PRIMARY KEY AUTOINCREMENT, recipe_id INTEGER NOT NULL, step_number INTEGER NOT NULL, text TEXT NOT NULL, FOREIGN KEY (recipe_id) REFERENCES ${RECIPES_TABLE}(id) ON DELETE CASCADE ) `); // Index for faster recipe lookups await sqlite.execute(` CREATE INDEX IF NOT EXISTS idx_ingredients_recipe_id ON ${INGREDIENTS_TABLE}(recipe_id) `); await sqlite.execute(` CREATE INDEX IF NOT EXISTS idx_instructions_recipe_id ON ${INSTRUCTIONS_TABLE}(recipe_id) `); } export { RECIPES_TABLE, INGREDIENTS_TABLE, INSTRUCTIONS_TABLE };

Step 2: Create queries file

// backend/database/queries.ts import { sqlite } from "https://esm.town/v/stevekrouse/sqlite/main.tsx"; import { RECIPES_TABLE, INGREDIENTS_TABLE, INSTRUCTIONS_TABLE } from "./migrations.ts"; import type { Recipe, Ingredient, Instruction, RecipeWithDetails } from "../../shared/types.ts"; export async function getAllRecipes(): Promise<Recipe[]> { const { rows } = await sqlite.execute(`SELECT * FROM ${RECIPES_TABLE} ORDER BY created_at DESC`); return rows as Recipe[]; } export async function getRecipeById(id: number): Promise<RecipeWithDetails | null> { const { rows: recipeRows } = await sqlite.execute( `SELECT * FROM ${RECIPES_TABLE} WHERE id = ?`, [id] ); if (recipeRows.length === 0) return null; const recipe = recipeRows[0] as Recipe; const { rows: ingredientRows } = await sqlite.execute( `SELECT * FROM ${INGREDIENTS_TABLE} WHERE recipe_id = ? ORDER BY sort_order`, [id] ); const { rows: instructionRows } = await sqlite.execute( `SELECT * FROM ${INSTRUCTIONS_TABLE} WHERE recipe_id = ? ORDER BY step_number`, [id] ); return { ...recipe, ingredients: ingredientRows as Ingredient[], instructions: instructionRows as Instruction[], }; } export async function createRecipe( recipe: Omit<Recipe, "id" | "created_at" | "updated_at">, ingredients: Omit<Ingredient, "id" | "recipe_id">[], instructions: Omit<Instruction, "id" | "recipe_id">[] ): Promise<number> { const { lastInsertRowid } = await sqlite.execute( `INSERT INTO ${RECIPES_TABLE} (title, source_url, servings, prep_time_minutes, cook_time_minutes) VALUES (?, ?, ?, ?, ?)`, [recipe.title, recipe.source_url, recipe.servings, recipe.prep_time_minutes, recipe.cook_time_minutes] ); const recipeId = Number(lastInsertRowid); for (const ing of ingredients) { await sqlite.execute( `INSERT INTO ${INGREDIENTS_TABLE} (recipe_id, name, quantity, unit, notes, sort_order) VALUES (?, ?, ?, ?, ?, ?)`, [recipeId, ing.name, ing.quantity, ing.unit, ing.notes, ing.sort_order] ); } for (const inst of instructions) { await sqlite.execute( `INSERT INTO ${INSTRUCTIONS_TABLE} (recipe_id, step_number, text) VALUES (?, ?, ?)`, [recipeId, inst.step_number, inst.text] ); } return recipeId; } export async function deleteRecipe(id: number): Promise<boolean> { // Delete ingredients and instructions first (cascade should handle, but be explicit) await sqlite.execute(`DELETE FROM ${INGREDIENTS_TABLE} WHERE recipe_id = ?`, [id]); await sqlite.execute(`DELETE FROM ${INSTRUCTIONS_TABLE} WHERE recipe_id = ?`, [id]); const { rowsAffected } = await sqlite.execute(`DELETE FROM ${RECIPES_TABLE} WHERE id = ?`, [id]); return rowsAffected > 0; } export async function getIngredientsByRecipeIds(recipeIds: number[]): Promise<(Ingredient & { recipe_title: string })[]> { if (recipeIds.length === 0) return []; const placeholders = recipeIds.map(() => "?").join(","); const { rows } = await sqlite.execute( `SELECT i.*, r.title as recipe_title FROM ${INGREDIENTS_TABLE} i JOIN ${RECIPES_TABLE} r ON i.recipe_id = r.id WHERE i.recipe_id IN (${placeholders}) ORDER BY i.name`, recipeIds ); return rows as (Ingredient & { recipe_title: string })[]; }

Step 3: Create database README

<!-- backend/database/README.md --> # Database SQLite persistence layer. ## Tables - `recipes_v1` - Core recipe metadata - `ingredients_v1` - Recipe ingredients (normalized) - `instructions_v1` - Step-by-step instructions ## Schema Changes When modifying schema, create new table versions (e.g., `recipes_v2`) rather than using ALTER TABLE. SQLite has limited ALTER support.

Step 4: Add migrations to backend startup

// backend/index.ts import { Hono } from "https://esm.sh/hono@4.4.2"; import { runMigrations } from "./database/migrations.ts"; const app = new Hono(); // Unwrap Hono errors to see original error details app.onError((err) => Promise.reject(err)); // Run migrations on startup await runMigrations(); app.get("/api/health", (c) => c.json({ status: "ok" })); export default app.fetch;

Step 5: Verify migrations run

Run: Deploy and hit /api/health Expected: No errors, tables created in SQLite

Step 6: Commit

git add backend/database/ backend/index.ts git commit -m "feat: add database schema and query functions"

Task 3: Recipe CRUD API Routes

Files:

  • Create: backend/routes/recipes.ts
  • Modify: backend/index.ts

Step 1: Create recipes routes

// backend/routes/recipes.ts import { Hono } from "https://esm.sh/hono@4.4.2"; import { getAllRecipes, getRecipeById, createRecipe, deleteRecipe } from "../database/queries.ts"; import type { RecipeWithDetails } from "../../shared/types.ts"; const recipes = new Hono(); // GET /api/recipes - List all recipes recipes.get("/", async (c) => { const allRecipes = await getAllRecipes(); return c.json(allRecipes); }); // GET /api/recipes/:id - Get single recipe with details recipes.get("/:id", async (c) => { const id = parseInt(c.req.param("id"), 10); if (isNaN(id)) { return c.json({ error: "Invalid recipe ID" }, 400); } const recipe = await getRecipeById(id); if (!recipe) { return c.json({ error: "Recipe not found" }, 404); } return c.json(recipe); }); // POST /api/recipes - Create recipe (manual entry) recipes.post("/", async (c) => { const body = await c.req.json<RecipeWithDetails>(); if (!body.title || !body.ingredients || !body.instructions) { return c.json({ error: "Missing required fields: title, ingredients, instructions" }, 400); } const recipeId = await createRecipe( { title: body.title, source_url: body.source_url || null, servings: body.servings || 4, prep_time_minutes: body.prep_time_minutes || null, cook_time_minutes: body.cook_time_minutes || null, }, body.ingredients.map((ing, idx) => ({ name: ing.name, quantity: ing.quantity, unit: ing.unit || null, notes: ing.notes || null, sort_order: ing.sort_order ?? idx, })), body.instructions.map((inst, idx) => ({ step_number: inst.step_number ?? idx + 1, text: inst.text, })) ); return c.json({ id: recipeId }, 201); }); // DELETE /api/recipes/:id - Delete recipe recipes.delete("/:id", async (c) => { const id = parseInt(c.req.param("id"), 10); if (isNaN(id)) { return c.json({ error: "Invalid recipe ID" }, 400); } const deleted = await deleteRecipe(id); if (!deleted) { return c.json({ error: "Recipe not found" }, 404); } return c.json({ success: true }); }); export default recipes;

Step 2: Register routes in main app

// backend/index.ts import { Hono } from "https://esm.sh/hono@4.4.2"; import { runMigrations } from "./database/migrations.ts"; import recipes from "./routes/recipes.ts"; const app = new Hono(); // Unwrap Hono errors to see original error details app.onError((err) => Promise.reject(err)); // Run migrations on startup await runMigrations(); // API routes app.get("/api/health", (c) => c.json({ status: "ok" })); app.route("/api/recipes", recipes); export default app.fetch;

Step 3: Test the API manually

Run: POST to /api/recipes with test data:

{ "title": "Test Recipe", "servings": 2, "ingredients": [{"name": "salt", "quantity": "1", "unit": "tsp"}], "instructions": [{"text": "Add salt"}] }

Expected: {"id": 1} with status 201

Run: GET /api/recipes Expected: Array containing the test recipe

Run: GET /api/recipes/1 Expected: Full recipe with ingredients and instructions

Run: DELETE /api/recipes/1 Expected: {"success": true}

Step 4: Commit

git add backend/routes/recipes.ts backend/index.ts git commit -m "feat: add recipe CRUD API endpoints"

Task 4: AI Recipe Extraction from URLs

Files:

  • Create: backend/routes/ingest.ts
  • Modify: backend/index.ts

Step 1: Create ingest route with URL fetching and AI extraction

// backend/routes/ingest.ts import { Hono } from "https://esm.sh/hono@4.4.2"; import { OpenAI } from "https://esm.town/v/std/openai"; import { createRecipe } from "../database/queries.ts"; import type { IngestRequest } from "../../shared/types.ts"; const ingest = new Hono(); const openai = new OpenAI(); // POST /api/ingest - Extract recipe from URL ingest.post("/", async (c) => { const body = await c.req.json<IngestRequest>(); if (!body.url) { return c.json({ error: "URL is required" }, 400); } // Validate URL let url: URL; try { url = new URL(body.url); } catch { return c.json({ error: "Invalid URL" }, 400); } // Fetch the page content let pageContent: string; try { const response = await fetch(url.toString(), { headers: { "User-Agent": "Mozilla/5.0 (compatible; RecipeBot/1.0)", }, }); if (!response.ok) { return c.json({ error: `Failed to fetch URL: ${response.status}` }, 400); } pageContent = await response.text(); } catch (err) { return c.json({ error: `Failed to fetch URL: ${err.message}` }, 400); } // Strip HTML to reduce token usage - basic extraction const textContent = pageContent .replace(/<script[^>]*>[\s\S]*?<\/script>/gi, "") .replace(/<style[^>]*>[\s\S]*?<\/style>/gi, "") .replace(/<[^>]+>/g, " ") .replace(/\s+/g, " ") .trim() .slice(0, 15000); // Limit content size // Use AI to extract structured recipe data const completion = await openai.chat.completions.create({ model: "gpt-4o-mini", max_tokens: 2000, messages: [ { role: "system", content: `You are a recipe extraction assistant. Extract recipe information from webpage text and return ONLY valid JSON matching this schema: { "title": "string", "servings": number, "prep_time_minutes": number or null, "cook_time_minutes": number or null, "ingredients": [ {"name": "string", "quantity": "string", "unit": "string or null", "notes": "string or null"} ], "instructions": [ {"step_number": number, "text": "string"} ] } Rules: - Extract ALL ingredients, even if formatting is inconsistent - Parse quantities as strings to preserve fractions like "1/2" - Unit should be null if not applicable (e.g., "2 eggs" has no unit) - Notes capture preparation details like "diced", "room temperature" - Instructions should be clear, numbered steps - If you cannot find a recipe, return {"error": "No recipe found"}`, }, { role: "user", content: `Extract the recipe from this webpage content:\n\n${textContent}`, }, ], }); const aiResponse = completion.choices[0]?.message?.content; if (!aiResponse) { return c.json({ error: "AI extraction failed" }, 500); } // Parse AI response let extracted; try { extracted = JSON.parse(aiResponse); } catch { return c.json({ error: "Failed to parse AI response" }, 500); } if (extracted.error) { return c.json({ error: extracted.error }, 400); } // Save to database const recipeId = await createRecipe( { title: extracted.title, source_url: body.url, servings: extracted.servings || 4, prep_time_minutes: extracted.prep_time_minutes, cook_time_minutes: extracted.cook_time_minutes, }, extracted.ingredients.map((ing: any, idx: number) => ({ name: ing.name, quantity: ing.quantity || "1", unit: ing.unit || null, notes: ing.notes || null, sort_order: idx, })), extracted.instructions.map((inst: any, idx: number) => ({ step_number: inst.step_number || idx + 1, text: inst.text, })) ); return c.json({ id: recipeId, title: extracted.title }, 201); }); export default ingest;

Step 2: Register ingest route

// backend/index.ts import { Hono } from "https://esm.sh/hono@4.4.2"; import { runMigrations } from "./database/migrations.ts"; import recipes from "./routes/recipes.ts"; import ingest from "./routes/ingest.ts"; const app = new Hono(); // Unwrap Hono errors to see original error details app.onError((err) => Promise.reject(err)); // Run migrations on startup await runMigrations(); // API routes app.get("/api/health", (c) => c.json({ status: "ok" })); app.route("/api/recipes", recipes); app.route("/api/ingest", ingest); export default app.fetch;

Step 3: Test with a real Hello Fresh URL

Run: POST to /api/ingest:

{"url": "https://www.hellofresh.com/recipes/one-pan-southwest-chicken-5f9b5c5a4e3b6e5a5c5d5e5f"}

Expected: {"id": <number>, "title": "<recipe title>"} with status 201

Run: GET /api/recipes/<id> Expected: Full extracted recipe with ingredients and instructions

Step 4: Commit

git add backend/routes/ingest.ts backend/index.ts git commit -m "feat: add AI-powered recipe extraction from URLs"

Task 5: Shopping List Generation

Files:

  • Create: backend/routes/shopping.ts
  • Modify: backend/index.ts

Step 1: Create shopping list route

// backend/routes/shopping.ts import { Hono } from "https://esm.sh/hono@4.4.2"; import { getIngredientsByRecipeIds } from "../database/queries.ts"; import type { ShoppingListRequest, ShoppingListItem } from "../../shared/types.ts"; const shopping = new Hono(); // POST /api/shopping-list - Generate shopping list from recipe IDs shopping.post("/", async (c) => { const body = await c.req.json<ShoppingListRequest>(); if (!body.recipe_ids || body.recipe_ids.length === 0) { return c.json({ error: "At least one recipe_id is required" }, 400); } const ingredients = await getIngredientsByRecipeIds(body.recipe_ids); // Group ingredients by normalized name const grouped = new Map<string, ShoppingListItem>(); for (const ing of ingredients) { const normalizedName = ing.name.toLowerCase().trim(); if (grouped.has(normalizedName)) { const existing = grouped.get(normalizedName)!; // Simple aggregation: just list quantities, don't try to add them existing.total_quantity += `, ${ing.quantity}${ing.unit ? " " + ing.unit : ""}`; if (!existing.recipes.includes(ing.recipe_title)) { existing.recipes.push(ing.recipe_title); } } else { grouped.set(normalizedName, { name: ing.name, total_quantity: `${ing.quantity}${ing.unit ? " " + ing.unit : ""}`, unit: ing.unit, recipes: [ing.recipe_title], }); } } const shoppingList = Array.from(grouped.values()).sort((a, b) => a.name.localeCompare(b.name) ); return c.json(shoppingList); }); export default shopping;

Step 2: Register shopping route

// backend/index.ts import { Hono } from "https://esm.sh/hono@4.4.2"; import { runMigrations } from "./database/migrations.ts"; import recipes from "./routes/recipes.ts"; import ingest from "./routes/ingest.ts"; import shopping from "./routes/shopping.ts"; const app = new Hono(); // Unwrap Hono errors to see original error details app.onError((err) => Promise.reject(err)); // Run migrations on startup await runMigrations(); // API routes app.get("/api/health", (c) => c.json({ status: "ok" })); app.route("/api/recipes", recipes); app.route("/api/ingest", ingest); app.route("/api/shopping-list", shopping); export default app.fetch;

Step 3: Test shopping list generation

Run: Create 2 test recipes with overlapping ingredients, then POST to /api/shopping-list:

{"recipe_ids": [1, 2]}

Expected: Aggregated shopping list with ingredient sources

Step 4: Commit

git add backend/routes/shopping.ts backend/index.ts git commit -m "feat: add shopping list generation from recipes"

Task 6: Static File Serving

Files:

  • Create: backend/routes/static.ts
  • Modify: backend/index.ts

Step 1: Create static file serving route

// backend/routes/static.ts import { Hono } from "https://esm.sh/hono@4.4.2"; import { readFile, serveFile } from "https://esm.town/v/std/utils/index.ts"; const staticRoutes = new Hono(); // Serve frontend and shared files staticRoutes.get("/frontend/*", (c) => serveFile(c.req.path, import.meta.url)); staticRoutes.get("/shared/*", (c) => serveFile(c.req.path, import.meta.url)); // Serve index.html for root path staticRoutes.get("/", async (c) => { const html = await readFile("/frontend/index.html", import.meta.url); return c.html(html); }); export default staticRoutes;

Step 2: Add static routes to main app

// backend/index.ts import { Hono } from "https://esm.sh/hono@4.4.2"; import { runMigrations } from "./database/migrations.ts"; import recipes from "./routes/recipes.ts"; import ingest from "./routes/ingest.ts"; import shopping from "./routes/shopping.ts"; import staticRoutes from "./routes/static.ts"; const app = new Hono(); // Unwrap Hono errors to see original error details app.onError((err) => Promise.reject(err)); // Run migrations on startup await runMigrations(); // API routes app.get("/api/health", (c) => c.json({ status: "ok" })); app.route("/api/recipes", recipes); app.route("/api/ingest", ingest); app.route("/api/shopping-list", shopping); // Static file serving (must be after API routes) app.route("", staticRoutes); export default app.fetch;

Step 3: Verify static serving works

Run: Deploy and hit / Expected: HTML page loads (may be blank until React is added)

Step 4: Commit

git add backend/routes/static.ts backend/index.ts git commit -m "feat: add static file serving for frontend"

Task 7: React Frontend - Core Components

Files:

  • Create: frontend/index.tsx
  • Create: frontend/components/App.tsx
  • Create: frontend/components/RecipeList.tsx
  • Create: frontend/components/RecipeDetail.tsx

Step 1: Create React entry point

/** @jsxImportSource https://esm.sh/react@18.2.0 */ // frontend/index.tsx import { createRoot } from "https://esm.sh/react-dom@18.2.0/client"; import App from "./components/App.tsx"; const root = createRoot(document.getElementById("root")!); root.render(<App />);

Step 2: Create App component with routing state

/** @jsxImportSource https://esm.sh/react@18.2.0 */ // frontend/components/App.tsx import { useState, useEffect } from "https://esm.sh/react@18.2.0"; import type { Recipe } from "../../shared/types.ts"; import RecipeList from "./RecipeList.tsx"; import RecipeDetail from "./RecipeDetail.tsx"; export default function App() { const [recipes, setRecipes] = useState<Recipe[]>([]); const [selectedRecipeId, setSelectedRecipeId] = useState<number | null>(null); const [loading, setLoading] = useState(true); const fetchRecipes = async () => { setLoading(true); const res = await fetch("/api/recipes"); const data = await res.json(); setRecipes(data); setLoading(false); }; useEffect(() => { fetchRecipes(); }, []); if (loading) { return ( <div class="min-h-screen bg-gray-50 flex items-center justify-center"> <p class="text-gray-500">Loading...</p> </div> ); } return ( <div class="min-h-screen bg-gray-50"> <header class="bg-white shadow-sm"> <div class="max-w-4xl mx-auto px-4 py-4"> <h1 class="text-2xl font-bold text-gray-900 cursor-pointer" onClick={() => setSelectedRecipeId(null)} > Recipes </h1> </div> </header> <main class="max-w-4xl mx-auto px-4 py-6"> {selectedRecipeId ? ( <RecipeDetail recipeId={selectedRecipeId} onBack={() => setSelectedRecipeId(null)} onDelete={() => { setSelectedRecipeId(null); fetchRecipes(); }} /> ) : ( <RecipeList recipes={recipes} onSelect={setSelectedRecipeId} onRecipeAdded={fetchRecipes} /> )} </main> </div> ); }

Step 3: Create RecipeList component

/** @jsxImportSource https://esm.sh/react@18.2.0 */ // frontend/components/RecipeList.tsx import { useState } from "https://esm.sh/react@18.2.0"; import type { Recipe } from "../../shared/types.ts"; interface Props { recipes: Recipe[]; onSelect: (id: number) => void; onRecipeAdded: () => void; } export default function RecipeList({ recipes, onSelect, onRecipeAdded }: Props) { const [url, setUrl] = useState(""); const [importing, setImporting] = useState(false); const [error, setError] = useState<string | null>(null); const handleImport = async (e: React.FormEvent) => { e.preventDefault(); if (!url.trim()) return; setImporting(true); setError(null); try { const res = await fetch("/api/ingest", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ url }), }); const data = await res.json(); if (!res.ok) { throw new Error(data.error || "Import failed"); } setUrl(""); onRecipeAdded(); } catch (err) { setError(err.message); } finally { setImporting(false); } }; return ( <div> {/* Import form */} <form onSubmit={handleImport} class="mb-6"> <div class="flex gap-2"> <input type="url" value={url} onChange={(e) => setUrl(e.target.value)} placeholder="Paste recipe URL..." class="flex-1 px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent" disabled={importing} /> <button type="submit" disabled={importing || !url.trim()} class="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed" > {importing ? "Importing..." : "Import"} </button> </div> {error && <p class="mt-2 text-red-600 text-sm">{error}</p>} </form> {/* Recipe list */} {recipes.length === 0 ? ( <p class="text-gray-500 text-center py-8"> No recipes yet. Import one from a URL above! </p> ) : ( <div class="grid gap-4"> {recipes.map((recipe) => ( <div key={recipe.id} onClick={() => onSelect(recipe.id)} class="bg-white p-4 rounded-lg shadow-sm cursor-pointer hover:shadow-md transition-shadow" > <h2 class="text-lg font-semibold text-gray-900">{recipe.title}</h2> <div class="mt-1 text-sm text-gray-500 flex gap-4"> <span>{recipe.servings} servings</span> {recipe.prep_time_minutes && <span>Prep: {recipe.prep_time_minutes}min</span>} {recipe.cook_time_minutes && <span>Cook: {recipe.cook_time_minutes}min</span>} </div> </div> ))} </div> )} </div> ); }

Step 4: Create RecipeDetail component

/** @jsxImportSource https://esm.sh/react@18.2.0 */ // frontend/components/RecipeDetail.tsx import { useState, useEffect } from "https://esm.sh/react@18.2.0"; import type { RecipeWithDetails } from "../../shared/types.ts"; interface Props { recipeId: number; onBack: () => void; onDelete: () => void; } export default function RecipeDetail({ recipeId, onBack, onDelete }: Props) { const [recipe, setRecipe] = useState<RecipeWithDetails | null>(null); const [loading, setLoading] = useState(true); useEffect(() => { const fetchRecipe = async () => { setLoading(true); const res = await fetch(`/api/recipes/${recipeId}`); const data = await res.json(); setRecipe(data); setLoading(false); }; fetchRecipe(); }, [recipeId]); const handleDelete = async () => { if (!confirm("Delete this recipe?")) return; await fetch(`/api/recipes/${recipeId}`, { method: "DELETE" }); onDelete(); }; if (loading) { return <p class="text-gray-500">Loading recipe...</p>; } if (!recipe) { return <p class="text-red-600">Recipe not found</p>; } return ( <div> <button onClick={onBack} class="mb-4 text-blue-600 hover:underline" > ← Back to recipes </button> <div class="bg-white rounded-lg shadow-sm p-6"> <div class="flex justify-between items-start mb-4"> <h2 class="text-2xl font-bold text-gray-900">{recipe.title}</h2> <button onClick={handleDelete} class="text-red-600 hover:text-red-800 text-sm" > Delete </button> </div> {recipe.source_url && ( <a href={recipe.source_url} target="_blank" rel="noopener noreferrer" class="text-blue-600 hover:underline text-sm block mb-4" > View original recipe → </a> )} <div class="flex gap-4 text-sm text-gray-600 mb-6"> <span>{recipe.servings} servings</span> {recipe.prep_time_minutes && <span>Prep: {recipe.prep_time_minutes} min</span>} {recipe.cook_time_minutes && <span>Cook: {recipe.cook_time_minutes} min</span>} </div> <h3 class="text-lg font-semibold mb-2">Ingredients</h3> <ul class="mb-6 space-y-1"> {recipe.ingredients.map((ing) => ( <li key={ing.id} class="text-gray-700"> <span class="font-medium">{ing.quantity}</span> {ing.unit && <span> {ing.unit}</span>} <span> {ing.name}</span> {ing.notes && <span class="text-gray-500"> ({ing.notes})</span>} </li> ))} </ul> <h3 class="text-lg font-semibold mb-2">Instructions</h3> <ol class="space-y-3"> {recipe.instructions.map((inst) => ( <li key={inst.id} class="flex gap-3"> <span class="font-bold text-blue-600">{inst.step_number}.</span> <span class="text-gray-700">{inst.text}</span> </li> ))} </ol> </div> </div> ); }

Step 5: Verify frontend renders

Run: Deploy and hit / Expected: App loads with recipe list and import form

Step 6: Commit

git add frontend/ git commit -m "feat: add React frontend with recipe list and detail views"

Task 8: Shopping List UI

Files:

  • Create: frontend/components/ShoppingList.tsx
  • Modify: frontend/components/App.tsx

Step 1: Create ShoppingList component

/** @jsxImportSource https://esm.sh/react@18.2.0 */ // frontend/components/ShoppingList.tsx import { useState } from "https://esm.sh/react@18.2.0"; import type { Recipe, ShoppingListItem } from "../../shared/types.ts"; interface Props { recipes: Recipe[]; onClose: () => void; } export default function ShoppingList({ recipes, onClose }: Props) { const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set()); const [shoppingList, setShoppingList] = useState<ShoppingListItem[] | null>(null); const [loading, setLoading] = useState(false); const toggleRecipe = (id: number) => { const newSet = new Set(selectedIds); if (newSet.has(id)) { newSet.delete(id); } else { newSet.add(id); } setSelectedIds(newSet); }; const generateList = async () => { if (selectedIds.size === 0) return; setLoading(true); const res = await fetch("/api/shopping-list", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ recipe_ids: Array.from(selectedIds) }), }); const data = await res.json(); setShoppingList(data); setLoading(false); }; return ( <div class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4"> <div class="bg-white rounded-lg shadow-xl max-w-lg w-full max-h-[80vh] overflow-hidden flex flex-col"> <div class="p-4 border-b flex justify-between items-center"> <h2 class="text-xl font-bold">Shopping List</h2> <button onClick={onClose} class="text-gray-500 hover:text-gray-700">✕</button> </div> <div class="p-4 overflow-y-auto flex-1"> {!shoppingList ? ( <> <p class="text-gray-600 mb-4">Select recipes to include:</p> <div class="space-y-2 mb-4"> {recipes.map((recipe) => ( <label key={recipe.id} class="flex items-center gap-2 cursor-pointer"> <input type="checkbox" checked={selectedIds.has(recipe.id)} onChange={() => toggleRecipe(recipe.id)} class="w-4 h-4" /> <span>{recipe.title}</span> </label> ))} </div> <button onClick={generateList} disabled={selectedIds.size === 0 || loading} class="w-full py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50" > {loading ? "Generating..." : "Generate List"} </button> </> ) : ( <> <button onClick={() => setShoppingList(null)} class="text-blue-600 hover:underline mb-4" > ← Back to selection </button> <ul class="space-y-2"> {shoppingList.map((item, idx) => ( <li key={idx} class="flex justify-between items-start"> <div> <span class="font-medium">{item.name}</span> <span class="text-gray-600 ml-2">{item.total_quantity}</span> </div> <span class="text-xs text-gray-400"> {item.recipes.join(", ")} </span> </li> ))} </ul> </> )} </div> </div> </div> ); }

Step 2: Add shopping list button to App

/** @jsxImportSource https://esm.sh/react@18.2.0 */ // frontend/components/App.tsx import { useState, useEffect } from "https://esm.sh/react@18.2.0"; import type { Recipe } from "../../shared/types.ts"; import RecipeList from "./RecipeList.tsx"; import RecipeDetail from "./RecipeDetail.tsx"; import ShoppingList from "./ShoppingList.tsx"; export default function App() { const [recipes, setRecipes] = useState<Recipe[]>([]); const [selectedRecipeId, setSelectedRecipeId] = useState<number | null>(null); const [showShoppingList, setShowShoppingList] = useState(false); const [loading, setLoading] = useState(true); const fetchRecipes = async () => { setLoading(true); const res = await fetch("/api/recipes"); const data = await res.json(); setRecipes(data); setLoading(false); }; useEffect(() => { fetchRecipes(); }, []); if (loading) { return ( <div class="min-h-screen bg-gray-50 flex items-center justify-center"> <p class="text-gray-500">Loading...</p> </div> ); } return ( <div class="min-h-screen bg-gray-50"> <header class="bg-white shadow-sm"> <div class="max-w-4xl mx-auto px-4 py-4 flex justify-between items-center"> <h1 class="text-2xl font-bold text-gray-900 cursor-pointer" onClick={() => setSelectedRecipeId(null)} > Recipes </h1> {recipes.length > 0 && ( <button onClick={() => setShowShoppingList(true)} class="px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700" > Shopping List </button> )} </div> </header> <main class="max-w-4xl mx-auto px-4 py-6"> {selectedRecipeId ? ( <RecipeDetail recipeId={selectedRecipeId} onBack={() => setSelectedRecipeId(null)} onDelete={() => { setSelectedRecipeId(null); fetchRecipes(); }} /> ) : ( <RecipeList recipes={recipes} onSelect={setSelectedRecipeId} onRecipeAdded={fetchRecipes} /> )} </main> {showShoppingList && ( <ShoppingList recipes={recipes} onClose={() => setShowShoppingList(false)} /> )} </div> ); }

Step 3: Test shopping list flow

Run: Deploy, import 2+ recipes, click "Shopping List" Expected: Modal appears, can select recipes and generate combined list

Step 4: Commit

git add frontend/components/ git commit -m "feat: add shopping list UI with recipe selection"

Task 9: Final Polish and Testing

Files:

  • Modify: frontend/index.html (add favicon)
  • Create: frontend/favicon.svg

Step 1: Create favicon

<!-- frontend/favicon.svg --> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"> <text y=".9em" font-size="90">🍳</text> </svg>

Step 2: Add favicon to HTML

<!-- frontend/index.html --> <!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Recipes</title> <link rel="icon" href="/frontend/favicon.svg" type="image/svg+xml"> <script src="https://cdn.twind.style" crossorigin></script> <script src="https://esm.town/v/std/catch"></script> </head> <body> <div id="root"></div> <script type="module" src="/frontend/index.tsx"></script> </body> </html>

Step 3: End-to-end test

Run full test flow:

  1. Open app at /
  2. Import a Hello Fresh recipe URL
  3. View the recipe details
  4. Import another recipe
  5. Generate a shopping list from both recipes
  6. Delete a recipe

Expected: All operations complete without errors

Step 4: Commit

git add frontend/ git commit -m "feat: add favicon and finalize frontend"

Future Enhancements (Out of Scope)

These are natural extensions when needed:

  1. Recipe editing - Edit imported recipes to fix AI extraction errors
  2. Tags/Categories - Add recipe_tags table for organization
  3. Meal planning - Add meal_plans table with date + recipe_id
  4. Unit conversion - Smart aggregation that converts "1 cup + 8 oz" into "2 cups"
  5. PDF import - Add PDF parsing endpoint using a PDF extraction library
  6. Recipe scaling - Adjust servings and recalculate ingredient quantities