planmymeal/app/types/index.ts

182 lines
5.9 KiB
TypeScript
Raw Normal View History

2026-10-31 20:44:29 -05:00
// ---------------------------------------------------------------------------
// Shared types for the whole app (client + server).
// Keep this file dependency-free so it can be imported anywhere.
// ---------------------------------------------------------------------------
export type Appliance = "stovetop" | "oven" | "air_fryer" | "slow_cooker" | "microwave" | "blender" | "rice_cooker";
export type Cuisine =
| "american"
| "southern"
| "mexican"
| "caribbean"
| "italian"
| "mediterranean"
| "middle_eastern"
| "indian"
| "chinese"
| "japanese"
| "korean"
| "thai";
/** "caydens_corner" marks the family recipes collection. */
export type CookingStyle = "comfort" | "quick" | "healthy" | "meal_prep" | "vegetarian" | "seafood" | "caydens_corner";
/**
* Breakfast slots only get breakfast recipes; lunch/dinner slots get mains.
* Sides and desserts live in the library and can be added to a slot by hand,
* but auto-fill never picks them.
*/
export type MealKind = "breakfast" | "main" | "side" | "dessert";
// --- Units -----------------------------------------------------------------
/** Units recipes are written in (what a human would type). */
export type RecipeUnit = "g" | "oz" | "lb" | "ml" | "tsp" | "tbsp" | "cup" | "each";
/** Units the store catalog is priced in. Every recipe unit converts to one of these. */
export type BaseUnit = "g" | "ml" | "each";
// --- Store catalog ---------------------------------------------------------
export type ItemId = string;
export type Aisle = "Meat & Eggs" | "Seafood" | "Dairy" | "Produce" | "Frozen" | "Pantry" | "International" | "Spices & Sauces" | "Bakery";
/** One purchasable product, e.g. "Long Grain White Rice, 2 lb bag". */
export interface StoreItem {
id: ItemId;
name: string;
aisle: Aisle;
/** How much is in one package, in `baseUnit`. */
packageSize: number;
baseUnit: BaseUnit;
/** Human label for one package, e.g. "2 lb bag". */
packageLabel: string;
/** Price of one package, USD. */
price: number;
/**
* Lets weight-priced items be measured by volume in recipes
* (e.g. rice: 1 cup ≈ 185 g → 0.78 g/ml). Only needed for `g` items.
*/
gramsPerMl?: number;
/** Salt, oil, etc. Marked "have it" by default for new users. */
pantryStaple?: boolean;
}
// --- Recipes ---------------------------------------------------------------
export interface RecipeIngredient {
/** Points at a StoreItem. Cost is derived from the catalog, never hard-coded per recipe. */
itemId: ItemId;
amount: number;
unit: RecipeUnit;
/** Optional display note, e.g. "diced". */
note?: string;
}
export interface Recipe {
id: string;
title: string;
description: string;
cuisine: Cuisine;
styles: CookingStyle[];
kind: MealKind;
/** Minutes, hands-on + cook. */
prepTime: number;
/** ALL of these are needed. An empty array means no-cook. */
requiredAppliances: Appliance[];
/** How many people one batch feeds. Ingredients scale to household size. */
servings: number;
ingredients: RecipeIngredient[];
/** Cooking instructions, in order. Kept in recipes/steps/ and joined in by recipes/index.ts. */
steps: string[];
/** Optional cook's tip shown under the steps. */
tip?: string;
}
/** A recipe as written in the data files, before its steps are attached. */
export type RecipeData = Omit<Recipe, "steps">;
// --- Plan state --------------------------------------------------------------
export interface MealSlot {
dayIndex: number; // 0-6
mealNumber: number; // 1-3
recipeId: string | null;
}
export interface MealPlanState {
targetBudget: number;
daysCount: number; // 1-7
mealsPerDay: number; // 1-3
householdSize: number; // 1-6
selectedAppliances: Appliance[];
/** Empty = any cuisine. */
preferredCuisines: Cuisine[];
/** Empty = any style. */
preferredStyles: CookingStyle[];
/** Never plan recipes that use these store items. */
avoidedItems: ItemId[];
/** Already in the kitchen: listed on the grocery list but cost $0. */
onHandItems: ItemId[];
/**
* Keyed "<day>-<meal>" (day 0 = Mon; meal 1/2/3 = Breakfast/Lunch/Dinner) → recipe id.
* Only filled slots are stored. Slots outside daysCount/mealsPerDay are kept but hidden.
*/
slots: Record<string, string>;
}
// --- Grocery output ----------------------------------------------------------
export interface GroceryLine {
item: StoreItem;
/** Total amount all recipes need, in the item's base unit. */
neededAmount: number;
/** Whole packages to buy. */
packages: number;
/** What you pay at checkout for this line. */
checkoutCost: number;
/** Value of what actually gets eaten this week. */
usedCost: number;
/** Left over after the week, in base unit (goes to your pantry). */
leftoverAmount: number;
/** User marked this item as already in their kitchen. */
onHand: boolean;
/** Recipe titles that use this item. Length > 1 means ingredient overlap. */
usedBy: string[];
}
export interface GrocerySummary {
lines: GroceryLine[];
/** Sum of checkout costs (excludes on-hand lines). */
checkoutTotal: number;
/** Value of food actually eaten this plan. */
usedTotal: number;
/** checkoutTotal - usedTotal: what you carry into next week. */
carryoverValue: number;
/** How many store items are shared by 2+ recipes. */
sharedItemCount: number;
}
/** Per-ingredient setting in the Ingredients panel. */
export type ItemPreference = "avoid" | "use" | "have";
// --- Server action contract -------------------------------------------------
export type FillMode = "empty" | "all";
export interface GeneratePlanInput {
state: MealPlanState;
fillMode: FillMode;
/** Changing the seed gives a different (but still cost-optimized) plan. */
seed: number;
}
export interface GeneratePlanResult {
slots: Record<string, string>;
summary: GrocerySummary;
/** Human-readable notes, e.g. when filters had to be relaxed. */
notes: string[];
}