Build Planthood Recipe Site with Gantt Charts - #6
Conversation
Implements a complete static site generator for Planthood recipes with: Architecture: - Web scraper for planthood.co.uk cooking instructions - LLM-powered parser (OpenAI/Anthropic/Gemini) for structured recipe steps - Dependency-aware scheduler with Gantt timeline computation - Next.js static site with interactive visualizations - GitHub Actions CI/CD for weekly automated builds Key Features: - Interactive Gantt charts showing cooking timeline and dependencies - Color-coded steps (prep/cook/finish) with duration tracking - LLM caching to minimize API costs - Provider-agnostic design supporting multiple LLM providers - Fully static output compatible with GitHub Pages - Automated weekly scraping and deployment - Responsive design optimized for tablets and desktops Components: - scraper/: Python web scraper for recipe extraction - parser/: LLM-backed recipe step parser with caching - scheduler/: Dependency resolution and timeline computation - site/: Next.js static site with Gantt chart components - .github/workflows/: CI/CD pipeline for automated builds Documentation: - README.md: Complete project documentation - SETUP.md: Step-by-step deployment guide - LICENSE: MIT license The system is designed for minimal cost with free-tier LLM usage, smart caching, and weekly-only rebuilds. Perfect for visualizing complex recipe timelines to make cooking easier and clearer.
Adds essential TypeScript files for the Next.js site: - site/lib/types.ts: Recipe and step type definitions - site/lib/data.ts: Data loading utilities for static generation - data/recipes_with_schedule.json: Empty placeholder for build - data/.cache/.gitkeep: Preserve cache directory structure These files are required for the Next.js static site to build successfully even before any recipes have been scraped.
- Fix TypeScript error: specify timeMarks as number[] type - Add mock_parse.py for testing pipeline without LLM dependencies - Add package-lock.json for reproducible builds This allows testing the full pipeline end-to-end without requiring LLM API calls, useful for development and CI/CD testing.
- Add scheduled recipe data for 3 sample recipes (test data) - Update tsconfig.json with Next.js suggested settings - Add next-env.d.ts (Next.js TypeScript declarations) Sample recipes demonstrate full pipeline functionality: - Super Green Orzo (86 min, Nourish) - Thai Red Curry (111 min, Feast) - Mediterranean Buddha Bowl (87 min, Detox) All recipes include complete Gantt chart timelines with dependency-aware scheduling.
Reviewer's GuideThis PR implements a complete end-to-end static site generator for Planthood recipes, introducing a Python pipeline (scraper → LLM parser → scheduler) and a Next.js frontend with interactive Gantt chart timelines, styled via a new global CSS and deployed via GitHub Actions to GitHub Pages. Class diagram for the Python recipe parsing and scheduling pipelineclassDiagram
class Recipe {
+id: str
+title: str
+source_url: str
+week_label: Optional[str]
+category: Optional[str]
+ingredients: List[str]
+method: str
+nutrition: Optional[Dict[str, float]]
}
class RecipeStep {
+id: str
+raw_text: str
+label: str
+type: str
+estimated_duration_minutes: int
+requires: List[str]
+can_overlap_with: List[str]
+equipment: List[str]
+temperature_c: Optional[int]
+notes: str
}
class ParsedRecipe {
+id: str
+title: str
+source_url: str
+week_label: Optional[str]
+category: Optional[str]
+ingredients: List[str]
+nutrition: Optional[Dict[str, float]]
+steps: List[RecipeStep]
}
class ScheduledStep {
+id: str
+raw_text: str
+label: str
+type: str
+duration_min: int
+start_min: int
+end_min: int
+requires: List[str]
+can_overlap_with: List[str]
+equipment: List[str]
+temperature_c: Optional[int]
+notes: str
}
class ScheduledRecipe {
+id: str
+title: str
+source_url: str
+week_label: Optional[str]
+category: Optional[str]
+ingredients: List[str]
+nutrition: Optional[Dict]
+steps: List[ScheduledStep]
+total_time_min: int
+active_time_min: int
}
RecipeStep <|-- ParsedRecipe : contains
ScheduledStep <|-- ScheduledRecipe : contains
Class diagram for the TypeScript Recipe and RecipeStep types in the Next.js frontendclassDiagram
class RecipeStep {
+id: string
+raw_text: string
+label: string
+type: 'prep' | 'cook' | 'finish'
+duration_min: number
+start_min: number
+end_min: number
+requires: string[]
+can_overlap_with: string[]
+equipment: string[]
+temperature_c?: number
+notes: string
}
class Recipe {
+id: string
+title: string
+source_url: string
+week_label?: string
+category?: string
+ingredients: string[]
+nutrition?: Nutrition
+steps: RecipeStep[]
+total_time_min: number
+active_time_min: number
}
class Nutrition {
+calories?: number
+protein_g?: number
+fat_g?: number
+carbs_g?: number
+fibre_g?: number
+salt_g?: number
}
Nutrition <|-- Recipe : nutrition
RecipeStep <|-- Recipe : steps
File-Level Changes
Tips and commandsInteracting with Sourcery
Customizing Your ExperienceAccess your dashboard to:
Getting Help
|
There was a problem hiding this comment.
Hey there - I've reviewed your changes - here's some feedback:
- The scheduler currently ignores the
can_overlap_withhints and schedules tasks strictly sequentially; consider updating the algorithm to leverage parallelizable steps for more realistic timelines. - The CI workflow marks critical data pipeline steps (scrape, parse, schedule) as
continue-on-error, which can mask failures and deploy empty data; consider failing the build or surfacing errors when those steps fail. - The site uses one massive global CSS file, which can be tough to maintain—consider migrating to CSS modules or component-scoped styles to better organize and encapsulate your styles.
Prompt for AI Agents
Please address the comments from this code review:
## Overall Comments
- The scheduler currently ignores the `can_overlap_with` hints and schedules tasks strictly sequentially; consider updating the algorithm to leverage parallelizable steps for more realistic timelines.
- The CI workflow marks critical data pipeline steps (scrape, parse, schedule) as `continue-on-error`, which can mask failures and deploy empty data; consider failing the build or surfacing errors when those steps fail.
- The site uses one massive global CSS file, which can be tough to maintain—consider migrating to CSS modules or component-scoped styles to better organize and encapsulate your styles.
## Individual Comments
### Comment 1
<location> `planthood-site/site/app/recipe/[id]/page.tsx:29` </location>
<code_context>
+ return (
+ <div className="recipe-page">
+ <div className="recipe-header">
+ <a href="/" className="back-link">← Back to all recipes</a>
+
+ <h1>{recipe.title}</h1>
</code_context>
<issue_to_address>
**suggestion:** Direct use of <a> for navigation may cause full page reloads.
Use Next.js Link instead of <a> to maintain client-side navigation and prevent unnecessary page reloads.
Suggested implementation:
```typescript
import Link from "next/link";
export default function RecipePage({ params }: RecipePageProps) {
```
```typescript
<Link href="/" legacyBehavior>
<a className="back-link">← Back to all recipes</a>
</Link>
```
</issue_to_address>
### Comment 2
<location> `planthood-site/site/components/GanttChart.tsx:28-33` </location>
<code_context>
+ );
+ }
+
+ const maxTime = Math.max(...steps.map(s => s.end_min), 0);
+ const timeMarks: number[] = [];
+ const markInterval = 5;
</code_context>
<issue_to_address>
**suggestion:** No handling for steps with zero or undefined duration.
Add a check to prevent division by zero when maxTime is zero, and handle cases where step durations are undefined or zero appropriately.
```suggestion
// Filter out steps with undefined or zero end_min
const validSteps = steps.filter(s => typeof s.end_min === 'number' && s.end_min > 0);
const maxTime = validSteps.length > 0 ? Math.max(...validSteps.map(s => s.end_min)) : 0;
// Prevent division by zero and handle cases with no valid durations
if (maxTime === 0) {
return (
<div className="gantt-empty">
No valid timeline durations available
</div>
);
}
const timeMarks: number[] = [];
const markInterval = 5;
for (let i = 0; i <= maxTime; i += markInterval) {
timeMarks.push(i);
}
```
</issue_to_address>
### Comment 3
<location> `planthood-site/site/components/GanttChart.tsx:107` </location>
<code_context>
+ <div>
+ <strong>Time:</strong> {selectedStep.start_min}–{selectedStep.end_min} min
+ </div>
+ {selectedStep.equipment.length > 0 && (
+ <div>
+ <strong>Equipment:</strong> {selectedStep.equipment.join(', ')}
</code_context>
<issue_to_address>
**issue (bug_risk):** Assumes equipment is always an array; may throw if undefined.
Accessing length on undefined will cause a runtime error. Use optional chaining or default to an empty array to prevent this.
</issue_to_address>
### Comment 4
<location> `planthood-site/site/components/RecipeCard.tsx:37-44` </location>
<code_context>
+ </div>
+ </div>
+
+ {recipe.nutrition && (
+ <div className="recipe-nutrition">
+ {recipe.nutrition.calories && (
+ <span>{recipe.nutrition.calories} kcal</span>
+ )}
+ {recipe.nutrition.protein_g && (
+ <span>Protein: {recipe.nutrition.protein_g}g</span>
+ )}
</code_context>
<issue_to_address>
**suggestion (bug_risk):** Nutrition fields may be zero but still valid.
Replace '&&' with a check for undefined to ensure zero values are displayed, e.g., 'recipe.nutrition.calories !== undefined'.
```suggestion
{recipe.nutrition && (
<div className="recipe-nutrition">
{recipe.nutrition.calories !== undefined && (
<span>{recipe.nutrition.calories} kcal</span>
)}
{recipe.nutrition.protein_g !== undefined && (
<span>Protein: {recipe.nutrition.protein_g}g</span>
)}
```
</issue_to_address>
### Comment 5
<location> `planthood-site/site/app/layout.tsx:25` </location>
<code_context>
+ </div>
+ </header>
+
+ <main className="site-main">
+ <div className="container">
+ {children}
</code_context>
<issue_to_address>
**nitpick:** Site-main class is not defined in globals.css.
Please define the 'site-main' class in globals.css or remove its usage if it's not needed.
</issue_to_address>
### Comment 6
<location> `planthood-site/site/components/GanttChart.tsx:35` </location>
<code_context>
+ timeMarks.push(i);
+ }
+
+ const renderHorizontalChart = () => (
+ <div className="gantt-chart horizontal">
+ <div className="gantt-controls">
</code_context>
<issue_to_address>
**issue (complexity):** Consider refactoring the chart rendering logic into reusable sub-components to eliminate duplication and centralize orientation handling.
You can collapse both render functions into a single `Chart` component by extracting three sub‐components—`Axis`, `StepBars`, and `Details`—and driving orientation through props (and CSS modifiers). Example:
```tsx
// Axis.tsx
interface AxisProps {
timeMarks: number[]
maxTime: number
orientation: 'horizontal' | 'vertical'
}
export function Axis({ timeMarks, maxTime, orientation }: AxisProps) {
return (
<div className={`gantt-axis gantt-axis--${orientation}`}>
{timeMarks.map(mark => {
const percent = (mark / maxTime) * 100 + '%'
const style =
orientation === 'horizontal'
? { left: percent }
: { top: percent }
return (
<div key={mark} className="gantt-axis-mark" style={style}>
<span>{mark}{orientation === 'horizontal' ? ' min' : ''}</span>
</div>
)
})}
{orientation === 'vertical' && <div className="gantt-axis-label">Minutes</div>}
</div>
)
}
```
```tsx
// StepBars.tsx
interface StepBarsProps {
steps: RecipeStep[]
maxTime: number
selected: RecipeStep | null
onSelect: (s: RecipeStep) => void
orientation: 'horizontal' | 'vertical'
}
export function StepBars({ steps, maxTime, selected, onSelect, orientation }: StepBarsProps) {
return (
<div className={`gantt-steps gantt-steps--${orientation}`}>
{steps.map(step => {
const startPct = (step.start_min / maxTime) * 100 + '%'
const sizePct = (step.duration_min / maxTime) * 100 + '%'
const posStyle = orientation === 'horizontal'
? { left: startPct, width: sizePct }
: { top: startPct, height: sizePct }
return (
<div
key={step.id}
className={`gantt-step${selected?.id === step.id ? ' is-selected' : ''}`}
onClick={() => onSelect(step)}
>
<div
className={`gantt-step-bar gantt-step-bar--${orientation}`}
style={{ ...posStyle, backgroundColor: STEP_TYPE_COLORS[step.type] }}
>
<span className="gantt-step-label">{step.label}</span>
<span className="gantt-step-duration">
{orientation === 'horizontal' ? `${step.duration_min} MIN` : `${step.duration_min}m`}
</span>
</div>
</div>
)
})}
</div>
)
}
```
```tsx
// Details.tsx (unchanged)
export function Details({ step, onClose }: { step: RecipeStep; onClose: () => void }) {
/* ... existing markup ... */
}
```
```tsx
// GanttChart.tsx
export default function GanttChart({ steps }: GanttChartProps) {
const [sel, setSel] = useState<RecipeStep | null>(null)
const [ori, setOri] = useState<'horizontal' | 'vertical'>('horizontal')
const maxTime = Math.max(...steps.map(s => s.end_min), 0)
const timeMarks = Array.from({ length: Math.floor(maxTime / 5) + 1 }, (_, i) => i * 5)
if (!steps.length) return <div className="gantt-empty">No timeline data available</div>
return (
<div className={`gantt-chart gantt-chart--${ori}`}>
<div className="gantt-controls">
<button onClick={() => setOri(ori === 'horizontal' ? 'vertical' : 'horizontal')}>
Switch to {ori === 'horizontal' ? 'Vertical' : 'Horizontal'}
</button>
</div>
<Axis timeMarks={timeMarks} maxTime={maxTime} orientation={ori} />
<StepBars
steps={steps}
maxTime={maxTime}
selected={sel}
onSelect={setSel}
orientation={ori}
/>
{sel && <Details step={sel} onClose={() => setSel(null)} />}
</div>
)
}
```
This reduces duplication, keeps all functionality, and lets you tweak axis/step/details in one place.
</issue_to_address>
### Comment 7
<location> `planthood-site/parser/llm_providers.py:29` </location>
<code_context>
+ pass
+
+
+class OpenAIProvider(LLMProvider):
+ """OpenAI GPT provider"""
+
</code_context>
<issue_to_address>
**issue (complexity):** Consider refactoring the provider classes to move shared initialization logic into the base class and only override provider-specific details in subclasses.
```markdown
You can DRY-up most of the boilerplate in `__init__` and `get_name` by moving it into the base class and having each subclass only supply its env-var names and client init. For example:
```python
# base class
class LLMProvider(ABC):
def __init__(
self,
api_key_env: str,
model_env: str,
default_model: str,
api_key: str | None = None,
model: str | None = None,
):
self.api_key = api_key or os.getenv(api_key_env)
if not self.api_key:
raise ValueError(f"{api_key_env} not set")
self.model = model or os.getenv(model_env, default_model)
self._init_client()
@abstractmethod
def _init_client(self):
"""Provider-specific client setup."""
def get_name(self) -> str:
return f"{self.PROVIDER_NAME}:{self.model}"
```
Then each provider becomes much slimmer:
```python
class OpenAIProvider(LLMProvider):
PROVIDER_NAME = "openai"
def __init__(self, api_key=None, model=None):
super().__init__(
api_key_env="OPENAI_API_KEY",
model_env="OPENAI_MODEL",
default_model="gpt-4o-mini",
api_key=api_key,
model=model,
)
def _init_client(self):
from openai import OpenAI
self.client = OpenAI(api_key=self.api_key)
def generate(self, prompt: str, system_prompt: str | None = None) -> str:
msgs = []
if system_prompt:
msgs.append({"role": "system", "content": system_prompt})
msgs.append({"role": "user", "content": prompt})
resp = self.client.chat.completions.create(
model=self.model, messages=msgs, temperature=0.1
)
return resp.choices[0].message.content
```
You can apply the same pattern to `AnthropicProvider` and `GeminiProvider`, only overriding `_init_client` and `generate`. This cuts out ~50 lines of duplication while preserving every bit of functionality.
</issue_to_address>
### Comment 8
<location> `planthood-site/scraper/scrape.py:190` </location>
<code_context>
nutrition=nutrition if nutrition else None
</code_context>
<issue_to_address>
**suggestion (code-quality):** Replace if-expression with `or` ([`or-if-exp-identity`](https://docs.sourcery.ai/Reference/Rules-and-In-Line-Suggestions/Python/Default-Rules/or-if-exp-identity))
```suggestion
nutrition=nutrition or None
```
<br/><details><summary>Explanation</summary>Here we find ourselves setting a value if it evaluates to `True`, and otherwise
using a default.
The 'After' case is a bit easier to read and avoids the duplication of
`input_currency`.
It works because the left-hand side is evaluated first. If it evaluates to
true then `currency` will be set to this and the right-hand side will not be
evaluated. If it evaluates to false the right-hand side will be evaluated and
`currency` will be set to `DEFAULT_CURRENCY`.
</details>
</issue_to_address>
### Comment 9
<location> `planthood-site/parser/llm_providers.py:101-104` </location>
<code_context>
def generate(self, prompt: str, system_prompt: Optional[str] = None) -> str:
full_prompt = prompt
if system_prompt:
full_prompt = f"{system_prompt}\n\n{prompt}"
response = self.model.generate_content(
full_prompt,
generation_config={"temperature": 0.1},
)
return response.text
</code_context>
<issue_to_address>
**suggestion (code-quality):** We've found these issues:
- Move setting of default value for variable into `else` branch ([`introduce-default-else`](https://docs.sourcery.ai/Reference/Default-Rules/refactorings/introduce-default-else/))
- Replace if statement with if expression ([`assign-if-exp`](https://docs.sourcery.ai/Reference/Default-Rules/refactorings/assign-if-exp/))
```suggestion
full_prompt = f"{system_prompt}\n\n{prompt}" if system_prompt else prompt
```
</issue_to_address>
### Comment 10
<location> `planthood-site/parser/llm_providers.py:138` </location>
<code_context>
def get_llm_provider(
provider_name: Optional[str] = None,
api_key: Optional[str] = None,
model: Optional[str] = None,
) -> LLMProvider:
"""Factory function to get configured LLM provider"""
provider_name = provider_name or os.getenv("LLM_PROVIDER", "openai")
provider_name = provider_name.lower()
providers = {
"openai": OpenAIProvider,
"anthropic": AnthropicProvider,
"gemini": GeminiProvider,
}
if provider_name not in providers:
raise ValueError(
f"Unknown provider: {provider_name}. Choose from: {', '.join(providers.keys())}"
)
try:
return providers[provider_name](api_key=api_key, model=model)
except Exception as e:
raise RuntimeError(f"Failed to initialize {provider_name} provider: {e}")
</code_context>
<issue_to_address>
**suggestion (code-quality):** Explicitly raise from a previous error ([`raise-from-previous-error`](https://docs.sourcery.ai/Reference/Default-Rules/suggestions/raise-from-previous-error/))
```suggestion
raise RuntimeError(
f"Failed to initialize {provider_name} provider: {e}"
) from e
```
</issue_to_address>
### Comment 11
<location> `planthood-site/parser/mock_parse.py:59` </location>
<code_context>
def extract_steps_from_method(self, method: str) -> List[Dict]:
"""Extract steps from method text using simple sentence splitting"""
# Split on sentence boundaries
sentences = re.split(r'(?<=[.!?])\s+', method)
steps = []
step_num = 1
for sentence in sentences:
if len(sentence.strip()) < 10:
continue
# Determine step type
step_type = "cook"
if any(word in sentence.lower() for word in ["preheat", "prepare", "rinse", "chop", "dice", "slice", "mince"]):
step_type = "prep"
elif any(word in sentence.lower() for word in ["serve", "garnish", "plate", "drizzle"]):
step_type = "finish"
# Extract duration
duration = 10 # default
time_match = re.search(r'(\d+)[-–]?(\d+)?\s*(minute|min)', sentence.lower())
if time_match:
if time_match.group(2):
# Range like "8-10 minutes"
duration = (int(time_match.group(1)) + int(time_match.group(2))) // 2
else:
duration = int(time_match.group(1))
# Extract temperature
temp = None
temp_match = re.search(r'(\d+)\s*°C', sentence)
if temp_match:
temp = int(temp_match.group(1))
# Extract equipment
equipment = []
equip_keywords = ["oven", "pan", "pot", "bowl", "tray", "wok", "saucepan", "frying pan"]
for equip in equip_keywords:
if equip in sentence.lower():
equipment.append(equip)
# Create step label (first 50 chars or until comma/period)
label = sentence[:50]
if ',' in label:
label = label[:label.index(',')]
elif '.' in label:
label = label[:label.index('.')]
step = {
"id": f"step-{step_num}",
"raw_text": sentence.strip(),
"label": label.strip(),
"type": step_type,
"estimated_duration_minutes": duration,
"requires": [f"step-{step_num-1}"] if step_num > 1 and step_type != "prep" else [],
"can_overlap_with": [],
"equipment": equipment,
"temperature_c": temp,
"notes": ""
}
steps.append(step)
step_num += 1
return steps
</code_context>
<issue_to_address>
**issue (code-quality):** We've found these issues:
- Use named expression to simplify assignment and conditional [×2] ([`use-named-expression`](https://docs.sourcery.ai/Reference/Default-Rules/refactorings/use-named-expression/))
- Replace m.group(x) with m[x] for re.Match objects [×3] ([`use-getitem-for-re-match-groups`](https://docs.sourcery.ai/Reference/Default-Rules/suggestions/use-getitem-for-re-match-groups/))
</issue_to_address>
### Comment 12
<location> `planthood-site/parser/mock_parse.py:118` </location>
<code_context>
def parse_recipe(self, recipe: Dict) -> ParsedRecipe:
"""Parse a single recipe"""
method = recipe.get('method', '').strip()
steps_data = []
if method:
steps_data = self.extract_steps_from_method(method)
steps = [RecipeStep(**step) for step in steps_data]
return ParsedRecipe(
id=recipe['id'],
title=recipe['title'],
source_url=recipe['source_url'],
week_label=recipe.get('week_label'),
category=recipe.get('category'),
ingredients=recipe.get('ingredients', []),
nutrition=recipe.get('nutrition'),
steps=steps,
)
</code_context>
<issue_to_address>
**issue (code-quality):** We've found these issues:
- Move setting of default value for variable into `else` branch ([`introduce-default-else`](https://docs.sourcery.ai/Reference/Default-Rules/refactorings/introduce-default-else/))
- Use named expression to simplify assignment and conditional ([`use-named-expression`](https://docs.sourcery.ai/Reference/Default-Rules/refactorings/use-named-expression/))
</issue_to_address>
### Comment 13
<location> `planthood-site/parser/parse.py:156` </location>
<code_context>
def parse_recipe_steps(self, recipe: Dict) -> List[RecipeStep]:
"""Parse recipe method into structured steps"""
recipe_id = recipe['id']
method = recipe.get('method', '').strip()
if not method:
print(f"⚠️ No method text for {recipe_id}, skipping")
return []
# Check cache
cached_steps = self.cache.get(recipe_id, method)
if cached_steps:
print(f"✓ Cache hit for {recipe_id}")
return [RecipeStep(**step) for step in cached_steps]
# Build prompt
ingredients_text = '\n'.join(f"- {ing}" for ing in recipe.get('ingredients', []))
prompt = self.USER_PROMPT_TEMPLATE.format(
title=recipe['title'],
ingredients=ingredients_text or "(not provided)",
method=method,
)
try:
print(f"🤖 Parsing {recipe_id} with LLM...")
response = self._call_llm(prompt)
# Extract JSON from response (in case LLM adds extra text)
response = response.strip()
if response.startswith('```'):
# Remove markdown code blocks
lines = response.split('\n')
response = '\n'.join(line for line in lines if not line.startswith('```'))
steps_data = json.loads(response)
# Validate and create RecipeStep objects
steps = []
for i, step_data in enumerate(steps_data, 1):
# Ensure ID is set
if 'id' not in step_data:
step_data['id'] = f"step-{i}"
# Ensure raw_text is set
if 'raw_text' not in step_data:
step_data['raw_text'] = step_data.get('label', '')
# Validate required fields
required = ['label', 'type', 'estimated_duration_minutes']
if not all(k in step_data for k in required):
print(f"⚠️ Step {i} missing required fields, skipping")
continue
steps.append(RecipeStep(**step_data))
# Cache successful parse
self.cache.set(recipe_id, method, [asdict(s) for s in steps])
print(f"✓ Parsed {len(steps)} steps for {recipe_id}")
return steps
except json.JSONDecodeError as e:
print(f"❌ Failed to parse JSON response for {recipe_id}: {e}")
print(f"Response: {response[:200]}...")
return []
except Exception as e:
print(f"❌ Error parsing {recipe_id}: {e}")
return []
</code_context>
<issue_to_address>
**issue (code-quality):** We've found these issues:
- Use named expression to simplify assignment and conditional ([`use-named-expression`](https://docs.sourcery.ai/Reference/Default-Rules/refactorings/use-named-expression/))
- Invert any/all to simplify comparisons ([`invert-any-all`](https://docs.sourcery.ai/Reference/Default-Rules/refactorings/invert-any-all/))
</issue_to_address>
### Comment 14
<location> `planthood-site/scheduler/schedule.py:93` </location>
<code_context>
def topological_sort(self, steps: List[Dict]) -> List[str]:
"""
Topologically sort steps based on dependencies.
Returns ordered list of step IDs.
"""
# Build adjacency list and in-degree map
graph = defaultdict(list)
in_degree = defaultdict(int)
all_step_ids = {step['id'] for step in steps}
# Initialize in-degree for all steps
for step in steps:
if step['id'] not in in_degree:
in_degree[step['id']] = 0
# Build graph
for step in steps:
step_id = step['id']
for dep in step.get('requires', []):
if dep in all_step_ids:
graph[dep].append(step_id)
in_degree[step_id] += 1
# Kahn's algorithm for topological sort
queue = deque([sid for sid in all_step_ids if in_degree[sid] == 0])
sorted_steps = []
while queue:
current = queue.popleft()
sorted_steps.append(current)
for neighbor in graph[current]:
in_degree[neighbor] -= 1
if in_degree[neighbor] == 0:
queue.append(neighbor)
# Check for cycles
if len(sorted_steps) != len(all_step_ids):
print(f"⚠️ Warning: Cycle detected in dependencies. Using fallback ordering.")
# Fallback: use original order
return [step['id'] for step in steps]
return sorted_steps
</code_context>
<issue_to_address>
**suggestion (code-quality):** Replace f-string with no interpolated values with string ([`remove-redundant-fstring`](https://docs.sourcery.ai/Reference/Default-Rules/refactorings/remove-redundant-fstring/))
```suggestion
print("⚠️ Warning: Cycle detected in dependencies. Using fallback ordering.")
```
</issue_to_address>
### Comment 15
<location> `planthood-site/scraper/scrape.py:63-68` </location>
<code_context>
def fetch_page(self, url: str) -> Optional[BeautifulSoup]:
"""Fetch and parse a page with rate limiting"""
if url in self.visited_urls:
return None
try:
print(f"Fetching: {url}")
response = self.session.get(url, timeout=30)
response.raise_for_status()
self.visited_urls.add(url)
time.sleep(REQUEST_DELAY)
return BeautifulSoup(response.text, 'lxml')
except Exception as e:
print(f"Error fetching {url}: {e}")
return None
</code_context>
<issue_to_address>
**issue (code-quality):** Extract code out into method ([`extract-method`](https://docs.sourcery.ai/Reference/Default-Rules/refactorings/extract-method/))
</issue_to_address>
### Comment 16
<location> `planthood-site/scraper/scrape.py:92` </location>
<code_context>
def extract_recipe(self, url: str) -> Optional[Recipe]:
"""Extract recipe data from a recipe page"""
soup = self.fetch_page(url)
if not soup:
return None
try:
# Extract recipe ID from URL
recipe_id = url.split('/products/')[-1].split('?')[0]
# Extract title
title_elem = soup.find('h1', class_='product-single__title') or soup.find('h1')
title = title_elem.get_text(strip=True) if title_elem else recipe_id.replace('-', ' ').title()
# Extract week label (if present in product description or tags)
week_label = None
week_patterns = [
r'(?:MENU|Delivery)\s*(?:\||w/c)\s*(?:DELIVERED\s*)?([A-Z][a-z]+\s+\d{1,2}(?:st|nd|rd|th)?\s*[A-Z][a-z]+\s*\d{4})',
r'(?:Week of|w/c)\s+(\d{1,2}/\d{1,2}/\d{4})',
]
page_text = soup.get_text()
for pattern in week_patterns:
match = re.search(pattern, page_text, re.IGNORECASE)
if match:
week_label = match.group(1) if match.lastindex else match.group(0)
break
# Extract category (Detox/Nourish/Feast) if mentioned
category = None
for cat in ['Detox', 'Nourish', 'Feast', 'Cleanse']:
if cat.lower() in page_text.lower():
category = cat
break
# Extract ingredients
ingredients = []
ingredients_section = soup.find(['div', 'section'], class_=lambda x: x and 'ingredient' in x.lower()) if soup else None
if not ingredients_section:
# Try alternative selectors
for header in soup.find_all(['h2', 'h3', 'strong']):
if 'ingredient' in header.get_text().lower():
ingredients_section = header.find_next(['ul', 'div'])
break
if ingredients_section:
for li in ingredients_section.find_all('li'):
ing_text = li.get_text(strip=True)
if ing_text:
ingredients.append(ing_text)
# Extract method/instructions
method = ""
method_section = soup.find(['div', 'section'], class_=lambda x: x and ('method' in str(x).lower() or 'instruction' in str(x).lower()))
if not method_section:
# Try finding by header
for header in soup.find_all(['h2', 'h3', 'strong']):
header_text = header.get_text().lower()
if 'method' in header_text or 'instruction' in header_text or 'how to' in header_text:
# Get the next sibling(s) until next header
method_parts = []
for sibling in header.find_next_siblings():
if sibling.name in ['h2', 'h3']:
break
text = sibling.get_text(strip=True)
if text:
method_parts.append(text)
method = '\n'.join(method_parts)
break
else:
method = method_section.get_text(separator='\n', strip=True)
# Extract nutrition info
nutrition = {}
nutrition_patterns = {
'calories': r'(\d+)\s*kcal',
'protein_g': r'Protein[:\s]*(\d+\.?\d*)g',
'fat_g': r'Fat[:\s]*(\d+\.?\d*)g',
'carbs_g': r'Carb(?:ohydrate)?s?[:\s]*(\d+\.?\d*)g',
'fibre_g': r'Fibre[:\s]*(\d+\.?\d*)g',
'salt_g': r'Salt[:\s]*(\d+\.?\d*)g',
}
for key, pattern in nutrition_patterns.items():
match = re.search(pattern, page_text, re.IGNORECASE)
if match:
try:
nutrition[key] = float(match.group(1))
except ValueError:
pass
recipe = Recipe(
id=recipe_id,
title=title,
source_url=url,
week_label=week_label,
category=category,
ingredients=ingredients,
method=method,
nutrition=nutrition if nutrition else None
)
print(f"Extracted recipe: {title}")
return recipe
except Exception as e:
print(f"Error extracting recipe from {url}: {e}")
return None
</code_context>
<issue_to_address>
**issue (code-quality):** We've found these issues:
- Use named expression to simplify assignment and conditional [×4] ([`use-named-expression`](https://docs.sourcery.ai/Reference/Default-Rules/refactorings/use-named-expression/))
- Use the built-in function `next` instead of a for-loop ([`use-next`](https://docs.sourcery.ai/Reference/Default-Rules/refactorings/use-next/))
- Replace m.group(x) with m[x] for re.Match objects [×2] ([`use-getitem-for-re-match-groups`](https://docs.sourcery.ai/Reference/Default-Rules/suggestions/use-getitem-for-re-match-groups/))
- Low code quality found in PlanthoodScraper.extract\_recipe - 8% ([`low-code-quality`](https://docs.sourcery.ai/Reference/Default-Rules/comments/low-code-quality/))
<br/><details><summary>Explanation</summary>
The quality score for this function is below the quality threshold of 25%.
This score is a combination of the method length, cognitive complexity and working memory.
How can you solve this?
It might be worth refactoring this function to make it shorter and more readable.
- Reduce the function length by extracting pieces of functionality out into
their own functions. This is the most important thing you can do - ideally a
function should be less than 10 lines.
- Reduce nesting, perhaps by introducing guard clauses to return early.
- Ensure that variables are tightly scoped, so that code using related concepts
sits together within the function rather than being scattered.</details>
</issue_to_address>
### Comment 17
<location> `planthood-site/scraper/scrape.py:206-207` </location>
<code_context>
def scrape_all(self) -> List[Recipe]:
"""Scrape all recipes from Planthood"""
recipe_urls = self.discover_recipe_urls()
recipes = []
for url in recipe_urls:
recipe = self.extract_recipe(url)
if recipe:
recipes.append(recipe)
return recipes
</code_context>
<issue_to_address>
**suggestion (code-quality):** Use named expression to simplify assignment and conditional ([`use-named-expression`](https://docs.sourcery.ai/Reference/Default-Rules/refactorings/use-named-expression/))
```suggestion
if recipe := self.extract_recipe(url):
```
</issue_to_address>Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.
| return ( | ||
| <div className="recipe-page"> | ||
| <div className="recipe-header"> | ||
| <a href="/" className="back-link">← Back to all recipes</a> |
There was a problem hiding this comment.
suggestion: Direct use of for navigation may cause full page reloads.
Use Next.js Link instead of to maintain client-side navigation and prevent unnecessary page reloads.
Suggested implementation:
import Link from "next/link";
export default function RecipePage({ params }: RecipePageProps) { <Link href="/" legacyBehavior>
<a className="back-link">← Back to all recipes</a>
</Link>| const maxTime = Math.max(...steps.map(s => s.end_min), 0); | ||
| const timeMarks: number[] = []; | ||
| const markInterval = 5; | ||
| for (let i = 0; i <= maxTime; i += markInterval) { | ||
| timeMarks.push(i); | ||
| } |
There was a problem hiding this comment.
suggestion: No handling for steps with zero or undefined duration.
Add a check to prevent division by zero when maxTime is zero, and handle cases where step durations are undefined or zero appropriately.
| const maxTime = Math.max(...steps.map(s => s.end_min), 0); | |
| const timeMarks: number[] = []; | |
| const markInterval = 5; | |
| for (let i = 0; i <= maxTime; i += markInterval) { | |
| timeMarks.push(i); | |
| } | |
| // Filter out steps with undefined or zero end_min | |
| const validSteps = steps.filter(s => typeof s.end_min === 'number' && s.end_min > 0); | |
| const maxTime = validSteps.length > 0 ? Math.max(...validSteps.map(s => s.end_min)) : 0; | |
| // Prevent division by zero and handle cases with no valid durations | |
| if (maxTime === 0) { | |
| return ( | |
| <div className="gantt-empty"> | |
| No valid timeline durations available | |
| </div> | |
| ); | |
| } | |
| const timeMarks: number[] = []; | |
| const markInterval = 5; | |
| for (let i = 0; i <= maxTime; i += markInterval) { | |
| timeMarks.push(i); | |
| } |
| <div> | ||
| <strong>Time:</strong> {selectedStep.start_min}–{selectedStep.end_min} min | ||
| </div> | ||
| {selectedStep.equipment.length > 0 && ( |
There was a problem hiding this comment.
issue (bug_risk): Assumes equipment is always an array; may throw if undefined.
Accessing length on undefined will cause a runtime error. Use optional chaining or default to an empty array to prevent this.
| {recipe.nutrition && ( | ||
| <div className="recipe-nutrition"> | ||
| {recipe.nutrition.calories && ( | ||
| <span>{recipe.nutrition.calories} kcal</span> | ||
| )} | ||
| {recipe.nutrition.protein_g && ( | ||
| <span>Protein: {recipe.nutrition.protein_g}g</span> | ||
| )} |
There was a problem hiding this comment.
suggestion (bug_risk): Nutrition fields may be zero but still valid.
Replace '&&' with a check for undefined to ensure zero values are displayed, e.g., 'recipe.nutrition.calories !== undefined'.
| {recipe.nutrition && ( | |
| <div className="recipe-nutrition"> | |
| {recipe.nutrition.calories && ( | |
| <span>{recipe.nutrition.calories} kcal</span> | |
| )} | |
| {recipe.nutrition.protein_g && ( | |
| <span>Protein: {recipe.nutrition.protein_g}g</span> | |
| )} | |
| {recipe.nutrition && ( | |
| <div className="recipe-nutrition"> | |
| {recipe.nutrition.calories !== undefined && ( | |
| <span>{recipe.nutrition.calories} kcal</span> | |
| )} | |
| {recipe.nutrition.protein_g !== undefined && ( | |
| <span>Protein: {recipe.nutrition.protein_g}g</span> | |
| )} |
| </div> | ||
| </header> | ||
|
|
||
| <main className="site-main"> |
There was a problem hiding this comment.
nitpick: Site-main class is not defined in globals.css.
Please define the 'site-main' class in globals.css or remove its usage if it's not needed.
| def parse_recipe_steps(self, recipe: Dict) -> List[RecipeStep]: | ||
| """Parse recipe method into structured steps""" | ||
| recipe_id = recipe['id'] | ||
| method = recipe.get('method', '').strip() |
There was a problem hiding this comment.
issue (code-quality): We've found these issues:
- Use named expression to simplify assignment and conditional (
use-named-expression) - Invert any/all to simplify comparisons (
invert-any-all)
| print(f"Fetching: {url}") | ||
| response = self.session.get(url, timeout=30) | ||
| response.raise_for_status() | ||
| self.visited_urls.add(url) | ||
| time.sleep(REQUEST_DELAY) | ||
| return BeautifulSoup(response.text, 'lxml') |
There was a problem hiding this comment.
issue (code-quality): Extract code out into method (extract-method)
| recipe = self.extract_recipe(url) | ||
| if recipe: |
There was a problem hiding this comment.
suggestion (code-quality): Use named expression to simplify assignment and conditional (use-named-expression)
| recipe = self.extract_recipe(url) | |
| if recipe: | |
| if recipe := self.extract_recipe(url): |
CI/CD Fixes: - Add working-directory to all workflow steps for planthood-site/ subdir - Update cache paths for pip and npm to include planthood-site/ prefix - Change permissions from 'contents: read' to 'contents: write' for data commits - Add .nojekyll file to public/ for proper GitHub Pages serving This ensures the workflow can: - Install dependencies from correct paths - Run scripts in the right directories - Upload the built site from correct location - Commit scraped data back to the repository Fixes GitHub Actions failures when deploying to Pages.
Add DEPLOYMENT.md with: - Step-by-step deployment instructions - GitHub Secrets configuration - Troubleshooting common issues - Cost estimates for LLM providers - Weekly automation schedule - Quick reference commands Makes it easier for users to deploy and maintain the site.
Pre-commit hook check-shebang-scripts-are-executable requires scripts with shebangs to have executable permissions. Made executable: - scraper/scrape.py - parser/parse.py - parser/mock_parse.py - scheduler/schedule.py This fixes CI pre-commit failures.
Pre-commit hook fixes: - Run ruff-format on all Python files (black-style formatting) - Fix end-of-file newlines in JSON files - Add 'nd' to codespell ignore list (used in regex patterns for ordinals) Changes: - Reformatted Python files with consistent spacing and quotes - Added final newlines to data/recipes_with_schedule.json - Updated codespell-ignore.txt to allow 'nd' in ordinal patterns All pre-commit checks now pass.
Pre-commit end-of-file-fixer added missing final newline.
Pylint fixes to achieve 10.00/10 rating: scraper/scrape.py: - Remove unused urlparse import - Extract nested method extraction logic into _extract_method_from_headers() helper method to reduce nesting from 6 to 4 levels - Resolves R1702: too-many-nested-blocks parser/parse.py: - Remove unused Any import from typing - Fix import to use relative import with fallback - Add pylint disable comments for false-positive deprecated-module warning (our local parser module, not built-in deprecated parser) parser/llm_providers.py: - Remove unnecessary pass statements from abstract methods - Fix exception chaining with 'raise ... from e' (W0707) scheduler/schedule.py: - Remove unused Set import from typing - Convert f-string without interpolation to regular string All pylint checks now pass with 10.00/10 rating.
Ruff reformatted multi-line list comprehension to single line for better readability.
Summary by Sourcery
Provide a complete static website generator for Planthood recipes that scrapes and parses recipe data weekly using LLMs, computes cooking schedules into interactive Gantt charts, and deploys an optimized, responsive Next.js site to GitHub Pages.
New Features:
Enhancements:
Build:
CI: