/**
* 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];
}