Skip to content

Commit 532ded7

Browse files
authored
Unit toggle between imperial and metric (#85)
1 parent 592093a commit 532ded7

9 files changed

Lines changed: 245 additions & 23 deletions

File tree

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## 2026-02-13
99

10+
### Added
11+
12+
- Metric to imperial conversion for recipe ingredient units
13+
1014
### Changed
1115

1216
- Tuned prompt to remove ingredient preparation from recipe ingredients

src/CookTime/client-app/src/components/IngredientRequirements/IngredientRequirementList.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,21 @@
11
import React from "react";
22
import { Row } from "react-bootstrap";
33
import { IngredientRequirement, MeasureUnit } from "src/shared/CookTime";
4+
import { UnitPreference } from "src/shared/units";
45
import { IngredientDisplay } from "../Ingredients/IngredientDisplay";
56

67
interface IngredientRequirementListProps {
78
ingredientRequirements: IngredientRequirement[];
89
units: MeasureUnit[];
910
multiplier: number;
11+
unitPreference: UnitPreference;
1012
}
1113

1214
export function IngredientRequirementList({
1315
ingredientRequirements,
1416
units,
1517
multiplier,
18+
unitPreference,
1619
}: IngredientRequirementListProps) {
1720
return (
1821
<>
@@ -23,6 +26,7 @@ export function IngredientRequirementList({
2326
<IngredientDisplay
2427
showAlternatUnit={true}
2528
units={units}
29+
unitPreference={unitPreference}
2630
ingredientRequirement={{ ...ingredient, quantity: scaledQuantity }}
2731
/>
2832
</Row>

src/CookTime/client-app/src/components/Ingredients/IngredientDisplay.tsx

Lines changed: 16 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,36 +1,35 @@
11
import React from "react";
22
import { IngredientRequirement, MeasureUnit } from "src/shared/CookTime";
3+
import { UnitPreference, convertQuantity, formatNumber, formatUnitName } from "src/shared/units";
34

45
type IngredientDisplayProps = {
56
ingredientRequirement: IngredientRequirement
67
strikethrough?: boolean,
78
showAlternatUnit?: boolean
89
units?: MeasureUnit[]
10+
unitPreference?: UnitPreference
911
}
1012
export class IngredientDisplay extends React.Component<IngredientDisplayProps, {}> {
1113

1214
render() {
1315
let ingredient = this.props.ingredientRequirement.ingredient
14-
var unitName = ""
15-
switch (this.props.ingredientRequirement.unit) {
16-
case "count":
17-
unitName = ""
18-
break;
16+
const unitPreference = this.props.unitPreference ?? "recipe";
17+
const conversion = convertQuantity({
18+
quantity: this.props.ingredientRequirement.quantity,
19+
unitName: this.props.ingredientRequirement.unit,
20+
units: this.props.units,
21+
preference: unitPreference,
22+
});
1923

20-
case "fluid_ounce":
21-
unitName = "fluid ounce"
22-
break;
23-
24-
default:
25-
unitName = this.props.ingredientRequirement.unit.toLowerCase()
26-
break;
27-
}
28-
var quantity = <>{this.props.ingredientRequirement.quantity.toString()}</>
29-
let fraction = this.Fraction(this.props.ingredientRequirement.quantity);
24+
const unitName = conversion.unitName === "count" ? "" : formatUnitName(conversion.unitName);
25+
let fraction = this.Fraction(conversion.quantity);
26+
let quantity = unitPreference === "metric"
27+
? <>{formatNumber(conversion.quantity)}</>
28+
: <>{fraction}</>;
3029

3130
// Show IR text, but if that's not available then show the ingredient canonical name.
3231
var ingredientName = (this.props.ingredientRequirement.text ?? ingredient.name.split(";").map(s => s.trim())[0]).toLowerCase()
33-
var text = <>{fraction} {unitName} {this.props.showAlternatUnit ? this.getAlternateUnit() : null} {ingredientName}
32+
var text = <>{quantity} {unitName} {this.props.showAlternatUnit && unitPreference === "recipe" ? this.getAlternateUnit() : null} {ingredientName}
3433
</>
3534
if (this.props.strikethrough) {
3635
text = <s>{text}</s>
@@ -131,4 +130,4 @@ export class IngredientDisplay extends React.Component<IngredientDisplayProps, {
131130
return ""
132131
}
133132
}
134-
}
133+
}

src/CookTime/client-app/src/components/Recipe/RecipeComponentEditor.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ export function RecipeComponentEditor({ component, componentIndex }: RecipeCompo
1515
recipe,
1616
edit,
1717
units,
18+
unitPreference,
1819
newServings,
1920
updateComponent,
2021
appendIngredientToComponent,
@@ -86,6 +87,7 @@ export function RecipeComponentEditor({ component, componentIndex }: RecipeCompo
8687
<IngredientRequirementList
8788
ingredientRequirements={component.ingredients ?? []}
8889
units={units}
90+
unitPreference={unitPreference}
8991
multiplier={newServings / recipe.servingsProduced}
9092
/>
9193
)}
@@ -102,6 +104,8 @@ export function RecipeComponentEditor({ component, componentIndex }: RecipeCompo
102104
recipe={recipe}
103105
component={component}
104106
newServings={newServings}
107+
unitPreference={unitPreference}
108+
units={units}
105109
edit={edit}
106110
onDeleteStep={(idx) => deleteStepFromComponent(componentIndex, idx)}
107111
onChange={(newSteps) => updateStepsInComponent(componentIndex, newSteps)}

src/CookTime/client-app/src/components/Recipe/RecipeContext.tsx

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
toRecipeUpdateDto,
1616
addToList,
1717
} from 'src/shared/CookTime';
18+
import { UnitPreference } from 'src/shared/units';
1819

1920
export type PendingImage = {
2021
id: string;
@@ -35,6 +36,7 @@ interface RecipeContextState {
3536
imageOperationInProgress: boolean;
3637
edit: boolean;
3738
units: MeasureUnit[];
39+
unitPreference: UnitPreference;
3840
newServings: number;
3941
errorMessage: string | null;
4042
operationInProgress: boolean;
@@ -51,6 +53,7 @@ interface RecipeContextActions {
5153
setErrorMessage: (message: string | null) => void;
5254
setNewServings: (servings: number) => void;
5355
setToastMessage: (message: string | null) => void;
56+
setUnitPreference: (preference: UnitPreference) => void;
5457

5558
// Recipe updates
5659
updateRecipe: (updates: Partial<MultiPartRecipe>) => void;
@@ -129,6 +132,7 @@ export function RecipeProvider({ recipeId, generatedRecipe, children }: RecipePr
129132
const [imageOperationInProgress, setImageOperationInProgress] = useState(false);
130133
const [edit, setEdit] = useState(false);
131134
const [units, setUnits] = useState<MeasureUnit[]>([]);
135+
const [unitPreference, setUnitPreference] = useState<UnitPreference>('recipe');
132136
const [newServings, setNewServings] = useState(1);
133137
const [errorMessage, setErrorMessage] = useState<string | null>(null);
134138
const [operationInProgress, setOperationInProgress] = useState(false);
@@ -175,6 +179,11 @@ export function RecipeProvider({ recipeId, generatedRecipe, children }: RecipePr
175179

176180
// Load initial data
177181
useEffect(() => {
182+
const savedPreference = window.localStorage.getItem('cooktime.unitPreference');
183+
if (savedPreference === 'recipe' || savedPreference === 'imperial' || savedPreference === 'metric') {
184+
setUnitPreference(savedPreference);
185+
}
186+
178187
// Fetch units
179188
fetch('/api/recipe/units')
180189
.then((res) => res.json())
@@ -223,6 +232,10 @@ export function RecipeProvider({ recipeId, generatedRecipe, children }: RecipePr
223232
.then((result) => setNutritionFacts(result as RecipeNutritionFacts));
224233
}, [recipeId, generatedRecipe, applyGeneratedRecipe]);
225234

235+
useEffect(() => {
236+
window.localStorage.setItem('cooktime.unitPreference', unitPreference);
237+
}, [unitPreference]);
238+
226239
// Recipe updates
227240
const updateRecipe = useCallback((updates: Partial<MultiPartRecipe>) => {
228241
setRecipe((prev) => ({ ...prev, ...updates }));
@@ -521,6 +534,7 @@ export function RecipeProvider({ recipeId, generatedRecipe, children }: RecipePr
521534
imageOperationInProgress,
522535
edit,
523536
units,
537+
unitPreference,
524538
newServings,
525539
errorMessage,
526540
operationInProgress,
@@ -534,6 +548,7 @@ export function RecipeProvider({ recipeId, generatedRecipe, children }: RecipePr
534548
setErrorMessage,
535549
setNewServings,
536550
setToastMessage,
551+
setUnitPreference,
537552
updateRecipe,
538553
updateComponent,
539554
appendIngredientToComponent,

src/CookTime/client-app/src/components/Recipe/RecipeFields.tsx

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
import React from 'react';
2-
import { Button, Col, Form, Row } from 'react-bootstrap';
2+
import { Button, Col, DropdownButton, Dropdown, Form, Row } from 'react-bootstrap';
33
import { useRecipeContext } from './RecipeContext';
44
import { ImageEditor } from './ImageEditor';
55
import { Tags } from '../Tags/Tags';
6+
import { UnitPreference } from 'src/shared/units';
67

78
export function RecipeFields() {
89
const {
@@ -14,8 +15,10 @@ export function RecipeFields() {
1415
pendingImages,
1516
imageOrder,
1617
imageOperationInProgress,
18+
unitPreference,
1719
setNewServings,
1820
updateRecipe,
21+
setUnitPreference,
1922
handleAddImages,
2023
handleRemoveExistingImage,
2124
handleRemovePendingImage,
@@ -223,10 +226,48 @@ export function RecipeFields() {
223226
);
224227
};
225228

229+
const renderUnitPreference = () => (
230+
<Row className="padding-right-0 d-flex align-items-center recipe-edit-row">
231+
<Col className="col-3 recipe-field-title">Units</Col>
232+
<Col className="col d-flex align-items-center">
233+
<DropdownButton
234+
variant="secondary"
235+
title={
236+
unitPreference === 'imperial'
237+
? 'Imperial'
238+
: unitPreference === 'metric'
239+
? 'Metric'
240+
: 'Recipe'
241+
}
242+
>
243+
<Dropdown.Item
244+
active={unitPreference === 'recipe'}
245+
onClick={() => setUnitPreference('recipe' as UnitPreference)}
246+
>
247+
Recipe
248+
</Dropdown.Item>
249+
<Dropdown.Item
250+
active={unitPreference === 'imperial'}
251+
onClick={() => setUnitPreference('imperial' as UnitPreference)}
252+
>
253+
Imperial
254+
</Dropdown.Item>
255+
<Dropdown.Item
256+
active={unitPreference === 'metric'}
257+
onClick={() => setUnitPreference('metric' as UnitPreference)}
258+
>
259+
Metric
260+
</Dropdown.Item>
261+
</DropdownButton>
262+
</Col>
263+
</Row>
264+
);
265+
226266
return (
227267
<div>
228268
{renderCaloriesPerServing()}
229269
{renderServings()}
270+
{renderUnitPreference()}
230271
{renderCategories()}
231272
{renderCookTime()}
232273
{renderSource()}

src/CookTime/client-app/src/components/Recipe/RecipeStep.tsx

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { OverlayTrigger, Tooltip } from "react-bootstrap";
2-
import { IngredientRequirement, MultiPartRecipe, Recipe, RecipeComponent } from "src/shared/CookTime";
2+
import { IngredientRequirement, MeasureUnit, MultiPartRecipe, Recipe, RecipeComponent } from "src/shared/CookTime";
3+
import { UnitPreference, convertQuantity, formatNumber, formatUnitName } from "src/shared/units";
34

45
type Segment = {
56
ingredient: IngredientRequirement | null,
@@ -12,6 +13,8 @@ interface StepProps {
1213
multipart: boolean;
1314
component?: RecipeComponent;
1415
newServings: number;
16+
unitPreference: UnitPreference;
17+
units: MeasureUnit[];
1518
}
1619

1720
function trifurcate(s: string, position: number, length: number) {
@@ -22,7 +25,7 @@ function trifurcate(s: string, position: number, length: number) {
2225
};
2326
}
2427

25-
export function Step({ recipe, stepText, multipart, component, newServings }: StepProps) {
28+
export function Step({ recipe, stepText, multipart, component, newServings, unitPreference, units }: StepProps) {
2629
let segments: Segment[] = [{ ingredient: null, text: stepText }];
2730

2831
const ingredientRequirements: IngredientRequirement[] = multipart
@@ -81,7 +84,14 @@ export function Step({ recipe, stepText, multipart, component, newServings }: St
8184
}
8285

8386
const newQuantity = segment.ingredient.quantity * newServings / recipe.servingsProduced;
84-
const tooltipTitle = `${newQuantity} ${segment.ingredient.unit}`;
87+
const conversion = convertQuantity({
88+
quantity: newQuantity,
89+
unitName: segment.ingredient.unit,
90+
units,
91+
preference: unitPreference,
92+
});
93+
const unitLabel = conversion.unitName === "count" ? "" : formatUnitName(conversion.unitName);
94+
const tooltipTitle = `${formatNumber(conversion.quantity)}${unitLabel ? ` ${unitLabel}` : ""}`;
8595

8696
return (
8797
<OverlayTrigger

src/CookTime/client-app/src/components/Recipe/RecipeStepList.tsx

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,14 @@ import { Step } from "./RecipeStep";
44
import { DndContext, closestCenter, KeyboardSensor, PointerSensor, useSensor, useSensors, DragEndEvent } from "@dnd-kit/core";
55
import { SortableContext, sortableKeyboardCoordinates, useSortable, verticalListSortingStrategy, arrayMove } from "@dnd-kit/sortable";
66
import { CSS } from "@dnd-kit/utilities";
7-
import { MultiPartRecipe, Recipe, RecipeComponent } from "src/shared/CookTime";
7+
import { MeasureUnit, MultiPartRecipe, Recipe, RecipeComponent } from "src/shared/CookTime";
8+
import { UnitPreference } from "src/shared/units";
89

910
type RecipeStepListProps = {
1011
recipe: Recipe | MultiPartRecipe,
1112
newServings: number,
13+
unitPreference: UnitPreference,
14+
units: MeasureUnit[],
1215
multipart: boolean,
1316
component?: RecipeComponent,
1417
onDeleteStep: (i: number, component?: RecipeComponent) => void,
@@ -64,7 +67,7 @@ function SortableStep({ id, index, stepText, onTextChange, onDelete }: SortableS
6467
}
6568

6669
export function RecipeStepList(props: RecipeStepListProps) {
67-
const { recipe, multipart, component, newServings, edit, onChange, onDeleteStep, onNewStep } = props;
70+
const { recipe, multipart, component, newServings, unitPreference, units, edit, onChange, onDeleteStep, onNewStep } = props;
6871

6972
const sensors = useSensors(
7073
useSensor(PointerSensor),
@@ -138,6 +141,8 @@ export function RecipeStepList(props: RecipeStepListProps) {
138141
multipart={multipart}
139142
recipe={recipe}
140143
stepText={step}
144+
unitPreference={unitPreference}
145+
units={units}
141146
newServings={newServings} />
142147
</Col>
143148
</Row>
@@ -156,6 +161,8 @@ export function RecipeStepList(props: RecipeStepListProps) {
156161
recipe={recipe}
157162
stepText={step}
158163
component={component}
164+
unitPreference={unitPreference}
165+
units={units}
159166
newServings={newServings} />
160167
</Col>
161168
</Row>

0 commit comments

Comments
 (0)