Skip to content

Build Planthood Recipe Site with Gantt Charts - #6

Open
blooop wants to merge 11 commits into
mainfrom
claude/planthood-recipe-site-generator-01Nk4ceaZggng9EimX3JNLvT
Open

Build Planthood Recipe Site with Gantt Charts#6
blooop wants to merge 11 commits into
mainfrom
claude/planthood-recipe-site-generator-01Nk4ceaZggng9EimX3JNLvT

Conversation

@blooop

@blooop blooop commented Nov 14, 2025

Copy link
Copy Markdown
Owner

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:

  • Add end-to-end pipeline: scraper fetches Planthood recipes, LLM-based parser extracts structured, dependency-aware steps, and scheduler computes Gantt timelines
  • Implement a Next.js static site with recipe listings, detailed pages, step-by-step instructions, and interactive Gantt charts with horizontal/vertical views

Enhancements:

  • Introduce global CSS and responsive styling for consistent UI and recipe card components
  • Include comprehensive documentation (README and SETUP) covering architecture, configuration, and usage

Build:

  • Define Python and Node.js dependencies and scripts for data pipeline and static site generation

CI:

  • Add GitHub Actions workflow to automate weekly scraping, parsing, scheduling, building, and deployment to GitHub Pages

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.
@sourcery-ai

sourcery-ai Bot commented Nov 14, 2025

Copy link
Copy Markdown

Reviewer's Guide

This 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 pipeline

classDiagram
    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
Loading

Class diagram for the TypeScript Recipe and RecipeStep types in the Next.js frontend

classDiagram
    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
Loading

File-Level Changes

Change Details Files
Introduce global styling and responsive layout
  • Add comprehensive globals.css with color variables, layout, components, Gantt styles, and media queries
  • Integrate CSS into Next.js RootLayout for header, footer, hero, recipe cards, stats, and timeline sections
site/app/globals.css
site/app/layout.tsx
site/app/page.tsx
site/app/recipe/[id]/page.tsx
site/components/RecipeCard.tsx
site/components/GanttChart.tsx
Add Python data-pipeline modules
  • Implement scraper using requests and BeautifulSoup to fetch and extract raw recipe data
  • Build LLM-backed parser with caching, dataclasses, retry logic, and JSON output
  • Develop scheduler with topological sort and timeline computation for Gantt charts
  • Include a rule-based mock parser for offline testing
scraper/scrape.py
parser/parse.py
parser/mock_parse.py
scheduler/schedule.py
parser/llm_providers.py
requirements.txt
Create Next.js frontend with data utilities and types
  • Define TypeScript types and data loaders to read scheduled JSON data
  • Generate static routes for recipe listing and detail pages
  • Render recipe listings, week selectors, nutrition, ingredients, steps, and Gantt chart components
site/lib/types.ts
site/lib/data.ts
site/app/page.tsx
site/app/recipe/[id]/page.tsx
site/components/RecipeCard.tsx
site/components/GanttChart.tsx
Configure project metadata, scripts, and dependencies
  • Add root and site package.json with scripts for scrape/parse/schedule/build and Next.js commands
  • List Python dependencies in requirements.txt and .env.example for environment configuration
  • Add tsconfig.json and next.config.js for TypeScript and export settings
package.json
site/package.json
requirements.txt
.env.example
site/tsconfig.json
site/next.config.js
Provide comprehensive documentation and setup guides
  • Draft README.md with architecture diagram, features, quickstart, config, and troubleshooting
  • Add SETUP.md detailing GitHub Pages deployment, secrets, and local development
  • Include code comments and examples for data formats and future enhancements
README.md
SETUP.md
Implement CI/CD workflow for automated builds and deployment
  • Create GitHub Actions workflow to run scraper, parser, scheduler, build Next.js site, and deploy to GitHub Pages
  • Configure caching, concurrency, environment permissions, and conditional commits of updated data
.github/workflows/build-and-deploy.yml

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey there - I've reviewed your changes - here's some feedback:

  • 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.
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 conditional4] ([`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 objects2] ([`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>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
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>

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>

Comment on lines +28 to +33
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);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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 && (

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +37 to +44
{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>
)}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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'.

Suggested change
{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">

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread planthood-site/parser/parse.py Outdated
def parse_recipe_steps(self, recipe: Dict) -> List[RecipeStep]:
"""Parse recipe method into structured steps"""
recipe_id = recipe['id']
method = recipe.get('method', '').strip()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (code-quality): We've found these issues:

Comment thread planthood-site/scheduler/schedule.py Outdated
Comment thread planthood-site/scraper/scrape.py Outdated
Comment on lines +63 to +68
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')

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (code-quality): Extract code out into method (extract-method)

Comment thread planthood-site/scraper/scrape.py
Comment on lines +206 to +207
recipe = self.extract_recipe(url)
if recipe:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (code-quality): Use named expression to simplify assignment and conditional (use-named-expression)

Suggested change
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants