Skill: recipe-scaler

## Overview

7 stars

Best use case

Skill: recipe-scaler is best used when you need a repeatable AI agent workflow instead of a one-off prompt.

## Overview

Teams using Skill: recipe-scaler should expect a more consistent output, faster repeated execution, less prompt rewriting.

When to use this skill

  • You want a reusable workflow that can be run more than once with consistent structure.

When not to use this skill

  • You only need a quick one-off answer and do not need a reusable workflow.
  • You cannot install or maintain the underlying files, dependencies, or repository context.

Installation

Claude Code / Cursor / Codex

$curl -o ~/.claude/skills/recipe-scaler/SKILL.md --create-dirs "https://raw.githubusercontent.com/heldernoid/agentic-build-templates/main/projects/web-applications/recipe-scaler/skills/recipe-scaler/SKILL.md"

Manual Installation

  1. Download SKILL.md from GitHub
  2. Place it in .claude/skills/recipe-scaler/SKILL.md inside your project
  3. Restart your AI agent — it will auto-discover the skill

How Skill: recipe-scaler Compares

Feature / AgentSkill: recipe-scalerStandard Approach
Platform SupportNot specifiedLimited / Varies
Context Awareness High Baseline
Installation ComplexityUnknownN/A

Frequently Asked Questions

What does this skill do?

## Overview

Where can I find the source code?

You can find the source code on GitHub using the link provided at the top of the page.

SKILL.md Source

# Skill: recipe-scaler

## Overview

Full-stack recipe storage and scaling application. Users store recipes with ingredient quantities and serving counts. A scaling endpoint multiplies all quantities by a factor and applies smart unit conversion (e.g., 0.5 cup -> 8 tbsp). The frontend has a live-updating serving slider. Backend: Node.js/Express/SQLite. Frontend: React 18/Vite/TypeScript.

## Core Scaling Algorithm

```typescript
// apps/api/src/lib/recipe-scaler.ts

import { parseFraction } from './fraction-parser';
import { smartUnit, convertUnit } from './unit-converter';
import { formatQuantity } from './fraction-format';

interface Ingredient {
  id: number;
  quantity: number | null;  // NULL = "to taste"
  unit: string | null;
  name: string;
  preparation: string | null;
  isOptional: boolean;
}

interface ScaledIngredient {
  id: number;
  name: string;
  originalQuantity: number | null;
  originalUnit: string | null;
  scaledQuantity: number | null;
  scaledUnit: string | null;
  display: string;
  preparation: string | null;
  isOptional: boolean;
}

export function scaleIngredients(
  ingredients: Ingredient[],
  scaleFactor: number,
  unitSystem: 'us' | 'metric' = 'us'
): ScaledIngredient[] {
  return ingredients.map(ing => {
    if (ing.quantity === null) {
      return {
        id: ing.id,
        name: ing.name,
        originalQuantity: null,
        originalUnit: ing.unit,
        scaledQuantity: null,
        scaledUnit: ing.unit,
        display: 'to taste',
        preparation: ing.preparation,
        isOptional: ing.isOptional,
      };
    }

    const rawScaled = ing.quantity * scaleFactor;

    // Apply smart unit conversion and system conversion
    const { qty, unit } = smartUnit(rawScaled, ing.unit ?? '', unitSystem);

    return {
      id: ing.id,
      name: ing.name,
      originalQuantity: ing.quantity,
      originalUnit: ing.unit,
      scaledQuantity: qty,
      scaledUnit: unit,
      display: formatDisplay(qty, unit),
      preparation: ing.preparation,
      isOptional: ing.isOptional,
    };
  });
}

function formatDisplay(qty: number, unit: string | null): string {
  const qtyStr = formatQuantity(qty);
  if (!unit) return qtyStr;

  // Pluralize unit for qty > 1
  const unitStr = qty > 1 && !unit.endsWith('s') && !['g','kg','ml','l','oz','lb'].includes(unit)
    ? `${unit}s`
    : unit;

  return `${qtyStr} ${unitStr}`;
}
```

## Scale API Route

```typescript
// GET /api/recipes/:id/scale?servings=N&units=us|metric
router.get('/:id/scale', requireAuth, (req, res) => {
  const recipe = db.prepare('SELECT * FROM recipes WHERE id = ? AND user_id = ?')
    .get(req.params.id, req.session.userId);

  if (!recipe) return res.status(404).json({ error: 'Not found' });

  const targetServings = Math.max(1, Math.min(100, parseInt(req.query.servings as string) || recipe.servings));
  const scaleFactor = targetServings / recipe.servings;
  const unitSystem = req.query.units === 'metric' ? 'metric' : 'us';

  const ingredients = db.prepare(
    'SELECT * FROM ingredients WHERE recipe_id = ? ORDER BY sort_order'
  ).all(req.params.id);

  const scaled = scaleIngredients(ingredients, scaleFactor, unitSystem);

  res.json({
    recipeId: recipe.id,
    originalServings: recipe.servings,
    targetServings,
    scaleFactor,
    ingredients: scaled,
  });
});
```

## Frontend useScaler Hook

```typescript
// apps/web/src/hooks/useScaler.ts
import { useState, useCallback } from 'react';
import { useDebounce } from './useDebounce';
import { api } from '../lib/api';

interface ScaledIngredient {
  id: number;
  name: string;
  scaledQuantity: number | null;
  scaledUnit: string | null;
  display: string;
  preparation: string | null;
  isOptional: boolean;
}

interface ScaleResult {
  scaleFactor: number;
  ingredients: ScaledIngredient[];
}

export function useScaler(recipeId: number, originalServings: number) {
  const [servings, setServings] = useState(originalServings);
  const [unitSystem, setUnitSystem] = useState<'us' | 'metric'>('us');
  const [result, setResult] = useState<ScaleResult | null>(null);
  const [loading, setLoading] = useState(false);

  const debouncedServings = useDebounce(servings, 200);

  const fetchScaled = useCallback(async (s: number, u: 'us' | 'metric') => {
    setLoading(true);
    try {
      const data = await api.get(`/recipes/${recipeId}/scale?servings=${s}&units=${u}`);
      setResult(data);
    } finally {
      setLoading(false);
    }
  }, [recipeId]);

  // Fetch on debounced change
  useState(() => {
    fetchScaled(debouncedServings, unitSystem);
  });

  const reset = () => {
    setServings(originalServings);
    fetchScaled(originalServings, unitSystem);
  };

  return {
    servings,
    setServings,
    unitSystem,
    setUnitSystem,
    result,
    loading,
    reset,
    scaleFactor: result?.scaleFactor ?? 1,
  };
}
```

## Fraction Format (frontend display)

```typescript
// apps/web/src/lib/fraction-format.ts

const FRACTIONS: [number, string][] = [
  [1/8,  '1/8'],
  [1/6,  '1/6'],
  [1/4,  '1/4'],
  [1/3,  '1/3'],
  [3/8,  '3/8'],
  [1/2,  '1/2'],
  [5/8,  '5/8'],
  [2/3,  '2/3'],
  [3/4,  '3/4'],
  [5/6,  '5/6'],
  [7/8,  '7/8'],
];

export function formatQuantity(value: number): string {
  if (value === 0) return '0';

  const whole = Math.floor(value);
  const decimal = value - whole;

  if (decimal < 0.01) return String(whole);

  // Find closest fraction
  let best: [number, string] | null = null;
  let bestDiff = Infinity;

  for (const [frac, str] of FRACTIONS) {
    const diff = Math.abs(decimal - frac);
    if (diff < bestDiff) {
      bestDiff = diff;
      best = [frac, str];
    }
  }

  if (best && bestDiff < 0.04) {
    const fracStr = best[1];
    return whole > 0 ? `${whole} ${fracStr}` : fracStr;
  }

  // Fall back to 1 decimal place
  return value.toFixed(1).replace(/\.0$/, '');
}
```

## Smart Unit Selection

```typescript
// apps/api/src/lib/unit-converter.ts

type UnitSystem = 'us' | 'metric';

interface UnitResult {
  qty: number;
  unit: string;
}

// Volume conversion in milliliters
const ML: Record<string, number> = {
  tsp: 4.929,
  tbsp: 14.787,
  'fl oz': 29.574,
  cup: 236.588,
  pint: 473.176,
  quart: 946.353,
  liter: 1000,
  ml: 1,
};

// Weight conversion in grams
const G: Record<string, number> = {
  g: 1,
  kg: 1000,
  oz: 28.3495,
  lb: 453.592,
};

export function convertUnit(qty: number, from: string, to: string): number {
  if (from === to) return qty;

  // Volume conversion
  if (ML[from] && ML[to]) {
    return qty * ML[from] / ML[to];
  }

  // Weight conversion
  if (G[from] && G[to]) {
    return qty * G[from] / G[to];
  }

  throw new Error(`Cannot convert ${from} to ${to}`);
}

export function smartUnit(qty: number, unit: string, system: UnitSystem = 'us'): UnitResult {
  if (!unit || !ML[unit] && !G[unit]) {
    // No unit (count items): round to nearest whole
    return { qty: Math.round(qty * 4) / 4, unit: unit };
  }

  if (ML[unit]) {
    return smartVolumeUnit(qty, unit, system);
  }

  if (G[unit]) {
    return smartWeightUnit(qty, unit, system);
  }

  return { qty, unit };
}

function smartVolumeUnit(qty: number, unit: string, system: UnitSystem): UnitResult {
  const totalMl = qty * ML[unit];

  if (system === 'metric') {
    if (totalMl < 5) return round({ qty: totalMl / ML['ml'], unit: 'ml' });
    if (totalMl < 50) return round({ qty: totalMl / ML['tsp'], unit: 'tsp' });
    if (totalMl < 200) return round({ qty: totalMl / ML['tbsp'], unit: 'tbsp' });
    if (totalMl < 900) return round({ qty: totalMl / ML['cup'], unit: 'cup' });
    return round({ qty: totalMl / ML['liter'], unit: 'L' });
  }

  // US customary
  if (totalMl < 2.5) return round({ qty: totalMl / ML['tsp'], unit: 'tsp' });
  if (totalMl < 14) return round({ qty: totalMl / ML['tsp'], unit: 'tsp' });
  if (totalMl < 59) return round({ qty: totalMl / ML['tbsp'], unit: 'tbsp' });
  if (totalMl < 400) return round({ qty: totalMl / ML['cup'], unit: 'cup' });
  if (totalMl < 800) return round({ qty: totalMl / ML['pint'], unit: 'pint' });
  return round({ qty: totalMl / ML['quart'], unit: 'quart' });
}

function smartWeightUnit(qty: number, unit: string, system: UnitSystem): UnitResult {
  const totalG = qty * G[unit];

  if (system === 'metric') {
    if (totalG < 1000) return round({ qty: totalG, unit: 'g' });
    return round({ qty: totalG / 1000, unit: 'kg' });
  }

  if (totalG < 28.35) return round({ qty: totalG, unit: 'g' });
  if (totalG < 453.6) return round({ qty: totalG / G['oz'], unit: 'oz' });
  return round({ qty: totalG / G['lb'], unit: 'lb' });
}

function round(r: UnitResult): UnitResult {
  // Round to 3 significant figures
  return { qty: parseFloat(r.qty.toPrecision(3)), unit: r.unit };
}
```

## pnpm Commands

```bash
pnpm install              # install all workspace deps
pnpm dev                  # api (3000) + web (5173) concurrently
pnpm build                # tsc + vite build
pnpm test                 # vitest
pnpm db:migrate           # run SQL migrations
pnpm db:seed              # seed sample recipes
```

Related Skills

Skill: Uptime Monitoring

7
from heldernoid/agentic-build-templates

## Overview

Skill: Status Page

7
from heldernoid/agentic-build-templates

## Overview

Skill: unit-conversion

7
from heldernoid/agentic-build-templates

## Overview

reading-list

7
from heldernoid/agentic-build-templates

Operate the reading-list API to save, manage, tag, search, and export articles.

email-digest

7
from heldernoid/agentic-build-templates

Configure, test, and troubleshoot the reading-list daily email digest delivered via nodemailer.

websocket-realtime

7
from heldernoid/agentic-build-templates

Use the WebSocket connection in poll-builder to receive live vote updates. Use when you need to stream real-time poll results, monitor a poll for new votes, or build a live dashboard. Triggers include "live results", "real-time updates", "stream votes", "watch poll", or "WebSocket".

poll-builder

7
from heldernoid/agentic-build-templates

Self-hosted poll creation tool with real-time results. Use when you need to create a poll, check vote counts, close a poll, export results, or get the shareable link for a poll. Triggers include "create poll", "vote", "poll results", "survey", "collect votes", "share poll", or any task involving polling or voting.

Skill: personal-finance

7
from heldernoid/agentic-build-templates

## Overview

Skill: csv-import

7
from heldernoid/agentic-build-templates

## Overview

Skill: Syntax Highlighting

7
from heldernoid/agentic-build-templates

## Purpose

Skill: Pastebin Core

7
from heldernoid/agentic-build-templates

## Purpose

Skill: Cost Reporting

7
from heldernoid/agentic-build-templates

## Overview