Skip to content

Instantly share code, notes, and snippets.

@briznad
Last active July 16, 2025 11:22
Show Gist options
  • Save briznad/0c4ef53381ae092f3cc53a44dee36bc0 to your computer and use it in GitHub Desktop.
Save briznad/0c4ef53381ae092f3cc53a44dee36bc0 to your computer and use it in GitHub Desktop.
Gigs Code Review
import type { Item, UserSettings, MeasurementUpdatePayload } from './types';
import React, { useState } from 'react';
import { IonList, IonListHeader, IonItem, IonLabel } from '@ionic/react';
import MeasurementConverter from './MeasurementConverter';
import '@ionic/react/css/core.css';
export default function Demo() {
const settings: UserSettings = {
showAbbreviations: false,
showFractions: true,
};
const scale = 1;
const [items, setItems] = useState<Item[]>([
{
id: '1',
quantity: 2,
measurement: 'cup',
measurementType: 'volume',
description: 'flour',
originalInput: '2 cups flour',
},
{
id: '2',
quantity: 8,
measurement: 'fluid ounce',
measurementType: 'volume',
description: 'milk',
originalInput: '8 fl oz milk',
},
{
id: '3',
quantity: 1,
measurement: 'pound',
measurementType: 'weight',
description: 'sugar',
originalInput: '1 lb sugar',
},
]);
const handleUpdate = (itemId: string, updates: MeasurementUpdatePayload) => {
setItems(prev => prev.map(item =>
item.id === itemId
? { ...item, ...updates }
: item
));
};
return (
<>
<main>
<header>
<h1>Measurement Converter Demo</h1>
<p>
Click on any measurement to see conversion options.
</p>
</header>
<IonList>
<IonListHeader>
<IonLabel>Recipe Items</IonLabel>
</IonListHeader>
{items.map(item => (
<IonItem key={item.id}>
<IonLabel>
<span className="item-quantity">
{item.quantity * scale}
</span>
<MeasurementConverter
item={item}
settings={settings}
scale={scale}
onUpdate={(updates) => handleUpdate(item.id, updates)}
/>
<span className="item-description">
{item.description}
</span>
</IonLabel>
</IonItem>
))}
</IonList>
</main>
</>
);
}
import type { Item, UserSettings, ConversionGroup, Measurement, ConversionOption, MeasurementType } from './types';
import React from 'react';
import { getMeasurementConversion, getMeasurementsByType, getMeasurementAbbreviation, getFraction, round, pluralizeMeasurement } from './utilities';
export function useMeasurementConverter({ item, settings, scale = 1 }: { item: Item; settings: UserSettings; scale?: number }) {
const conversionGroup = React.useMemo((): ConversionGroup => {
const tempGroup: ConversionGroup = { options: [], altOption: undefined };
const sourceMeasurement = getMeasurementConversion(item.measurement, item.measurementType) ?? 1;
getMeasurementsByType(item.measurementType)
.filter((measurement: Measurement) => measurement !== 'undefined')
.forEach((measurement: Measurement) => {
const quantity = getQuantity(sourceMeasurement, getMeasurementConversion(measurement, item.measurementType) as number);
const displayValue = getDisplayValue(settings, undefined, measurement, quantity, true);
const payload: ConversionOption = { measurement, quantity, displayValue };
tempGroup.options.push(payload);
if (['ounce', 'fluid ounce'].includes(measurement)) {
tempGroup.altOption = getAltOption(payload, settings);
}
});
return tempGroup;
}, [item.measurementType, item.measurement, item.quantity, settings, scale]);
function getQuantity(sourceMeasurement: number, conversion: number): number {
return conversion === undefined || sourceMeasurement === undefined
? 1 : (item.quantity / sourceMeasurement) * conversion;
}
function getDisplayValue(
settings: UserSettings,
type?: MeasurementType,
measurement?: Measurement,
quantity?: number,
includeQuantity: boolean = false
): string {
const calculatedQuantity = (quantity ?? item.quantity) * scale;
const thisType = type ?? item.measurementType;
const thisMeasurement = measurement ?? item.measurement;
const abbreviation = settings.showAbbreviations && getMeasurementAbbreviation(thisMeasurement, thisType);
let displayQuantity = '';
if (includeQuantity && thisType !== 'count') {
displayQuantity = ' — ';
displayQuantity += settings.showFractions
? getFraction(calculatedQuantity)
: round(calculatedQuantity).toString();
}
return (abbreviation || thisMeasurement + pluralizeMeasurement(thisMeasurement, calculatedQuantity)) + displayQuantity;
}
function getAltOption(payload: ConversionOption, settings: UserSettings): ConversionOption {
const measurement = payload.measurement === 'ounce' ? 'fluid ounce' : 'ounce';
const type = measurement === 'ounce' ? 'weight' : 'volume';
const displayValue = getDisplayValue(settings, type, measurement, payload.quantity, true);
return { measurement, type, displayValue, quantity: payload.quantity };
}
const currentDisplayValue = React.useMemo(() => {
return item.measurement ? getDisplayValue(settings) : 'Select measurement';
}, [item.measurement, settings, scale]);
return { conversionGroup, currentDisplayValue };
}
import type { Item, UserSettings, MeasurementUpdatePayload } from './types';
import { IonButton } from '@ionic/react';
import { useMeasurementConverter } from './hooks';
import MeasurementPopover from './MeasurementPopover';
export default function MeasurementConverter({ item, settings, scale = 1, onUpdate }: {
item: Item;
settings: UserSettings;
scale?: number;
onUpdate: (payload: MeasurementUpdatePayload) => void;
}) {
const triggerId = `measurementPopoverTrigger_${item.id}`;
const { conversionGroup, currentDisplayValue } = useMeasurementConverter({ item, settings, scale });
return (
<>
<IonButton
id={triggerId}
fill="outline"
size="small"
>
{currentDisplayValue}
</IonButton>
<MeasurementPopover
triggerId={triggerId}
conversionGroup={conversionGroup}
currentItem={item}
onUpdate={onUpdate}
/>
</>
);
}
import type { Item, MeasurementUpdatePayload, ConversionGroup } from './types';
import { IonPopover, IonContent, IonList, IonItem, IonLabel, IonIcon } from '@ionic/react';
import { checkmark } from 'ionicons/icons';
export default function MeasurementPopover({ triggerId, conversionGroup, currentItem, onUpdate }: {
triggerId: string;
conversionGroup: ConversionGroup;
currentItem: Item;
onUpdate: (payload: MeasurementUpdatePayload) => void;
}) {
const handleUpdate = (measurement: string, quantity: number, measurementType?: string) => {
onUpdate({
measurement: measurement as any,
quantity,
measurementType: measurementType as any,
});
};
return (
<IonPopover trigger={triggerId} dismiss-on-select={true}>
<IonContent>
<IonList>
<IonItem lines="full">
<IonLabel>
CONVERT MEASUREMENT
</IonLabel>
</IonItem>
{conversionGroup.options.map((option, index) => {
const isCurrentMeasurement = option.measurement === currentItem.measurement;
const showBottomLine = index === conversionGroup.options.length - 1 && conversionGroup.altOption
? "full"
: undefined;
return (
<IonItem
key={option.measurement}
button={true}
detail={false}
lines={showBottomLine}
disabled={isCurrentMeasurement}
onClick={() => handleUpdate(option.measurement, option.quantity)}
>
<IonLabel>{option.displayValue}</IonLabel>
{isCurrentMeasurement && <IonIcon slot="end" icon={checkmark}></IonIcon>}
</IonItem>
);
})}
{conversionGroup.altOption && (
<>
<IonItem lines="full">
<IonLabel>
CONVERT TO {conversionGroup.altOption.type?.toUpperCase()}
</IonLabel>
</IonItem>
<IonItem
button={true}
detail={false}
lines="none"
onClick={() => handleUpdate(
conversionGroup.altOption!.measurement,
conversionGroup.altOption!.quantity,
conversionGroup.altOption!.type
)}
>
<IonLabel>{conversionGroup.altOption.displayValue}</IonLabel>
</IonItem>
</>
)}
</IonList>
</IonContent>
</IonPopover>
);
}
export type MeasurementType = 'volume' | 'weight' | 'length' | 'count';
export type Volume = 'dash' | 'pinch' | 'teaspoon' | 'tablespoon' | 'cup' | 'pint' | 'quart' | 'gallon' | 'milliliter' | 'liter' | 'fluid ounce';
export type Weight = 'ounce' | 'pound' | 'milligram' | 'gram' | 'kilogram';
export type Length = 'millimeter' | 'centimeter' | 'meter' | 'inch' | 'yard' | 'foot';
export type Count = 'undefined' | 'part' | 'bushel' | 'head' | 'bunch' | 'thing' | 'box' | 'piece' | 'portion' | 'bit' | 'slice' | 'chunk' | 'segment' | 'section' | 'lump' | 'hunk' | 'wedge' | 'slab' | 'block' | 'cake' | 'bar' | 'cube' | 'stick' | 'case' | 'flat' | 'can' | 'helping' | 'bottle' | 'jar' | 'sprig';
export type Measurement = Volume | Weight | Length | Count;
export interface Item {
id: string;
quantity: number;
measurement: Measurement;
measurementType: MeasurementType;
description: string;
originalInput: string;
}
export interface UserSettings {
showAbbreviations: boolean;
showFractions: boolean;
}
export interface ConversionOption {
measurement: Measurement;
quantity: number;
displayValue: string;
type?: MeasurementType;
}
export interface ConversionGroup {
options: ConversionOption[];
altOption?: ConversionOption;
}
export interface MeasurementUpdatePayload {
measurement: Measurement;
quantity: number;
measurementType?: MeasurementType;
}
import type { Measurement, MeasurementType, MeasurementMap, MeasurementsListMap } from './types';
import { objectKeys, roundToDecimals } from 'briznads-helpers';
// the base measurement for each type has a conversion value of 1
// all other conversion values represent the conversion to the base measurement
// to convert from one measurement to another, we convert from the source measurement,
// to the the base measurement, then to the target measurement, aka:
// [qty] / [from source measurement] * [to target measurement]
// for example, to convert 2 quarts to cups:
// 2 / 0.00105669 * 0.00422675 = 8
// Main data structure containing all measurement units organized by type
// For each type, the base unit has conversion = 1, others are relative to it
// Example: milliliter is the base unit for volume, gram for weight, millimeter for length
const measurementMap : MeasurementMap = {
volume : {
'milliliter' : {
conversion : 1,
abbreviation : 'ML',
},
'pinch' : {
conversion : 3.246144,
},
'dash' : {
conversion : 1.623072,
},
'teaspoon' : {
conversion : 0.202884,
abbreviation : 'TSP',
alternateSpellings : [ 'tea spoon' ],
},
'tablespoon' : {
conversion : 0.067628,
abbreviation : 'TBSP',
alternateSpellings : [ 'tbs', 'tb', 'table spoon' ],
},
'cup' : {
conversion : 0.00422675,
abbreviation : 'C',
},
'pint' : {
conversion : 0.00211338,
abbreviation : 'PT',
},
'quart' : {
conversion : 0.00105669,
abbreviation : 'QT',
},
'gallon' : {
conversion : 0.000264172,
abbreviation : 'GAL',
},
'liter' : {
conversion : 0.001,
abbreviation : 'L',
alternateSpellings : [ 'litre' ],
},
'fluid ounce' : {
conversion : 0.033814,
abbreviation : 'FL OZ',
alternateSpellings : [ 'fluid oz', 'fl ounce' ],
},
},
// Weight measurements with gram as the base unit
weight : {
'gram' : {
conversion : 1,
abbreviation : 'G',
alternateSpellings : [ 'gramme' ],
},
'milligram' : {
conversion : 1000,
abbreviation : 'MG',
alternateSpellings : [ 'milligramme' ],
},
'ounce' : {
conversion : 0.035274,
abbreviation : 'OZ',
},
'pound' : {
conversion : 0.00220462,
abbreviation : 'LB',
alternateSpellings : [ '#' ],
},
'kilogram' : {
conversion : 0.001,
abbreviation : 'KG',
alternateSpellings : [ 'kilo', 'kilogramme' ],
},
},
// Length measurements with millimeter as the base unit
length : {
'millimeter' : {
conversion : 1,
abbreviation : 'MM',
alternateSpellings : [ 'millimetre' ],
},
'centimeter' : {
conversion : 0.1,
abbreviation : 'CM',
alternateSpellings : [ 'centimetre' ],
},
'meter' : {
conversion : 0.001,
abbreviation : 'M',
alternateSpellings : [ 'metre' ],
},
'inch' : {
conversion : 0.0393701,
abbreviation : 'IN',
alternateSpellings : [ '"' ],
},
'yard' : {
conversion : 0.00109361,
abbreviation : 'YD',
},
'foot' : {
conversion : 0.00328084,
abbreviation : 'FT',
alternateSpellings : [ 'feet', `'` ],
},
},
// Count measurements (units without standard conversion factors)
count : {
'undefined' : {},
'part' : {},
'bushel' : {
abbreviation : 'BSH',
},
'head' : {},
'bunch' : {},
'thing' : {},
'box' : {},
'piece' : {
abbreviation : 'PC',
alternateSpellings : [ 'peice' ],
},
'portion' : {},
'bit' : {},
'slice' : {},
'chunk' : {},
'segment' : {},
'section' : {},
'lump' : {},
'hunk' : {},
'wedge' : {},
'slab' : {},
'block' : {},
'cake' : {},
'bar' : {},
'cube' : {},
'stick' : {},
'case' : {},
'flat' : {},
'can' : {},
'bottle' : {},
'jar' : {},
'sprig' : {},
},
};
const fractionsMap : Record<number, string> = {
0.25 : '¼',
0.333 : '⅓',
0.5 : '½',
0.667 : '⅔',
0.75 : '¾',
0.2 : '⅕',
0.4 : '⅖',
0.6 : '⅗',
0.8 : '⅘',
0.167 : '⅙',
0.833 : '⅚',
0.125 : '⅛',
0.375 : '⅜',
0.625 : '⅝',
0.875 : '⅞',
};
// Returns an array of all measurements for a specific type
// Example: getMeasurementsByType('volume') returns ['milliliter', 'pinch', ...]
export function getMeasurementsByType(type : MeasurementType) : Measurement[] {
return objectKeys(measurementMap[ type ] ?? {});
}
// Gets the conversion factor for a specific measurement and type
// Used for converting between measurements of the same type
export function getMeasurementConversion(measurement : Measurement, type : MeasurementType) : number | undefined {
return measurementMap[ type ]?.[ measurement ]?.conversion;
}
// Gets the standard abbreviation for a measurement (e.g., 'ML' for 'milliliter')
export function getMeasurementAbbreviation(measurement : Measurement, type : MeasurementType) : string | undefined {
return measurementMap[ type ]?.[ measurement ]?.abbreviation;
}
// List of measurements that need 'es' instead of 's' when pluralized
const specialPluralMeasurements : Measurement[] = [
'pinch',
'dash',
'inch',
'bunch',
'box',
];
// Returns the appropriate suffix for pluralizing a measurement
// Returns empty string for singular, 's' or 'es' for plural based on the measurement
export function pluralizeMeasurement(measurement : Measurement, quantity : number) : string {
if (quantity === 1) {
return '';
}
return specialPluralMeasurements.includes(measurement)
? 'es'
: 's';
}
export function round(float : number) : number {
return roundToDecimals(float, 5);
}
export function getFraction(float : number) : string {
const rounded = round(float);
const decimal : number = roundToDecimals(rounded % 1, 3);
if (!fractionsMap[ decimal ]) {
return rounded.toString();
}
const whole : number = roundToDecimals(rounded - decimal, 0);
return (whole === 0 ? '' : whole) + fractionsMap[ decimal ];
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment