Last active
July 16, 2025 11:22
-
-
Save briznad/0c4ef53381ae092f3cc53a44dee36bc0 to your computer and use it in GitHub Desktop.
Gigs Code Review
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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> | |
</> | |
); | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 }; | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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} | |
/> | |
</> | |
); | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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> | |
); | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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