Skip to main content
Glama
cooking.ts17.6 kB
/** * Cooking Service * Core BBQ cooking logic and calculations */ import { PROTEIN_PROFILES, COOK_METHOD_INFO, DONENESS_INFO, DEFAULT_SMOKER_TEMP, HOT_FAST_TEMP, } from "../constants.js"; import type { ProteinType, CookMethod, DonenessLevel, ProteinProfile, CookTimeEstimate, TemperatureAnalysis, } from "../types.js"; /** * Get the protein profile for a given protein type */ export function getProteinProfile(proteinType: ProteinType): ProteinProfile { return PROTEIN_PROFILES[proteinType]; } /** * Get the target temperature for a protein at a specific doneness */ export function getTargetTemperature( proteinType: ProteinType, doneness?: DonenessLevel ): { targetTemp: number; pullTemp: number; doneness: DonenessLevel } { const profile = getProteinProfile(proteinType); // Determine the doneness to use let actualDoneness: DonenessLevel; if (doneness && profile.donenessTemps[doneness] !== undefined) { actualDoneness = doneness; } else { // Use the first available doneness (most common/recommended) const availableDoneness = Object.keys(profile.donenessTemps) as DonenessLevel[]; actualDoneness = availableDoneness[0]; } const targetTemp = profile.donenessTemps[actualDoneness] ?? profile.usdaSafeTemp; const pullTemp = targetTemp - profile.carryoverDegrees; return { targetTemp, pullTemp, doneness: actualDoneness }; } /** * Estimate total cook time */ export function estimateCookTime( proteinType: ProteinType, weightPounds: number, cookMethod: CookMethod, smokerTemp?: number ): CookTimeEstimate { const profile = getProteinProfile(proteinType); const methodInfo = COOK_METHOD_INFO[cookMethod]; // Get base time per pound for this method const baseTimePerPound = profile.estimatedTimePerPound[cookMethod]; if (baseTimePerPound === 0) { return { totalMinutes: 0, hoursAndMinutes: "Not recommended", estimatedDoneTime: new Date(), confidence: "low", assumptions: [], warnings: [`${methodInfo.displayName} is not recommended for ${profile.displayName}`], }; } // Adjust for smoker temperature (baseline is 225°F for low/slow, 300°F for hot/fast) let tempAdjustment = 1.0; const baseTemp = cookMethod.includes("hot_fast") ? HOT_FAST_TEMP : DEFAULT_SMOKER_TEMP; if (smokerTemp) { // Higher temp = faster cook (roughly 10% faster per 25°F) const tempDiff = smokerTemp - baseTemp; tempAdjustment = 1 - tempDiff / 250; tempAdjustment = Math.max(0.5, Math.min(1.5, tempAdjustment)); // Clamp between 0.5x and 1.5x } // Calculate base cook time let totalMinutes = baseTimePerPound * weightPounds * tempAdjustment; // Add time for stall if applicable const assumptions: string[] = []; const warnings: string[] = []; if (profile.stallRange) { totalMinutes += 60; // Add 1 hour buffer for stall assumptions.push("Includes ~1 hour buffer for the stall (150-175°F range)"); assumptions.push("Wrapping can reduce stall time by 30-50%"); } // Add rest time to total timeline if (profile.requiresRest && profile.restTimeMinutes > 0) { assumptions.push(`Add ${profile.restTimeMinutes} minutes rest time before serving`); } // Calculate done time const estimatedDoneTime = new Date(); estimatedDoneTime.setMinutes(estimatedDoneTime.getMinutes() + totalMinutes); // Determine confidence level let confidence: "high" | "medium" | "low" = "medium"; if (profile.stallRange) { confidence = "low"; // Stall-prone cooks are unpredictable warnings.push("Large cuts with stalls are unpredictable - plan for variability"); } else if (baseTimePerPound > 50) { confidence = "medium"; } else { confidence = "high"; } // Format hours and minutes const hours = Math.floor(totalMinutes / 60); const minutes = Math.round(totalMinutes % 60); const hoursAndMinutes = hours > 0 ? `${hours} hour${hours > 1 ? "s" : ""} ${minutes} minutes` : `${minutes} minutes`; return { totalMinutes: Math.round(totalMinutes), hoursAndMinutes, estimatedDoneTime, confidence, assumptions, warnings, }; } /** * Calculate when to start cooking to be ready by a target time */ export function calculateStartTime( proteinType: ProteinType, weightPounds: number, cookMethod: CookMethod, targetServingTime: Date, smokerTemp?: number ): { startTime: Date; cookTime: CookTimeEstimate; restTime: number; bufferMinutes: number } { const profile = getProteinProfile(proteinType); const cookTime = estimateCookTime(proteinType, weightPounds, cookMethod, smokerTemp); // Add rest time and buffer const restTime = profile.restTimeMinutes; const bufferMinutes = cookTime.confidence === "low" ? 120 : cookTime.confidence === "medium" ? 60 : 30; // Total time needed const totalMinutesNeeded = cookTime.totalMinutes + restTime + bufferMinutes; // Calculate start time const startTime = new Date(targetServingTime); startTime.setMinutes(startTime.getMinutes() - totalMinutesNeeded); return { startTime, cookTime, restTime, bufferMinutes, }; } /** * Analyze current temperature progress and provide recommendations */ export function analyzeTemperature( currentTemp: number, targetTemp: number, proteinType: ProteinType, cookMethod?: CookMethod, cookStartTime?: Date, previousReadings?: Array<{ temp: number; timestamp: Date }> ): TemperatureAnalysis { const profile = getProteinProfile(proteinType); const tempDelta = targetTemp - currentTemp; const startingTemp = 40; // Assume refrigerator temp as starting point const totalTempRange = targetTemp - startingTemp; const tempProgress = currentTemp - startingTemp; const percentComplete = Math.min(100, Math.max(0, (tempProgress / totalTempRange) * 100)); // Determine trend from previous readings let trend: "rising" | "falling" | "stable" | "stalled" = "stable"; let trendRatePerHour = 0; if (previousReadings && previousReadings.length >= 2) { const recentReadings = previousReadings.slice(-5); // Look at last 5 readings const firstReading = recentReadings[0]; const lastReading = recentReadings[recentReadings.length - 1]; const tempChange = lastReading.temp - firstReading.temp; const timeChange = (lastReading.timestamp.getTime() - firstReading.timestamp.getTime()) / (1000 * 60 * 60); // hours if (timeChange > 0) { trendRatePerHour = tempChange / timeChange; if (Math.abs(trendRatePerHour) < 2) { // Less than 2°F/hour trend = profile.stallRange && currentTemp >= profile.stallRange.start && currentTemp <= profile.stallRange.end ? "stalled" : "stable"; } else if (trendRatePerHour > 0) { trend = "rising"; } else { trend = "falling"; } } } // Check if in stall zone const inStallZone = profile.stallRange !== undefined && currentTemp >= profile.stallRange.start && currentTemp <= profile.stallRange.end; // Estimate time remaining let estimatedMinutesRemaining: number | null = null; if (trendRatePerHour > 0 && trend === "rising") { const hoursRemaining = tempDelta / trendRatePerHour; estimatedMinutesRemaining = Math.round(hoursRemaining * 60); } else if (trend === "stalled") { // During stall, estimate based on typical stall duration estimatedMinutesRemaining = null; // Can't reliably estimate during stall } // Generate recommendations const recommendations: string[] = []; if (inStallZone && trend === "stalled") { recommendations.push("🛑 You're in the stall zone! Temperature may plateau for 2-4 hours."); recommendations.push( "💡 Consider wrapping in butcher paper or foil (Texas crutch) to push through faster." ); } else if (inStallZone && trend === "rising") { recommendations.push("📈 Temperature is rising through the stall zone - looking good!"); } if (tempDelta <= profile.carryoverDegrees + 5) { recommendations.push( `🎯 Getting close! Consider pulling at ${targetTemp - profile.carryoverDegrees}°F to account for carryover.` ); } if (tempDelta <= 0) { recommendations.push("✅ Target temperature reached! Time to rest."); if (profile.requiresRest) { recommendations.push(`⏰ Rest for ${profile.restTimeMinutes} minutes before slicing.`); } } if (trend === "falling") { recommendations.push("⚠️ Temperature is dropping - check your heat source!"); if (cookMethod?.includes("smoke")) { recommendations.push("🔥 You may need to add more fuel or adjust airflow."); } } if (percentComplete < 25 && previousReadings && previousReadings.length > 0) { recommendations.push("🕐 Still in early stages - patience is key!"); } return { currentTemp, targetTemp, tempDelta, percentComplete: Math.round(percentComplete * 10) / 10, trend, trendRatePerHour: Math.round(trendRatePerHour * 10) / 10, estimatedMinutesRemaining, inStallZone, recommendations, }; } /** * Detect if a cook is in a stall */ export function detectStall( proteinType: ProteinType, currentTemp: number, readings: Array<{ temp: number; timestamp: Date }> ): { isStalled: boolean; stallDurationMinutes: number; inStallZone: boolean; recommendation: string; } { const profile = getProteinProfile(proteinType); // Check if this protein type typically stalls if (!profile.stallRange) { return { isStalled: false, stallDurationMinutes: 0, inStallZone: false, recommendation: `${profile.displayName} typically doesn't experience a stall.`, }; } const inStallZone = currentTemp >= profile.stallRange.start && currentTemp <= profile.stallRange.end; if (!inStallZone) { if (currentTemp < profile.stallRange.start) { return { isStalled: false, stallDurationMinutes: 0, inStallZone: false, recommendation: `Approaching stall zone (${profile.stallRange.start}-${profile.stallRange.end}°F). Be prepared for a plateau.`, }; } else { return { isStalled: false, stallDurationMinutes: 0, inStallZone: false, recommendation: "You've pushed through the stall! Temperature should rise steadily now.", }; } } // Analyze readings to determine if actually stalled (temp not rising) const recentReadings = readings.slice(-6); // Last 6 readings if (recentReadings.length < 3) { return { isStalled: false, stallDurationMinutes: 0, inStallZone: true, recommendation: "In the stall zone but need more readings to confirm stall status.", }; } // Calculate temperature change over the readings const tempChange = recentReadings[recentReadings.length - 1].temp - recentReadings[0].temp; const timeSpanMinutes = (recentReadings[recentReadings.length - 1].timestamp.getTime() - recentReadings[0].timestamp.getTime()) / (1000 * 60); const tempRatePerHour = timeSpanMinutes > 0 ? (tempChange / timeSpanMinutes) * 60 : 0; // Consider stalled if temp is rising less than 3°F per hour in the stall zone const isStalled = tempRatePerHour < 3; // Calculate how long the stall has lasted let stallDurationMinutes = 0; if (isStalled && readings.length >= 3) { // Find when temp first entered stall zone for (let i = readings.length - 1; i >= 0; i--) { if (readings[i].temp >= profile.stallRange.start && readings[i].temp <= profile.stallRange.end) { stallDurationMinutes = (new Date().getTime() - readings[i].timestamp.getTime()) / (1000 * 60); } else { break; } } } let recommendation: string; if (isStalled) { if (stallDurationMinutes < 60) { recommendation = "Stall detected! This is normal - evaporative cooling is slowing the temp rise. The stall can last 2-4 hours."; } else if (stallDurationMinutes < 180) { recommendation = `Stall has lasted ${Math.round(stallDurationMinutes)} minutes. Consider wrapping in butcher paper (Texas crutch) to push through faster.`; } else { recommendation = `Extended stall (${Math.round(stallDurationMinutes)} minutes). Wrapping is strongly recommended, or you can ride it out - eventually, it will break through.`; } } else { recommendation = "In the stall zone but temperature is still rising. Keep monitoring - you may push through without a major plateau."; } return { isStalled, stallDurationMinutes: Math.round(stallDurationMinutes), inStallZone, recommendation, }; } /** * Calculate recommended rest time and expected carryover */ export function calculateRestTime( proteinType: ProteinType, currentTemp: number, targetFinalTemp?: number ): { recommendedRestMinutes: number; expectedCarryover: number; expectedFinalTemp: number; instructions: string[]; } { const profile = getProteinProfile(proteinType); const expectedCarryover = profile.carryoverDegrees; const expectedFinalTemp = currentTemp + expectedCarryover; const recommendedRestMinutes = profile.restTimeMinutes; const instructions: string[] = []; if (!profile.requiresRest) { instructions.push(`${profile.displayName} doesn't require significant resting.`); instructions.push("Serve immediately for best results."); } else { instructions.push(`Rest for ${recommendedRestMinutes} minutes.`); instructions.push(`Temperature will rise approximately ${expectedCarryover}°F during rest.`); instructions.push(`Expected final temperature: ${expectedFinalTemp}°F`); // Specific resting advice based on protein type if (profile.category === "beef" && (proteinType === "beef_brisket" || proteinType === "beef_prime_rib")) { instructions.push("For large roasts, rest in a cooler (without ice) wrapped in towels for up to 4 hours."); } else if (profile.category === "poultry") { instructions.push("Rest uncovered or loosely tented to keep skin crispy."); } else { instructions.push("Rest loosely tented with foil to retain heat."); } if (targetFinalTemp && expectedFinalTemp < targetFinalTemp) { const shortfall = targetFinalTemp - expectedFinalTemp; instructions.push( `⚠️ Final temp may be ${shortfall}°F below target. Consider pulling a bit later next time.` ); } else if (targetFinalTemp && expectedFinalTemp > targetFinalTemp + 5) { instructions.push( `⚠️ Final temp may exceed target. Next time, pull earlier at ${targetFinalTemp - expectedCarryover}°F.` ); } } return { recommendedRestMinutes, expectedCarryover, expectedFinalTemp, instructions, }; } /** * Get cooking tips for a protein type and optional context */ export function getCookingTips( proteinType: ProteinType, cookMethod?: CookMethod, currentPhase?: string ): string[] { const profile = getProteinProfile(proteinType); const tips: string[] = [...profile.tips]; // Add method-specific tips if (cookMethod) { const methodInfo = COOK_METHOD_INFO[cookMethod]; tips.push(`For ${methodInfo.displayName}: Cook at ${methodInfo.tempRange}`); if (cookMethod === "reverse_sear") { tips.push("Start at 225°F until 10-15°F below target, then sear at high heat for 1-2 minutes per side."); } else if (cookMethod === "spatchcock") { tips.push( "Remove backbone with kitchen shears, flatten bird for more even cooking and crispier skin." ); } } // Add phase-specific tips if (currentPhase) { switch (currentPhase) { case "prep": tips.push("Season liberally - salt enhances flavor and aids bark formation."); tips.push("Let meat come to room temperature (30-60 min) for more even cooking."); break; case "stall": tips.push("The stall is caused by evaporative cooling - moisture on the surface keeps temps flat."); tips.push("Options: Wrap in butcher paper/foil (Texas crutch) or ride it out."); break; case "wrapping": tips.push("Butcher paper allows some smoke penetration while speeding the cook."); tips.push("Foil is faster but can soften the bark."); break; case "resting": tips.push("Don't skip the rest! It allows juices to redistribute."); tips.push("Large cuts can rest in a cooler for hours without losing much heat."); break; } } return tips; } /** * Convert temperature between units */ export function convertTemperature( temp: number, fromUnit: "fahrenheit" | "celsius", toUnit: "fahrenheit" | "celsius" ): number { if (fromUnit === toUnit) return temp; if (fromUnit === "fahrenheit" && toUnit === "celsius") { return Math.round(((temp - 32) * 5) / 9 * 10) / 10; } else { return Math.round((temp * 9) / 5 + 32); } } /** * Format a time estimate for display */ export function formatTimeEstimate(minutes: number): string { if (minutes < 60) { return `${Math.round(minutes)} minutes`; } const hours = Math.floor(minutes / 60); const remainingMinutes = Math.round(minutes % 60); if (remainingMinutes === 0) { return `${hours} hour${hours > 1 ? "s" : ""}`; } return `${hours} hour${hours > 1 ? "s" : ""} ${remainingMinutes} minutes`; } /** * Get the recommended cook method for a protein type */ export function getRecommendedCookMethod(proteinType: ProteinType): CookMethod { const profile = getProteinProfile(proteinType); return profile.recommendedMethods[0]; }

Implementation Reference

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/jweingardt12/bbq-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server