diff --git a/CHANGELOG.md b/CHANGELOG.md index 5cd9c66..6eb2f38 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## 2026-02-13 +### Added + +- Metric to imperial conversion for recipe ingredient units + ### Changed - Tuned prompt to remove ingredient preparation from recipe ingredients diff --git a/src/CookTime/client-app/src/components/IngredientRequirements/IngredientRequirementList.tsx b/src/CookTime/client-app/src/components/IngredientRequirements/IngredientRequirementList.tsx index a4ef431..8b8e26d 100644 --- a/src/CookTime/client-app/src/components/IngredientRequirements/IngredientRequirementList.tsx +++ b/src/CookTime/client-app/src/components/IngredientRequirements/IngredientRequirementList.tsx @@ -1,18 +1,21 @@ import React from "react"; import { Row } from "react-bootstrap"; import { IngredientRequirement, MeasureUnit } from "src/shared/CookTime"; +import { UnitPreference } from "src/shared/units"; import { IngredientDisplay } from "../Ingredients/IngredientDisplay"; interface IngredientRequirementListProps { ingredientRequirements: IngredientRequirement[]; units: MeasureUnit[]; multiplier: number; + unitPreference: UnitPreference; } export function IngredientRequirementList({ ingredientRequirements, units, multiplier, + unitPreference, }: IngredientRequirementListProps) { return ( <> @@ -23,6 +26,7 @@ export function IngredientRequirementList({ diff --git a/src/CookTime/client-app/src/components/Ingredients/IngredientDisplay.tsx b/src/CookTime/client-app/src/components/Ingredients/IngredientDisplay.tsx index ca59ff3..abcde1c 100644 --- a/src/CookTime/client-app/src/components/Ingredients/IngredientDisplay.tsx +++ b/src/CookTime/client-app/src/components/Ingredients/IngredientDisplay.tsx @@ -1,36 +1,35 @@ import React from "react"; import { IngredientRequirement, MeasureUnit } from "src/shared/CookTime"; +import { UnitPreference, convertQuantity, formatNumber, formatUnitName } from "src/shared/units"; type IngredientDisplayProps = { ingredientRequirement: IngredientRequirement strikethrough?: boolean, showAlternatUnit?: boolean units?: MeasureUnit[] + unitPreference?: UnitPreference } export class IngredientDisplay extends React.Component { render() { let ingredient = this.props.ingredientRequirement.ingredient - var unitName = "" - switch (this.props.ingredientRequirement.unit) { - case "count": - unitName = "" - break; + const unitPreference = this.props.unitPreference ?? "recipe"; + const conversion = convertQuantity({ + quantity: this.props.ingredientRequirement.quantity, + unitName: this.props.ingredientRequirement.unit, + units: this.props.units, + preference: unitPreference, + }); - case "fluid_ounce": - unitName = "fluid ounce" - break; - - default: - unitName = this.props.ingredientRequirement.unit.toLowerCase() - break; - } - var quantity = <>{this.props.ingredientRequirement.quantity.toString()} - let fraction = this.Fraction(this.props.ingredientRequirement.quantity); + const unitName = conversion.unitName === "count" ? "" : formatUnitName(conversion.unitName); + let fraction = this.Fraction(conversion.quantity); + let quantity = unitPreference === "metric" + ? <>{formatNumber(conversion.quantity)} + : <>{fraction}; // Show IR text, but if that's not available then show the ingredient canonical name. var ingredientName = (this.props.ingredientRequirement.text ?? ingredient.name.split(";").map(s => s.trim())[0]).toLowerCase() - var text = <>{fraction} {unitName} {this.props.showAlternatUnit ? this.getAlternateUnit() : null} {ingredientName} + var text = <>{quantity} {unitName} {this.props.showAlternatUnit && unitPreference === "recipe" ? this.getAlternateUnit() : null} {ingredientName} if (this.props.strikethrough) { text = {text} @@ -131,4 +130,4 @@ export class IngredientDisplay extends React.Component )} @@ -102,6 +104,8 @@ export function RecipeComponentEditor({ component, componentIndex }: RecipeCompo recipe={recipe} component={component} newServings={newServings} + unitPreference={unitPreference} + units={units} edit={edit} onDeleteStep={(idx) => deleteStepFromComponent(componentIndex, idx)} onChange={(newSteps) => updateStepsInComponent(componentIndex, newSteps)} diff --git a/src/CookTime/client-app/src/components/Recipe/RecipeContext.tsx b/src/CookTime/client-app/src/components/Recipe/RecipeContext.tsx index 29b61f6..bcac22d 100644 --- a/src/CookTime/client-app/src/components/Recipe/RecipeContext.tsx +++ b/src/CookTime/client-app/src/components/Recipe/RecipeContext.tsx @@ -15,6 +15,7 @@ import { toRecipeUpdateDto, addToList, } from 'src/shared/CookTime'; +import { UnitPreference } from 'src/shared/units'; export type PendingImage = { id: string; @@ -35,6 +36,7 @@ interface RecipeContextState { imageOperationInProgress: boolean; edit: boolean; units: MeasureUnit[]; + unitPreference: UnitPreference; newServings: number; errorMessage: string | null; operationInProgress: boolean; @@ -51,6 +53,7 @@ interface RecipeContextActions { setErrorMessage: (message: string | null) => void; setNewServings: (servings: number) => void; setToastMessage: (message: string | null) => void; + setUnitPreference: (preference: UnitPreference) => void; // Recipe updates updateRecipe: (updates: Partial) => void; @@ -129,6 +132,7 @@ export function RecipeProvider({ recipeId, generatedRecipe, children }: RecipePr const [imageOperationInProgress, setImageOperationInProgress] = useState(false); const [edit, setEdit] = useState(false); const [units, setUnits] = useState([]); + const [unitPreference, setUnitPreference] = useState('recipe'); const [newServings, setNewServings] = useState(1); const [errorMessage, setErrorMessage] = useState(null); const [operationInProgress, setOperationInProgress] = useState(false); @@ -175,6 +179,11 @@ export function RecipeProvider({ recipeId, generatedRecipe, children }: RecipePr // Load initial data useEffect(() => { + const savedPreference = window.localStorage.getItem('cooktime.unitPreference'); + if (savedPreference === 'recipe' || savedPreference === 'imperial' || savedPreference === 'metric') { + setUnitPreference(savedPreference); + } + // Fetch units fetch('/api/recipe/units') .then((res) => res.json()) @@ -223,6 +232,10 @@ export function RecipeProvider({ recipeId, generatedRecipe, children }: RecipePr .then((result) => setNutritionFacts(result as RecipeNutritionFacts)); }, [recipeId, generatedRecipe, applyGeneratedRecipe]); + useEffect(() => { + window.localStorage.setItem('cooktime.unitPreference', unitPreference); + }, [unitPreference]); + // Recipe updates const updateRecipe = useCallback((updates: Partial) => { setRecipe((prev) => ({ ...prev, ...updates })); @@ -521,6 +534,7 @@ export function RecipeProvider({ recipeId, generatedRecipe, children }: RecipePr imageOperationInProgress, edit, units, + unitPreference, newServings, errorMessage, operationInProgress, @@ -534,6 +548,7 @@ export function RecipeProvider({ recipeId, generatedRecipe, children }: RecipePr setErrorMessage, setNewServings, setToastMessage, + setUnitPreference, updateRecipe, updateComponent, appendIngredientToComponent, diff --git a/src/CookTime/client-app/src/components/Recipe/RecipeFields.tsx b/src/CookTime/client-app/src/components/Recipe/RecipeFields.tsx index c802fe5..edffdee 100644 --- a/src/CookTime/client-app/src/components/Recipe/RecipeFields.tsx +++ b/src/CookTime/client-app/src/components/Recipe/RecipeFields.tsx @@ -1,8 +1,9 @@ import React from 'react'; -import { Button, Col, Form, Row } from 'react-bootstrap'; +import { Button, Col, DropdownButton, Dropdown, Form, Row } from 'react-bootstrap'; import { useRecipeContext } from './RecipeContext'; import { ImageEditor } from './ImageEditor'; import { Tags } from '../Tags/Tags'; +import { UnitPreference } from 'src/shared/units'; export function RecipeFields() { const { @@ -14,8 +15,10 @@ export function RecipeFields() { pendingImages, imageOrder, imageOperationInProgress, + unitPreference, setNewServings, updateRecipe, + setUnitPreference, handleAddImages, handleRemoveExistingImage, handleRemovePendingImage, @@ -223,10 +226,48 @@ export function RecipeFields() { ); }; + const renderUnitPreference = () => ( + + Units + + + setUnitPreference('recipe' as UnitPreference)} + > + Recipe + + setUnitPreference('imperial' as UnitPreference)} + > + Imperial + + setUnitPreference('metric' as UnitPreference)} + > + Metric + + + + + ); + return (
{renderCaloriesPerServing()} {renderServings()} + {renderUnitPreference()} {renderCategories()} {renderCookTime()} {renderSource()} diff --git a/src/CookTime/client-app/src/components/Recipe/RecipeStep.tsx b/src/CookTime/client-app/src/components/Recipe/RecipeStep.tsx index 51cb233..e27ea4b 100644 --- a/src/CookTime/client-app/src/components/Recipe/RecipeStep.tsx +++ b/src/CookTime/client-app/src/components/Recipe/RecipeStep.tsx @@ -1,5 +1,6 @@ import { OverlayTrigger, Tooltip } from "react-bootstrap"; -import { IngredientRequirement, MultiPartRecipe, Recipe, RecipeComponent } from "src/shared/CookTime"; +import { IngredientRequirement, MeasureUnit, MultiPartRecipe, Recipe, RecipeComponent } from "src/shared/CookTime"; +import { UnitPreference, convertQuantity, formatNumber, formatUnitName } from "src/shared/units"; type Segment = { ingredient: IngredientRequirement | null, @@ -12,6 +13,8 @@ interface StepProps { multipart: boolean; component?: RecipeComponent; newServings: number; + unitPreference: UnitPreference; + units: MeasureUnit[]; } function trifurcate(s: string, position: number, length: number) { @@ -22,7 +25,7 @@ function trifurcate(s: string, position: number, length: number) { }; } -export function Step({ recipe, stepText, multipart, component, newServings }: StepProps) { +export function Step({ recipe, stepText, multipart, component, newServings, unitPreference, units }: StepProps) { let segments: Segment[] = [{ ingredient: null, text: stepText }]; const ingredientRequirements: IngredientRequirement[] = multipart @@ -81,7 +84,14 @@ export function Step({ recipe, stepText, multipart, component, newServings }: St } const newQuantity = segment.ingredient.quantity * newServings / recipe.servingsProduced; - const tooltipTitle = `${newQuantity} ${segment.ingredient.unit}`; + const conversion = convertQuantity({ + quantity: newQuantity, + unitName: segment.ingredient.unit, + units, + preference: unitPreference, + }); + const unitLabel = conversion.unitName === "count" ? "" : formatUnitName(conversion.unitName); + const tooltipTitle = `${formatNumber(conversion.quantity)}${unitLabel ? ` ${unitLabel}` : ""}`; return ( void, @@ -64,7 +67,7 @@ function SortableStep({ id, index, stepText, onTextChange, onDelete }: SortableS } export function RecipeStepList(props: RecipeStepListProps) { - const { recipe, multipart, component, newServings, edit, onChange, onDeleteStep, onNewStep } = props; + const { recipe, multipart, component, newServings, unitPreference, units, edit, onChange, onDeleteStep, onNewStep } = props; const sensors = useSensors( useSensor(PointerSensor), @@ -138,6 +141,8 @@ export function RecipeStepList(props: RecipeStepListProps) { multipart={multipart} recipe={recipe} stepText={step} + unitPreference={unitPreference} + units={units} newServings={newServings} /> @@ -156,6 +161,8 @@ export function RecipeStepList(props: RecipeStepListProps) { recipe={recipe} stepText={step} component={component} + unitPreference={unitPreference} + units={units} newServings={newServings} /> diff --git a/src/CookTime/client-app/src/shared/units.ts b/src/CookTime/client-app/src/shared/units.ts new file mode 100644 index 0000000..bf15f36 --- /dev/null +++ b/src/CookTime/client-app/src/shared/units.ts @@ -0,0 +1,138 @@ +import { MeasureUnit } from "./CookTime"; + +export type UnitPreference = "recipe" | "imperial" | "metric"; + +const IMPERIAL_VOLUME = [ + "teaspoon", + "tablespoon", + "fluid_ounce", + "cup", + "pint", + "quart", + "gallon", +]; + +const METRIC_VOLUME = ["milliliter", "liter"]; + +const IMPERIAL_WEIGHT = ["ounce", "pound"]; +const METRIC_WEIGHT = ["gram", "kilogram"]; + +const UNIT_DISPLAY_NAME: Record = { + fluid_ounce: "fluid ounce", +}; + +export function formatUnitName(unitName: string): string { + if (!unitName || unitName === "count") { + return ""; + } + if (UNIT_DISPLAY_NAME[unitName]) { + return UNIT_DISPLAY_NAME[unitName]; + } + return unitName.replace(/_/g, " ").toLowerCase(); +} + +export function formatNumber(value: number, maxDecimals = 2): string { + if (Number.isInteger(value)) { + return value.toString(); + } + const fixed = value.toFixed(maxDecimals); + return fixed.replace(/\.?0+$/, ""); +} + +type ConversionResult = { + quantity: number; + unitName: string; + displayName: string; + converted: boolean; +}; + +function getUnit(units: MeasureUnit[] | undefined, name: string): MeasureUnit | undefined { + return units?.find((unit) => unit.name === name); +} + +function pickBestUnit(baseSiValue: number, candidates: MeasureUnit[]): MeasureUnit | null { + if (candidates.length === 0) { + return null; + } + const sorted = [...candidates].sort((a, b) => a.siValue - b.siValue); + if (baseSiValue <= 0) { + return sorted[0]; + } + let best = sorted[0]; + for (const unit of sorted) { + if (baseSiValue / unit.siValue >= 1) { + best = unit; + } + } + return best; +} + +function getCandidates( + units: MeasureUnit[] | undefined, + siType: string, + preference: UnitPreference +): MeasureUnit[] { + const normalizedType = siType.toLowerCase(); + const isVolume = normalizedType === "volume"; + const isWeight = normalizedType === "weight"; + + if (!isVolume && !isWeight) { + return []; + } + + const names = + preference === "metric" + ? isVolume + ? METRIC_VOLUME + : METRIC_WEIGHT + : isVolume + ? IMPERIAL_VOLUME + : IMPERIAL_WEIGHT; + + return names + .map((name) => getUnit(units, name)) + .filter((unit): unit is MeasureUnit => unit != null); +} + +export function convertQuantity({ + quantity, + unitName, + units, + preference, +}: { + quantity: number; + unitName: string; + units?: MeasureUnit[]; + preference: UnitPreference; +}): ConversionResult { + const unit = getUnit(units, unitName); + + if (!unit || preference === "recipe" || unit.siType === "count") { + return { + quantity, + unitName, + displayName: formatUnitName(unitName), + converted: false, + }; + } + + const candidates = getCandidates(units, unit.siType, preference); + const best = pickBestUnit(quantity * unit.siValue, candidates); + + if (!best) { + return { + quantity, + unitName, + displayName: formatUnitName(unitName), + converted: false, + }; + } + + const convertedQuantity = (quantity * unit.siValue) / best.siValue; + return { + quantity: convertedQuantity, + unitName: best.name, + displayName: formatUnitName(best.name), + converted: true, + }; +}