) => {
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,
+ };
+}