Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions backend/alembic/versions/0034_favorite_item_portion.py
Original file line number Diff line number Diff line change
@@ -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")
5 changes: 3 additions & 2 deletions backend/luma/api/family.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 {}),
},
)
Expand Down
23 changes: 19 additions & 4 deletions backend/luma/api/favorites.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {}


Expand All @@ -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 {},
}

Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -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),
},
)
Expand Down Expand Up @@ -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,
Expand All @@ -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),
},
)
Expand Down
6 changes: 6 additions & 0 deletions backend/luma/db/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
8 changes: 2 additions & 6 deletions frontend/src/components/LogSheet.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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'] })
Expand Down
43 changes: 37 additions & 6 deletions frontend/src/components/log-sheet/DraftItemList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -64,6 +65,10 @@ export function DraftItemList({ draftItems, onRemoveItem, onUpdateWeight, onUpda
const showPerServing = (servings ?? 1) > 1
const [editingIndex, setEditingIndex] = useState<number | null>(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)
Expand Down Expand Up @@ -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 (
<div key={idx} className="builder-ingredient-card">
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
Expand Down Expand Up @@ -174,21 +186,40 @@ export function DraftItemList({ draftItems, onRemoveItem, onUpdateWeight, onUpda
</div>
</div>

{/* Portion: editable grams + relative multiplier chips */}
{/* Portion: editable amount in the unit it was built with (falling
back to grams) + relative multiplier chips */}
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 4, minWidth: 0 }}>
<input
type="number"
value={current}
onChange={(e) => 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',
fontSize: 14, fontWeight: 700, border: '1px solid var(--glass-edge)',
fontFamily: 'var(--font-mono)', color: 'var(--sky-400)',
}}
/>
<span style={{ fontSize: 12, color: 'var(--fg-tertiary)', fontWeight: 500 }}>g</span>
<span style={{
fontSize: 12, color: 'var(--fg-tertiary)', fontWeight: 500,
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
}}>
{unitLabel}
{perUnit && (
<span style={{ color: 'var(--fg-quiet)', fontWeight: 400 }}> · {current}g</span>
)}
</span>
</div>
<div className="multiplier-btn-group" style={{ flex: 1 }}>
{PORTION_MULTIPLIERS.map(({ factor, label }) => {
Expand All @@ -197,7 +228,7 @@ export function DraftItemList({ draftItems, onRemoveItem, onUpdateWeight, onUpda
return (
<button
key={factor}
onClick={() => onUpdateWeight(idx, target)}
onClick={() => { setPortionDraft(null); onUpdateWeight(idx, target) }}
title={`${target}g`}
className={`multiplier-btn ${active ? 'multiplier-btn--active' : ''}`}
style={{ flex: 1 }}
Expand Down
22 changes: 5 additions & 17 deletions frontend/src/components/log-sheet/IngredientBuilder.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,11 @@ import { Html5Qrcode, Html5QrcodeSupportedFormats } from 'html5-qrcode'
import { api } from '../../lib/api'
import {
type PortionUnit, type HouseholdMeasure, PORTION_UNITS, PORTION_UNIT_LABELS, PRESETS_BY_UNIT,
gramsForFoodUnit, defaultQtyForUnit,
gramsForFoodUnit, defaultQtyForUnit, draftPortion,
} from '../../lib/portions'
import { scaleNutrients, toNutrients } from '../../lib/nutrients'
import { scaleNutrients } from '../../lib/nutrients'
import { DraftItemList } from './DraftItemList'
import { nutrientSourceForFood, type DraftItem, type Favorite } from './types'
import { draftFromFavoriteItem, nutrientSourceForFood, type DraftItem, type Favorite } from './types'

const FOOD_FORMATS = [
Html5QrcodeSupportedFormats.EAN_13,
Expand Down Expand Up @@ -230,14 +230,10 @@ export function IngredientBuilder({ draftItems, onAddItem, onRemoveItem, onUpdat
if (!pending) return
const qty = Math.max(0, parseFloat(pendingQty) || 0)
const grams = Math.max(1, Math.round(gramsForFoodUnit(pending, pendingUnit, qty)))
const unitLabel = pendingUnit.startsWith('hm:')
? (pending.household_measures?.[Number(pendingUnit.slice(3))]?.label ?? 'serving')
: pendingUnit
const item: DraftItem = {
name: pending.name,
brand: pending.brand,
quantity: qty,
unit: unitLabel,
...draftPortion(pending, pendingUnit, qty),
estimated_weight_g: grams,
base_weight_g: grams,
nutrients: scaleNutrients(pending.nutrients_per_100g, grams),
Expand All @@ -264,15 +260,7 @@ export function IngredientBuilder({ draftItems, onAddItem, onRemoveItem, onUpdat
: []

function pickFavorite(fav: Favorite) {
const items: DraftItem[] = fav.items.map((i) => ({
name: i.food_name,
brand: i.brand ?? undefined,
quantity: i.quantity_g,
unit: 'g',
estimated_weight_g: i.quantity_g,
base_weight_g: i.quantity_g,
nutrients: toNutrients(i.nutrients),
}))
const items: DraftItem[] = fav.items.map(draftFromFavoriteItem)
onPickFavorite?.(items, fav.name)
setQuery('')
setResults([])
Expand Down
11 changes: 2 additions & 9 deletions frontend/src/components/log-sheet/QuickTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { api } from '../../lib/api'
import { Zap, ChevronLeft, ChevronRight } from 'lucide-react'
import { toNutrients } from '../../lib/nutrients'
import { draftFromFavoriteItem } from './types'
import type { DraftItem, Favorite } from './types'

type FrequentMeal = {
Expand Down Expand Up @@ -203,14 +203,7 @@ export function QuickTab({ currentSlot, onAddItems, favorites, onLogFavoriteDire
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{pagedFavs.map((fav) => {
const kcal = Math.round(fav.items.reduce((sum, i) => sum + (i.nutrients.calories ?? 0), 0))
const favDraftItems: DraftItem[] = fav.items.map((i) => ({
name: i.food_name,
brand: i.brand ?? undefined,
quantity: i.quantity_g,
unit: 'g',
estimated_weight_g: i.quantity_g,
nutrients: toNutrients(i.nutrients),
}))
const favDraftItems: DraftItem[] = fav.items.map(draftFromFavoriteItem)
return (
<button
key={fav.id}
Expand Down
7 changes: 2 additions & 5 deletions frontend/src/components/log-sheet/ScanTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
PRESETS_BY_UNIT,
gramsForFoodUnit,
defaultQtyForUnit,
draftPortion,
} from '../../lib/portions'
import { DraftItemList } from './DraftItemList'
import { nutrientSourceForFood, type DraftItem } from './types'
Expand Down Expand Up @@ -161,9 +162,6 @@ export function ScanTab({ onAddItems, draftItems, onRemoveItem, onUpdateWeight,
if (!pending || confirmBusy) return
const qty = Math.max(0, parseFloat(pendingQty) || 0)
const grams = Math.max(1, Math.round(gramsForFoodUnit(pending, pendingUnit, qty)))
const unitLabel = pendingUnit.startsWith('hm:')
? (pending.household_measures?.[Number(pendingUnit.slice(3))]?.label ?? 'serving')
: pendingUnit

let foodId: string | undefined = pending.id
let nutrientSource = nutrientSourceForFood(pending.source, pending.brand)
Expand Down Expand Up @@ -191,8 +189,7 @@ export function ScanTab({ onAddItems, draftItems, onRemoveItem, onUpdateWeight,
onAddItems([{
name: pending.name,
brand: pending.brand,
quantity: qty,
unit: unitLabel,
...draftPortion(pending, pendingUnit, qty),
estimated_weight_g: grams,
nutrients: scaleNutrients(editPer100g, grams),
food_id: foodId,
Expand Down
Loading