diff --git a/backend/alembic/versions/0034_favorite_item_portion.py b/backend/alembic/versions/0034_favorite_item_portion.py new file mode 100644 index 0000000..086f9f1 --- /dev/null +++ b/backend/alembic/versions/0034_favorite_item_portion.py @@ -0,0 +1,27 @@ +"""record the portion unit a favorite item was built with + +Revision ID: 0034_favorite_item_portion +Revises: 0033_add_water_presets +""" +from collections.abc import Sequence + +import sqlalchemy as sa + +from alembic import op + +revision: str = '0034_favorite_item_portion' +down_revision: str | None = '0033_add_water_presets' +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + # Nullable: favorites saved before this migration have no recorded measure + # and keep displaying in grams. + op.add_column("favorite_items", sa.Column("quantity", sa.Float(), nullable=True)) + op.add_column("favorite_items", sa.Column("unit", sa.Text(), nullable=True)) + + +def downgrade() -> None: + op.drop_column("favorite_items", "unit") + op.drop_column("favorite_items", "quantity") diff --git a/backend/luma/api/family.py b/backend/luma/api/family.py index 3d4e2c8..752c41e 100644 --- a/backend/luma/api/family.py +++ b/backend/luma/api/family.py @@ -556,14 +556,15 @@ async def copy_shared_resource( await db.execute( text(""" INSERT INTO favorite_items - (id, favorite_id, sort_order, food_name, brand, quantity_g, nutrients) + (id, favorite_id, sort_order, food_name, brand, quantity_g, quantity, unit, nutrients) VALUES - (:id, :fid, :order, :name, :brand, :qty, CAST(:nutrients AS jsonb)) + (:id, :fid, :order, :name, :brand, :qty, :quantity, :unit, CAST(:nutrients AS jsonb)) """), { "id": str(uuid.uuid4()), "fid": new_id, "order": item.sort_order, "name": item.food_name, "brand": item.brand, "qty": item.quantity_g, + "quantity": item.quantity, "unit": item.unit, "nutrients": json.dumps(item.nutrients or {}), }, ) diff --git a/backend/luma/api/favorites.py b/backend/luma/api/favorites.py index 0f65eb3..7cad776 100644 --- a/backend/luma/api/favorites.py +++ b/backend/luma/api/favorites.py @@ -18,6 +18,11 @@ class FavoriteItemIn(BaseModel): food_name: str brand: str | None = None quantity_g: float + # The measure the item was built with — 2 "cup", 4 "oz". quantity_g stays the + # resolved weight; these ride along so editing the favorite can show the unit + # the user picked. Omitted by clients that only ever work in grams. + quantity: float | None = None + unit: str | None = None nutrients: dict = {} @@ -40,6 +45,8 @@ def _item_row_to_dict(r: Any) -> dict[str, Any]: "food_name": r.food_name, "brand": r.brand, "quantity_g": r.quantity_g, + "quantity": r.quantity, + "unit": r.unit, "nutrients": r.nutrients if r.nutrients is not None else {}, } @@ -58,6 +65,8 @@ async def _fetch_favorite(favorite_id: str, user_id: str, db: Any) -> dict[str, fi.food_name, fi.brand, fi.quantity_g, + fi.quantity, + fi.unit, fi.nutrients FROM favorites f LEFT JOIN favorite_items fi ON fi.favorite_id = f.id @@ -139,6 +148,8 @@ async def list_favorites( fi.food_name, fi.brand, fi.quantity_g, + fi.quantity, + fi.unit, fi.nutrients FROM paged p LEFT JOIN favorite_items fi ON fi.favorite_id = p.id @@ -194,8 +205,8 @@ async def create_favorite( item_id = str(uuid.uuid4()) await db.execute( text(""" - INSERT INTO favorite_items (id, favorite_id, sort_order, food_name, brand, quantity_g, nutrients) - VALUES (:id, :fav_id, :sort_order, :food_name, :brand, :quantity_g, CAST(:nutrients AS jsonb)) + INSERT INTO favorite_items (id, favorite_id, sort_order, food_name, brand, quantity_g, quantity, unit, nutrients) + VALUES (:id, :fav_id, :sort_order, :food_name, :brand, :quantity_g, :quantity, :unit, CAST(:nutrients AS jsonb)) """), { "id": item_id, @@ -204,6 +215,8 @@ async def create_favorite( "food_name": item.food_name, "brand": item.brand, "quantity_g": item.quantity_g, + "quantity": item.quantity, + "unit": item.unit, "nutrients": json.dumps(item.nutrients), }, ) @@ -256,8 +269,8 @@ async def update_favorite( item_id = str(uuid.uuid4()) await db.execute( text(""" - INSERT INTO favorite_items (id, favorite_id, sort_order, food_name, brand, quantity_g, nutrients) - VALUES (:id, :fav_id, :sort_order, :food_name, :brand, :quantity_g, CAST(:nutrients AS jsonb)) + INSERT INTO favorite_items (id, favorite_id, sort_order, food_name, brand, quantity_g, quantity, unit, nutrients) + VALUES (:id, :fav_id, :sort_order, :food_name, :brand, :quantity_g, :quantity, :unit, CAST(:nutrients AS jsonb)) """), { "id": item_id, @@ -266,6 +279,8 @@ async def update_favorite( "food_name": item.food_name, "brand": item.brand, "quantity_g": item.quantity_g, + "quantity": item.quantity, + "unit": item.unit, "nutrients": json.dumps(item.nutrients), }, ) diff --git a/backend/luma/db/models.py b/backend/luma/db/models.py index bb607fd..e0209aa 100644 --- a/backend/luma/db/models.py +++ b/backend/luma/db/models.py @@ -346,6 +346,12 @@ class FavoriteItem(Base): food_name: Mapped[str] = mapped_column(Text, nullable=False) brand: Mapped[str | None] = mapped_column(Text) quantity_g: Mapped[float] = mapped_column(Float, nullable=False) + # The measure the user actually picked when building the item ("2" + "cup"). + # quantity_g stays the resolved weight everything else computes from; these + # two only carry the portion forward so editing shows the original unit. + # Null on favorites saved before portions were recorded. + quantity: Mapped[float | None] = mapped_column(Float) + unit: Mapped[str | None] = mapped_column(Text) nutrients: Mapped[dict[str, Any]] = mapped_column(JSONB, nullable=False, default=dict) favorite = relationship("Favorite", back_populates="items") diff --git a/frontend/src/components/LogSheet.tsx b/frontend/src/components/LogSheet.tsx index 78af6ca..06346d2 100644 --- a/frontend/src/components/LogSheet.tsx +++ b/frontend/src/components/LogSheet.tsx @@ -7,6 +7,7 @@ import { VoiceTab } from './log-sheet/VoiceTab' import { SearchTab } from './log-sheet/SearchTab' import { ScanTab } from './log-sheet/ScanTab' import { QuickTab } from './log-sheet/QuickTab' +import { favoriteItemFromDraft } from './log-sheet/types' import type { DraftItem, Favorite } from './log-sheet/types' import { scaleByRatio, sumNutrients } from '../lib/nutrients' import { getCurrentSlot } from '../lib/format' @@ -192,12 +193,7 @@ export default function LogSheet({ mode = 'sheet', onClose }: LogSheetProps) { const favMutation = useMutation({ mutationFn: (name: string) => api.post('/favorites', { name: name.trim() || 'My favorite', - items: draftItems.map((item) => ({ - food_name: item.name, - brand: item.brand ?? null, - quantity_g: item.estimated_weight_g, - nutrients: item.nutrients, - })), + items: draftItems.map(favoriteItemFromDraft), }), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['favorites'] }) diff --git a/frontend/src/components/log-sheet/DraftItemList.tsx b/frontend/src/components/log-sheet/DraftItemList.tsx index 4c967ee..20c8c3b 100644 --- a/frontend/src/components/log-sheet/DraftItemList.tsx +++ b/frontend/src/components/log-sheet/DraftItemList.tsx @@ -2,6 +2,7 @@ import { useState } from 'react' import { Replace, Utensils, X, SlidersHorizontal, Check } from 'lucide-react' import type { DraftItem } from './types' import { scaleNutrients, scaleByRatio } from '../../lib/nutrients' +import { formatQuantity, isGramUnit } from '../../lib/portions' import { NutritionFactsEditor } from './NutritionFactsEditor' type Props = { @@ -64,6 +65,10 @@ export function DraftItemList({ draftItems, onRemoveItem, onUpdateWeight, onUpda const showPerServing = (servings ?? 1) > 1 const [editingIndex, setEditingIndex] = useState(null) const [editSave, setEditSave] = useState(true) + // Portion input text while it has focus. The committed value is always grams; + // holding the raw string lets a partial entry ("0.", "1.2") survive the + // round-trip through grams and back without snapping to a rounded quantity. + const [portionDraft, setPortionDraft] = useState<{ index: number; text: string } | null>(null) const openEditor = (idx: number) => { setEditSave(true) @@ -92,6 +97,13 @@ export function DraftItemList({ draftItems, onRemoveItem, onUpdateWeight, onUpda const base = item.base_weight_g ?? item.estimated_weight_g const current = Math.round(item.estimated_weight_g) const perServingG = showPerServing ? item.estimated_weight_g / (servings as number) : 0 + // Show the portion in the unit it was built with when one survived + // (a favorite saved as cups), otherwise fall back to plain grams. + const perUnit = !isGramUnit(item.unit) && item.unit_grams && item.unit_grams > 0 + ? item.unit_grams + : null + const unitLabel = perUnit ? item.unit : 'g' + const displayQty = perUnit ? formatQuantity(item.estimated_weight_g / perUnit) : current return (
@@ -174,13 +186,24 @@ export function DraftItemList({ draftItems, onRemoveItem, onUpdateWeight, onUpda
- {/* Portion: editable grams + relative multiplier chips */} + {/* Portion: editable amount in the unit it was built with (falling + back to grams) + relative multiplier chips */}
-
+
onUpdateWeight(idx, Math.max(1, parseInt(e.target.value) || 0))} + step={perUnit ? 'any' : 1} + inputMode="decimal" + value={portionDraft?.index === idx ? portionDraft.text : displayQty} + aria-label={`Portion in ${unitLabel}`} + onChange={(e) => { + const raw = e.target.value + setPortionDraft({ index: idx, text: raw }) + const parsed = parseFloat(raw) + if (!Number.isFinite(parsed)) return + onUpdateWeight(idx, Math.max(1, Math.round(parsed * (perUnit ?? 1)))) + }} + onBlur={() => setPortionDraft(null)} className="field-input" style={{ width: 62, textAlign: 'center', borderRadius: 8, padding: '5px 4px', @@ -188,7 +211,15 @@ export function DraftItemList({ draftItems, onRemoveItem, onUpdateWeight, onUpda fontFamily: 'var(--font-mono)', color: 'var(--sky-400)', }} /> - g + + {unitLabel} + {perUnit && ( + · {current}g + )} +
{PORTION_MULTIPLIERS.map(({ factor, label }) => { @@ -197,7 +228,7 @@ export function DraftItemList({ draftItems, onRemoveItem, onUpdateWeight, onUpda return (
- {fav.items.map((item, idx) => ( + {fav.items.map((item, idx) => { + const measure = measureLabel(item.quantity, item.unit) + return (
{item.food_name} {item.brand && {item.brand}} + {measure && {measure}}
{Math.round(item.quantity_g)} @@ -935,7 +920,8 @@ export default function FavoritesRoute() { prot
- ))} + ) + })}
diff --git a/frontend/src/test/DraftItemList.test.tsx b/frontend/src/test/DraftItemList.test.tsx index 352b253..15f3480 100644 --- a/frontend/src/test/DraftItemList.test.tsx +++ b/frontend/src/test/DraftItemList.test.tsx @@ -162,3 +162,81 @@ describe('DraftItemList nutrition editor', () => { expect(last[1].estimated_weight_g).toBe(240) }) }) + +describe('DraftItemList portion unit', () => { + it('shows the portion in grams when no unit was carried', () => { + render( + , + ) + expect(screen.getByLabelText('Portion in g')).toHaveValue(150) + }) + + it('shows the portion in the unit it was built with', () => { + render( + , + ) + expect(screen.getByLabelText('Portion in cup')).toHaveValue(2) + // Grams stay visible alongside so the weight is never hidden. + expect(screen.getByText(/480g/)).toBeInTheDocument() + }) + + it('commits an edited quantity back as grams', () => { + const onUpdateWeight = vi.fn() + render( + , + ) + fireEvent.change(screen.getByLabelText('Portion in cup'), { target: { value: '1.5' } }) + expect(onUpdateWeight).toHaveBeenCalledWith(0, 360) + }) + + it('leaves a cleared field empty instead of snapping the quantity back', () => { + const onUpdateWeight = vi.fn() + render( + , + ) + const input = screen.getByLabelText('Portion in cup') as HTMLInputElement + // Mid-edit the field is empty. Without the draft-string state React would + // re-render the derived quantity over it, and the old code committed 1g. + fireEvent.change(input, { target: { value: '' } }) + expect(input.value).toBe('') + expect(onUpdateWeight).not.toHaveBeenCalled() + + fireEvent.change(input, { target: { value: '3' } }) + expect(onUpdateWeight).toHaveBeenCalledWith(0, 720) + }) + + it('restores the derived quantity when the field loses focus', () => { + render( + , + ) + const input = screen.getByLabelText('Portion in cup') as HTMLInputElement + fireEvent.change(input, { target: { value: '' } }) + fireEvent.blur(input) + expect(input).toHaveValue(1) + }) + + it('falls back to grams when the unit has no gram anchor', () => { + render( + , + ) + expect(screen.getByLabelText('Portion in g')).toHaveValue(320) + }) +}) diff --git a/frontend/src/test/favorite-portion.test.ts b/frontend/src/test/favorite-portion.test.ts new file mode 100644 index 0000000..c37e01b --- /dev/null +++ b/frontend/src/test/favorite-portion.test.ts @@ -0,0 +1,102 @@ +import { describe, it, expect } from 'vitest' +import { favoriteItemFromDraft, draftFromFavoriteItem } from '../components/log-sheet/types' +import type { DraftItem, FavoriteItem } from '../components/log-sheet/types' +import { draftPortion } from '../lib/portions' +import { toNutrients } from '../lib/nutrients' + +function makeDraft(overrides: Partial = {}): DraftItem { + return { + name: 'Whole milk', + quantity: 1, + unit: 'cup', + unit_grams: 240, + estimated_weight_g: 240, + base_weight_g: 240, + nutrients: toNutrients({ calories: 149, protein_g: 8 }), + ...overrides, + } +} + +function makeSaved(overrides: Partial = {}): FavoriteItem { + return { + id: 'fi-1', + sort_order: 0, + food_name: 'Whole milk', + brand: null, + quantity_g: 240, + quantity: 1, + unit: 'cup', + nutrients: { calories: 149, protein_g: 8 }, + ...overrides, + } +} + +describe('favoriteItemFromDraft', () => { + it('saves the measure the item was built with alongside the grams', () => { + expect(favoriteItemFromDraft(makeDraft())).toMatchObject({ + food_name: 'Whole milk', + quantity_g: 240, + quantity: 1, + unit: 'cup', + }) + }) + + it('re-derives the quantity from the current weight', () => { + // The ½× chip halved the portion after it was added — save it as ½ cup. + const halved = makeDraft({ estimated_weight_g: 120 }) + expect(favoriteItemFromDraft(halved)).toMatchObject({ quantity_g: 120, quantity: 0.5, unit: 'cup' }) + }) + + it('saves no measure for an item entered in grams', () => { + const grams = makeDraft({ unit: 'g', unit_grams: undefined, estimated_weight_g: 150 }) + expect(favoriteItemFromDraft(grams)).toMatchObject({ quantity_g: 150, quantity: null, unit: null }) + }) + + it('saves no measure when the unit has no gram anchor', () => { + const unanchored = makeDraft({ unit: 'plate', unit_grams: undefined }) + expect(favoriteItemFromDraft(unanchored)).toMatchObject({ quantity: null, unit: null }) + }) +}) + +describe('draftFromFavoriteItem', () => { + it('restores the saved unit instead of falling back to grams', () => { + const draft = draftFromFavoriteItem(makeSaved({ quantity: 2, quantity_g: 480 })) + expect(draft.quantity).toBe(2) + expect(draft.unit).toBe('cup') + expect(draft.unit_grams).toBe(240) + expect(draft.estimated_weight_g).toBe(480) + }) + + it('falls back to grams for favorites saved before the measure was recorded', () => { + const legacy = draftFromFavoriteItem(makeSaved({ quantity: null, unit: null })) + expect(legacy.quantity).toBe(240) + expect(legacy.unit).toBe('g') + expect(legacy.unit_grams).toBeUndefined() + }) + + it('ignores a recorded measure that is itself in grams', () => { + const inGrams = draftFromFavoriteItem(makeSaved({ quantity: 240, unit: 'g' })) + expect(inGrams.unit).toBe('g') + expect(inGrams.unit_grams).toBeUndefined() + }) +}) + +describe('portion round trip', () => { + it('survives build → save → reopen', () => { + const food = { name: 'Whole milk', household_measures: [{ label: '1 cup', grams: 240 }] } + const built: DraftItem = { + name: food.name, + ...draftPortion(food, 'hm:0', 2), + estimated_weight_g: 480, + nutrients: toNutrients({ calories: 298 }), + } + + const saved = favoriteItemFromDraft(built) + expect(saved).toMatchObject({ quantity_g: 480, quantity: 2, unit: 'cup' }) + + const reopened = draftFromFavoriteItem({ id: 'x', sort_order: 0, ...saved } as FavoriteItem) + expect(reopened.quantity).toBe(2) + expect(reopened.unit).toBe('cup') + expect(reopened.estimated_weight_g).toBe(480) + }) +}) diff --git a/frontend/src/test/portions.test.ts b/frontend/src/test/portions.test.ts index 41f0830..8e686f5 100644 --- a/frontend/src/test/portions.test.ts +++ b/frontend/src/test/portions.test.ts @@ -1,5 +1,8 @@ import { describe, it, expect } from 'vitest' -import { densityForFood, unitToGrams, defaultQtyForUnit, gramsForFoodUnit } from '../lib/portions' +import { + densityForFood, unitToGrams, defaultQtyForUnit, gramsForFoodUnit, + isGramUnit, gramsPerUnit, formatQuantity, measureLabel, splitMeasureLabel, draftPortion, +} from '../lib/portions' describe('densityForFood', () => { it('returns 0.92 for olive oil', () => { @@ -104,3 +107,111 @@ describe('defaultQtyForUnit', () => { expect(defaultQtyForUnit('ml')).toBe(100) }) }) + +describe('isGramUnit', () => { + it('treats the gram spellings as gram units', () => { + expect(isGramUnit('g')).toBe(true) + expect(isGramUnit('G')).toBe(true) + expect(isGramUnit('gram')).toBe(true) + expect(isGramUnit('grams')).toBe(true) + }) + + it('treats a missing unit as grams', () => { + expect(isGramUnit(undefined)).toBe(true) + expect(isGramUnit(null)).toBe(true) + expect(isGramUnit('')).toBe(true) + }) + + it('leaves real measures alone', () => { + expect(isGramUnit('cup')).toBe(false) + expect(isGramUnit('oz')).toBe(false) + }) +}) + +describe('gramsPerUnit', () => { + it('back-calculates grams in one unit', () => { + expect(gramsPerUnit(2, 480)).toBe(240) + }) + + it('returns null when the quantity cannot anchor a conversion', () => { + expect(gramsPerUnit(0, 240)).toBeNull() + expect(gramsPerUnit(null, 240)).toBeNull() + expect(gramsPerUnit(undefined, 240)).toBeNull() + expect(gramsPerUnit(-1, 240)).toBeNull() + }) + + it('returns null when the weight is missing', () => { + expect(gramsPerUnit(2, 0)).toBeNull() + expect(gramsPerUnit(2, null)).toBeNull() + }) +}) + +describe('formatQuantity', () => { + it('drops floating-point noise', () => { + expect(formatQuantity(1.0000001)).toBe('1') + expect(formatQuantity(0.4988)).toBe('0.5') + }) + + it('keeps meaningful fractions', () => { + expect(formatQuantity(1.5)).toBe('1.5') + expect(formatQuantity(0.25)).toBe('0.25') + }) +}) + +describe('measureLabel', () => { + it('labels a real measure', () => { + expect(measureLabel(2, 'cup')).toBe('2 cup') + expect(measureLabel(0.5, 'cup')).toBe('0.5 cup') + }) + + it('is empty for grams and for missing quantities', () => { + expect(measureLabel(240, 'g')).toBe('') + expect(measureLabel(null, 'cup')).toBe('') + expect(measureLabel(0, 'cup')).toBe('') + }) +}) + +describe('splitMeasureLabel', () => { + it('splits the count out of a USDA household measure', () => { + expect(splitMeasureLabel('1 cup')).toEqual({ count: 1, unit: 'cup' }) + expect(splitMeasureLabel('0.5 cup')).toEqual({ count: 0.5, unit: 'cup' }) + expect(splitMeasureLabel('3 oz')).toEqual({ count: 3, unit: 'oz' }) + }) + + it('keeps multi-word remainders as the unit name', () => { + expect(splitMeasureLabel('1 cup, chopped')).toEqual({ count: 1, unit: 'cup, chopped' }) + }) + + it('defaults to a count of 1 when the label has no leading number', () => { + expect(splitMeasureLabel('serving')).toEqual({ count: 1, unit: 'serving' }) + }) +}) + +describe('draftPortion', () => { + const milk = { name: 'milk', household_measures: [{ label: '1 cup', grams: 240 }] } + + it('records grams-per-unit for a plain volume unit', () => { + const p = draftPortion({ name: 'milk' }, 'cup', 2) + expect(p.quantity).toBe(2) + expect(p.unit).toBe('cup') + expect(p.unit_grams).toBeCloseTo(236.59, 2) + }) + + it('folds a household measure count into the quantity', () => { + expect(draftPortion(milk, 'hm:0', 2)).toEqual({ quantity: 2, unit: 'cup', unit_grams: 240 }) + }) + + it('normalizes a fractional household measure to a whole unit', () => { + const half = { name: 'milk', household_measures: [{ label: '0.5 cup', grams: 120 }] } + // 3 × "0.5 cup" is 1.5 cups — and still 360g either way. + expect(draftPortion(half, 'hm:0', 3)).toEqual({ quantity: 1.5, unit: 'cup', unit_grams: 240 }) + }) + + it('carries no unit_grams for a gram portion', () => { + expect(draftPortion({ name: 'rice' }, 'g', 150)).toEqual({ quantity: 150, unit: 'g' }) + }) + + it('falls back to a serving when the household measure index is missing', () => { + expect(draftPortion({ name: 'milk' }, 'hm:4', 1)).toEqual({ quantity: 1, unit: 'serving' }) + }) +})